スレッドとコメント(返信への返信・編集・削除の為) * 通知はNotifications(data/notifications/)。 * * 新しいコメントは全部の管理者に通知する。ローカルのユーザーには、自分のコメントへの返信といいねを通知する。 * マルチユーザーモードでFediverseのロールを持つユーザーのコメントは、連合する記事なら本人のアカウントの投稿 * (記事又はコメントへの返信)としてもFediverseに送る。コメントを消すと投稿も消え、マイクロブログで編集・削除すると * コメントも変わる。 */ final class Comments { /** コメントの最大文字数 */ public const MAX_LENGTH = 5000; /** 返信の字下げの最大の深さ(これより深い返信は同じ深さで表示する) */ public const MAX_DEPTH = 5; /** @var string|null 保存先(テスト用。既定はdata) */ public static ?string $dataDir = null; /** * コメント機能が有効か(config.phpのCOMMENTS_ENABLED) */ public static function enabled(): bool { return defined('COMMENTS_ENABLED') && COMMENTS_ENABLED; } /** * スレッドの名前が正しいか(「ディレクトリ/記事」又は「ディレクトリ/科目/記事」) */ public static function validThread(string $thread): bool { return (bool)preg_match('#^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*){1,2}$#', $thread); } /** * コメント出来るユーザーか(ログイン中、BANされていない、メールアドレス確認済み) */ public static function canPost(?\stdClass $user): bool { return $user !== null && !empty($user->username) && ($user->role ?? Roles::BANNED) !== Roles::BANNED && ($user->verified ?? true) !== false; } /** * 管理者(コメントを削除出来る)か */ public static function isModerator(?\stdClass $user): bool { return $user !== null && (bool)(($user->role ?? 0) & (Roles::ADMIN | Roles::STAFF)); } //////// 読む /** * スレッドのコメント(古い順、削除済みを含む) * * @param string $thread スレッド * @return array ID => コメント */ public static function all(string $thread): array { if (!self::validThread($thread)) return []; return Fedi::read(self::threadPath($thread), ['comments' => []])['comments'] ?? []; } /** * 削除されていないコメントの数 */ public static function count(string $thread): int { return count(array_filter(self::all($thread), fn($c) => empty($c['deleted']))); } /** * 表示用のコメント(返信は親の下、深さ付き) * * @param string $thread スレッド * @param \stdClass|null $viewer 見ているユーザー * @return array コメントの配列(id、depth、html、who、at、likes、liked、canLike、canReply、canDelete、deleted) */ public static function view(string $thread, ?\stdClass $viewer): array { $all = self::all($thread); $children = []; foreach ($all as $id => $c) { $parent = $c['parent'] ?? null; // 親が無い(壊れた)返信は一番上に表示する $children[$parent !== null && isset($all[$parent]) ? $parent : ''][] = $id; } $out = []; $canPost = self::canPost($viewer); $walk = function(string $parent, int $depth) use (&$walk, &$out, $all, $children, $viewer, $canPost): void { foreach ($children[$parent] ?? [] as $id) { $c = $all[$id]; $deleted = !empty($c['deleted']); $likes = (array)($c['likes'] ?? []); $mine = $viewer !== null && ($c['author']['username'] ?? null) === ($viewer->username ?? ''); $out[] = [ 'id' => $id, 'depth' => min($depth, self::MAX_DEPTH), 'deleted' => $deleted, 'html' => $deleted ? '' : (string)($c['html'] ?? ''), 'who' => self::who($c['author'] ?? []), 'at' => date('Y-m-d H:i', (int)($c['at'] ?? 0)), 'edited' => !$deleted && !empty($c['edited']), 'likes' => count($likes), 'liked' => $viewer !== null && isset($likes[$viewer->username ?? '']), 'canLike' => $canPost && !$deleted && !$mine, 'canReply' => $canPost && !$deleted, 'canDelete' => !$deleted && $viewer !== null && ($mine || self::isModerator($viewer)), ]; $walk($id, $depth + 1); } }; $walk('', 0); return $out; } /** * Fediverseにある、記事又はコメントへの直接の返信のID(古い順、削除済みを除く) * Fediverseから受け取った返信と、Fediverseのロールを持つユーザーのコメント(本人の投稿)。他のコメントは含まない * * @param string $thread スレッド * @param string|null $parent コメントのID(記事への返信ならnull) * @return array 投稿のID */ public static function fediReplies(string $thread, ?string $parent = null): array { $out = []; foreach (self::all($thread) as $c) { if (empty($c['deleted']) && isset($c['apId']) && ($c['parent'] ?? null) === $parent) $out[] = $c['apId']; } return $out; } /** * Fediverseの返信のIDからコメントを探す * * @param string $apId リモートの投稿のID * @return array|null [スレッド, コメントのID, 記事のタイトル] */ public static function findRemote(string $apId): ?array { $ref = Fedi::read(self::path('comments/ap.json'))[$apId] ?? null; if (!is_array($ref) || !isset(self::all($ref['thread'])[$ref['id']])) return null; $title = Fedi::read(self::threadPath($ref['thread']), [])['title'] ?? ''; return [$ref['thread'], $ref['id'], $title]; } //////// 書く /** * ローカルのユーザーのコメント(本文はMarkdown) * * @param string $thread スレッド * @param string $title 記事のタイトル(通知用) * @param \stdClass $user コメントするユーザー * @param string $text 本文 * @param string|null $parent 返信先のコメントのID * @param array|null $postNote 記事のNote(連合する記事の場合。Fediverseのロールを持つユーザーなら投稿としても送る) * @return Result data:id */ public static function addLocal(string $thread, string $title, \stdClass $user, string $text, ?string $parent = null, ?array $postNote = null): Result { if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); if (!self::canPost($user)) return Result::Error('エラー:コメントする権限がありません。'); $text = trim(str_replace(["\r\n", "\r"], "\n", $text)); if ($text === '') return Result::Error('エラー:コメントをご入力下さい。'); if (mb_strlen($text) > self::MAX_LENGTH) return Result::Error('エラー:コメントは'.self::MAX_LENGTH.'文字以内でご入力下さい。'); $res = self::add($thread, $title, [ 'author' => ['username' => $user->username], 'source' => $text, 'html' => Html::render($text, [], false), ], $parent); if ($res->isSuccess && $postNote !== null) self::federateLocal($thread, $res->data['id'], $user->username, $text, $postNote); return $res; } /** * Fediverseからの返信(受付ボックスで検証済みの事) * * @param string $thread スレッド * @param string $title 記事のタイトル * @param array $actor 返信したアクター * @param array $obj 返信(Note等) * @param string|null $parent 返信先のコメントのID(記事への返信ならnull) * @return Result data:id */ public static function addRemote(string $thread, string $title, array $actor, array $obj, ?string $parent = null): Result { if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); $apId = (string)($obj['id'] ?? ''); if ($apId === '') return Result::Error('エラー:IDがありません。'); // 同じ返信が2回届いても(再送等)1つにする $known = self::findRemote($apId); if ($known !== null) return Result::Success('', ['id' => $known[1]]); $res = self::add($thread, $title, [ 'author' => self::remoteAuthor($actor), 'html' => self::remoteHtml($obj), 'apId' => $apId, ], $parent); if ($res->isSuccess) { Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apId, $thread, $res, $actor): array { $l[$apId] = ['thread' => $thread, 'id' => $res->data['id'], 'actor' => (string)($actor['id'] ?? '')]; return $l; }); } return $res; } /** * Fediverseで編集された返信を更新する * * @param array $obj 返信(Note等) * @param string $actor アクティビティのアクター * @return bool 更新したらtrue */ public static function updateRemote(array $obj, string $actor): bool { if (!self::enabled()) return false; $id = Fedi::id($obj['id'] ?? null); $ref = $id !== null ? self::remoteRef($id) : null; if ($ref === null || $ref['actor'] !== $actor || Fedi::id($obj['attributedTo'] ?? null) !== $actor) return false; $html = self::remoteHtml($obj); return self::modify($ref['thread'], function(array $t) use ($ref, $html): array { if (isset($t['comments'][$ref['id']]) && empty($t['comments'][$ref['id']]['deleted'])) { $t['comments'][$ref['id']]['html'] = $html; $t['comments'][$ref['id']]['edited'] = time(); } return $t; }); } /** * Fediverseで削除された返信を消す * * @param string $apId 返信のID * @param string $actor アクティビティのアクター(返信した本人だけ消せる) * @return bool 消したらtrue */ public static function deleteRemote(string $apId, string $actor): bool { if (!self::enabled()) return false; $ref = self::remoteRef($apId); if ($ref === null || $ref['actor'] !== $actor) return false; self::remove($ref['thread'], $ref['id']); return true; } /** * 削除されたアカウントの返信を全部消す * * @param string $actor アクターのURL * @return int 消した数 */ public static function forgetActor(string $actor): int { $n = 0; foreach (Fedi::read(self::path('comments/ap.json')) as $apId => $ref) { if (($ref['actor'] ?? '') === $actor && self::deleteRemote((string)$apId, $actor)) $n++; } return $n; } /** * いいね(もう一度でいいねを取り消す) * * @param string $thread スレッド * @param string $id コメントのID * @param \stdClass $user いいねするユーザー * @return Result data:liked(いいねした状態ならtrue) */ public static function like(string $thread, string $id, \stdClass $user): Result { if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); if (!self::canPost($user)) return Result::Error('エラー:いいねする権限がありません。'); $c = self::all($thread)[$id] ?? null; if ($c === null || !empty($c['deleted'])) return Result::Error('エラー:コメントが見つかりません。'); $author = $c['author']['username'] ?? null; if ($author === $user->username) return Result::Error('エラー:自分のコメントにはいいね出来ません。'); $liked = false; self::modify($thread, function(array $t) use ($id, $user, &$liked): array { if (!isset($t['comments'][$id])) return $t; $likes = (array)($t['comments'][$id]['likes'] ?? []); if (isset($likes[$user->username])) unset($likes[$user->username]); else $likes[$user->username] = time(); $liked = isset($likes[$user->username]); $t['comments'][$id]['likes'] = $likes; return $t; }); if ($liked && $author !== null) { $title = Fedi::read(self::threadPath($thread), [])['title'] ?? ''; self::notify($author, 'like', $thread, $id, $title, ['username' => $user->username]); } return Result::Success('', ['liked' => $liked]); } /** * コメントを削除する(本人又は管理者) * 返信があるコメントは「削除されました」として残し、返信が無ければ完全に消す * * @param string $thread スレッド * @param string $id コメントのID * @param \stdClass $user 削除するユーザー * @return Result */ public static function delete(string $thread, string $id, \stdClass $user): Result { if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); $c = self::all($thread)[$id] ?? null; if ($c === null || !empty($c['deleted'])) return Result::Error('エラー:コメントが見つかりません。'); $mine = ($c['author']['username'] ?? null) === ($user->username ?? ''); if (!$mine && !self::isModerator($user)) return Result::Error('エラー:権限がありません。'); self::remove($thread, $id); // Fediverseにも投稿したコメントは、投稿も消す(コメントは先に消したので、Social::deleteからは何もしない) $note = isset($c['apId']) && isset($c['author']['username']) ? Fedi::localNoteId($c['apId']) : null; if ($note !== null && $note[0] === $c['author']['username'] && Fedi::enabled()) Social::delete($note[0], $note[1]); return Result::Success(); } //////// 通知 /** * 通知(Notifications)の表示用のデータ * * @param array $n 通知(コメントはtype:comment、reply、like、thread、comment、title。他はlabel・page・excerpt) * @return array who(表示名等)、label、page(コメントのURL)、excerpt、at、read */ public static function present(array $n): array { // コメント以外(Gitler等)は追加した時の文 if (isset($n['label'], $n['page'])) { return ['type' => $n['type'] ?? '', 'who' => self::who((array)($n['from'] ?? [])), 'label' => (string)$n['label'], 'page' => (string)$n['page'], 'excerpt' => (string)($n['excerpt'] ?? ''), 'at' => date('Y-m-d H:i', (int)($n['at'] ?? 0)), 'read' => !empty($n['read'])]; } $thread = (string)($n['thread'] ?? ''); $c = self::all($thread)[$n['comment'] ?? ''] ?? null; $title = (string)($n['title'] ?? ''); return [ 'type' => $n['type'] ?? '', 'who' => self::who((array)($n['from'] ?? [])), 'label' => match ($n['type'] ?? '') { 'comment' => 'が「'.($title !== '' ? $title : $thread).'」にコメントしました', 'reply' => 'があなたのコメントに返信しました', 'like' => 'があなたのコメントにいいねしました', default => '', }, 'page' => '/'.$thread.'#c-'.rawurlencode((string)($n['comment'] ?? '')), // ブロック(コード、リスト等)の間は空ける 'excerpt' => $c !== null && empty($c['deleted']) ? Html::text(preg_replace('#(?:pre|li|blockquote|summary)>#i', '$0 ', (string)($c['html'] ?? '')), 80) : '(削除されました)', 'at' => date('Y-m-d H:i', (int)($n['at'] ?? 0)), 'read' => !empty($n['read']), ]; } //////// 内部 /** * コメントを追加し、通知する * * @param array $c author、html、source又はapId * @return Result data:id */ private static function add(string $thread, string $title, array $c, ?string $parent): Result { if (!self::validThread($thread)) return Result::Error('エラー:不正なスレッドです。'); $parent = $parent === '' ? null : $parent; $all = self::all($thread); if ($parent !== null && (!isset($all[$parent]) || !empty($all[$parent]['deleted']))) { return Result::Error('エラー:返信先のコメントが見つかりません。'); } $id = Fedi::newId(); $c = ['id' => $id, 'parent' => $parent, 'at' => time()] + $c + ['likes' => []]; $ok = self::modify($thread, function(array $t) use ($id, $c, $title): array { $t['title'] = $title !== '' ? $title : ($t['title'] ?? ''); $t['comments'][$id] = $c; return $t; }); if (!$ok) return Result::Error('エラー:コメントの保存に失敗しました。'); // 返信先がローカルのユーザーなら「返信」、管理者には「コメント」(両方なら返信だけ) $from = $c['author']; $me = $from['username'] ?? null; $to = []; $parentAuthor = $parent !== null ? ($all[$parent]['author']['username'] ?? null) : null; if ($parentAuthor !== null && $parentAuthor !== $me) $to[$parentAuthor] = 'reply'; foreach (self::admins() as $admin) { if ($admin !== $me && !isset($to[$admin])) $to[$admin] = 'comment'; } foreach ($to as $u => $type) self::notify($u, $type, $thread, $id, $title, $from); return Result::Success('', ['id' => $id]); } /** * Fediverseのロールを持つユーザーのコメントを、本人の投稿(記事又はFediverseにある一番近い親コメントへの返信)として送る * 失敗してもコメントはそのまま(ログに残す) */ private static function federateLocal(string $thread, string $id, string $username, string $text, array $postNote): void { if (!Fedi::enabled() || Fedi::localActor($username) === null || !is_string($postNote['id'] ?? null)) return; $all = self::all($thread); $inReplyTo = $postNote['id']; for ($x = $all[$id]['parent'] ?? null; $x !== null && isset($all[$x]); $x = $all[$x]['parent'] ?? null) { if (isset($all[$x]['apId'])) { $inReplyTo = $all[$x]['apId']; break; } } try { $res = Social::post($username, $text, ['visibility' => 'public', 'inReplyTo' => $inReplyTo, 'parentObject' => $postNote]); } catch (\Throwable $e) { $res = Result::Error($e->getMessage()); } if (!$res->isSuccess) { logger(\LogType::ActivityPub, "コメント「{$thread}#{$id}」をFediverseに送れませんでした:{$res->message}"); return; } $apId = Notes::url($res->data['note']); self::modify($thread, function(array $t) use ($id, $apId): array { if (isset($t['comments'][$id])) $t['comments'][$id]['apId'] = $apId; return $t; }); Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apId, $thread, $id, $username): array { $l[$apId] = ['thread' => $thread, 'id' => $id, 'actor' => Fedi::actorUrl($username)]; return $l; }); } /** * コメントを消す(返信があれば中身だけ消す)。中身だけ消したコメントは、返信が無くなれば完全に消す */ private static function remove(string $thread, string $id): void { $apIds = []; self::modify($thread, function(array $t) use ($id, &$apIds): array { $all = &$t['comments']; if (!isset($all[$id])) return $t; $hasChildren = function(string $x) use (&$all): bool { foreach ($all as $c) if (($c['parent'] ?? null) === $x) return true; return false; }; if ($hasChildren($id)) { if (isset($all[$id]['apId'])) $apIds[] = $all[$id]['apId']; $all[$id] = ['id' => $id, 'parent' => $all[$id]['parent'] ?? null, 'at' => $all[$id]['at'] ?? 0, 'author' => [], 'deleted' => true]; return $t; } for ($x = $id; $x !== null && isset($all[$x]) && ($x === $id || (!empty($all[$x]['deleted']) && !$hasChildren($x)));) { $parent = $all[$x]['parent'] ?? null; if (isset($all[$x]['apId'])) $apIds[] = $all[$x]['apId']; unset($all[$x]); $x = $parent; } return $t; }); if ($apIds) { Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apIds): array { foreach ($apIds as $a) unset($l[$a]); return $l; }); } } /** * スレッドのファイルをロックして書き換える * * @param callable $fn スレッド(title、comments)を受け取り、新しいスレッドを返す * @return bool 保存出来たらtrue */ private static function modify(string $thread, callable $fn): bool { if (!self::validThread($thread)) return false; $path = self::threadPath($thread); $new = Fedi::update($path, function(array $t) use ($fn): array { $t += ['title' => '', 'comments' => []]; return $fn($t); }, ['title' => '', 'comments' => []]); return is_array($new) && is_file($path); } private static function notify(string $username, string $type, string $thread, string $comment, string $title, array $from): void { // 同じいいねを(取り消して又いいねしても)重複させない Notifications::add($username, ['type' => $type, 'thread' => $thread, 'comment' => $comment, 'title' => $title, 'from' => $from], fn(array $n) => ($n['type'] ?? '') === $type && ($n['comment'] ?? '') === $comment && ($n['from'] ?? []) === $from); } /** * 管理者のユーザー名 */ private static function admins(): array { $dir = Fedi::$userDir ?? ROOT.'/data/user/'; $out = []; foreach (scandir($dir) ?: [] as $f) { if (!preg_match('/^\d+\.([A-Za-z0-9]+)\.json$/', $f, $m)) continue; $u = json_decode((string)file_get_contents($dir.$f)); if ($u instanceof \stdClass && (($u->role ?? 0) & Roles::ADMIN)) $out[] = $m[1]; } return $out; } /** * 表示用の名前等(ローカルのユーザー又はリモートのアクター) * * @param array $author username、又はactor・name・handle・url・icon * @return array name(HTML)、handle、url、avatar、remote */ private static function who(array $author): array { if (isset($author['username'])) { $name = (string)$author['username']; $u = Fedi::localUser($name); return [ 'name' => $u ? namecolor($u) : Html::esc($name), 'handle' => '', 'url' => AUTH_ENABLED ? '/profile/'.rawurlencode($name) : '', 'avatar' => $u ? avatar_url($u) : ass_url('user/noicon.png'), 'remote' => false, ]; } if (empty($author)) return ['name' => '', 'handle' => '', 'url' => '', 'avatar' => ass_url('user/noicon.png'), 'remote' => false]; $icon = (string)($author['icon'] ?? ''); return [ 'name' => Html::esc((string)($author['name'] ?? '')), 'handle' => (string)($author['handle'] ?? ''), 'url' => (string)($author['url'] ?? ''), // リモートの画像は直接読み込まない(閲覧者のIPアドレスが渡る)。マルチユーザーではこのサイト経由で表示する 'avatar' => $icon !== '' && Fedi::enabled() ? Remote::imageUrl($icon) : ass_url('user/noicon.png'), 'remote' => true, ]; } /** * リモートのアクターから保存する情報(表示用) */ private static function remoteAuthor(array $actor): array { $url = fn(mixed $u): string => is_string($u) && preg_match('#^https?://[^\s"<>]+$#i', $u) ? $u : ''; $icon = $actor['icon'] ?? null; if (is_array($icon) && array_is_list($icon)) $icon = $icon[0] ?? null; if (is_array($icon)) $icon = $icon['url'] ?? null; if (is_array($icon)) $icon = $icon['href'] ?? null; $id = (string)($actor['id'] ?? ''); $profile = is_array($actor['url'] ?? null) ? Fedi::id($actor['url']) : ($actor['url'] ?? null); return [ 'actor' => $id, 'name' => mb_substr(trim(strip_tags((string)(($actor['name'] ?? '') ?: ($actor['preferredUsername'] ?? '')))), 0, 100) ?: Remote::handle($actor), 'handle' => Remote::handle($actor), 'url' => $url($profile) ?: $url($id), 'icon' => $url($icon), ]; } /** * リモートの返信の本文(安全なHTML)。先頭のブログのアクターへのメンションは外す */ private static function remoteHtml(array $obj): string { $content = $obj['content'] ?? ''; if (!is_string($content) && is_array($obj['contentMap'] ?? null)) $content = (string)(array_values($obj['contentMap'])[0] ?? ''); $html = Html::sanitize(mb_substr((string)$content, 0, 50000)); $blog = array_map(fn($u) => preg_quote(Html::esc($u), '#'), [Fedi::blogActorUrl(), Fedi::base(), Fedi::base().'/']); $html = preg_replace('#^(
)?(?:)?]*>@?(?:)?[^<]*(?:)?(?:)?\s*#u', '$1', $html) ?? $html; $html = preg_replace('#^
\s*
#', '', $html) ?? $html; $summary = trim((string)($obj['summary'] ?? '')); if ($summary !== '') $html = '