master
ブランチ master
LittleBeast / src / Site / Controller / Microblog.php
33.2 KB、744行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, 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
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Site\Controller;
39
40use Site\Controller\Mods;
41use Std\Lib\Auth;
42use Std\Lib\Template;
43use Std\Lib\Fedi\Actor;
44use Std\Lib\Fedi\Fedi;
45use Std\Lib\Fedi\Html;
46use Std\Lib\Fedi\Media;
47use Std\Lib\Fedi\Notes;
48use Std\Lib\Fedi\Remote;
49use Std\Lib\Fedi\Social;
50use Std\Lib\Fedi\Timeline;
51use Std\Lib\Fedi\Delivery;
52use Roles;
53
54/**
55 * マイクロブログ(/microblog)。JavaScriptは使わず、全部の操作はフォームで行う
56 */
57class Microblog {
58 use Mods;
59
60 private ?\stdClass $user = null;
61 /** Fediverseを使えるログイン中のユーザー名 */
62 private ?string $me = null;
63
64 public function __construct() {
65 if (!Fedi::enabled()) {
66 header('Location: /404');
67 exit;
68 }
69 $this->user = (new Auth())->getLoggedInUser();
70 if ($this->user && can_federate($this->user) && (($this->user->verified ?? true) !== false)) $this->me = $this->user->username;
71 }
72
73 //////// タイムライン
74
75 public function home(array $params): void {
76 if ($this->me === null) $this->redirect('/microblog/local');
77 $this->timeline('home', 'ホーム');
78 }
79
80 public function local(array $params): void {
81 $this->timeline('local', 'ローカル');
82 }
83
84 public function federated(array $params): void {
85 $this->timeline('federated', '連合');
86 }
87
88 public function tag(array $params): void {
89 $tag = mb_strtolower((string)($params['tag'] ?? ''));
90 [$items, $next] = Timeline::query('tag', $this->me, ['tag' => $tag, 'before' => $this->before()]);
91 $this->render('microblog/timeline', "#{$tag}", [
92 'scope' => 'tag',
93 'heading' => "#{$tag}",
94 'items' => $this->present($items),
95 'next' => $next !== null ? "/microblog/tag/".rawurlencode($tag)."?before={$next}" : null,
96 ]);
97 }
98
99 private function timeline(string $scope, string $title): void {
100 [$items, $next] = Timeline::query($scope, $this->me, ['before' => $this->before()]);
101 $path = $scope === 'home' ? '/microblog' : "/microblog/{$scope}";
102 $this->render('microblog/timeline', $title, [
103 'scope' => $scope,
104 'heading' => $title,
105 'items' => $this->present($items),
106 'next' => $next !== null ? "{$path}?before={$next}" : null,
107 'replyTo' => null,
108 ]);
109 }
110
111 //////// プロフィール
112
113 /** ローカルのユーザーの投稿(/microblog/user/{name}) */
114 public function user(array $params): void {
115 $name = (string)($params['name'] ?? '');
116 $u = Fedi::localActor($name);
117 if (!$u) $this->notFound();
118 $url = Fedi::actorUrl($name);
119 [$items, $next] = Timeline::query('user', $this->me, ['user' => $name, 'before' => $this->before()]);
120 $this->render('microblog/profile', $u->displayname ?: $u->username, [
121 'profile' => [
122 'url' => $url,
123 'name' => $u->displayname ?: $u->username,
124 'color' => namecolor($u),
125 'handle' => "@{$name}@".Fedi::host(),
126 'avatar' => avatar_url($u),
127 'bio' => ($u->bio ?? '') !== '' ? Html::render($u->bio) : '',
128 'local' => true,
129 'profilePage' => "/profile/{$name}",
130 'locked' => (bool)Actor::settings($name)['locked'],
131 'followers' => count(Actor::followers($name)),
132 'following' => count(array_filter(Actor::following($name), fn($f) => $f['state'] === 'accepted')),
133 ],
134 'relation' => $this->relation($url),
135 'items' => $this->present($items),
136 'next' => $next !== null ? "/microblog/user/{$name}?before={$next}" : null,
137 ]);
138 }
139
140 /** リモートのアクター(/microblog/remote?actor=URL)。送信ボックスから最近の投稿を取得する */
141 public function remote(array $params): void {
142 $url = (string)($_GET['actor'] ?? '');
143 $local = Fedi::localUsername($url);
144 if ($local !== null) $this->redirect("/microblog/user/{$local}");
145 if ($url === Fedi::blogActorUrl()) $this->blogActor();
146 if (!Remote::isSafeUrl($url) || Actor::isDomainBlocked($url)) $this->notFound();
147 // 他のサーバーへの問い合わせはログインしたユーザーだけ(誰でも取得させられると、踏み台にされる)
148 $doc = $this->me !== null ? Remote::actor($url, isset($_GET['refresh'])) : Remote::cachedActor($url);
149 if (!$doc) $this->notFound();
150
151 $items = [];
152 $notes = $this->me !== null ? Remote::outbox($doc) : [];
153 if ($notes && Actor::isFollowing($this->me, $url)) foreach ($notes as $obj) Timeline::add($obj);
154 foreach ($notes as $obj) {
155 $items[] = ['entry' => ['type' => 'note', 'actor' => $url, 'object' => $obj['id']], 'object' => $obj];
156 }
157 $this->render('microblog/profile', (string)($doc['name'] ?? $doc['preferredUsername'] ?? ''), [
158 'profile' => [
159 'url' => $url,
160 'name' => $rname = (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')),
161 'color' => htmlspecialchars($rname, ENT_QUOTES, 'UTF-8'),
162 'handle' => Remote::handle($doc),
163 'avatar' => Remote::imageUrl(self::safeUrl($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : ''))) ?: ass_url('user/noicon.png'),
164 'bio' => Html::sanitize((string)($doc['summary'] ?? '')),
165 'local' => false,
166 'profilePage' => self::safeUrl(is_string($doc['url'] ?? null) ? $doc['url'] : ''),
167 'locked' => (bool)($doc['manuallyApprovesFollowers'] ?? false),
168 'followers' => null,
169 'following' => null,
170 ],
171 'relation' => $this->relation($url),
172 'items' => $this->present($items),
173 'next' => null,
174 ]);
175 }
176
177 /**
178 * このサイトのブログのアカウント(FEDIINFOのactor)のプロフィール
179 * 記事はブログで読むので投稿は表示せず、マイクロブログからはフォロー出来ない(Social::follow)
180 */
181 private function blogActor(): never {
182 try {
183 $doc = Remote::actor(Fedi::blogActorUrl());
184 } catch (\Exception $e) {
185 $doc = null; // 公開鍵が読めない等
186 }
187 if (!$doc) $this->notFound();
188 $this->render('microblog/profile', (string)($doc['name'] ?? $doc['preferredUsername'] ?? ''), [
189 'profile' => [
190 'url' => $doc['id'],
191 'name' => $name = (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? '')),
192 'color' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'),
193 'handle' => Remote::handle($doc),
194 'avatar' => self::safeUrl(is_string($doc['icon']['url'] ?? null) ? $doc['icon']['url'] : '') ?: ass_url('user/noicon.png'),
195 'bio' => Html::sanitize((string)($doc['summary'] ?? '')),
196 'local' => true,
197 'profilePage' => '',
198 'locked' => false,
199 'followers' => null,
200 'following' => null,
201 ],
202 'relation' => ['self' => false, 'can' => false],
203 'blogActor' => true,
204 'items' => [],
205 'next' => null,
206 ]);
207 exit;
208 }
209
210 //////// 投稿のページ
211
212 /** 投稿とスレッド(/microblog/note?id=URL) */
213 public function note(array $params): void {
214 $id = (string)($_GET['id'] ?? '');
215 $obj = $this->visibleObject($id);
216 if (!$obj) $this->notFound();
217
218 // 返信先を辿る(5件まで)
219 $ancestors = [];
220 $parent = Fedi::id($obj['inReplyTo'] ?? null);
221 for ($i = 0; $i < 5 && $parent !== null; $i++) {
222 $p = $this->visibleObject($parent, $this->me !== null);
223 if (!$p) break;
224 array_unshift($ancestors, ['entry' => ['type' => 'note', 'actor' => Fedi::id($p['attributedTo']), 'object' => $p['id']], 'object' => $p]);
225 $parent = Fedi::id($p['inReplyTo'] ?? null);
226 }
227 $replies = [];
228 foreach (Timeline::replies($obj['id']) as $e) {
229 if (!Timeline::canSee($this->me, $e)) continue;
230 if ($this->me && Actor::isBlocked($this->me, $e['author'])) continue;
231 $r = Timeline::object($e['object']);
232 if ($r) $replies[] = ['entry' => $e, 'object' => $r];
233 }
234
235 $item = $this->present([['entry' => ['type' => 'note', 'actor' => Fedi::id($obj['attributedTo']), 'object' => $obj['id']], 'object' => $obj]])[0];
236 // 返信の初期値:相手と、メンションされた人
237 $mention = '';
238 if ($this->me) {
239 $handles = [];
240 $authorDoc = Remote::cachedActor($item['author']['url']);
241 if ($authorDoc && $item['author']['url'] !== Fedi::actorUrl($this->me)) $handles[] = Remote::handle($authorDoc);
242 foreach ($obj['tag'] ?? [] as $t) {
243 if (is_array($t) && ($t['type'] ?? '') === 'Mention' && ($t['href'] ?? '') !== Fedi::actorUrl($this->me) && is_string($t['name'] ?? null)) {
244 $n = str_contains(ltrim($t['name'], '@'), '@') ? $t['name'] : $t['name'].'@'.Fedi::domainOf((string)$t['href']);
245 $handles[] = '@'.ltrim($n, '@');
246 }
247 }
248 $handles = array_unique(array_map(fn($h) => preg_replace('/@'.preg_quote(Fedi::host(), '/').'$/', '', $h), $handles));
249 $mention = $handles ? implode(' ', $handles).' ' : '';
250 }
251
252 $this->render('microblog/note', Html::text((string)($obj['summary'] ?? '') ?: (string)($obj['content'] ?? ''), 30), [
253 'ancestors' => $this->present($ancestors),
254 'item' => $item,
255 'replies' => $this->present($replies),
256 'replyTo' => $obj['id'],
257 'replyText' => $mention,
258 'replyVis' => $item['visKey'] === 'direct' ? 'direct' : ($item['visKey'] === 'followers' ? 'followers' : 'public'),
259 ]);
260 }
261
262 /** 自分の投稿の編集(GET /microblog/edit?id=) */
263 public function edit(array $params): void {
264 $this->requireMe();
265 $note = Notes::get((string)($_GET['id'] ?? $_POST['id'] ?? ''));
266 if (!$note || $note['author'] !== $this->me) $this->notFound();
267 $error = '';
268 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
269 if (!$this->csrfOk()) {
270 $error = 'セッションの有効期限が切れました。もう一度送信して下さい。';
271 $note['source'] = (string)($_POST['content'] ?? $note['source']);
272 } else {
273 $res = Social::edit($this->me, $note['id'], (string)($_POST['content'] ?? ''), (string)($_POST['cw'] ?? ''));
274 if ($res->isSuccess) $this->redirect('/microblog/note?id='.rawurlencode(Notes::url($note)));
275 $error = $res->message;
276 $note['source'] = (string)($_POST['content'] ?? '');
277 $note['summary'] = (string)($_POST['cw'] ?? '');
278 }
279 }
280 $this->render('microblog/edit', '投稿の編集', ['note' => $note, 'error' => $error]);
281 }
282
283 //////// 投稿と操作
284
285 /** 投稿する(POST /microblog/post、multipart/form-data) */
286 public function post(array $params): void {
287 $this->requireMe();
288 $text = (string)($_POST['content'] ?? '');
289 $back = safe_local_path($_POST['back'] ?? '', '/microblog');
290 $error = '';
291
292 if (empty($_POST) && (int)($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
293 $error = '送信したファイルが大き過ぎます(サーバーの上限を超えました)。';
294 } else if (!$this->csrfOk()) {
295 $error = 'セッションの有効期限が切れました。もう一度送信して下さい。';
296 } else {
297 // 添付
298 $media = [];
299 $files = $_FILES['media'] ?? null;
300 if (is_array($files) && is_array($files['name'] ?? null)) {
301 foreach ($files['name'] as $i => $name) {
302 if (($files['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) continue;
303 if (count($media) >= Fedi::MAX_ATTACHMENTS) break;
304 $one = ['name' => $name, 'tmp_name' => $files['tmp_name'][$i], 'size' => $files['size'][$i], 'error' => $files['error'][$i]];
305 $res = Media::save($one, 'media/'.$this->me, video: true);
306 if (!$res->isSuccess) {
307 $error = $res->message;
308 break;
309 }
310 $media[] = $res->data;
311 }
312 }
313 if ($error === '') {
314 $res = Social::post($this->me, $text, [
315 'cw' => (string)($_POST['cw'] ?? ''),
316 'visibility' => (string)($_POST['visibility'] ?? 'public'),
317 'inReplyTo' => (string)($_POST['inReplyTo'] ?? ''),
318 'media' => $media,
319 'alts' => array_map('strval', (array)($_POST['alt'] ?? [])),
320 ]);
321 if ($res->isSuccess) $this->redirect($back);
322 $error = $res->message;
323 }
324 foreach ($media ?? [] as $m) Media::remove($m['path']);
325 }
326
327 // 失敗:入力した内容を残して表示する
328 [$items, $next] = Timeline::query('home', $this->me);
329 $this->render('microblog/timeline', 'ホーム', [
330 'scope' => 'home',
331 'heading' => 'ホーム',
332 'items' => $this->present($items),
333 'next' => null,
334 'error' => $error,
335 'draft' => $text,
336 'draftCw' => (string)($_POST['cw'] ?? ''),
337 'replyTo' => (string)($_POST['inReplyTo'] ?? '') ?: null,
338 ]);
339 }
340
341 /**
342 * いいね・ブースト・削除・フォロー・ブロック等(POST /microblog/action)
343 * do:操作、target:投稿又はアクターのURL、back:戻るページ
344 */
345 public function action(array $params): void {
346 $this->requireMe();
347 $back = safe_local_path($_POST['back'] ?? '', '/microblog');
348 if (!$this->csrfOk()) $this->message('セッションの有効期限が切れました。前のページに戻って、もう一度お試し下さい。', $back);
349
350 $do = (string)($_POST['do'] ?? '');
351 $target = trim((string)($_POST['target'] ?? ''));
352 $me = $this->me;
353 $res = match ($do) {
354 'like' => Social::like($me, $target),
355 'unlike' => Social::unlike($me, $target),
356 'boost' => Social::boost($me, $target),
357 'unboost' => Social::unboost($me, $target),
358 'delete' => ($ids = Fedi::localNoteId($target)) ? Social::delete($me, $ids[1]) : \Result::Error('投稿が見つかりません。'),
359 'follow' => Social::follow($me, $target),
360 'unfollow' => Social::unfollow($me, $target),
361 'accept' => Social::answerRequest($me, $target, true),
362 'reject' => Social::answerRequest($me, $target, false),
363 'removefollower' => Social::removeFollower($me, $target),
364 'block' => Social::block($me, $target),
365 'unblock' => Social::unblock($me, $target),
366 'blockdomain' => Social::blockDomain($me, $target, true),
367 'unblockdomain' => Social::blockDomain($me, $target, false),
368 'report' => Social::report($me, $target, array_values(array_filter([(string)($_POST['object'] ?? '')])),
369 (string)($_POST['comment'] ?? ''), isset($_POST['forward'])),
370 default => \Result::Error('不明な操作です。'),
371 };
372 // 削除した投稿のページには戻れない
373 if ($do === 'delete' && str_starts_with($back, '/microblog/note')) $back = '/microblog';
374 if (!$res->isSuccess) $this->message((string)$res->message, $back);
375 $this->redirect($back);
376 }
377
378 //////// 検索
379
380 /**
381 * 検索(/microblog/search?q=)
382 * 「@user@host」・URL:アカウントか投稿を探し、見つかればそのページへ
383 * 「#タグ」:タグのページへ
384 * それ以外:ローカルと受け取った投稿の本文を検索する
385 */
386 public function search(array $params): void {
387 $q = trim((string)($_GET['q'] ?? ''));
388 $items = [];
389 $error = '';
390 if ($q !== '') {
391 if (preg_match('/^#([\p{L}\p{N}_]+)$/u', $q, $m)) $this->redirect('/microblog/tag/'.rawurlencode(mb_strtolower($m[1])));
392
393 $isLocalHandle = (bool)preg_match('/^@[A-Za-z0-9]+$/', $q);
394 if ($isLocalHandle || preg_match('/^@?[A-Za-z0-9_.-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/', $q)) {
395 // 他のサーバーへの問い合わせはログインしたユーザーだけ
396 $url = $this->me !== null || $isLocalHandle ? Remote::resolve($q) : null;
397 if ($url !== null) $this->redirect($this->actorPage($url));
398 $error = $this->me === null ? '他のサーバーのアカウントを探すにはログインして下さい。' : 'アカウントが見つかりません。';
399 } else if (preg_match('#^https://#', $q)) {
400 if ($this->me === null) {
401 $error = '他のサーバーを検索するにはログインして下さい。';
402 } else if (($doc = Remote::actor($q)) !== null) {
403 $this->redirect($this->actorPage($doc['id']));
404 } else if (($obj = Remote::object($q)) !== null && in_array($obj['type'] ?? '', ['Note', 'Article', 'Question', 'Page'], true)) {
405 // 見つけた投稿は保存し、いいね・ブースト・返信が出来る様にする
406 if (!Fedi::isLocal($obj['id'])) {
407 Remote::store($obj);
408 Timeline::add($obj);
409 Remote::actor((string)Fedi::id($obj['attributedTo'] ?? null));
410 }
411 $this->redirect('/microblog/note?id='.rawurlencode($obj['id']));
412 } else {
413 $error = '見つかりません。';
414 }
415 } else {
416 $items = Timeline::search($q, $this->me);
417 if (empty($items)) $error = '見つかりません。';
418 }
419 }
420 $this->render('microblog/search', '検索', ['q' => $q, 'items' => $this->present($items), 'error' => $error]);
421 }
422
423 //////// 通知・ブロック・管理
424
425 public function notifications(array $params): void {
426 $this->requireMe();
427 $list = [];
428 foreach (Actor::notifications($this->me) as $n) {
429 $a = $this->who($n['actor']);
430 $obj = $n['object'] ? Timeline::object($n['object']) : null;
431 $list[] = [
432 'type' => $n['type'],
433 'label' => ['follow' => 'があなたをフォローしました', 'follow_request' => 'がフォローをリクエストしました',
434 'mention' => 'があなたをメンションしました', 'reply' => 'が返信しました',
435 'like' => 'がいいねしました', 'boost' => 'がブーストしました'][$n['type']] ?? $n['type'],
436 'who' => $a,
437 'object' => $n['object'],
438 'page' => $n['object'] ? '/microblog/note?id='.rawurlencode($n['object']) : null,
439 'excerpt' => $obj ? Html::text((string)($obj['content'] ?? ''), 80) : '',
440 'at' => date('Y-m-d H:i', $n['at']),
441 'read' => $n['read'],
442 ];
443 }
444 $requests = [];
445 foreach (Actor::requests($this->me) as $actor => $r) $requests[] = ['who' => $this->who($actor), 'actor' => $actor];
446 Actor::markRead($this->me);
447 $this->render('microblog/notifications', '通知', ['list' => $list, 'requests' => $requests]);
448 }
449
450 public function blocks(array $params): void {
451 $this->requireMe();
452 $b = Actor::blocks($this->me);
453 $this->render('microblog/blocks', 'ブロック', [
454 'actors' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], $b['actors']),
455 'domains' => $b['domains'],
456 ]);
457 }
458
459 public function follows(array $params): void {
460 $this->requireMe();
461 $following = [];
462 foreach (array_reverse(Actor::following($this->me), true) as $a => $f) {
463 $following[] = ['actor' => $a, 'who' => $this->who($a), 'pending' => ($f['state'] ?? '') !== 'accepted'];
464 }
465 $this->render('microblog/follows', 'フォロー', [
466 'scope' => 'follows',
467 'following' => $following,
468 'followers' => array_map(fn($a) => ['actor' => $a, 'who' => $this->who($a)], array_reverse(array_keys(Actor::followers($this->me)))),
469 ]);
470 }
471
472 /** 管理(サイト全体のドメインのブロック、通報)。管理者だけ */
473 public function admin(array $params): void {
474 if (!$this->user || !($this->user->role & Roles::ADMIN)) $this->notFound();
475 $error = '';
476 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
477 if (!$this->csrfOk()) {
478 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
479 } else {
480 $do = (string)($_POST['do'] ?? '');
481 $res = match ($do) {
482 'blockinstance' => Social::blockInstance((string)($_POST['domain'] ?? ''), true),
483 'unblockinstance' => Social::blockInstance((string)($_POST['domain'] ?? ''), false),
484 'resolve' => (function() { Social::resolveReport((string)($_POST['id'] ?? '')); return \Result::Success(); })(),
485 default => \Result::Error('不明な操作です。'),
486 };
487 if ($res->isSuccess) $this->redirect('/microblog/admin');
488 $error = (string)$res->message;
489 }
490 }
491 $reports = [];
492 foreach (Social::reports() as $r) {
493 $reports[] = $r + ['fromWho' => $this->who($r['from']), 'actorWho' => $this->who($r['actor']), 'at' => date('Y-m-d H:i', $r['at']),
494 'objectPages' => array_map(fn($o) => '/microblog/note?id='.rawurlencode($o), $r['objects'])];
495 }
496 $this->render('microblog/admin', 'Fediverseの管理', [
497 'domains' => Actor::blockedDomains(),
498 'reports' => $reports,
499 'queue' => Delivery::pending(),
500 'error' => $error,
501 ]);
502 }
503
504 //////// 表示用
505
506 /**
507 * タイムラインの項目をテンプレート用の配列にする
508 *
509 * @param array $items ['entry' => 行, 'object' => Note]
510 * @return array
511 */
512 private function present(array $items): array {
513 $out = [];
514 $likes = $this->me ? Fedi::read(Fedi::userPath($this->me, 'likes.json')) : [];
515 $boosts = $this->me ? Fedi::read(Fedi::userPath($this->me, 'boosts.json')) : [];
516 foreach ($items as ['entry' => $e, 'object' => $o]) {
517 $id = (string)$o['id'];
518 $authorUrl = (string)Fedi::id($o['attributedTo'] ?? null);
519 $to = Fedi::ids($o['to'] ?? []);
520 $cc = Fedi::ids($o['cc'] ?? []);
521 $vis = Fedi::visibility($to, $cc, Fedi::isLocal($authorUrl) ? $authorUrl.'/followers' : null);
522 $local = Fedi::localNoteId($id);
523 $note = $local ? Notes::get($local[1]) : null;
524
525 $attachments = [];
526 foreach ((array)($o['attachment'] ?? []) as $a) {
527 if (!is_array($a)) continue;
528 $url = self::safeUrl(is_string($a['url'] ?? null) ? $a['url'] : (string)($a['url'][0]['href'] ?? ''));
529 if ($url === '') continue;
530 $mt = (string)($a['mediaType'] ?? '');
531 $kind = str_starts_with($mt, 'video/') ? 'video' : (str_starts_with($mt, 'audio/') ? 'audio'
532 : (str_starts_with($mt, 'image/') || ($a['type'] ?? '') === 'Image' ? 'image' : 'link'));
533 $attachments[] = ['url' => $url, 'src' => $kind === 'image' ? Remote::imageUrl($url) : $url, 'alt' => (string)($a['name'] ?? ''), 'kind' => $kind];
534 }
535
536 $out[] = [
537 'id' => $id,
538 'page' => '/microblog/note?id='.rawurlencode($id),
539 'original' => self::safeUrl(is_string($o['url'] ?? null) ? $o['url'] : $id),
540 'author' => $this->who($authorUrl),
541 'booster' => ($e['type'] ?? '') === 'boost' ? $this->who($e['actor']) : null,
542 'content' => $this->localMentionLinks(Html::sanitize((string)($o['content'] ?? '')), $o),
543 'summary' => (string)($o['summary'] ?? ''),
544 'sensitive' => (bool)($o['sensitive'] ?? false) || ($o['summary'] ?? '') !== '',
545 'attachments' => $attachments,
546 'published' => date('Y-m-d H:i', strtotime((string)($o['published'] ?? '')) ?: time()),
547 'edited' => !empty($o['updated']),
548 'visKey' => $vis,
549 'vis' => Social::VISIBILITIES[$vis],
550 'reply' => ($r = Fedi::id($o['inReplyTo'] ?? null)) ? '/microblog/note?id='.rawurlencode($r) : null,
551 'likes' => $note ? count($note['likes']) : null,
552 'boosts' => $note ? count($note['boosts']) : null,
553 'replies' => $note ? count($note['replies']) : null,
554 'liked' => isset($likes[$id]),
555 'boosted' => isset($boosts[$id]),
556 'canBoost' => in_array($vis, ['public', 'unlisted'], true),
557 'mine' => $this->me !== null && $authorUrl === Fedi::actorUrl($this->me),
558 'editUrl' => $note ? '/microblog/edit?id='.$note['id'] : null,
559 ];
560 }
561 return $out;
562 }
563
564 /**
565 * リモートの画像(/microblog/media?u=URL&s=署名)
566 * CSPのimg-srcで他のサーバーの画像は表示できないので、このサイトから出す
567 */
568 public function media(array $params): void {
569 $url = (string)($_GET['u'] ?? '');
570 $img = $url !== '' && hash_equals(Remote::imageSig($url), (string)($_GET['s'] ?? '')) ? Remote::image($url) : null;
571 if ($img === null) {
572 header('HTTP/1.1 404 Not Found');
573 exit;
574 }
575 header('Content-Type: '.$img['type']);
576 header('Content-Length: '.filesize($img['path']));
577 header('Cache-Control: public, max-age=86400');
578 header("Content-Security-Policy: default-src 'none'");
579 readfile($img['path']);
580 exit;
581 }
582
583 /**
584 * アクターの表示用の情報(キャッシュだけ使う。取得しない)
585 * color:namecolor()のHTML(リモートのアクターはエスケープした名前)
586 */
587 private function who(string $url): array {
588 $local = Fedi::localUsername($url);
589 if ($local !== null) {
590 $u = Fedi::localUser($local);
591 return ['url' => $url, 'name' => $u ? ($u->displayname ?: $u->username) : $local,
592 'color' => $u ? namecolor($u) : htmlspecialchars($local, ENT_QUOTES, 'UTF-8'), 'handle' => "@{$local}",
593 'avatar' => $u ? avatar_url($u) : ass_url('user/noicon.png'), 'page' => "/microblog/user/{$local}"];
594 }
595 $doc = Remote::cachedActor($url);
596 $handle = $doc ? Remote::handle($doc) : '@'.basename((string)parse_url($url, PHP_URL_PATH)).'@'.Fedi::domainOf($url);
597 $icon = $doc ? ($doc['icon']['url'] ?? (is_string($doc['icon'] ?? null) ? $doc['icon'] : '')) : '';
598 return [
599 'url' => $url,
600 'name' => $name = $doc ? (string)(($doc['name'] ?? '') ?: ($doc['preferredUsername'] ?? $handle)) : $handle,
601 'color' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'),
602 'handle' => $handle,
603 'avatar' => Remote::imageUrl(self::safeUrl(is_string($icon) ? $icon : '')) ?: ass_url('user/noicon.png'),
604 'page' => '/microblog/remote?actor='.rawurlencode($url),
605 ];
606 }
607
608 /**
609 * 本文のメンションのリンクを、相手のサーバーではなくこのサイトのプロフィールのページ(/microblog/remote等)にする
610 * 投稿のtag(Mention)でアクターが分かるリンクだけ。ログインしていなければ他のサーバーに問い合わせないので、
611 * キャッシュしているアクターだけ(それ以外は元のリンクのまま)
612 *
613 * @param string $html 安全にした本文
614 * @param array $o 投稿(Note等)
615 * @return string
616 */
617 private function localMentionLinks(string $html, array $o): string {
618 $actors = [];
619 foreach ((array)($o['tag'] ?? []) as $t) {
620 if (!is_array($t) || ($t['type'] ?? '') !== 'Mention' || !is_string($t['href'] ?? null)) continue;
621 $actors[$t['href']] = $t['href'];
622 // Mastodon・Misskey等はリンクにプロフィールのURL(https://host/@user)を使うので、サーバーとユーザー名でも探す
623 $name = strtolower(explode('@', ltrim((string)($t['name'] ?? ''), '@'))[0]);
624 if ($name !== '') $actors[Fedi::domainOf($t['href']).'/'.$name] ??= $t['href'];
625 }
626 if (empty($actors)) return $html;
627
628 return preg_replace_callback('#<a href="([^"]*)" class="([^"]*)"#', function(array $m) use ($actors): string {
629 $classes = explode(' ', $m[2]);
630 if (!in_array('mention', $classes, true) || in_array('hashtag', $classes, true)) return $m[0];
631 $href = html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
632 $user = strtolower(ltrim(basename((string)parse_url($href, PHP_URL_PATH)), '@'));
633 $actor = $actors[$href] ?? $actors[Fedi::domainOf($href).'/'.$user] ?? null;
634 if ($actor === null) return $m[0];
635 if ($this->me === null && !Fedi::isLocal($actor) && Remote::cachedActor($actor) === null) return $m[0];
636 return '<a href="'.Html::esc($this->actorPage($actor)).'" class="'.$m[2].'"';
637 }, $html) ?? $html;
638 }
639
640 /** ログイン中のユーザーとアクターの関係 */
641 private function relation(string $url): array {
642 if ($this->me === null) return ['self' => false, 'can' => false];
643 $f = Actor::following($this->me)[$url] ?? null;
644 return [
645 'self' => $url === Fedi::actorUrl($this->me),
646 'can' => true,
647 'following' => ($f['state'] ?? '') === 'accepted',
648 'pending' => ($f['state'] ?? '') === 'pending',
649 'followsYou' => Actor::isFollowedBy($this->me, $url),
650 'blocked' => in_array($url, Actor::blocks($this->me)['actors'], true),
651 'domain' => Fedi::domainOf($url),
652 'remote' => !Fedi::isLocal($url),
653 ];
654 }
655
656 /**
657 * 見る事が出来るオブジェクト(ローカル又は保存しているもの。$fetchなら取得もする)
658 */
659 private function visibleObject(string $id, bool $fetch = false): ?array {
660 if ($id === '') return null;
661 $obj = Timeline::object($id);
662 if (!$obj && $fetch && !Fedi::isLocal($id) && Remote::isSafeUrl($id)) {
663 $obj = Remote::object($id);
664 if ($obj && in_array(Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? [])), ['public', 'unlisted'], true)) {
665 Remote::store($obj);
666 Timeline::add($obj);
667 } else {
668 $obj = null;
669 }
670 }
671 if (!$obj) return null;
672 $author = (string)Fedi::id($obj['attributedTo'] ?? null);
673 $e = ['vis' => Fedi::visibility(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []), Fedi::isLocal($author) ? $author.'/followers' : null),
674 'author' => $author, 'aud' => array_merge(Fedi::ids($obj['to'] ?? []), Fedi::ids($obj['cc'] ?? []))];
675 return Timeline::canSee($this->me, $e) ? $obj : null;
676 }
677
678 /**
679 * リモートのアクターの最近の投稿(送信ボックスの最初のページ、公開のNoteだけ)
680 */
681 private function actorPage(string $url): string {
682 $local = Fedi::localUsername($url);
683 return $local !== null ? "/microblog/user/{$local}" : '/microblog/remote?actor='.rawurlencode($url);
684 }
685
686 private static function safeUrl(string $url): string {
687 return preg_match('#^https?://[^\s"<>]+$#', $url) ? $url : '';
688 }
689
690 private function before(): ?float {
691 return isset($_GET['before']) && is_numeric($_GET['before']) ? (float)$_GET['before'] : null;
692 }
693
694 private function csrfOk(): bool {
695 return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']);
696 }
697
698 private function requireMe(): void {
699 if ($this->me !== null) return;
700 if ($this->user === null) $this->redirect('/login');
701 $this->message('Fediverseの機能を使う権限がありません。', '/microblog/local');
702 }
703
704 private function redirect(string $to): never {
705 header('Location: '.$to);
706 exit;
707 }
708
709 private function notFound(): never {
710 header('Location: /404');
711 exit;
712 }
713
714 private function message(string $msg, string $back): never {
715 $this->render('microblog/message', 'マイクロブログ', ['msg' => $msg, 'back' => $back]);
716 exit;
717 }
718
719 private function render(string $view, string $title, array $vars): void {
720 $tmpl = new Template('/');
721 $tmpl->assign('pagetit', $title);
722 $tmpl->assign('curPage', 'microblog');
723 $tmpl->assign('menu', $this->getMenu());
724 $tmpl->assign('support', $this->getSupport());
725 $tmpl->assign('sns', $this->getSns());
726 $tmpl->assign('description', SITEINFO['description']);
727 $tmpl->assign('user', $this->user);
728 $tmpl->assign('me', $this->me);
729 $tmpl->assign('unread', $this->me ? Actor::unreadCount($this->me) : 0);
730 $tmpl->assign('isAdmin', $this->user && ($this->user->role & Roles::ADMIN));
731 $tmpl->assign('here', safe_local_path($_SERVER['REQUEST_URI'] ?? '', '/microblog'));
732 $tmpl->assign('visibilities', Social::VISIBILITIES);
733 $tmpl->assign('maxLength', Fedi::MAX_LENGTH);
734 $tmpl->assign('maxMedia', Fedi::MAX_ATTACHMENTS);
735 // クッキーはテンプレートの出力の前に設定する
736 $tmpl->assign('csrf', make_csrf_token());
737 foreach (['error' => '', 'draft' => '', 'draftCw' => '', 'replyTo' => null, 'replyText' => '', 'replyVis' => 'public', 'scope' => ''] as $k => $v) {
738 $tmpl->assign($k, $vars[$k] ?? $v);
739 }
740 foreach ($vars as $k => $v) $tmpl->assign($k, $v);
741 $tmpl->addCss('microblog');
742 $tmpl->render($view);
743 }
744}