フェディバース
守矢諏訪子 <suwako@076.moe>、2026-11-11 00:12
コミット 20054fe009722078719aabeaa74feb1e63bae908
・親 0d792b6
・ファイルを見る
23個のファイル、+427 -85
変更
README-JP.md
| @@ -224,6 +224,8 @@ | ||
| 224 | 224 | php cron.php add "@daily" apcleanup |
| 225 | 225 | ``` |
| 226 | 226 | アップロードした画像(PNG・JPEG・GIF、4 MiB まで。PHP の `upload_max_filesize` もご確認下さい)からはメタデータ(Exif、テキスト)を取り除きます。 |
| 227 | +他のサーバーのアイコンや画像は `/microblog/media` 経由で表示するので、CSP の `img-src` は `'self'` のままで構いません(閲覧者の IP アドレスも相手のサーバーに渡りません)。画像は `data/ap/remote/media/` に1週間キャッシュします。\ | |
| 228 | +ユーザーの鍵は初めて使う時に作ります。chroot(OpenBSD の httpd と php-fpm 等)では `openssl.cnf` が見えないので、`data/ap/openssl.cnf`(空)を使います。 | |
| 227 | 229 | |
| 228 | 230 | ## 必要な PHP モジュール |
| 229 | 231 | * php_gmp |
| @@ -51,7 +51,7 @@ | ||
| 51 | 51 | 'pass' => 'hogehogehoge', |
| 52 | 52 | ]); |
| 53 | 53 | define('FEDIINFO', [ |
| 54 | - 'domain' => '', | |
| 54 | + 'domain' => '', // 例:example.com 又は https://example.com | |
| 55 | 55 | // falseにするとマルチユーザーモード:ブログのアクターはそのままで、 |
| 56 | 56 | // Fediverseのロールを持つユーザーはそれぞれアクター(@ユーザー名@ドメイン)になり、/microblogが使える。 |
| 57 | 57 | // AUTH_ENABLEDとcron(apdeliver)も必要 |
| @@ -54,14 +54,21 @@ | ||
| 54 | 54 | } |
| 55 | 55 | |
| 56 | 56 | .mb-note { |
| 57 | - border: 1px solid #550f75; | |
| 57 | + border: 1px solid var(--color-b8b515, #c016c6); | |
| 58 | 58 | border-radius: 2px; |
| 59 | 59 | padding: 8px; |
| 60 | - margin: 8px 0; | |
| 60 | + margin: 14px 0; | |
| 61 | + box-shadow: 0 0 6px 1px var(--color-dddf78, #dc78df); | |
| 62 | + animation: mbGlow 0.7s ease-in-out infinite alternate; | |
| 61 | 63 | } |
| 62 | 64 | |
| 65 | +@keyframes mbGlow { | |
| 66 | + from { box-shadow: 0 0 4px 1px var(--color-dddf78, #dc78df); } | |
| 67 | + to { box-shadow: 0 0 8px 2px var(--color-dddf78, #dc78df); } | |
| 68 | +} | |
| 69 | + | |
| 63 | 70 | .mb-focus .mb-note { |
| 64 | - border-color: #ea79d8; | |
| 71 | + border-color: var(--color-ecea71, #ea79d8); | |
| 65 | 72 | } |
| 66 | 73 | |
| 67 | 74 | .mb-ancestor .mb-note, .mb-replyitem .mb-note { |
| @@ -72,6 +79,17 @@ | ||
| 72 | 79 | display: flex; |
| 73 | 80 | align-items: center; |
| 74 | 81 | gap: 8px; |
| 82 | +} | |
| 83 | + | |
| 84 | +.mb-author { | |
| 85 | + padding-bottom: 6px; | |
| 86 | + border-bottom: 1px solid var(--color-ac7718, #550f75); | |
| 87 | +} | |
| 88 | + | |
| 89 | +.mb-meta { | |
| 90 | + margin-top: 6px; | |
| 91 | + padding-top: 4px; | |
| 92 | + border-top: 1px solid var(--color-ac7718, #550f75); | |
| 75 | 93 | } |
| 76 | 94 | |
| 77 | 95 | .mb-row { |
変更
route.php
| @@ -97,9 +97,11 @@ | ||
| 97 | 97 | $routes[] = Route::add('GET', 'microblog/user/{name}', Microblog::class.'@user'); |
| 98 | 98 | $routes[] = Route::add('GET', 'microblog/remote', Microblog::class.'@remote'); |
| 99 | 99 | $routes[] = Route::add('GET', 'microblog/note', Microblog::class.'@note'); |
| 100 | + $routes[] = Route::add('GET', 'microblog/media', Microblog::class.'@media'); | |
| 100 | 101 | $routes[] = Route::add('GET', 'microblog/search', Microblog::class.'@search'); |
| 101 | 102 | $routes[] = Route::add('GET', 'microblog/notifications', Microblog::class.'@notifications'); |
| 102 | 103 | $routes[] = Route::add('GET', 'microblog/blocks', Microblog::class.'@blocks'); |
| 104 | + $routes[] = Route::add('GET', 'microblog/follows', Microblog::class.'@follows'); | |
| 103 | 105 | $routes[] = Route::add('GET', 'microblog/edit', Microblog::class.'@edit'); |
| 104 | 106 | $routes[] = Route::add('POST', 'microblog/edit', Microblog::class.'@edit'); |
| 105 | 107 | $routes[] = Route::add('POST', 'microblog/post', Microblog::class.'@post'); |
| @@ -121,6 +121,7 @@ | ||
| 121 | 121 | 'profile' => [ |
| 122 | 122 | 'url' => $url, |
| 123 | 123 | 'name' => $u->displayname ?: $u->username, |
| 124 | + 'color' => namecolor($u), | |
| 124 | 125 | 'handle' => "@{$name}@".Fedi::host(), |
| 125 | 126 | 'avatar' => avatar_url($u), |
| 126 | 127 | 'bio' => ($u->bio ?? '') !== '' ? Html::render($u->bio) : '', |
| @@ -147,15 +148,18 @@ | ||
| 147 | 148 | if (!$doc) $this->notFound(); |
| 148 | 149 | |
| 149 | 150 | $items = []; |
| 150 | - foreach ($this->me !== null ? $this->fetchOutbox($doc) : [] as $obj) { | |
| 151 | + $notes = $this->me !== null ? Remote::outbox($doc) : []; | |
| 152 | + if ($notes && Actor::isFollowing($this->me, $url)) foreach ($notes as $obj) Timeline::add($obj); | |
| 153 | + foreach ($notes as $obj) { | |
| 151 | 154 | $items[] = ['entry' => ['type' => 'note', 'actor' => $url, 'object' => $obj['id']], 'object' => $obj]; |
| 152 | 155 | } |
| 153 | 156 | $this->render('microblog/profile', (string)($doc['name'] ?? $doc['preferredUsername'] ?? ''), [ |
| 154 | 157 | 'profile' => [ |
| 155 | 158 | 'url' => $url, |
| 156 | - 'name' => (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')), | |
| 159 | + 'name' => $rname = (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')), | |
| 160 | + 'color' => htmlspecialchars($rname, ENT_QUOTES, 'UTF-8'), | |
| 157 | 161 | 'handle' => Remote::handle($doc), |
| 158 | - 'avatar' => self::safeUrl($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : '')) ?: '/static/user/noicon.png', | |
| 162 | + 'avatar' => Remote::imageUrl(self::safeUrl($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : ''))) ?: '/static/user/noicon.png', | |
| 159 | 163 | 'bio' => Html::sanitize((string)($doc['summary'] ?? '')), |
| 160 | 164 | 'local' => false, |
| 161 | 165 | 'profilePage' => self::safeUrl(is_string($doc['url'] ?? null) ? $doc['url'] : ''), |
| @@ -413,10 +417,22 @@ | ||
| 413 | 417 | $this->render('microblog/blocks', 'ブロック', [ |
| 414 | 418 | 'actors' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], $b['actors']), |
| 415 | 419 | 'domains' => $b['domains'], |
| 416 | - 'followers' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], array_keys(Actor::followers($this->me))), | |
| 417 | 420 | ]); |
| 418 | 421 | } |
| 419 | 422 | |
| 423 | + public function follows(array $params): void { | |
| 424 | + $this->requireMe(); | |
| 425 | + $following = []; | |
| 426 | + foreach (array_reverse(Actor::following($this->me), true) as $a => $f) { | |
| 427 | + $following[] = ['actor' => $a, 'who' => $this->who($a), 'pending' => ($f['state'] ?? '') !== 'accepted']; | |
| 428 | + } | |
| 429 | + $this->render('microblog/follows', 'フォロー', [ | |
| 430 | + 'scope' => 'follows', | |
| 431 | + 'following' => $following, | |
| 432 | + 'followers' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], array_reverse(array_keys(Actor::followers($this->me)))), | |
| 433 | + ]); | |
| 434 | + } | |
| 435 | + | |
| 420 | 436 | /** 管理(サイト全体のドメインのブロック、通報)。管理者だけ */ |
| 421 | 437 | public function admin(array $params): void { |
| 422 | 438 | if (!$this->user || !($this->user->role & Roles::ADMIN)) $this->notFound(); |
| @@ -478,7 +494,7 @@ | ||
| 478 | 494 | $mt = (string)($a['mediaType'] ?? ''); |
| 479 | 495 | $kind = str_starts_with($mt, 'video/') ? 'video' : (str_starts_with($mt, 'audio/') ? 'audio' |
| 480 | 496 | : (str_starts_with($mt, 'image/') || ($a['type'] ?? '') === 'Image' ? 'image' : 'link')); |
| 481 | - $attachments[] = ['url' => $url, 'alt' => (string)($a['name'] ?? ''), 'kind' => $kind]; | |
| 497 | + $attachments[] = ['url' => $url, 'src' => $kind === 'image' ? Remote::imageUrl($url) : $url, 'alt' => (string)($a['name'] ?? ''), 'kind' => $kind]; | |
| 482 | 498 | } |
| 483 | 499 | |
| 484 | 500 | $out[] = [ |
| @@ -510,13 +526,34 @@ | ||
| 510 | 526 | } |
| 511 | 527 | |
| 512 | 528 | /** |
| 529 | + * リモートの画像(/microblog/media?u=URL&s=署名) | |
| 530 | + * CSPのimg-srcで他のサーバーの画像は表示できないので、このサイトから出す | |
| 531 | + */ | |
| 532 | + public function media(array $params): void { | |
| 533 | + $url = (string)($_GET['u'] ?? ''); | |
| 534 | + $img = $url !== '' && hash_equals(Remote::imageSig($url), (string)($_GET['s'] ?? '')) ? Remote::image($url) : null; | |
| 535 | + if ($img === null) { | |
| 536 | + header('HTTP/1.1 404 Not Found'); | |
| 537 | + exit; | |
| 538 | + } | |
| 539 | + header('Content-Type: '.$img['type']); | |
| 540 | + header('Content-Length: '.filesize($img['path'])); | |
| 541 | + header('Cache-Control: public, max-age=86400'); | |
| 542 | + header("Content-Security-Policy: default-src 'none'"); | |
| 543 | + readfile($img['path']); | |
| 544 | + exit; | |
| 545 | + } | |
| 546 | + | |
| 547 | + /** | |
| 513 | 548 | * アクターの表示用の情報(キャッシュだけ使う。取得しない) |
| 549 | + * color:namecolor()のHTML(リモートのアクターはエスケープした名前) | |
| 514 | 550 | */ |
| 515 | 551 | private function who(string $url): array { |
| 516 | 552 | $local = Fedi::localUsername($url); |
| 517 | 553 | if ($local !== null) { |
| 518 | 554 | $u = Fedi::localUser($local); |
| 519 | - return ['url' => $url, 'name' => $u ? ($u->displayname ?: $u->username) : $local, 'handle' => "@{$local}", | |
| 555 | + return ['url' => $url, 'name' => $u ? ($u->displayname ?: $u->username) : $local, | |
| 556 | + 'color' => $u ? namecolor($u) : htmlspecialchars($local, ENT_QUOTES, 'UTF-8'), 'handle' => "@{$local}", | |
| 520 | 557 | 'avatar' => $u ? avatar_url($u) : '/static/user/noicon.png', 'page' => "/microblog/user/{$local}"]; |
| 521 | 558 | } |
| 522 | 559 | $doc = Remote::cachedActor($url); |
| @@ -524,9 +561,10 @@ | ||
| 524 | 561 | $icon = $doc ? ($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : '')) : ''; |
| 525 | 562 | return [ |
| 526 | 563 | 'url' => $url, |
| 527 | - 'name' => $doc ? (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? $handle)) : $handle, | |
| 564 | + 'name' => $name = $doc ? (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? $handle)) : $handle, | |
| 565 | + 'color' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'), | |
| 528 | 566 | 'handle' => $handle, |
| 529 | - 'avatar' => self::safeUrl(is_string($icon) ? $icon : '') ?: '/static/user/noicon.png', | |
| 567 | + 'avatar' => Remote::imageUrl(self::safeUrl(is_string($icon) ? $icon : '')) ?: '/static/user/noicon.png', | |
| 530 | 568 | 'page' => '/microblog/remote?actor='.rawurlencode($url), |
| 531 | 569 | ]; |
| 532 | 570 | } |
| @@ -572,29 +610,6 @@ | ||
| 572 | 610 | /** |
| 573 | 611 | * リモートのアクターの最近の投稿(送信ボックスの最初のページ、公開のNoteだけ) |
| 574 | 612 | */ |
| 575 | - private function fetchOutbox(array $doc): array { | |
| 576 | - $outbox = is_string($doc['outbox'] ?? null) ? Remote::fetch($doc['outbox']) : null; | |
| 577 | - if (!$outbox) return []; | |
| 578 | - $page = $outbox['orderedItems'] ?? null; | |
| 579 | - if ($page === null) { | |
| 580 | - $first = $outbox['first'] ?? null; | |
| 581 | - $page = is_array($first) ? ($first['orderedItems'] ?? []) : (is_string($first) ? (Remote::fetch($first)['orderedItems'] ?? []) : []); | |
| 582 | - } | |
| 583 | - $out = []; | |
| 584 | - foreach (array_slice((array)$page, 0, 20) as $act) { | |
| 585 | - if (!is_array($act) || ($act['type'] ?? '') !== 'Create' || !is_array($act['object'] ?? null)) continue; | |
| 586 | - $obj = $act['object']; | |
| 587 | - $id = Fedi::id($obj['id'] ?? null); | |
| 588 | - if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf((string)$doc['id'])) continue; | |
| 589 | - if (Fedi::id($obj['attributedTo'] ?? null) !== $doc['id']) continue; | |
| 590 | - if (Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? [])) !== 'public') continue; | |
| 591 | - // いいね・ブースト・返信が出来る様に保存する(タイムラインには載せない) | |
| 592 | - Remote::store($obj); | |
| 593 | - $out[] = $obj; | |
| 594 | - } | |
| 595 | - return $out; | |
| 596 | - } | |
| 597 | - | |
| 598 | 613 | private function actorPage(string $url): string { |
| 599 | 614 | $local = Fedi::localUsername($url); |
| 600 | 615 | return $local !== null ? "/microblog/user/{$local}" : '/microblog/remote?actor='.rawurlencode($url); |
| @@ -279,14 +279,15 @@ | ||
| 279 | 279 | $in = [ |
| 280 | 280 | 'displayname' => (string)($_POST['displayname'] ?? ''), |
| 281 | 281 | 'gender' => (string)($_POST['gender'] ?? '-1'), |
| 282 | - 'namecolor' => isset($_POST['usecolor']) ? (string)($_POST['namecolor'] ?? '') : '', | |
| 282 | + 'namecolor' => (string)($_POST['namecolor'] ?? ''), | |
| 283 | 283 | 'bio' => (string)($_POST['bio'] ?? ''), |
| 284 | 284 | 'removeAvatar' => isset($_POST['removeAvatar']), |
| 285 | 285 | ]; |
| 286 | 286 | if ($edit->isSelf) { |
| 287 | 287 | foreach (['currentPassword', 'newPassword', 'newPasswordVerify', 'email'] as $k) $in[$k] = (string)($_POST[$k] ?? ''); |
| 288 | 288 | } |
| 289 | - if ($edit->isAdmin) $in['roles'] = array_map('intval', (array)($_POST['roles'] ?? [])); | |
| 289 | + // ロールの欄が送られた時だけ変える(欄が無いフォームから送られても、全部外してBANにしない様に) | |
| 290 | + if ($edit->isAdmin && isset($_POST['rolesForm'])) $in['roles'] = array_map('intval', (array)($_POST['roles'] ?? [])); | |
| 290 | 291 | $res = $auth->updateProfile($name, $in, $_FILES['avatar'] ?? null); |
| 291 | 292 | if ($res->isSuccess) { |
| 292 | 293 | // Fediverseの設定とプロフィールの配信 |
| @@ -74,7 +74,7 @@ | ||
| 74 | 74 | if (!ACTIVITYPUB_ENABLED) return; |
| 75 | 75 | $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://'; |
| 76 | 76 | // CLI(cron)にはHTTP_HOSTが無いので、FEDIINFOの「domain」を優先する |
| 77 | - $this->domain = rtrim((string)(FEDIINFO['domain'] ?? ''), '/'); | |
| 77 | + $this->domain = \Std\Lib\Fedi\Fedi::configuredDomain(); | |
| 78 | 78 | if ($this->domain === '') $this->domain = $this->https.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000'); |
| 79 | 79 | $this->actor = FEDIINFO['actor']; |
| 80 | 80 | $this->actorNick = FEDIINFO['actorNick']; |
| @@ -386,7 +386,7 @@ | ||
| 386 | 386 | * 管理者:他のユーザーの表示名等とロール(パスワードとメールアドレスは本人だけ) |
| 387 | 387 | * |
| 388 | 388 | * @param string $username 編集するユーザー |
| 389 | - * @param array $in displayname, gender, namecolor(#rrggbb又は空), bio, removeAvatar, | |
| 389 | + * @param array $in displayname, gender, namecolor(名前のインラインCSS、空なら性別の色), bio, removeAvatar, | |
| 390 | 390 | * currentPassword, newPassword, newPasswordVerify, email, roles(管理者:ビットの配列) |
| 391 | 391 | * @param array|null $avatar $_FILES['avatar'] |
| 392 | 392 | * @return Result |
| @@ -416,12 +416,11 @@ | ||
| 416 | 416 | $g = (int)$in['gender']; |
| 417 | 417 | $user->gender = in_array($g, [\Gender::UNKNOWN, \Gender::MALE, \Gender::FEMALE], true) ? $g : \Gender::UNKNOWN; |
| 418 | 418 | } |
| 419 | - // 名前の色(styleに入るので、色だけ許可する) | |
| 419 | + // 名前のスタイル(インラインCSS。namecolor()でエスケープしてstyle属性に入れる) | |
| 420 | 420 | if (array_key_exists('namecolor', $in)) { |
| 421 | - $c = trim((string)$in['namecolor']); | |
| 422 | - if ($c === '') $user->namecolor = ''; | |
| 423 | - else if (preg_match('/^#[0-9a-fA-F]{6}$/', $c)) $user->namecolor = "color: {$c};"; | |
| 424 | - else $errors[] = 'エラー:名前の色は「#rrggbb」の形式で入力して下さい。'; | |
| 421 | + $c = trim(preg_replace('/[\x00-\x1f\x7f]/u', '', (string)$in['namecolor'])); | |
| 422 | + if (mb_strlen($c) > 500) $errors[] = 'エラー:名前のスタイルは500文字までです。'; | |
| 423 | + else $user->namecolor = $c; | |
| 425 | 424 | } |
| 426 | 425 | // 自己紹介 |
| 427 | 426 | if (array_key_exists('bio', $in)) { |
| @@ -542,7 +541,6 @@ | ||
| 542 | 541 | unset($user->password, $user->tokens, $user->verifyToken); |
| 543 | 542 | $user->isSelf = $me->id == $user->id; |
| 544 | 543 | $user->isAdmin = (bool)($me->role & Roles::ADMIN); |
| 545 | - $user->colorHex = preg_match('/#[0-9a-fA-F]{6}/', (string)($user->namecolor ?? ''), $m) ? $m[0] : ''; | |
| 546 | 544 | $user->fedi = can_federate($user); |
| 547 | 545 | $user->avatarUrl = avatar_url($user); |
| 548 | 546 | return $user; |
| @@ -53,8 +53,15 @@ | ||
| 53 | 53 | if ($lock) flock($lock, LOCK_EX); |
| 54 | 54 | try { |
| 55 | 55 | if (!is_file($file)) { |
| 56 | - $key = @openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); | |
| 57 | - if ($key === false || !openssl_pkey_export($key, $pem)) { | |
| 56 | + $opts = ['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]; | |
| 57 | + $key = @openssl_pkey_new($opts); | |
| 58 | + if ($key === false) { | |
| 59 | + $cnf = Fedi::path('openssl.cnf'); | |
| 60 | + if (!is_file($cnf)) file_put_contents($cnf, "# LittleBeastが鍵を作る時に使う(chrootでは/etc/ssl/openssl.cnfが見えない為)\n"); | |
| 61 | + $opts['config'] = $cnf; | |
| 62 | + $key = @openssl_pkey_new($opts); | |
| 63 | + } | |
| 64 | + if ($key === false || !openssl_pkey_export($key, $pem, null, $opts)) { | |
| 58 | 65 | logger(\LogType::ActivityPub, "「{$username}」の鍵を作れませんでした:".openssl_error_string()); |
| 59 | 66 | return null; |
| 60 | 67 | } |
| @@ -122,7 +122,7 @@ | ||
| 122 | 122 | if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) return \Result::Success('前の配信がまだ終わっていません。'); |
| 123 | 123 | |
| 124 | 124 | $start = time(); |
| 125 | - $sent = $failed = $dropped = 0; | |
| 125 | + $sent = $failed = $dropped = $filled = 0; | |
| 126 | 126 | try { |
| 127 | 127 | $files = glob(Fedi::path('queue/').'*.json') ?: []; |
| 128 | 128 | sort($files); |
| @@ -151,13 +151,42 @@ | ||
| 151 | 151 | Fedi::write($file, $job); |
| 152 | 152 | $failed++; |
| 153 | 153 | } |
| 154 | + $filled = static::runBackfill($budget - (time() - $start)); | |
| 154 | 155 | } finally { |
| 155 | 156 | flock($lock, LOCK_UN); |
| 156 | 157 | fclose($lock); |
| 157 | 158 | } |
| 158 | 159 | |
| 159 | - $data = ['sent' => $sent, 'failed' => $failed, 'dropped' => $dropped]; | |
| 160 | - return \Result::Success("配信:成功{$sent}件、失敗{$failed}件、諦めた{$dropped}件", $data); | |
| 160 | + $data = ['sent' => $sent, 'failed' => $failed, 'dropped' => $dropped, 'backfilled' => $filled]; | |
| 161 | + return \Result::Success("配信:成功{$sent}件、失敗{$failed}件、諦めた{$dropped}件、取得した投稿{$filled}件", $data); | |
| 162 | + } | |
| 163 | + | |
| 164 | + /** | |
| 165 | + * フォローが承認された相手の最近の投稿を、次の配信の時に取得する | |
| 166 | + */ | |
| 167 | + public static function queueBackfill(string $actor): void { | |
| 168 | + if (Fedi::isLocal($actor)) return; | |
| 169 | + Fedi::update(Fedi::path('backfill.json'), fn($l) => $l + [$actor => time()]); | |
| 170 | + } | |
| 171 | + | |
| 172 | + /** | |
| 173 | + * 待っている相手の投稿を取得する(1人1回だけ。失敗しても、新しい投稿はこれから届く) | |
| 174 | + * | |
| 175 | + * @param int $budget 使って良い秒数 | |
| 176 | + * @return int タイムラインに入れた件数 | |
| 177 | + */ | |
| 178 | + protected static function runBackfill(int $budget): int { | |
| 179 | + $start = time(); | |
| 180 | + $n = 0; | |
| 181 | + foreach (array_keys(Fedi::read(Fedi::path('backfill.json'))) as $actor) { | |
| 182 | + if (time() - $start > $budget) break; | |
| 183 | + Fedi::update(Fedi::path('backfill.json'), function($l) use ($actor) { | |
| 184 | + unset($l[$actor]); | |
| 185 | + return $l; | |
| 186 | + }); | |
| 187 | + $n += Social::backfill((string)$actor); | |
| 188 | + } | |
| 189 | + return $n; | |
| 161 | 190 | } |
| 162 | 191 | |
| 163 | 192 | /** 待ち行列の件数 */ |
| @@ -92,10 +92,27 @@ | ||
| 92 | 92 | */ |
| 93 | 93 | public static function base(): string { |
| 94 | 94 | if (self::$baseUrl !== null) return self::$baseUrl; |
| 95 | - $domain = rtrim((string)(FEDIINFO['domain'] ?? ''), '/'); | |
| 95 | + $domain = self::configuredDomain(); | |
| 96 | 96 | if ($domain !== '') return $domain; |
| 97 | 97 | $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; |
| 98 | 98 | return ($https ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000'); |
| 99 | + } | |
| 100 | + | |
| 101 | + /** | |
| 102 | + * FEDIINFOの「domain」をURLにする(「example.com」だけでも「https://example.com」にする) | |
| 103 | + * | |
| 104 | + * @return string 末尾の「/」なし、未設定なら空 | |
| 105 | + */ | |
| 106 | + public static function configuredDomain(): string { | |
| 107 | + return self::normalizeDomain((string)(defined('FEDIINFO') ? (FEDIINFO['domain'] ?? '') : '')); | |
| 108 | + } | |
| 109 | + | |
| 110 | + public static function normalizeDomain(string $domain): string { | |
| 111 | + $domain = rtrim(trim($domain), '/'); | |
| 112 | + if ($domain === '') return ''; | |
| 113 | + // 以前はスキームが無いと「example.com/ap/actor」の様な不正なIDになった | |
| 114 | + if (!preg_match('#^https?://#i', $domain)) $domain = 'https://'.$domain; | |
| 115 | + return $domain; | |
| 99 | 116 | } |
| 100 | 117 | |
| 101 | 118 | public static function host(): string { |
| @@ -128,6 +128,8 @@ | ||
| 128 | 128 | else unset($l[$actor]); |
| 129 | 129 | return $l; |
| 130 | 130 | }); |
| 131 | + // 相手の最近の投稿をホームに入れる(取得はcronで。ここで待たせると相手のサーバーが再送する) | |
| 132 | + if ($accepted) Delivery::queueBackfill($actor); | |
| 131 | 133 | } |
| 132 | 134 | return [202, ['status' => 'OK']]; |
| 133 | 135 | } |
| @@ -38,6 +38,7 @@ | ||
| 38 | 38 | namespace Std\Lib\Fedi; |
| 39 | 39 | |
| 40 | 40 | use Std\Lib\Activitypub; |
| 41 | +use Std\Lib\Curl; | |
| 41 | 42 | |
| 42 | 43 | /** |
| 43 | 44 | * リモートのアクターとオブジェクトの取得とキャッシュ |
| @@ -48,7 +49,19 @@ | ||
| 48 | 49 | final class Remote { |
| 49 | 50 | /** アクターのキャッシュの有効期限 */ |
| 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 | |
| 51 | 62 | |
| 63 | + private static ?string $secret = null; | |
| 64 | + | |
| 52 | 65 | private static ?Activitypub $ap = null; |
| 53 | 66 | |
| 54 | 67 | private static function ap(): Activitypub { |
| @@ -75,7 +88,7 @@ | ||
| 75 | 88 | * @param bool $refresh キャッシュを使わない |
| 76 | 89 | * @return array|null |
| 77 | 90 | */ |
| 78 | - public static function actor(string $url, bool $refresh = false): ?array { | |
| 91 | + public static function actor(string $url, bool $refresh = false, bool $canonical = true): ?array { | |
| 79 | 92 | if (Fedi::isLocal($url)) { |
| 80 | 93 | $name = Fedi::localUsername($url); |
| 81 | 94 | if ($name === null) return $url === Fedi::blogActorUrl() ? json_decode(self::ap()->getActor(), true) : null; |
| @@ -88,7 +101,12 @@ | ||
| 88 | 101 | if (!$refresh && $cached && ($cached['fetchedAt'] ?? 0) > time() - self::ACTOR_TTL) return $cached['doc']; |
| 89 | 102 | |
| 90 | 103 | $doc = self::fetch($url); |
| 91 | - if (!$doc || !self::isActor($doc) || ($doc['id'] ?? '') !== $url || !self::isSafeUrl($doc['inbox'] ?? null)) { | |
| 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)) { | |
| 92 | 110 | return $cached['doc'] ?? null; |
| 93 | 111 | } |
| 94 | 112 | Fedi::write($file, ['doc' => $doc, 'fetchedAt' => time()]); |
| @@ -132,9 +150,11 @@ | ||
| 132 | 150 | } |
| 133 | 151 | |
| 134 | 152 | // 同じ人を何度も探さない様に、結果を1日キャッシュする |
| 153 | + // 見つからなかった場合は短くする(以前は1日で、相手のサーバーが一時的に応答しないと1日探せなかった) | |
| 135 | 154 | $file = Fedi::path('remote/webfinger/'.sha1(strtolower("{$user}@{$host}")).'.json'); |
| 136 | 155 | $cached = Fedi::read($file, null); |
| 137 | - if ($cached && ($cached['at'] ?? 0) > time() - self::ACTOR_TTL) return $cached['url']; | |
| 156 | + $ttl = ($cached['url'] ?? null) !== null ? self::ACTOR_TTL : self::WEBFINGER_RETRY; | |
| 157 | + if ($cached && ($cached['at'] ?? 0) > time() - $ttl) return $cached['url']; | |
| 138 | 158 | |
| 139 | 159 | $jrd = self::fetch("https://{$host}/.well-known/webfinger?resource=".rawurlencode("acct:{$user}@{$host}")); |
| 140 | 160 | $url = null; |
| @@ -144,11 +164,54 @@ | ||
| 144 | 164 | break; |
| 145 | 165 | } |
| 146 | 166 | } |
| 167 | + if ($url === null) logger(\LogType::ActivityPub, "WebFingerで見つかりません:{$user}@{$host}"); | |
| 147 | 168 | Fedi::write($file, ['url' => $url, 'at' => time()]); |
| 148 | 169 | return $url; |
| 149 | 170 | } |
| 150 | 171 | |
| 151 | 172 | /** |
| 173 | + * アクターの送信ボックスから最近の投稿(公開・未収載)を取得して保存する | |
| 174 | + * | |
| 175 | + * @param array $doc アクター | |
| 176 | + * @return array 投稿(Note等)の配列 | |
| 177 | + */ | |
| 178 | + public static function outbox(array $doc): array { | |
| 179 | + $outbox = is_string($doc['outbox'] ?? null) ? self::fetch($doc['outbox']) : null; | |
| 180 | + if (!$outbox) return []; | |
| 181 | + $page = $outbox['orderedItems'] ?? null; | |
| 182 | + if ($page === null) { | |
| 183 | + $first = $outbox['first'] ?? null; | |
| 184 | + $page = is_array($first) ? ($first['orderedItems'] ?? []) : (is_string($first) ? (self::fetch($first)['orderedItems'] ?? []) : []); | |
| 185 | + } | |
| 186 | + $notes = self::outboxNotes($doc, (array)$page); | |
| 187 | + // いいね・ブースト・返信が出来る様に保存する | |
| 188 | + foreach ($notes as $obj) self::store($obj); | |
| 189 | + return $notes; | |
| 190 | + } | |
| 191 | + | |
| 192 | + /** | |
| 193 | + * 送信ボックスの項目から、そのアクターの投稿だけを取り出す | |
| 194 | + */ | |
| 195 | + public static function outboxNotes(array $doc, array $items): array { | |
| 196 | + $actor = (string)($doc['id'] ?? ''); | |
| 197 | + $followers = is_string($doc['followers'] ?? null) ? $doc['followers'] : null; | |
| 198 | + $out = []; | |
| 199 | + foreach ($items as $act) { | |
| 200 | + if (count($out) >= self::OUTBOX_LIMIT) break; | |
| 201 | + if (!is_array($act) || ($act['type'] ?? '') !== 'Create' || !is_array($act['object'] ?? null)) continue; | |
| 202 | + $obj = $act['object']; | |
| 203 | + $id = Fedi::id($obj['id'] ?? null); | |
| 204 | + if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) continue; | |
| 205 | + if (Fedi::id($obj['attributedTo'] ?? null) !== $actor) continue; | |
| 206 | + if (!in_array($obj['type'] ?? '', ['Note', 'Article', 'Question', 'Page'], true)) continue; | |
| 207 | + $vis = Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []), $followers); | |
| 208 | + if (!in_array($vis, ['public', 'unlisted'], true)) continue; | |
| 209 | + $out[] = $obj; | |
| 210 | + } | |
| 211 | + return $out; | |
| 212 | + } | |
| 213 | + | |
| 214 | + /** | |
| 152 | 215 | * 「@user@host」「user@host」「@localuser」又はURLからアクターのURL |
| 153 | 216 | */ |
| 154 | 217 | public static function resolve(string $q): ?string { |
| @@ -226,5 +289,97 @@ | ||
| 226 | 289 | public static function handle(array $actor): string { |
| 227 | 290 | $user = (string)($actor['preferredUsername'] ?? 'unknown'); |
| 228 | 291 | return '@'.$user.'@'.Fedi::domainOf((string)($actor['id'] ?? '')); |
| 292 | + } | |
| 293 | + | |
| 294 | + //////// 画像のプロキシ | |
| 295 | + | |
| 296 | + /** | |
| 297 | + * リモートの画像をこのサイト経由で表示するURL | |
| 298 | + * CSPのimg-srcを広げずに済み、閲覧者のIPアドレスも相手のサーバーに渡らない。 | |
| 299 | + * 署名付きなので、このサイトが出したURL以外は取得しない(踏み台にされない様に) | |
| 300 | + * | |
| 301 | + * @param string $url 画像のURL(空、又はこのサイトのURLならそのまま返す) | |
| 302 | + * @return string | |
| 303 | + */ | |
| 304 | + public static function imageUrl(string $url): string { | |
| 305 | + if ($url === '' || !preg_match('#^https?://#i', $url) || Fedi::isLocal($url)) return $url; | |
| 306 | + return '/microblog/media?u='.rawurlencode($url).'&s='.self::imageSig($url); | |
| 307 | + } | |
| 308 | + | |
| 309 | + public static function imageSig(string $url): string { | |
| 310 | + return substr(hash_hmac('sha256', $url, self::secret()), 0, 32); | |
| 311 | + } | |
| 312 | + | |
| 313 | + /** | |
| 314 | + * 画像を取得する(1週間キャッシュ) | |
| 315 | + * | |
| 316 | + * @param string $url 画像のURL | |
| 317 | + * @return array|null ['path' => キャッシュのファイル, 'type' => MIME]、画像でなければnull | |
| 318 | + */ | |
| 319 | + public static function image(string $url): ?array { | |
| 320 | + if (!self::isSafeUrl($url) || Actor::isDomainBlocked($url)) return null; | |
| 321 | + $base = Fedi::path('remote/media/'.sha1($url)); | |
| 322 | + $meta = Fedi::read($base.'.json', null); | |
| 323 | + if (is_array($meta)) { | |
| 324 | + $age = time() - (int)($meta['at'] ?? 0); | |
| 325 | + if (($meta['type'] ?? '') === '' && $age < self::IMAGE_RETRY) return null; | |
| 326 | + if (($meta['type'] ?? '') !== '' && $age < self::IMAGE_TTL && is_file($base.'.bin')) { | |
| 327 | + return ['path' => $base.'.bin', 'type' => $meta['type']]; | |
| 328 | + } | |
| 329 | + } | |
| 330 | + | |
| 331 | + // 非公開アドレスへの接続は、リダイレクト先もCurlが拒否する | |
| 332 | + $curl = new Curl($url); | |
| 333 | + $curl->setHeaders(['Accept: image/*']) | |
| 334 | + ->setFollowRedirects(true) | |
| 335 | + ->setMaxRedirects(3) | |
| 336 | + ->setTimeout(10) | |
| 337 | + ->setMaxResponseSize(self::IMAGE_MAX) | |
| 338 | + ->setCaInfo('/etc/ssl/cert.pem'); | |
| 339 | + $res = $curl->execute(); | |
| 340 | + $body = $curl->getResponseBody(); | |
| 341 | + $type = $res->isSuccess && $curl->getResponseCode() === 200 && strlen($body) <= self::IMAGE_MAX ? self::imageType($body) : null; | |
| 342 | + if ($type === null) { | |
| 343 | + logger(\LogType::ActivityPub, "画像を取得できません:{$url}(HTTP {$curl->getResponseCode()} {$curl->getError()})"); | |
| 344 | + Fedi::write($base.'.json', ['url' => $url, 'type' => '', 'at' => time()]); | |
| 345 | + return null; | |
| 346 | + } | |
| 347 | + $tmp = $base.'.'.bin2hex(random_bytes(4)).'.tmp'; | |
| 348 | + if (file_put_contents($tmp, $body) === false || !rename($tmp, $base.'.bin')) { | |
| 349 | + @unlink($tmp); | |
| 350 | + return null; | |
| 351 | + } | |
| 352 | + Fedi::write($base.'.json', ['url' => $url, 'type' => $type, 'at' => time()]); | |
| 353 | + return ['path' => $base.'.bin', 'type' => $type]; | |
| 354 | + } | |
| 355 | + | |
| 356 | + /** | |
| 357 | + * 中身から画像の形式を判断する(相手のContent-Typeは信用しない) | |
| 358 | + * SVGはスクリプトを含められるので受け付けない | |
| 359 | + */ | |
| 360 | + public static function imageType(string $data): ?string { | |
| 361 | + return match (true) { | |
| 362 | + str_starts_with($data, "\x89PNG\r\n\x1a\n") => 'image/png', | |
| 363 | + str_starts_with($data, "\xff\xd8\xff") => 'image/jpeg', | |
| 364 | + str_starts_with($data, 'GIF87a'), str_starts_with($data, 'GIF89a') => 'image/gif', | |
| 365 | + substr($data, 0, 4) === 'RIFF' && substr($data, 8, 4) === 'WEBP' => 'image/webp', | |
| 366 | + in_array(substr($data, 4, 8), ['ftypavif', 'ftypavis'], true) => 'image/avif', | |
| 367 | + default => null, | |
| 368 | + }; | |
| 369 | + } | |
| 370 | + | |
| 371 | + /** 画像のURLの署名の鍵(data/ap/secret.key、無ければ作る) */ | |
| 372 | + private static function secret(): string { | |
| 373 | + if (self::$secret !== null) return self::$secret; | |
| 374 | + $file = Fedi::path('secret.key'); | |
| 375 | + if (!is_file($file)) { | |
| 376 | + // 同時に作られても、先に出来た方を全員が使う(link()は既にあると失敗する) | |
| 377 | + $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; | |
| 378 | + file_put_contents($tmp, bin2hex(random_bytes(32))); | |
| 379 | + @chmod($tmp, 0600); | |
| 380 | + @link($tmp, $file); | |
| 381 | + @unlink($tmp); | |
| 382 | + } | |
| 383 | + return self::$secret = trim((string)file_get_contents($file)); | |
| 229 | 384 | } |
| 230 | 385 | } |
| @@ -296,6 +296,22 @@ | ||
| 296 | 296 | return Result::Success($state === 'accepted' ? 'フォローしました。' : 'フォローをリクエストしました。'); |
| 297 | 297 | } |
| 298 | 298 | |
| 299 | + /** | |
| 300 | + * フォローした相手の最近の投稿をタイムラインに入れる | |
| 301 | + * フォローする前の投稿は届かないので、これが無いとホームは相手の次の投稿まで空のまま | |
| 302 | + * | |
| 303 | + * @param string $actor リモートのアクター | |
| 304 | + * @return int 入れた件数 | |
| 305 | + */ | |
| 306 | + public static function backfill(string $actor): int { | |
| 307 | + if (Fedi::isLocal($actor) || empty(Inbox::localFollowersOf($actor))) return 0; | |
| 308 | + $doc = Remote::actor($actor); | |
| 309 | + if (!$doc) return 0; | |
| 310 | + $notes = Remote::outbox($doc); | |
| 311 | + foreach ($notes as $obj) Timeline::add($obj); | |
| 312 | + return count($notes); | |
| 313 | + } | |
| 314 | + | |
| 299 | 315 | public static function unfollow(string $username, string $target): Result { |
| 300 | 316 | $f = Actor::following($username)[$target] ?? null; |
| 301 | 317 | if ($f === null) return Result::Success(); |
| @@ -190,6 +190,9 @@ | ||
| 190 | 190 | foreach (['remote/actors/', 'remote/webfinger/'] as $dir) { |
| 191 | 191 | foreach (glob(Fedi::path($dir).'*.json') ?: [] as $f) if (filemtime($f) < $limit) @unlink($f); |
| 192 | 192 | } |
| 193 | + // 画像のキャッシュ(期限切れ) | |
| 194 | + $old = time() - Remote::IMAGE_TTL; | |
| 195 | + foreach (glob(Fedi::path('remote/media/').'*') ?: [] as $f) if (filemtime($f) < $old) @unlink($f); | |
| 193 | 196 | return \Result::Success("古い投稿{$n}件を削除しました。", ['removed' => $n]); |
| 194 | 197 | } |
| 195 | 198 |
| @@ -134,6 +134,13 @@ | ||
| 134 | 134 | $test->assertEquals("a\nb", Html::text('<p>a<br>b</p>')); |
| 135 | 135 | }); |
| 136 | 136 | |
| 137 | + $test->it('FEDIINFOのdomainをURLにするはず', function($test): void { | |
| 138 | + $test->assertEquals('https://technicalsuwako.moe', Fedi::normalizeDomain('technicalsuwako.moe')); | |
| 139 | + $test->assertEquals('https://technicalsuwako.moe', Fedi::normalizeDomain('https://technicalsuwako.moe/')); | |
| 140 | + $test->assertEquals('http://127.0.0.1:8000', Fedi::normalizeDomain('http://127.0.0.1:8000')); | |
| 141 | + $test->assertEquals('', Fedi::normalizeDomain('')); | |
| 142 | + }); | |
| 143 | + | |
| 137 | 144 | $test->it('公開範囲を判断するはず', function($test): void { |
| 138 | 145 | $f = 'https://x.example/users/a/followers'; |
| 139 | 146 | $test->assertEquals('public', Fedi::visibility([Fedi::PUBLIC], [$f])); |
| @@ -211,11 +218,48 @@ | ||
| 211 | 218 | $test->assertEquals(['Follow:202', 'Accept:202'], $pump()); |
| 212 | 219 | $use('A'); |
| 213 | 220 | $test->assertTrue(Actor::isFollowing('alice', $bob)); |
| 221 | + // 承認されたら、相手の最近の投稿を次の配信の時に取得する | |
| 222 | + $test->assertArrayHasKey($bob, Fedi::read(Fedi::path('backfill.json'))); | |
| 214 | 223 | $use('B'); |
| 215 | 224 | $test->assertTrue(Actor::isFollowedBy('bob', $alice)); |
| 216 | 225 | $test->assertEquals('follow', Actor::notifications('bob')[0]['type']); |
| 217 | 226 | }); |
| 218 | 227 | |
| 228 | + $test->it('送信ボックスからはそのアクターの公開・未収載の投稿だけを取り出すはず', function($test) use ($bob): void { | |
| 229 | + $doc = ['id' => $bob, 'followers' => "{$bob}/followers"]; | |
| 230 | + $note = fn(string $n, array $to, array $cc = [], ?string $by = null) => ['type' => 'Create', 'object' => [ | |
| 231 | + 'id' => "https://8.8.8.8/n/{$n}", 'type' => 'Note', 'attributedTo' => $by ?? $bob, 'to' => $to, 'cc' => $cc, 'content' => $n]]; | |
| 232 | + $items = [ | |
| 233 | + $note('public', [Fedi::PUBLIC]), | |
| 234 | + $note('unlisted', ["{$bob}/followers"], [Fedi::PUBLIC]), | |
| 235 | + $note('followers', ["{$bob}/followers"]), | |
| 236 | + $note('direct', ['https://1.1.1.1/ap/users/alice']), | |
| 237 | + $note('other', [Fedi::PUBLIC], [], 'https://8.8.8.8/ap/users/eve'), | |
| 238 | + ['type' => 'Announce', 'object' => 'https://9.9.9.9/n/x'], | |
| 239 | + ['type' => 'Create', 'object' => 'https://8.8.8.8/n/string'], | |
| 240 | + ['type' => 'Create', 'object' => ['id' => 'https://9.9.9.9/n/spoof', 'type' => 'Note', 'attributedTo' => $bob, 'to' => [Fedi::PUBLIC]]], | |
| 241 | + ]; | |
| 242 | + $got = array_map(fn($o) => $o['content'], Remote::outboxNotes($doc, $items)); | |
| 243 | + $test->assertEquals(['public', 'unlisted'], $got); | |
| 244 | + }); | |
| 245 | + | |
| 246 | + $test->it('リモートの画像は署名付きでこのサイト経由にするはず', function($test) use ($use): void { | |
| 247 | + $use('A'); | |
| 248 | + $test->assertEquals('', Remote::imageUrl('')); | |
| 249 | + $test->assertEquals(Fedi::base().'/static/a.png', Remote::imageUrl(Fedi::base().'/static/a.png')); | |
| 250 | + $url = 'https://8.8.8.8/media/a.webp'; | |
| 251 | + $test->assertEquals('/microblog/media?u='.rawurlencode($url).'&s='.Remote::imageSig($url), Remote::imageUrl($url)); | |
| 252 | + $test->assertTrue(Remote::imageSig($url) !== Remote::imageSig($url.'?x')); | |
| 253 | + // 形式は中身で判断し、SVG等は受け付けない | |
| 254 | + $test->assertEquals('image/png', Remote::imageType("\x89PNG\r\n\x1a\n....")); | |
| 255 | + $test->assertEquals('image/jpeg', Remote::imageType("\xff\xd8\xff\xe0....")); | |
| 256 | + $test->assertEquals('image/gif', Remote::imageType('GIF89a....')); | |
| 257 | + $test->assertEquals('image/webp', Remote::imageType('RIFF....WEBPVP8 ')); | |
| 258 | + $test->assertEquals('image/avif', Remote::imageType("\x00\x00\x00\x1cftypavif")); | |
| 259 | + $test->assertNull(Remote::imageType('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>')); | |
| 260 | + $test->assertNull(Remote::imageType('<html>')); | |
| 261 | + }); | |
| 262 | + | |
| 219 | 263 | $test->it('フォローしている人の投稿がホームに届くはず', function($test) use ($use, $pump): void { |
| 220 | 264 | $use('B'); |
| 221 | 265 | $test->assertTrue(Social::post('bob', 'Hello #fedi')->isSuccess); |
| @@ -403,10 +447,13 @@ | ||
| 403 | 447 | self::$calls++; |
| 404 | 448 | return self::$ok; |
| 405 | 449 | } |
| 450 | + protected static function runBackfill(int $budget): int { | |
| 451 | + return 0; | |
| 452 | + } | |
| 406 | 453 | }; |
| 407 | 454 | $failing::enqueue('alice', ['type' => 'Like', 'id' => 'x'], ['https://9.9.9.9/inbox']); |
| 408 | 455 | $res = $failing::run(); |
| 409 | - $test->assertEquals(['sent' => 0, 'failed' => 1, 'dropped' => 0], $res->data); | |
| 456 | + $test->assertEquals(['sent' => 0, 'failed' => 1, 'dropped' => 0, 'backfilled' => 0], $res->data); | |
| 410 | 457 | // 次の時刻までは送らない |
| 411 | 458 | $failing::run(); |
| 412 | 459 | $test->assertEquals(1, $failing::$calls); |
| @@ -415,7 +462,7 @@ | ||
| 415 | 462 | $data['next'] = time() - 1; |
| 416 | 463 | Fedi::write($job, $data); |
| 417 | 464 | $failing::$ok = true; |
| 418 | - $test->assertEquals(['sent' => 1, 'failed' => 0, 'dropped' => 0], $failing::run()->data); | |
| 465 | + $test->assertEquals(['sent' => 1, 'failed' => 0, 'dropped' => 0, 'backfilled' => 0], $failing::run()->data); | |
| 419 | 466 | $test->assertEquals(0, Delivery::pending()); |
| 420 | 467 | }); |
| 421 | 468 |
| @@ -40,10 +40,10 @@ | ||
| 40 | 40 | </td> |
| 41 | 41 | </tr> |
| 42 | 42 | <tr> |
| 43 | - <td><label for="namecolor">名前の色:</label></td> | |
| 43 | + <td><label for="namecolor">名前のスタイル(CSS):</label></td> | |
| 44 | 44 | <td> |
| 45 | - <label><input type="checkbox" name="usecolor" value="1"{{{ $e->colorHex !== '' ? ' checked="checked"' : '' }}} /> 色を指定する</label> | |
| 46 | - <input type="color" id="namecolor" name="namecolor" value="{{ $e->colorHex !== '' ? $e->colorHex : '#ea79d8' }}" /> | |
| 45 | + <textarea id="namecolor" name="namecolor" rows="2" cols="50" maxlength="500" placeholder="例:color: #ea79d8; font-weight: bold;">{{ $e->namecolor ?? '' }}</textarea><br /> | |
| 46 | + <small>空なら性別の色。プレビュー:{{{ namecolor($e) }}}</small> | |
| 47 | 47 | </td> |
| 48 | 48 | </tr> |
| 49 | 49 | <tr> |
| @@ -83,6 +83,7 @@ | ||
| 83 | 83 | <tr> |
| 84 | 84 | <td>ロール(管理者):</td> |
| 85 | 85 | <td> |
| 86 | + <input type="hidden" name="rolesForm" value="1" /> | |
| 86 | 87 | {@ foreach ($roles as $bit => $label) @} |
| 87 | 88 | <label><input type="checkbox" name="roles[]" value="{{ $bit }}"{{{ ($e->role & $bit) || ($bit === \Roles::FEDIVERSE && $e->fedi) ? ' checked="checked"' : '' }}} /> {{ $label }}</label><br /> |
| 88 | 89 | {@ endforeach @} |
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | {@ endif @} |
| 8 | 8 | {@ foreach ($actors as $a) @} |
| 9 | 9 | <div class="mb-row"> |
| 10 | - <a href="{{ $a['who']['page'] }}">{{ $a['who']['name'] }}</a> <span class="mb-handle">{{ $a['who']['handle'] }}</span> | |
| 10 | + <a href="{{ $a['who']['page'] }}">{{{ $a['who']['color'] }}}</a> <span class="mb-handle">{{ $a['who']['handle'] }}</span> | |
| 11 | 11 | <form method="post" action="/microblog/action"> |
| 12 | 12 | <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> |
| 13 | 13 | <input type="hidden" name="back" value="/microblog/blocks" /> |
| @@ -34,19 +34,4 @@ | ||
| 34 | 34 | <input type="text" name="target" placeholder="example.com" /> |
| 35 | 35 | <button name="do" value="blockdomain" class="danger">ドメインをブロック</button> |
| 36 | 36 | </form> |
| 37 | - <h2 class="paragraph">フォロワー</h2> | |
| 38 | -{@ if (empty($followers)) @} | |
| 39 | - <p class="paragraph">フォロワーはいません。</p> | |
| 40 | -{@ endif @} | |
| 41 | -{@ foreach ($followers as $a) @} | |
| 42 | - <div class="mb-row"> | |
| 43 | - <a href="{{ $a['who']['page'] }}">{{ $a['who']['name'] }}</a> <span class="mb-handle">{{ $a['who']['handle'] }}</span> | |
| 44 | - <form method="post" action="/microblog/action"> | |
| 45 | - <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 46 | - <input type="hidden" name="back" value="/microblog/blocks" /> | |
| 47 | - <input type="hidden" name="target" value="{{ $a['actor'] }}" /> | |
| 48 | - <button name="do" value="removefollower">フォロワーから外す</button> | |
| 49 | - </form> | |
| 50 | - </div> | |
| 51 | -{@ endforeach @} | |
| 52 | 37 | {@ include(common/footer) @} |
| @@ -0,0 +1,39 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | +{@ include(microblog/nav) @} | |
| 3 | + <h1 class="paragraph">フォロー</h1> | |
| 4 | + <h2 class="paragraph">フォロー中</h2> | |
| 5 | +{@ if (empty($following)) @} | |
| 6 | + <p class="paragraph">フォローしているアカウントはありません。</p> | |
| 7 | +{@ endif @} | |
| 8 | +{@ foreach ($following as $a) @} | |
| 9 | + <div class="mb-row"> | |
| 10 | + <img src="{{ $a['who']['avatar'] }}" alt="" class="mb-avatar" loading="lazy" /> | |
| 11 | + <a href="{{ $a['who']['page'] }}">{{{ $a['who']['color'] }}}</a> <span class="mb-handle">{{ $a['who']['handle'] }}</span> | |
| 12 | +{@ if ($a['pending']) @} | |
| 13 | + <span class="mb-small">(承認待ち)</span> | |
| 14 | +{@ endif @} | |
| 15 | + <form method="post" action="/microblog/action"> | |
| 16 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 17 | + <input type="hidden" name="back" value="/microblog/follows" /> | |
| 18 | + <input type="hidden" name="target" value="{{ $a['actor'] }}" /> | |
| 19 | + <button name="do" value="unfollow">{{ $a['pending'] ? 'リクエストを取り消す' : 'フォロー解除' }}</button> | |
| 20 | + </form> | |
| 21 | + </div> | |
| 22 | +{@ endforeach @} | |
| 23 | + <h2 class="paragraph">フォロワー</h2> | |
| 24 | +{@ if (empty($followers)) @} | |
| 25 | + <p class="paragraph">フォロワーはいません。</p> | |
| 26 | +{@ endif @} | |
| 27 | +{@ foreach ($followers as $a) @} | |
| 28 | + <div class="mb-row"> | |
| 29 | + <img src="{{ $a['who']['avatar'] }}" alt="" class="mb-avatar" loading="lazy" /> | |
| 30 | + <a href="{{ $a['who']['page'] }}">{{{ $a['who']['color'] }}}</a> <span class="mb-handle">{{ $a['who']['handle'] }}</span> | |
| 31 | + <form method="post" action="/microblog/action"> | |
| 32 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 33 | + <input type="hidden" name="back" value="/microblog/follows" /> | |
| 34 | + <input type="hidden" name="target" value="{{ $a['actor'] }}" /> | |
| 35 | + <button name="do" value="removefollower">フォロワーから外す</button> | |
| 36 | + </form> | |
| 37 | + </div> | |
| 38 | +{@ endforeach @} | |
| 39 | +{@ include(common/footer) @} |
| @@ -1,14 +1,14 @@ | ||
| 1 | 1 | <article class="mb-note"> |
| 2 | 2 | {@ if ($it['booster']) @} |
| 3 | - <p class="mb-small">🔁 <a href="{{ $it['booster']['page'] }}">{{ $it['booster']['name'] }}</a>がブースト</p> | |
| 3 | + <p class="mb-small">🔁 <a href="{{ $it['booster']['page'] }}">{{{ $it['booster']['color'] }}}</a>がブースト</p> | |
| 4 | 4 | {@ endif @} |
| 5 | - <header class="mb-author"> | |
| 5 | + <div class="mb-author"> | |
| 6 | 6 | <img src="{{ $it['author']['avatar'] }}" alt="" class="mb-avatar" loading="lazy" /> |
| 7 | 7 | <div> |
| 8 | - <a href="{{ $it['author']['page'] }}" class="mb-name">{{ $it['author']['name'] }}</a> | |
| 8 | + <a href="{{ $it['author']['page'] }}" class="mb-name">{{{ $it['author']['color'] }}}</a> | |
| 9 | 9 | <span class="mb-handle">{{ $it['author']['handle'] }}</span> |
| 10 | 10 | </div> |
| 11 | - </header> | |
| 11 | + </div> | |
| 12 | 12 | {@ if ($it['reply']) @} |
| 13 | 13 | <p class="mb-small"><a href="{{ $it['reply'] }}">↩ 返信先</a></p> |
| 14 | 14 | {@ endif @} |
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | <div class="mb-media"> |
| 22 | 22 | {@ foreach ($it['attachments'] as $a) @} |
| 23 | 23 | {@ if ($a['kind'] === 'image') @} |
| 24 | - <a href="{{ $a['url'] }}"><img src="{{ $a['url'] }}" alt="{{ $a['alt'] }}" title="{{ $a['alt'] }}" loading="lazy" /></a> | |
| 24 | + <a href="{{ $a['url'] }}"><img src="{{ $a['src'] }}" alt="{{ $a['alt'] }}" title="{{ $a['alt'] }}" loading="lazy" /></a> | |
| 25 | 25 | {@ elif ($a['kind'] === 'video') @} |
| 26 | 26 | <video src="{{ $a['url'] }}" controls="controls" preload="none" title="{{ $a['alt'] }}"></video> |
| 27 | 27 | {@ elif ($a['kind'] === 'audio') @} |
| @@ -35,13 +35,13 @@ | ||
| 35 | 35 | {@ if ($it['sensitive']) @} |
| 36 | 36 | </details> |
| 37 | 37 | {@ endif @} |
| 38 | - <footer class="mb-meta"> | |
| 38 | + <div class="mb-meta"> | |
| 39 | 39 | <a href="{{ $it['page'] }}">{{ $it['published'] }}</a>{{ $it['edited'] ? '(編集済み)' : '' }} · {{ $it['vis'] }} |
| 40 | 40 | {@ if ($it['likes'] !== null) @} |
| 41 | 41 | · ☆{{ $it['likes'] }} 🔁{{ $it['boosts'] }} 💬{{ $it['replies'] }} |
| 42 | 42 | {@ endif @} |
| 43 | 43 | · <a href="{{ $it['original'] }}">元の投稿</a> |
| 44 | - </footer> | |
| 44 | + </div> | |
| 45 | 45 | {@ if ($me) @} |
| 46 | 46 | <div class="mb-actions"> |
| 47 | 47 | <a href="{{ $it['page'] }}#reply" class="mb-button">返信</a> |
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | {@ if ($me) @} |
| 9 | 9 | <a href="/microblog/notifications" class="{{ $scope === 'notifications' ? 'active' : '' }}">通知{{ $unread > 0 ? '('.$unread.')' : '' }}</a> |
| 10 | 10 | <a href="/microblog/user/{{ $me }}">自分</a> |
| 11 | + <a href="/microblog/follows" class="{{ $scope === 'follows' ? 'active' : '' }}">フォロー</a> | |
| 11 | 12 | <a href="/microblog/blocks">ブロック</a> |
| 12 | 13 | {@ endif @} |
| 13 | 14 | {@ if ($isAdmin) @} |
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | {@ foreach ($requests as $r) @} |
| 6 | 6 | <div class="mb-row"> |
| 7 | 7 | <img src="{{ $r['who']['avatar'] }}" alt="" class="mb-avatar" /> |
| 8 | - <a href="{{ $r['who']['page'] }}">{{ $r['who']['name'] }}</a> <span class="mb-handle">{{ $r['who']['handle'] }}</span> | |
| 8 | + <a href="{{ $r['who']['page'] }}">{{{ $r['who']['color'] }}}</a> <span class="mb-handle">{{ $r['who']['handle'] }}</span> | |
| 9 | 9 | <form method="post" action="/microblog/action"> |
| 10 | 10 | <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> |
| 11 | 11 | <input type="hidden" name="back" value="/microblog/notifications" /> |
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | <div class="mb-row{{ $n['read'] ? '' : ' unread' }}"> |
| 25 | 25 | <img src="{{ $n['who']['avatar'] }}" alt="" class="mb-avatar" /> |
| 26 | 26 | <div> |
| 27 | - <a href="{{ $n['who']['page'] }}">{{ $n['who']['name'] }}</a>{{ $n['label'] }} <span class="mb-small">{{ $n['at'] }}</span> | |
| 27 | + <a href="{{ $n['who']['page'] }}">{{{ $n['who']['color'] }}}</a>{{ $n['label'] }} <span class="mb-small">{{ $n['at'] }}</span> | |
| 28 | 28 | {@ if ($n['page']) @} |
| 29 | 29 | <br /><a href="{{ $n['page'] }}" class="mb-small">{{ $n['excerpt'] !== '' ? $n['excerpt'] : '投稿' }}</a> |
| 30 | 30 | {@ endif @} |
| @@ -3,14 +3,18 @@ | ||
| 3 | 3 | <section class="mb-profile"> |
| 4 | 4 | <img src="{{ $profile['avatar'] }}" alt="" class="mb-avatar-large" /> |
| 5 | 5 | <div> |
| 6 | - <h1>{{ $profile['name'] }}{{ $profile['locked'] ? ' 🔒' : '' }}</h1> | |
| 6 | + <h1>{{{ $profile['color'] }}}{{ $profile['locked'] ? ' 🔒' : '' }}</h1> | |
| 7 | 7 | <p class="mb-handle">{{ $profile['handle'] }}</p> |
| 8 | 8 | {@ if ($profile['bio'] !== '') @} |
| 9 | 9 | <div class="mb-bio">{{{ $profile['bio'] }}}</div> |
| 10 | 10 | {@ endif @} |
| 11 | 11 | <p class="mb-small"> |
| 12 | 12 | {@ if ($profile['followers'] !== null) @} |
| 13 | +{@ if ($relation['self']) @} | |
| 14 | + <a href="/microblog/follows">フォロワー {{ $profile['followers'] }} · フォロー {{ $profile['following'] }}</a> · | |
| 15 | +{@ else @} | |
| 13 | 16 | フォロワー {{ $profile['followers'] }} · フォロー {{ $profile['following'] }} · |
| 17 | +{@ endif @} | |
| 14 | 18 | {@ endif @} |
| 15 | 19 | {@ if ($profile['profilePage'] !== '') @} |
| 16 | 20 | <a href="{{ $profile['profilePage'] }}">プロフィール</a> |