master
ブランチ master
LittleBeast / src / Std / Lib / Comments.php
27.4 KB、626行 生 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 Std\Lib;
39
40use Std\Lib\Fedi\Fedi;
41use Std\Lib\Fedi\Html;
42use Std\Lib\Fedi\Notes;
43use Std\Lib\Fedi\Remote;
44use Std\Lib\Fedi\Social;
45use Result;
46use Roles;
47
48/**
49 * 記事のコメント(ローカルのユーザーと、Fediverseからの返信)
50 *
51 * スレッドは記事毎に1つ。名前は記事のMarkdownのパスから「.md」を除いた物(「blog/記事」「tutorial/科目/記事」等)で、
52 * 記事のURLと同じ。データは全部data/の下のJSONファイル:
53 * comments/{スレッド}.json コメント(古い順)
54 * comments/ap.json Fediverseの返信のID => スレッドとコメント(返信への返信・編集・削除の為)
55 * 通知はNotifications(data/notifications/)。
56 *
57 * 新しいコメントは全部の管理者に通知する。ローカルのユーザーには、自分のコメントへの返信といいねを通知する。
58 * マルチユーザーモードでFediverseのロールを持つユーザーのコメントは、連合する記事なら本人のアカウントの投稿
59 * (記事又はコメントへの返信)としてもFediverseに送る。コメントを消すと投稿も消え、マイクロブログで編集・削除すると
60 * コメントも変わる。
61 */
62final class Comments {
63 /** コメントの最大文字数 */
64 public const MAX_LENGTH = 5000;
65 /** 返信の字下げの最大の深さ(これより深い返信は同じ深さで表示する) */
66 public const MAX_DEPTH = 5;
67
68 /** @var string|null 保存先(テスト用。既定はdata) */
69 public static ?string $dataDir = null;
70
71 /**
72 * コメント機能が有効か(config.phpのCOMMENTS_ENABLED)
73 */
74 public static function enabled(): bool {
75 return defined('COMMENTS_ENABLED') && COMMENTS_ENABLED;
76 }
77
78 /**
79 * スレッドの名前が正しいか(「ディレクトリ/記事」又は「ディレクトリ/科目/記事」)
80 */
81 public static function validThread(string $thread): bool {
82 return (bool)preg_match('#^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*){1,2}$#', $thread);
83 }
84
85 /**
86 * コメント出来るユーザーか(ログイン中、BANされていない、メールアドレス確認済み)
87 */
88 public static function canPost(?\stdClass $user): bool {
89 return $user !== null && !empty($user->username) && ($user->role ?? Roles::BANNED) !== Roles::BANNED && ($user->verified ?? true) !== false;
90 }
91
92 /**
93 * 管理者(コメントを削除出来る)か
94 */
95 public static function isModerator(?\stdClass $user): bool {
96 return $user !== null && (bool)(($user->role ?? 0) & (Roles::ADMIN | Roles::STAFF));
97 }
98
99 //////// 読む
100
101 /**
102 * スレッドのコメント(古い順、削除済みを含む)
103 *
104 * @param string $thread スレッド
105 * @return array ID => コメント
106 */
107 public static function all(string $thread): array {
108 if (!self::validThread($thread)) return [];
109 return Fedi::read(self::threadPath($thread), ['comments' => []])['comments'] ?? [];
110 }
111
112 /**
113 * 削除されていないコメントの数
114 */
115 public static function count(string $thread): int {
116 return count(array_filter(self::all($thread), fn($c) => empty($c['deleted'])));
117 }
118
119 /**
120 * 表示用のコメント(返信は親の下、深さ付き)
121 *
122 * @param string $thread スレッド
123 * @param \stdClass|null $viewer 見ているユーザー
124 * @return array コメントの配列(id、depth、html、who、at、likes、liked、canLike、canReply、canDelete、deleted)
125 */
126 public static function view(string $thread, ?\stdClass $viewer): array {
127 $all = self::all($thread);
128 $children = [];
129 foreach ($all as $id => $c) {
130 $parent = $c['parent'] ?? null;
131 // 親が無い(壊れた)返信は一番上に表示する
132 $children[$parent !== null && isset($all[$parent]) ? $parent : ''][] = $id;
133 }
134
135 $out = [];
136 $canPost = self::canPost($viewer);
137 $walk = function(string $parent, int $depth) use (&$walk, &$out, $all, $children, $viewer, $canPost): void {
138 foreach ($children[$parent] ?? [] as $id) {
139 $c = $all[$id];
140 $deleted = !empty($c['deleted']);
141 $likes = (array)($c['likes'] ?? []);
142 $mine = $viewer !== null && ($c['author']['username'] ?? null) === ($viewer->username ?? '');
143 $out[] = [
144 'id' => $id,
145 'depth' => min($depth, self::MAX_DEPTH),
146 'deleted' => $deleted,
147 'html' => $deleted ? '' : (string)($c['html'] ?? ''),
148 'who' => self::who($c['author'] ?? []),
149 'at' => date('Y-m-d H:i', (int)($c['at'] ?? 0)),
150 'edited' => !$deleted && !empty($c['edited']),
151 'likes' => count($likes),
152 'liked' => $viewer !== null && isset($likes[$viewer->username ?? '']),
153 'canLike' => $canPost && !$deleted && !$mine,
154 'canReply' => $canPost && !$deleted,
155 'canDelete' => !$deleted && $viewer !== null && ($mine || self::isModerator($viewer)),
156 ];
157 $walk($id, $depth + 1);
158 }
159 };
160 $walk('', 0);
161 return $out;
162 }
163
164 /**
165 * Fediverseにある、記事又はコメントへの直接の返信のID(古い順、削除済みを除く)
166 * Fediverseから受け取った返信と、Fediverseのロールを持つユーザーのコメント(本人の投稿)。他のコメントは含まない
167 *
168 * @param string $thread スレッド
169 * @param string|null $parent コメントのID(記事への返信ならnull)
170 * @return array 投稿のID
171 */
172 public static function fediReplies(string $thread, ?string $parent = null): array {
173 $out = [];
174 foreach (self::all($thread) as $c) {
175 if (empty($c['deleted']) && isset($c['apId']) && ($c['parent'] ?? null) === $parent) $out[] = $c['apId'];
176 }
177 return $out;
178 }
179
180 /**
181 * Fediverseの返信のIDからコメントを探す
182 *
183 * @param string $apId リモートの投稿のID
184 * @return array|null [スレッド, コメントのID, 記事のタイトル]
185 */
186 public static function findRemote(string $apId): ?array {
187 $ref = Fedi::read(self::path('comments/ap.json'))[$apId] ?? null;
188 if (!is_array($ref) || !isset(self::all($ref['thread'])[$ref['id']])) return null;
189 $title = Fedi::read(self::threadPath($ref['thread']), [])['title'] ?? '';
190 return [$ref['thread'], $ref['id'], $title];
191 }
192
193 //////// 書く
194
195 /**
196 * ローカルのユーザーのコメント(本文はMarkdown)
197 *
198 * @param string $thread スレッド
199 * @param string $title 記事のタイトル(通知用)
200 * @param \stdClass $user コメントするユーザー
201 * @param string $text 本文
202 * @param string|null $parent 返信先のコメントのID
203 * @param array|null $postNote 記事のNote(連合する記事の場合。Fediverseのロールを持つユーザーなら投稿としても送る)
204 * @return Result data:id
205 */
206 public static function addLocal(string $thread, string $title, \stdClass $user, string $text, ?string $parent = null, ?array $postNote = null): Result {
207 if (!self::enabled()) return Result::Error('エラー:コメントは無効です。');
208 if (!self::canPost($user)) return Result::Error('エラー:コメントする権限がありません。');
209 $text = trim(str_replace(["\r\n", "\r"], "\n", $text));
210 if ($text === '') return Result::Error('エラー:コメントをご入力下さい。');
211 if (mb_strlen($text) > self::MAX_LENGTH) return Result::Error('エラー:コメントは'.self::MAX_LENGTH.'文字以内でご入力下さい。');
212
213 $res = self::add($thread, $title, [
214 'author' => ['username' => $user->username],
215 'source' => $text,
216 'html' => Html::render($text, [], false),
217 ], $parent);
218 if ($res->isSuccess && $postNote !== null) self::federateLocal($thread, $res->data['id'], $user->username, $text, $postNote);
219 return $res;
220 }
221
222 /**
223 * Fediverseからの返信(受付ボックスで検証済みの事)
224 *
225 * @param string $thread スレッド
226 * @param string $title 記事のタイトル
227 * @param array $actor 返信したアクター
228 * @param array $obj 返信(Note等)
229 * @param string|null $parent 返信先のコメントのID(記事への返信ならnull)
230 * @return Result data:id
231 */
232 public static function addRemote(string $thread, string $title, array $actor, array $obj, ?string $parent = null): Result {
233 if (!self::enabled()) return Result::Error('エラー:コメントは無効です。');
234 $apId = (string)($obj['id'] ?? '');
235 if ($apId === '') return Result::Error('エラー:IDがありません。');
236 // 同じ返信が2回届いても(再送等)1つにする
237 $known = self::findRemote($apId);
238 if ($known !== null) return Result::Success('', ['id' => $known[1]]);
239
240 $res = self::add($thread, $title, [
241 'author' => self::remoteAuthor($actor),
242 'html' => self::remoteHtml($obj),
243 'apId' => $apId,
244 ], $parent);
245 if ($res->isSuccess) {
246 Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apId, $thread, $res, $actor): array {
247 $l[$apId] = ['thread' => $thread, 'id' => $res->data['id'], 'actor' => (string)($actor['id'] ?? '')];
248 return $l;
249 });
250 }
251 return $res;
252 }
253
254 /**
255 * Fediverseで編集された返信を更新する
256 *
257 * @param array $obj 返信(Note等)
258 * @param string $actor アクティビティのアクター
259 * @return bool 更新したらtrue
260 */
261 public static function updateRemote(array $obj, string $actor): bool {
262 if (!self::enabled()) return false;
263 $id = Fedi::id($obj['id'] ?? null);
264 $ref = $id !== null ? self::remoteRef($id) : null;
265 if ($ref === null || $ref['actor'] !== $actor || Fedi::id($obj['attributedTo'] ?? null) !== $actor) return false;
266 $html = self::remoteHtml($obj);
267 return self::modify($ref['thread'], function(array $t) use ($ref, $html): array {
268 if (isset($t['comments'][$ref['id']]) && empty($t['comments'][$ref['id']]['deleted'])) {
269 $t['comments'][$ref['id']]['html'] = $html;
270 $t['comments'][$ref['id']]['edited'] = time();
271 }
272 return $t;
273 });
274 }
275
276 /**
277 * Fediverseで削除された返信を消す
278 *
279 * @param string $apId 返信のID
280 * @param string $actor アクティビティのアクター(返信した本人だけ消せる)
281 * @return bool 消したらtrue
282 */
283 public static function deleteRemote(string $apId, string $actor): bool {
284 if (!self::enabled()) return false;
285 $ref = self::remoteRef($apId);
286 if ($ref === null || $ref['actor'] !== $actor) return false;
287 self::remove($ref['thread'], $ref['id']);
288 return true;
289 }
290
291 /**
292 * 削除されたアカウントの返信を全部消す
293 *
294 * @param string $actor アクターのURL
295 * @return int 消した数
296 */
297 public static function forgetActor(string $actor): int {
298 $n = 0;
299 foreach (Fedi::read(self::path('comments/ap.json')) as $apId => $ref) {
300 if (($ref['actor'] ?? '') === $actor && self::deleteRemote((string)$apId, $actor)) $n++;
301 }
302 return $n;
303 }
304
305 /**
306 * いいね(もう一度でいいねを取り消す)
307 *
308 * @param string $thread スレッド
309 * @param string $id コメントのID
310 * @param \stdClass $user いいねするユーザー
311 * @return Result data:liked(いいねした状態ならtrue)
312 */
313 public static function like(string $thread, string $id, \stdClass $user): Result {
314 if (!self::enabled()) return Result::Error('エラー:コメントは無効です。');
315 if (!self::canPost($user)) return Result::Error('エラー:いいねする権限がありません。');
316 $c = self::all($thread)[$id] ?? null;
317 if ($c === null || !empty($c['deleted'])) return Result::Error('エラー:コメントが見つかりません。');
318 $author = $c['author']['username'] ?? null;
319 if ($author === $user->username) return Result::Error('エラー:自分のコメントにはいいね出来ません。');
320
321 $liked = false;
322 self::modify($thread, function(array $t) use ($id, $user, &$liked): array {
323 if (!isset($t['comments'][$id])) return $t;
324 $likes = (array)($t['comments'][$id]['likes'] ?? []);
325 if (isset($likes[$user->username])) unset($likes[$user->username]);
326 else $likes[$user->username] = time();
327 $liked = isset($likes[$user->username]);
328 $t['comments'][$id]['likes'] = $likes;
329 return $t;
330 });
331
332 if ($liked && $author !== null) {
333 $title = Fedi::read(self::threadPath($thread), [])['title'] ?? '';
334 self::notify($author, 'like', $thread, $id, $title, ['username' => $user->username]);
335 }
336 return Result::Success('', ['liked' => $liked]);
337 }
338
339 /**
340 * コメントを削除する(本人又は管理者)
341 * 返信があるコメントは「削除されました」として残し、返信が無ければ完全に消す
342 *
343 * @param string $thread スレッド
344 * @param string $id コメントのID
345 * @param \stdClass $user 削除するユーザー
346 * @return Result
347 */
348 public static function delete(string $thread, string $id, \stdClass $user): Result {
349 if (!self::enabled()) return Result::Error('エラー:コメントは無効です。');
350 $c = self::all($thread)[$id] ?? null;
351 if ($c === null || !empty($c['deleted'])) return Result::Error('エラー:コメントが見つかりません。');
352 $mine = ($c['author']['username'] ?? null) === ($user->username ?? '');
353 if (!$mine && !self::isModerator($user)) return Result::Error('エラー:権限がありません。');
354 self::remove($thread, $id);
355 // Fediverseにも投稿したコメントは、投稿も消す(コメントは先に消したので、Social::deleteからは何もしない)
356 $note = isset($c['apId']) && isset($c['author']['username']) ? Fedi::localNoteId($c['apId']) : null;
357 if ($note !== null && $note[0] === $c['author']['username'] && Fedi::enabled()) Social::delete($note[0], $note[1]);
358 return Result::Success();
359 }
360
361 //////// 通知
362
363 /**
364 * 通知(Notifications)の表示用のデータ
365 *
366 * @param array $n 通知(コメントはtype:comment、reply、like、thread、comment、title。他はlabel・page・excerpt)
367 * @return array who(表示名等)、label、page(コメントのURL)、excerpt、at、read
368 */
369 public static function present(array $n): array {
370 // コメント以外(Gitler等)は追加した時の文
371 if (isset($n['label'], $n['page'])) {
372 return ['type' => $n['type'] ?? '', 'who' => self::who((array)($n['from'] ?? [])), 'label' => (string)$n['label'], 'page' => (string)$n['page'],
373 'excerpt' => (string)($n['excerpt'] ?? ''), 'at' => date('Y-m-d H:i', (int)($n['at'] ?? 0)), 'read' => !empty($n['read'])];
374 }
375 $thread = (string)($n['thread'] ?? '');
376 $c = self::all($thread)[$n['comment'] ?? ''] ?? null;
377 $title = (string)($n['title'] ?? '');
378 return [
379 'type' => $n['type'] ?? '',
380 'who' => self::who((array)($n['from'] ?? [])),
381 'label' => match ($n['type'] ?? '') {
382 'comment' => 'が「'.($title !== '' ? $title : $thread).'」にコメントしました',
383 'reply' => 'があなたのコメントに返信しました',
384 'like' => 'があなたのコメントにいいねしました',
385 default => '',
386 },
387 'page' => '/'.$thread.'#c-'.rawurlencode((string)($n['comment'] ?? '')),
388 // ブロック(コード、リスト等)の間は空ける
389 'excerpt' => $c !== null && empty($c['deleted'])
390 ? Html::text(preg_replace('#</(?:pre|li|blockquote|summary)>#i', '$0 ', (string)($c['html'] ?? '')), 80) : '(削除されました)',
391 'at' => date('Y-m-d H:i', (int)($n['at'] ?? 0)),
392 'read' => !empty($n['read']),
393 ];
394 }
395
396 //////// 内部
397
398 /**
399 * コメントを追加し、通知する
400 *
401 * @param array $c author、html、source又はapId
402 * @return Result data:id
403 */
404 private static function add(string $thread, string $title, array $c, ?string $parent): Result {
405 if (!self::validThread($thread)) return Result::Error('エラー:不正なスレッドです。');
406 $parent = $parent === '' ? null : $parent;
407 $all = self::all($thread);
408 if ($parent !== null && (!isset($all[$parent]) || !empty($all[$parent]['deleted']))) {
409 return Result::Error('エラー:返信先のコメントが見つかりません。');
410 }
411
412 $id = Fedi::newId();
413 $c = ['id' => $id, 'parent' => $parent, 'at' => time()] + $c + ['likes' => []];
414 $ok = self::modify($thread, function(array $t) use ($id, $c, $title): array {
415 $t['title'] = $title !== '' ? $title : ($t['title'] ?? '');
416 $t['comments'][$id] = $c;
417 return $t;
418 });
419 if (!$ok) return Result::Error('エラー:コメントの保存に失敗しました。');
420
421 // 返信先がローカルのユーザーなら「返信」、管理者には「コメント」(両方なら返信だけ)
422 $from = $c['author'];
423 $me = $from['username'] ?? null;
424 $to = [];
425 $parentAuthor = $parent !== null ? ($all[$parent]['author']['username'] ?? null) : null;
426 if ($parentAuthor !== null && $parentAuthor !== $me) $to[$parentAuthor] = 'reply';
427 foreach (self::admins() as $admin) {
428 if ($admin !== $me && !isset($to[$admin])) $to[$admin] = 'comment';
429 }
430 foreach ($to as $u => $type) self::notify($u, $type, $thread, $id, $title, $from);
431
432 return Result::Success('', ['id' => $id]);
433 }
434
435 /**
436 * Fediverseのロールを持つユーザーのコメントを、本人の投稿(記事又はFediverseにある一番近い親コメントへの返信)として送る
437 * 失敗してもコメントはそのまま(ログに残す)
438 */
439 private static function federateLocal(string $thread, string $id, string $username, string $text, array $postNote): void {
440 if (!Fedi::enabled() || Fedi::localActor($username) === null || !is_string($postNote['id'] ?? null)) return;
441 $all = self::all($thread);
442 $inReplyTo = $postNote['id'];
443 for ($x = $all[$id]['parent'] ?? null; $x !== null && isset($all[$x]); $x = $all[$x]['parent'] ?? null) {
444 if (isset($all[$x]['apId'])) {
445 $inReplyTo = $all[$x]['apId'];
446 break;
447 }
448 }
449
450 try {
451 $res = Social::post($username, $text, ['visibility' => 'public', 'inReplyTo' => $inReplyTo, 'parentObject' => $postNote]);
452 } catch (\Throwable $e) {
453 $res = Result::Error($e->getMessage());
454 }
455 if (!$res->isSuccess) {
456 logger(\LogType::ActivityPub, "コメント「{$thread}#{$id}」をFediverseに送れませんでした:{$res->message}");
457 return;
458 }
459
460 $apId = Notes::url($res->data['note']);
461 self::modify($thread, function(array $t) use ($id, $apId): array {
462 if (isset($t['comments'][$id])) $t['comments'][$id]['apId'] = $apId;
463 return $t;
464 });
465 Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apId, $thread, $id, $username): array {
466 $l[$apId] = ['thread' => $thread, 'id' => $id, 'actor' => Fedi::actorUrl($username)];
467 return $l;
468 });
469 }
470
471 /**
472 * コメントを消す(返信があれば中身だけ消す)。中身だけ消したコメントは、返信が無くなれば完全に消す
473 */
474 private static function remove(string $thread, string $id): void {
475 $apIds = [];
476 self::modify($thread, function(array $t) use ($id, &$apIds): array {
477 $all = &$t['comments'];
478 if (!isset($all[$id])) return $t;
479 $hasChildren = function(string $x) use (&$all): bool {
480 foreach ($all as $c) if (($c['parent'] ?? null) === $x) return true;
481 return false;
482 };
483
484 if ($hasChildren($id)) {
485 if (isset($all[$id]['apId'])) $apIds[] = $all[$id]['apId'];
486 $all[$id] = ['id' => $id, 'parent' => $all[$id]['parent'] ?? null, 'at' => $all[$id]['at'] ?? 0, 'author' => [], 'deleted' => true];
487 return $t;
488 }
489 for ($x = $id; $x !== null && isset($all[$x]) && ($x === $id || (!empty($all[$x]['deleted']) && !$hasChildren($x)));) {
490 $parent = $all[$x]['parent'] ?? null;
491 if (isset($all[$x]['apId'])) $apIds[] = $all[$x]['apId'];
492 unset($all[$x]);
493 $x = $parent;
494 }
495 return $t;
496 });
497 if ($apIds) {
498 Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apIds): array {
499 foreach ($apIds as $a) unset($l[$a]);
500 return $l;
501 });
502 }
503 }
504
505 /**
506 * スレッドのファイルをロックして書き換える
507 *
508 * @param callable $fn スレッド(title、comments)を受け取り、新しいスレッドを返す
509 * @return bool 保存出来たらtrue
510 */
511 private static function modify(string $thread, callable $fn): bool {
512 if (!self::validThread($thread)) return false;
513 $path = self::threadPath($thread);
514 $new = Fedi::update($path, function(array $t) use ($fn): array {
515 $t += ['title' => '', 'comments' => []];
516 return $fn($t);
517 }, ['title' => '', 'comments' => []]);
518 return is_array($new) && is_file($path);
519 }
520
521 private static function notify(string $username, string $type, string $thread, string $comment, string $title, array $from): void {
522 // 同じいいねを(取り消して又いいねしても)重複させない
523 Notifications::add($username, ['type' => $type, 'thread' => $thread, 'comment' => $comment, 'title' => $title, 'from' => $from],
524 fn(array $n) => ($n['type'] ?? '') === $type && ($n['comment'] ?? '') === $comment && ($n['from'] ?? []) === $from);
525 }
526
527 /**
528 * 管理者のユーザー名
529 */
530 private static function admins(): array {
531 $dir = Fedi::$userDir ?? ROOT.'/data/user/';
532 $out = [];
533 foreach (scandir($dir) ?: [] as $f) {
534 if (!preg_match('/^\d+\.([A-Za-z0-9]+)\.json$/', $f, $m)) continue;
535 $u = json_decode((string)file_get_contents($dir.$f));
536 if ($u instanceof \stdClass && (($u->role ?? 0) & Roles::ADMIN)) $out[] = $m[1];
537 }
538 return $out;
539 }
540
541 /**
542 * 表示用の名前等(ローカルのユーザー又はリモートのアクター)
543 *
544 * @param array $author username、又はactor・name・handle・url・icon
545 * @return array name(HTML)、handle、url、avatar、remote
546 */
547 private static function who(array $author): array {
548 if (isset($author['username'])) {
549 $name = (string)$author['username'];
550 $u = Fedi::localUser($name);
551 return [
552 'name' => $u ? namecolor($u) : Html::esc($name),
553 'handle' => '',
554 'url' => AUTH_ENABLED ? '/profile/'.rawurlencode($name) : '',
555 'avatar' => $u ? avatar_url($u) : ass_url('user/noicon.png'),
556 'remote' => false,
557 ];
558 }
559 if (empty($author)) return ['name' => '', 'handle' => '', 'url' => '', 'avatar' => ass_url('user/noicon.png'), 'remote' => false];
560 $icon = (string)($author['icon'] ?? '');
561 return [
562 'name' => Html::esc((string)($author['name'] ?? '')),
563 'handle' => (string)($author['handle'] ?? ''),
564 'url' => (string)($author['url'] ?? ''),
565 // リモートの画像は直接読み込まない(閲覧者のIPアドレスが渡る)。マルチユーザーではこのサイト経由で表示する
566 'avatar' => $icon !== '' && Fedi::enabled() ? Remote::imageUrl($icon) : ass_url('user/noicon.png'),
567 'remote' => true,
568 ];
569 }
570
571 /**
572 * リモートのアクターから保存する情報(表示用)
573 */
574 private static function remoteAuthor(array $actor): array {
575 $url = fn(mixed $u): string => is_string($u) && preg_match('#^https?://[^\s"<>]+$#i', $u) ? $u : '';
576 $icon = $actor['icon'] ?? null;
577 if (is_array($icon) && array_is_list($icon)) $icon = $icon[0] ?? null;
578 if (is_array($icon)) $icon = $icon['url'] ?? null;
579 if (is_array($icon)) $icon = $icon['href'] ?? null;
580 $id = (string)($actor['id'] ?? '');
581 $profile = is_array($actor['url'] ?? null) ? Fedi::id($actor['url']) : ($actor['url'] ?? null);
582 return [
583 'actor' => $id,
584 'name' => mb_substr(trim(strip_tags((string)(($actor['name'] ?? '') ?: ($actor['preferredUsername'] ?? '')))), 0, 100) ?: Remote::handle($actor),
585 'handle' => Remote::handle($actor),
586 'url' => $url($profile) ?: $url($id),
587 'icon' => $url($icon),
588 ];
589 }
590
591 /**
592 * リモートの返信の本文(安全なHTML)。先頭のブログのアクターへのメンションは外す
593 */
594 private static function remoteHtml(array $obj): string {
595 $content = $obj['content'] ?? '';
596 if (!is_string($content) && is_array($obj['contentMap'] ?? null)) $content = (string)(array_values($obj['contentMap'])[0] ?? '');
597 $html = Html::sanitize(mb_substr((string)$content, 0, 50000));
598
599 $blog = array_map(fn($u) => preg_quote(Html::esc($u), '#'), [Fedi::blogActorUrl(), Fedi::base(), Fedi::base().'/']);
600 $html = preg_replace('#^(<p>)?(?:<span class="h-card">)?<a href="(?:'.implode('|', $blog).')"[^>]*>@?(?:<span>)?[^<]*(?:</span>)?</a>(?:</span>)?\s*#u', '$1', $html) ?? $html;
601 $html = preg_replace('#^<p>\s*</p>#', '', $html) ?? $html;
602
603 $summary = trim((string)($obj['summary'] ?? ''));
604 if ($summary !== '') $html = '<details><summary>'.Html::esc(Html::text($summary, 200)).'</summary>'.$html.'</details>';
605 return $html;
606 }
607
608 private static function remoteRef(string $apId): ?array {
609 $ref = Fedi::read(self::path('comments/ap.json'))[$apId] ?? null;
610 return is_array($ref) && isset($ref['thread'], $ref['id'], $ref['actor']) ? $ref : null;
611 }
612
613 private static function threadPath(string $thread): string {
614 return self::path("comments/{$thread}.json");
615 }
616
617 /**
618 * data/の下のパス(ディレクトリは作る)
619 */
620 private static function path(string $rel): string {
621 $path = rtrim(self::$dataDir ?? ROOT.'/data', '/').'/'.$rel;
622 $dir = dirname($path);
623 if (!is_dir($dir)) @mkdir($dir, 0700, true);
624 return $path;
625 }
626}