| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, 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 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib; |
| 39 | |
| 40 | use Std\Lib\Fedi\Fedi; |
| 41 | use Std\Lib\Fedi\Html; |
| 42 | use Std\Lib\Fedi\Notes; |
| 43 | use Std\Lib\Fedi\Remote; |
| 44 | use Std\Lib\Fedi\Social; |
| 45 | use Result; |
| 46 | use 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 | */ |
| 62 | final 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 | } |