フェディバース

守矢諏訪子 <suwako@076.moe>、2026-11-11 00:12
コミット 20054fe009722078719aabeaa74feb1e63bae908 ・親 0d792b6 ・ファイルを見る

23個のファイル、+427 -85

変更 README-JP.md
@@ -224,6 +224,8 @@
224224 php cron.php add "@daily" apcleanup
225225 ```
226226 アップロードした画像(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`(空)を使います。
227229
228230 ## 必要な PHP モジュール
229231 * php_gmp
@@ -51,7 +51,7 @@
5151 'pass' => 'hogehogehoge',
5252 ]);
5353 define('FEDIINFO', [
54- 'domain' => '',
54+ 'domain' => '', // 例:example.com 又は https://example.com
5555 // falseにするとマルチユーザーモード:ブログのアクターはそのままで、
5656 // Fediverseのロールを持つユーザーはそれぞれアクター(@ユーザー名@ドメイン)になり、/microblogが使える。
5757 // AUTH_ENABLEDとcron(apdeliver)も必要
@@ -54,14 +54,21 @@
5454 }
5555
5656 .mb-note {
57- border: 1px solid #550f75;
57+ border: 1px solid var(--color-b8b515, #c016c6);
5858 border-radius: 2px;
5959 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;
6163 }
6264
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+
6370 .mb-focus .mb-note {
64- border-color: #ea79d8;
71+ border-color: var(--color-ecea71, #ea79d8);
6572 }
6673
6774 .mb-ancestor .mb-note, .mb-replyitem .mb-note {
@@ -72,6 +79,17 @@
7279 display: flex;
7380 align-items: center;
7481 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);
7593 }
7694
7795 .mb-row {
変更 route.php
@@ -97,9 +97,11 @@
9797 $routes[] = Route::add('GET', 'microblog/user/{name}', Microblog::class.'@user');
9898 $routes[] = Route::add('GET', 'microblog/remote', Microblog::class.'@remote');
9999 $routes[] = Route::add('GET', 'microblog/note', Microblog::class.'@note');
100+ $routes[] = Route::add('GET', 'microblog/media', Microblog::class.'@media');
100101 $routes[] = Route::add('GET', 'microblog/search', Microblog::class.'@search');
101102 $routes[] = Route::add('GET', 'microblog/notifications', Microblog::class.'@notifications');
102103 $routes[] = Route::add('GET', 'microblog/blocks', Microblog::class.'@blocks');
104+ $routes[] = Route::add('GET', 'microblog/follows', Microblog::class.'@follows');
103105 $routes[] = Route::add('GET', 'microblog/edit', Microblog::class.'@edit');
104106 $routes[] = Route::add('POST', 'microblog/edit', Microblog::class.'@edit');
105107 $routes[] = Route::add('POST', 'microblog/post', Microblog::class.'@post');
@@ -121,6 +121,7 @@
121121 'profile' => [
122122 'url' => $url,
123123 'name' => $u->displayname ?: $u->username,
124+ 'color' => namecolor($u),
124125 'handle' => "@{$name}@".Fedi::host(),
125126 'avatar' => avatar_url($u),
126127 'bio' => ($u->bio ?? '') !== '' ? Html::render($u->bio) : '',
@@ -147,15 +148,18 @@
147148 if (!$doc) $this->notFound();
148149
149150 $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) {
151154 $items[] = ['entry' => ['type' => 'note', 'actor' => $url, 'object' => $obj['id']], 'object' => $obj];
152155 }
153156 $this->render('microblog/profile', (string)($doc['name'] ?? $doc['preferredUsername'] ?? ''), [
154157 'profile' => [
155158 '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'),
157161 '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',
159163 'bio' => Html::sanitize((string)($doc['summary'] ?? '')),
160164 'local' => false,
161165 'profilePage' => self::safeUrl(is_string($doc['url'] ?? null) ? $doc['url'] : ''),
@@ -413,10 +417,22 @@
413417 $this->render('microblog/blocks', 'ブロック', [
414418 'actors' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], $b['actors']),
415419 'domains' => $b['domains'],
416- 'followers' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], array_keys(Actor::followers($this->me))),
417420 ]);
418421 }
419422
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+
420436 /** 管理(サイト全体のドメインのブロック、通報)。管理者だけ */
421437 public function admin(array $params): void {
422438 if (!$this->user || !($this->user->role & Roles::ADMIN)) $this->notFound();
@@ -478,7 +494,7 @@
478494 $mt = (string)($a['mediaType'] ?? '');
479495 $kind = str_starts_with($mt, 'video/') ? 'video' : (str_starts_with($mt, 'audio/') ? 'audio'
480496 : (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];
482498 }
483499
484500 $out[] = [
@@ -510,13 +526,34 @@
510526 }
511527
512528 /**
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+ /**
513548 * アクターの表示用の情報(キャッシュだけ使う。取得しない)
549+ * color:namecolor()のHTML(リモートのアクターはエスケープした名前)
514550 */
515551 private function who(string $url): array {
516552 $local = Fedi::localUsername($url);
517553 if ($local !== null) {
518554 $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}",
520557 'avatar' => $u ? avatar_url($u) : '/static/user/noicon.png', 'page' => "/microblog/user/{$local}"];
521558 }
522559 $doc = Remote::cachedActor($url);
@@ -524,9 +561,10 @@
524561 $icon = $doc ? ($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : '')) : '';
525562 return [
526563 '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'),
528566 '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',
530568 'page' => '/microblog/remote?actor='.rawurlencode($url),
531569 ];
532570 }
@@ -572,29 +610,6 @@
572610 /**
573611 * リモートのアクターの最近の投稿(送信ボックスの最初のページ、公開のNoteだけ)
574612 */
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-
598613 private function actorPage(string $url): string {
599614 $local = Fedi::localUsername($url);
600615 return $local !== null ? "/microblog/user/{$local}" : '/microblog/remote?actor='.rawurlencode($url);
@@ -279,14 +279,15 @@
279279 $in = [
280280 'displayname' => (string)($_POST['displayname'] ?? ''),
281281 'gender' => (string)($_POST['gender'] ?? '-1'),
282- 'namecolor' => isset($_POST['usecolor']) ? (string)($_POST['namecolor'] ?? '') : '',
282+ 'namecolor' => (string)($_POST['namecolor'] ?? ''),
283283 'bio' => (string)($_POST['bio'] ?? ''),
284284 'removeAvatar' => isset($_POST['removeAvatar']),
285285 ];
286286 if ($edit->isSelf) {
287287 foreach (['currentPassword', 'newPassword', 'newPasswordVerify', 'email'] as $k) $in[$k] = (string)($_POST[$k] ?? '');
288288 }
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'] ?? []));
290291 $res = $auth->updateProfile($name, $in, $_FILES['avatar'] ?? null);
291292 if ($res->isSuccess) {
292293 // Fediverseの設定とプロフィールの配信
@@ -74,7 +74,7 @@
7474 if (!ACTIVITYPUB_ENABLED) return;
7575 $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://';
7676 // CLI(cron)にはHTTP_HOSTが無いので、FEDIINFOの「domain」を優先する
77- $this->domain = rtrim((string)(FEDIINFO['domain'] ?? ''), '/');
77+ $this->domain = \Std\Lib\Fedi\Fedi::configuredDomain();
7878 if ($this->domain === '') $this->domain = $this->https.($_SERVER['HTTP_HOST'] ?? '127.0.0.1:8000');
7979 $this->actor = FEDIINFO['actor'];
8080 $this->actorNick = FEDIINFO['actorNick'];
@@ -386,7 +386,7 @@
386386 * 管理者:他のユーザーの表示名等とロール(パスワードとメールアドレスは本人だけ)
387387 *
388388 * @param string $username 編集するユーザー
389- * @param array $in displayname, gender, namecolor(#rrggbb又は空), bio, removeAvatar,
389+ * @param array $in displayname, gender, namecolor(名前のインラインCSS、空なら性別の色), bio, removeAvatar,
390390 * currentPassword, newPassword, newPasswordVerify, email, roles(管理者:ビットの配列)
391391 * @param array|null $avatar $_FILES['avatar']
392392 * @return Result
@@ -416,12 +416,11 @@
416416 $g = (int)$in['gender'];
417417 $user->gender = in_array($g, [\Gender::UNKNOWN, \Gender::MALE, \Gender::FEMALE], true) ? $g : \Gender::UNKNOWN;
418418 }
419- // 名前の色(styleに入るので、色だけ許可する)
419+ // 名前のスタイル(インラインCSS。namecolor()でエスケープしてstyle属性に入れる)
420420 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;
425424 }
426425 // 自己紹介
427426 if (array_key_exists('bio', $in)) {
@@ -542,7 +541,6 @@
542541 unset($user->password, $user->tokens, $user->verifyToken);
543542 $user->isSelf = $me->id == $user->id;
544543 $user->isAdmin = (bool)($me->role & Roles::ADMIN);
545- $user->colorHex = preg_match('/#[0-9a-fA-F]{6}/', (string)($user->namecolor ?? ''), $m) ? $m[0] : '';
546544 $user->fedi = can_federate($user);
547545 $user->avatarUrl = avatar_url($user);
548546 return $user;
@@ -53,8 +53,15 @@
5353 if ($lock) flock($lock, LOCK_EX);
5454 try {
5555 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)) {
5865 logger(\LogType::ActivityPub, "「{$username}」の鍵を作れませんでした:".openssl_error_string());
5966 return null;
6067 }
@@ -122,7 +122,7 @@
122122 if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) return \Result::Success('前の配信がまだ終わっていません。');
123123
124124 $start = time();
125- $sent = $failed = $dropped = 0;
125+ $sent = $failed = $dropped = $filled = 0;
126126 try {
127127 $files = glob(Fedi::path('queue/').'*.json') ?: [];
128128 sort($files);
@@ -151,13 +151,42 @@
151151 Fedi::write($file, $job);
152152 $failed++;
153153 }
154+ $filled = static::runBackfill($budget - (time() - $start));
154155 } finally {
155156 flock($lock, LOCK_UN);
156157 fclose($lock);
157158 }
158159
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;
161190 }
162191
163192 /** 待ち行列の件数 */
@@ -92,10 +92,27 @@
9292 */
9393 public static function base(): string {
9494 if (self::$baseUrl !== null) return self::$baseUrl;
95- $domain = rtrim((string)(FEDIINFO['domain'] ?? ''), '/');
95+ $domain = self::configuredDomain();
9696 if ($domain !== '') return $domain;
9797 $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
9898 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;
99116 }
100117
101118 public static function host(): string {
@@ -128,6 +128,8 @@
128128 else unset($l[$actor]);
129129 return $l;
130130 });
131+ // 相手の最近の投稿をホームに入れる(取得はcronで。ここで待たせると相手のサーバーが再送する)
132+ if ($accepted) Delivery::queueBackfill($actor);
131133 }
132134 return [202, ['status' => 'OK']];
133135 }
@@ -38,6 +38,7 @@
3838 namespace Std\Lib\Fedi;
3939
4040 use Std\Lib\Activitypub;
41+use Std\Lib\Curl;
4142
4243 /**
4344 * リモートのアクターとオブジェクトの取得とキャッシュ
@@ -48,7 +49,19 @@
4849 final class Remote {
4950 /** アクターのキャッシュの有効期限 */
5051 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
5162
63+ private static ?string $secret = null;
64+
5265 private static ?Activitypub $ap = null;
5366
5467 private static function ap(): Activitypub {
@@ -75,7 +88,7 @@
7588 * @param bool $refresh キャッシュを使わない
7689 * @return array|null
7790 */
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 {
7992 if (Fedi::isLocal($url)) {
8093 $name = Fedi::localUsername($url);
8194 if ($name === null) return $url === Fedi::blogActorUrl() ? json_decode(self::ap()->getActor(), true) : null;
@@ -88,7 +101,12 @@
88101 if (!$refresh && $cached && ($cached['fetchedAt'] ?? 0) > time() - self::ACTOR_TTL) return $cached['doc'];
89102
90103 $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)) {
92110 return $cached['doc'] ?? null;
93111 }
94112 Fedi::write($file, ['doc' => $doc, 'fetchedAt' => time()]);
@@ -132,9 +150,11 @@
132150 }
133151
134152 // 同じ人を何度も探さない様に、結果を1日キャッシュする
153+ // 見つからなかった場合は短くする(以前は1日で、相手のサーバーが一時的に応答しないと1日探せなかった)
135154 $file = Fedi::path('remote/webfinger/'.sha1(strtolower("{$user}@{$host}")).'.json');
136155 $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'];
138158
139159 $jrd = self::fetch("https://{$host}/.well-known/webfinger?resource=".rawurlencode("acct:{$user}@{$host}"));
140160 $url = null;
@@ -144,11 +164,54 @@
144164 break;
145165 }
146166 }
167+ if ($url === null) logger(\LogType::ActivityPub, "WebFingerで見つかりません:{$user}@{$host}");
147168 Fedi::write($file, ['url' => $url, 'at' => time()]);
148169 return $url;
149170 }
150171
151172 /**
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+ /**
152215 * 「@user@host」「user@host」「@localuser」又はURLからアクターのURL
153216 */
154217 public static function resolve(string $q): ?string {
@@ -226,5 +289,97 @@
226289 public static function handle(array $actor): string {
227290 $user = (string)($actor['preferredUsername'] ?? 'unknown');
228291 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));
229384 }
230385 }
@@ -296,6 +296,22 @@
296296 return Result::Success($state === 'accepted' ? 'フォローしました。' : 'フォローをリクエストしました。');
297297 }
298298
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+
299315 public static function unfollow(string $username, string $target): Result {
300316 $f = Actor::following($username)[$target] ?? null;
301317 if ($f === null) return Result::Success();
@@ -190,6 +190,9 @@
190190 foreach (['remote/actors/', 'remote/webfinger/'] as $dir) {
191191 foreach (glob(Fedi::path($dir).'*.json') ?: [] as $f) if (filemtime($f) < $limit) @unlink($f);
192192 }
193+ // 画像のキャッシュ(期限切れ)
194+ $old = time() - Remote::IMAGE_TTL;
195+ foreach (glob(Fedi::path('remote/media/').'*') ?: [] as $f) if (filemtime($f) < $old) @unlink($f);
193196 return \Result::Success("古い投稿{$n}件を削除しました。", ['removed' => $n]);
194197 }
195198
@@ -134,6 +134,13 @@
134134 $test->assertEquals("a\nb", Html::text('<p>a<br>b</p>'));
135135 });
136136
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+
137144 $test->it('公開範囲を判断するはず', function($test): void {
138145 $f = 'https://x.example/users/a/followers';
139146 $test->assertEquals('public', Fedi::visibility([Fedi::PUBLIC], [$f]));
@@ -211,11 +218,48 @@
211218 $test->assertEquals(['Follow:202', 'Accept:202'], $pump());
212219 $use('A');
213220 $test->assertTrue(Actor::isFollowing('alice', $bob));
221+ // 承認されたら、相手の最近の投稿を次の配信の時に取得する
222+ $test->assertArrayHasKey($bob, Fedi::read(Fedi::path('backfill.json')));
214223 $use('B');
215224 $test->assertTrue(Actor::isFollowedBy('bob', $alice));
216225 $test->assertEquals('follow', Actor::notifications('bob')[0]['type']);
217226 });
218227
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+
219263 $test->it('フォローしている人の投稿がホームに届くはず', function($test) use ($use, $pump): void {
220264 $use('B');
221265 $test->assertTrue(Social::post('bob', 'Hello #fedi')->isSuccess);
@@ -403,10 +447,13 @@
403447 self::$calls++;
404448 return self::$ok;
405449 }
450+ protected static function runBackfill(int $budget): int {
451+ return 0;
452+ }
406453 };
407454 $failing::enqueue('alice', ['type' => 'Like', 'id' => 'x'], ['https://9.9.9.9/inbox']);
408455 $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);
410457 // 次の時刻までは送らない
411458 $failing::run();
412459 $test->assertEquals(1, $failing::$calls);
@@ -415,7 +462,7 @@
415462 $data['next'] = time() - 1;
416463 Fedi::write($job, $data);
417464 $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);
419466 $test->assertEquals(0, Delivery::pending());
420467 });
421468
@@ -40,10 +40,10 @@
4040 </td>
4141 </tr>
4242 <tr>
43- <td><label for="namecolor">名前の色:</label></td>
43+ <td><label for="namecolor">名前のスタイル(CSS):</label></td>
4444 <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>
4747 </td>
4848 </tr>
4949 <tr>
@@ -83,6 +83,7 @@
8383 <tr>
8484 <td>ロール(管理者):</td>
8585 <td>
86+ <input type="hidden" name="rolesForm" value="1" />
8687 {@ foreach ($roles as $bit => $label) @}
8788 <label><input type="checkbox" name="roles[]" value="{{ $bit }}"{{{ ($e->role & $bit) || ($bit === \Roles::FEDIVERSE && $e->fedi) ? ' checked="checked"' : '' }}} /> {{ $label }}</label><br />
8889 {@ endforeach @}
@@ -7,7 +7,7 @@
77 {@ endif @}
88 {@ foreach ($actors as $a) @}
99 <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>
1111 <form method="post" action="/microblog/action">
1212 <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
1313 <input type="hidden" name="back" value="/microblog/blocks" />
@@ -34,19 +34,4 @@
3434 <input type="text" name="target" placeholder="example.com" />
3535 <button name="do" value="blockdomain" class="danger">ドメインをブロック</button>
3636 </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 @}
5237 {@ 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 @@
11 <article class="mb-note">
22 {@ 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>
44 {@ endif @}
5- <header class="mb-author">
5+ <div class="mb-author">
66 <img src="{{ $it['author']['avatar'] }}" alt="" class="mb-avatar" loading="lazy" />
77 <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>
99 <span class="mb-handle">{{ $it['author']['handle'] }}</span>
1010 </div>
11- </header>
11+ </div>
1212 {@ if ($it['reply']) @}
1313 <p class="mb-small"><a href="{{ $it['reply'] }}">↩ 返信先</a></p>
1414 {@ endif @}
@@ -21,7 +21,7 @@
2121 <div class="mb-media">
2222 {@ foreach ($it['attachments'] as $a) @}
2323 {@ 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>
2525 {@ elif ($a['kind'] === 'video') @}
2626 <video src="{{ $a['url'] }}" controls="controls" preload="none" title="{{ $a['alt'] }}"></video>
2727 {@ elif ($a['kind'] === 'audio') @}
@@ -35,13 +35,13 @@
3535 {@ if ($it['sensitive']) @}
3636 </details>
3737 {@ endif @}
38- <footer class="mb-meta">
38+ <div class="mb-meta">
3939 <a href="{{ $it['page'] }}">{{ $it['published'] }}</a>{{ $it['edited'] ? '(編集済み)' : '' }} · {{ $it['vis'] }}
4040 {@ if ($it['likes'] !== null) @}
4141 · ☆{{ $it['likes'] }} 🔁{{ $it['boosts'] }} 💬{{ $it['replies'] }}
4242 {@ endif @}
4343 · <a href="{{ $it['original'] }}">元の投稿</a>
44- </footer>
44+ </div>
4545 {@ if ($me) @}
4646 <div class="mb-actions">
4747 <a href="{{ $it['page'] }}#reply" class="mb-button">返信</a>
@@ -8,6 +8,7 @@
88 {@ if ($me) @}
99 <a href="/microblog/notifications" class="{{ $scope === 'notifications' ? 'active' : '' }}">通知{{ $unread > 0 ? '('.$unread.')' : '' }}</a>
1010 <a href="/microblog/user/{{ $me }}">自分</a>
11+ <a href="/microblog/follows" class="{{ $scope === 'follows' ? 'active' : '' }}">フォロー</a>
1112 <a href="/microblog/blocks">ブロック</a>
1213 {@ endif @}
1314 {@ if ($isAdmin) @}
@@ -5,7 +5,7 @@
55 {@ foreach ($requests as $r) @}
66 <div class="mb-row">
77 <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>
99 <form method="post" action="/microblog/action">
1010 <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
1111 <input type="hidden" name="back" value="/microblog/notifications" />
@@ -24,7 +24,7 @@
2424 <div class="mb-row{{ $n['read'] ? '' : ' unread' }}">
2525 <img src="{{ $n['who']['avatar'] }}" alt="" class="mb-avatar" />
2626 <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>
2828 {@ if ($n['page']) @}
2929 <br /><a href="{{ $n['page'] }}" class="mb-small">{{ $n['excerpt'] !== '' ? $n['excerpt'] : '投稿' }}</a>
3030 {@ endif @}
@@ -3,14 +3,18 @@
33 <section class="mb-profile">
44 <img src="{{ $profile['avatar'] }}" alt="" class="mb-avatar-large" />
55 <div>
6- <h1>{{ $profile['name'] }}{{ $profile['locked'] ? ' 🔒' : '' }}</h1>
6+ <h1>{{{ $profile['color'] }}}{{ $profile['locked'] ? ' 🔒' : '' }}</h1>
77 <p class="mb-handle">{{ $profile['handle'] }}</p>
88 {@ if ($profile['bio'] !== '') @}
99 <div class="mb-bio">{{{ $profile['bio'] }}}</div>
1010 {@ endif @}
1111 <p class="mb-small">
1212 {@ if ($profile['followers'] !== null) @}
13+{@ if ($relation['self']) @}
14+ <a href="/microblog/follows">フォロワー {{ $profile['followers'] }} · フォロー {{ $profile['following'] }}</a> ·
15+{@ else @}
1316 フォロワー {{ $profile['followers'] }} · フォロー {{ $profile['following'] }} ·
17+{@ endif @}
1418 {@ endif @}
1519 {@ if ($profile['profilePage'] !== '') @}
1620 <a href="{{ $profile['profilePage'] }}">プロフィール</a>