コメントシステム
守矢諏訪子 <suwako@076.moe>、2026-11-11 12:06
コミット b928dd14e223c28b44d1fbf28b4b5556e9ffae7d
・親 b4b752a
・ファイルを見る
21個のファイル、+1572 -36
変更
README-JP.md
| @@ -120,9 +120,15 @@ | ||
| 120 | 120 | location "/.well-known/webfinger" { |
| 121 | 121 | request rewrite "/index.php" |
| 122 | 122 | } |
| 123 | - location match "*.atom" { | |
| 123 | + location "*.atom" { | |
| 124 | 124 | request rewrite "/index.php" |
| 125 | 125 | } |
| 126 | + location match "/comments" { | |
| 127 | + request rewrite "/index.php" | |
| 128 | + } | |
| 129 | + location "/notifications" { | |
| 130 | + request rewrite "/index.php" | |
| 131 | + } | |
| 126 | 132 | } |
| 127 | 133 | |
| 128 | 134 | server "www.technicalsuwako.moe" { |
| @@ -193,6 +199,13 @@ | ||
| 193 | 199 | ### ブログと Atom フィード |
| 194 | 200 | 1つのサイトに複数のブログを置けます。`config/config.php` の `BLOGS` の各ディレクトリ(例:`blog`、`enblog`)がブログになり、それぞれ `/ディレクトリ.atom` の Atom フィードがあります(一覧と検索結果のページの上にリンクがあります)。\ |
| 195 | 201 | `BLOGS` が無ければ、今まで通り `blog` だけです。 |
| 202 | + | |
| 203 | +### コメント | |
| 204 | +`config/config.php` で `COMMENTS_ENABLED` を `true` にすると、全部のブログ記事の下にコメント欄が表示されます(書き込みには `AUTH_ENABLED` も必要)。 | |
| 205 | +* ログイン中のユーザーはコメント、返信、いいねが出来ます(Markdown:太字、斜体、取り消し線、コード、引用、リスト、リンク)。自分のコメントは削除出来、管理者とスタッフは全部のコメントを削除出来ます。JavaScript は不要です。 | |
| 206 | +* `BLOGS` で `'federate' => true` のブログは `FEDIINFO` のアカウントが投稿し(`apfederate`)、その投稿(と返信)への Fediverse からの公開・未収載の返信がコメントになります。Fediverse での編集と削除も反映します。 | |
| 207 | +* 新しいコメントは全部の管理者に、自分のコメントへの返信といいねは本人に `/notifications` で通知します。 | |
| 208 | +* コメントは `data/comments/`、通知は `data/notifications/` に保存します。 | |
| 196 | 209 | |
| 197 | 210 | ### Cron |
| 198 | 211 | 定期的なタスク(例えば、新しいブログ投稿を ActivityPub のフォロワーに送る `apfederate`)はシステムの cron で実行します。\ |
変更
README.md
| @@ -118,9 +118,15 @@ | ||
| 118 | 118 | location "/.well-known/webfinger" { |
| 119 | 119 | request rewrite "/index.php" |
| 120 | 120 | } |
| 121 | - location match "*.atom" { | |
| 121 | + location "*.atom" { | |
| 122 | 122 | request rewrite "/index.php" |
| 123 | 123 | } |
| 124 | + location match "/comments" { | |
| 125 | + request rewrite "/index.php" | |
| 126 | + } | |
| 127 | + location "/notifications" { | |
| 128 | + request rewrite "/index.php" | |
| 129 | + } | |
| 124 | 130 | } |
| 125 | 131 | |
| 126 | 132 | server "www.technicalsuwako.moe" { |
| @@ -191,6 +197,13 @@ | ||
| 191 | 197 | ### Blogs and Atom feeds |
| 192 | 198 | A site can have more than one blog: every directory in `BLOGS` in `config/config.php` (for example `blog`, `enblog`) is a blog with its own Atom feed at `/directory.atom`, linked from the top of its index and search result pages.\ |
| 193 | 199 | Without `BLOGS`, only `blog` exists, as before. |
| 200 | + | |
| 201 | +### Comments | |
| 202 | +Set `COMMENTS_ENABLED` to `true` in `config/config.php` to show a comment section under every blog post (posting needs `AUTH_ENABLED`). | |
| 203 | +* Logged-in users can comment, reply and like (Markdown: bold, italics, strikethrough, code, quotes, lists and links). Users can delete their own comments, and admins and staff can delete any comment. No JavaScript is needed. | |
| 204 | +* Blogs with `'federate' => true` in `BLOGS` are posted by the `FEDIINFO` account (`apfederate`), and public or unlisted Fediverse replies to those posts (and to replies) become comments. Edits and deletions on the Fediverse are followed. | |
| 205 | +* Every new comment notifies all admins, and users are notified when their comments get replies or likes, at `/notifications`. | |
| 206 | +* Comments are stored in `data/comments/` and notifications in `data/notifications/`. | |
| 194 | 207 | |
| 195 | 208 | ### Cron |
| 196 | 209 | Scheduled tasks (for example `apfederate`, which sends new blog posts to ActivityPub followers) are run by the system cron.\ |
| @@ -45,6 +45,8 @@ | ||
| 45 | 45 | ]); |
| 46 | 46 | // ブログ(URLのディレクトリ => 設定)。ブログ毎に「/ディレクトリ.atom」のフィードが出来る |
| 47 | 47 | // title:フィードの名前(省略するとSITEINFOのtitle) |
| 48 | +// federate:FEDIINFOのアカウントが新しい記事を投稿し、Fediverseからの返信をコメントにする(省略するとblogだけtrue) | |
| 49 | +// more:Fediverseの投稿の「続きを読む」リンクの文字(省略すると「読み続き」) | |
| 48 | 50 | define('BLOGS', [ |
| 49 | 51 | 'blog' => [], |
| 50 | 52 | ]); |
| @@ -86,5 +88,6 @@ | ||
| 86 | 88 | define('AUTH_REGISTER_ENABLED', false); |
| 87 | 89 | define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要) |
| 88 | 90 | define('AUTH_2FA_ENABLED', false); // 二段階認証(認証アプリのコード)を使えるようにする(各ユーザーがプロフィールの編集から有効にする) |
| 91 | +define('COMMENTS_ENABLED', false); // 記事のコメント(書き込みはAUTH_ENABLED、Fediverseからの返信はACTIVITYPUB_ENABLEDも必要) | |
| 89 | 92 | define('COPYRIGHT_YEAR', '2018-'.date('Y')); |
| 90 | 93 | define('DEBUG_MODE', false); |
変更
cron.php
| @@ -66,7 +66,7 @@ | ||
| 66 | 66 | |
| 67 | 67 | if (ACTIVITYPUB_ENABLED) { |
| 68 | 68 | // 新しいブログ投稿をフォロワーに送る |
| 69 | - $cron->register('apfederate', fn() => (new Activitypub((new BlogPost())->getPosts('/blog/', null)))->federate()); | |
| 69 | + $cron->register('apfederate', fn() => (new Activitypub((new BlogPost())->federatedPosts()))->federate()); | |
| 70 | 70 | // プロフィール(アクター)の更新をフォロワーに送る |
| 71 | 71 | $cron->register('apupdate', fn() => (new Activitypub())->sendActorUpdate()); |
| 72 | 72 | } |
| @@ -0,0 +1,122 @@ | ||
| 1 | +.comments { | |
| 2 | + margin: 20px 0; | |
| 3 | +} | |
| 4 | + | |
| 5 | +.comments h2, .comments h1 { | |
| 6 | + border-bottom: 1px solid var(--color-b8b515, #b8b515); | |
| 7 | + padding-bottom: 4px; | |
| 8 | +} | |
| 9 | + | |
| 10 | +.comment { | |
| 11 | + border: 1px solid var(--color-b8b515, #b8b515); | |
| 12 | + border-radius: 2px; | |
| 13 | + padding: 8px; | |
| 14 | + margin: 10px 0; | |
| 15 | +} | |
| 16 | + | |
| 17 | +.comment.depth-1 { margin-left: 24px; } | |
| 18 | +.comment.depth-2 { margin-left: 48px; } | |
| 19 | +.comment.depth-3 { margin-left: 72px; } | |
| 20 | +.comment.depth-4 { margin-left: 96px; } | |
| 21 | +.comment.depth-5 { margin-left: 120px; } | |
| 22 | + | |
| 23 | +.comment-head, .notification { | |
| 24 | + display: flex; | |
| 25 | + flex-wrap: wrap; | |
| 26 | + align-items: center; | |
| 27 | + gap: 8px; | |
| 28 | +} | |
| 29 | + | |
| 30 | +.comment-avatar { | |
| 31 | + width: 32px; | |
| 32 | + height: 32px; | |
| 33 | + border-radius: 4px; | |
| 34 | + object-fit: cover; | |
| 35 | +} | |
| 36 | + | |
| 37 | +.comment-handle, .comment-date, .comment-help, .comment-deleted, .comment-none, .comment-likes { | |
| 38 | + font-size: small; | |
| 39 | + opacity: 0.8; | |
| 40 | +} | |
| 41 | + | |
| 42 | +.comment-body { | |
| 43 | + margin: 6px 0; | |
| 44 | + overflow-wrap: anywhere; | |
| 45 | +} | |
| 46 | + | |
| 47 | +.comment-body pre { | |
| 48 | + overflow-x: auto; | |
| 49 | +} | |
| 50 | + | |
| 51 | +.comment-actions { | |
| 52 | + display: flex; | |
| 53 | + gap: 8px; | |
| 54 | + align-items: center; | |
| 55 | +} | |
| 56 | + | |
| 57 | +.comment-actions form { | |
| 58 | + display: inline; | |
| 59 | +} | |
| 60 | + | |
| 61 | +.comments button { | |
| 62 | + background: var(--color-2b2a01, #2b2a01); | |
| 63 | + color: var(--color-fcfcfc, #fcfcfc); | |
| 64 | + border: 1px solid var(--color-ecea71, #ecea71); | |
| 65 | + border-radius: 2px; | |
| 66 | + padding: 2px 8px; | |
| 67 | + cursor: pointer; | |
| 68 | +} | |
| 69 | + | |
| 70 | +.comments button.on { | |
| 71 | + background: var(--color-ac7718, #ac7718); | |
| 72 | +} | |
| 73 | + | |
| 74 | +.comment-reply summary { | |
| 75 | + cursor: pointer; | |
| 76 | + font-size: small; | |
| 77 | +} | |
| 78 | + | |
| 79 | +.comment-form { | |
| 80 | + display: flex; | |
| 81 | + flex-direction: column; | |
| 82 | + gap: 4px; | |
| 83 | + margin: 8px 0; | |
| 84 | +} | |
| 85 | + | |
| 86 | +.comment-form textarea { | |
| 87 | + width: 100%; | |
| 88 | + box-sizing: border-box; | |
| 89 | +} | |
| 90 | + | |
| 91 | +.comment-form-bottom { | |
| 92 | + display: flex; | |
| 93 | + justify-content: space-between; | |
| 94 | + align-items: center; | |
| 95 | + flex-wrap: wrap; | |
| 96 | + gap: 8px; | |
| 97 | +} | |
| 98 | + | |
| 99 | +.comment-form-bottom button { | |
| 100 | + margin-left: auto; | |
| 101 | +} | |
| 102 | + | |
| 103 | +.comment-fedi { | |
| 104 | + font-size: small; | |
| 105 | + overflow-wrap: anywhere; | |
| 106 | +} | |
| 107 | + | |
| 108 | +.notification { | |
| 109 | + margin: 8px 0; | |
| 110 | + flex-wrap: nowrap; | |
| 111 | +} | |
| 112 | + | |
| 113 | +.notification.unread { | |
| 114 | + border-left: 3px solid var(--color-ecea71, #ecea71); | |
| 115 | + padding-left: 4px; | |
| 116 | +} | |
| 117 | + | |
| 118 | +@media screen and (max-width: 480px) { | |
| 119 | + .comment.depth-1, .comment.depth-2, .comment.depth-3, .comment.depth-4, .comment.depth-5 { | |
| 120 | + margin-left: 12px; | |
| 121 | + } | |
| 122 | +} |
変更
route.php
| @@ -41,6 +41,7 @@ | ||
| 41 | 41 | use Std\Lib\Route; |
| 42 | 42 | |
| 43 | 43 | use Site\Controller\Atom; |
| 44 | +use Site\Controller\Comment; | |
| 44 | 45 | use Site\Controller\Fediverse; |
| 45 | 46 | use Site\Controller\Home; |
| 46 | 47 | use Site\Controller\Microblog; |
| @@ -74,7 +75,10 @@ | ||
| 74 | 75 | $routes[] = Route::add('GET', 'ap/outbox', Fediverse::class.'@apoutbox'); |
| 75 | 76 | $routes[] = Route::add('GET', 'ap/activities/create/{uuid}', |
| 76 | 77 | Fediverse::class.'@apactivity'); |
| 78 | + $routes[] = Route::add('GET', 'ap/activities/create/{section}/{uuid}', | |
| 79 | + Fediverse::class.'@apactivity'); | |
| 77 | 80 | $routes[] = Route::add('GET', 'ap/objects/{uuid}', Fediverse::class.'@apobject'); |
| 81 | + $routes[] = Route::add('GET', 'ap/objects/{section}/{uuid}', Fediverse::class.'@apobject'); | |
| 78 | 82 | $routes[] = Route::add('POST', 'ap/inbox', Fediverse::class.'@apinbox'); |
| 79 | 83 | $routes[] = Route::add('GET', 'ap/actor', Fediverse::class.'@apactor'); |
| 80 | 84 | } |
| @@ -128,6 +132,13 @@ | ||
| 128 | 132 | |
| 129 | 133 | $routes[] = Route::add('GET', 'memberonly', Page::class.'@memberonly'); |
| 130 | 134 | $routes[] = Route::add('GET', 'staffonly', Page::class.'@staffonly'); |
| 135 | +} | |
| 136 | + | |
| 137 | +if (AUTH_ENABLED && \Std\Lib\Comments::enabled()) { | |
| 138 | + $routes[] = Route::add('POST', 'comments', Comment::class.'@post'); | |
| 139 | + $routes[] = Route::add('POST', 'comments/like', Comment::class.'@like'); | |
| 140 | + $routes[] = Route::add('POST', 'comments/delete', Comment::class.'@delete'); | |
| 141 | + $routes[] = Route::add('GET', 'notifications', Comment::class.'@notifications'); | |
| 131 | 142 | } |
| 132 | 143 | |
| 133 | 144 | if (ATOM_ENABLED) { |
| @@ -37,7 +37,11 @@ | ||
| 37 | 37 | */ |
| 38 | 38 | namespace Site\Controller; |
| 39 | 39 | |
| 40 | +use Std\Lib\Activitypub; | |
| 41 | +use Std\Lib\Comments; | |
| 42 | +use Std\Lib\Fedi\Fedi; | |
| 40 | 43 | use Std\Lib\Markdown; |
| 44 | +use Std\Lib\Template; | |
| 41 | 45 | use Roles; |
| 42 | 46 | |
| 43 | 47 | class BlogPost { |
| @@ -109,5 +113,55 @@ | ||
| 109 | 113 | }); |
| 110 | 114 | |
| 111 | 115 | return $posts; |
| 116 | + } | |
| 117 | + | |
| 118 | + /** | |
| 119 | + * ActivityPubで配信するブログ(config.phpのBLOGSでfederateがtrue)の投稿 | |
| 120 | + * | |
| 121 | + * @return array 投稿の配列(「section」はブログのディレクトリ) | |
| 122 | + */ | |
| 123 | + public function federatedPosts(): array { | |
| 124 | + $posts = []; | |
| 125 | + foreach (blogs() as $name => $b) { | |
| 126 | + if (!$b['federate']) continue; | |
| 127 | + foreach ($this->getPosts("/{$name}/", null) as $p) $posts[] = $p + ['section' => $name]; | |
| 128 | + } | |
| 129 | + return $posts; | |
| 130 | + } | |
| 131 | + | |
| 132 | + /** | |
| 133 | + * 記事のコメント欄をテンプレートに割り当てる(COMMENTS_ENABLEDが無効なら何もしない) | |
| 134 | + * | |
| 135 | + * @param Template $tmpl テンプレート | |
| 136 | + * @param string $section 記事のディレクトリ(「blog」「tutorial/linalg」等) | |
| 137 | + * @param string $slug 記事 | |
| 138 | + * @param \stdClass $meta 記事のメタデータ | |
| 139 | + * @param \stdClass|null $user ログイン中のユーザー | |
| 140 | + * @return void | |
| 141 | + */ | |
| 142 | + protected function assignComments(Template $tmpl, string $section, string $slug, \stdClass $meta, ?\stdClass $user): void { | |
| 143 | + if (!Comments::enabled()) return; | |
| 144 | + $thread = "{$section}/{$slug}"; | |
| 145 | + | |
| 146 | + // 連合するブログの記事には、Fediverseからも返信出来る | |
| 147 | + $fedi = null; | |
| 148 | + if (ACTIVITYPUB_ENABLED && (blogs()[$section]['federate'] ?? false)) { | |
| 149 | + $post = ['uuid' => (string)($meta->uuid ?? ''), 'date' => (string)($meta->date ?? ''), 'category' => (array)($meta->category ?? []), | |
| 150 | + 'slug' => $slug, 'section' => $section, 'title' => (string)$meta->title]; | |
| 151 | + $fedi = (new Activitypub([$post]))->objectId($post); | |
| 152 | + } | |
| 153 | + | |
| 154 | + $tmpl->assign('comments', [ | |
| 155 | + 'thread' => $thread, | |
| 156 | + 'list' => Comments::view($thread, $user), | |
| 157 | + 'count' => Comments::count($thread), | |
| 158 | + 'canPost' => Comments::canPost($user), | |
| 159 | + 'maxLength' => Comments::MAX_LENGTH, | |
| 160 | + 'fedi' => $fedi, | |
| 161 | + 'actor' => $fedi !== null ? '@'.FEDIINFO['actor'].'@'.Fedi::host() : '', | |
| 162 | + ]); | |
| 163 | + // クッキーはテンプレートの出力の前に設定する | |
| 164 | + if ($user !== null) $tmpl->assign('csrf', make_csrf_token()); | |
| 165 | + $tmpl->addCss('comments'); | |
| 112 | 166 | } |
| 113 | 167 | } |
| @@ -0,0 +1,199 @@ | ||
| 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 Site\Controller; | |
| 39 | + | |
| 40 | +use Site\Controller\BlogPost; | |
| 41 | +use Site\Controller\Mods; | |
| 42 | +use Std\Lib\Auth; | |
| 43 | +use Std\Lib\Comments; | |
| 44 | +use Std\Lib\Template; | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * 記事のコメントの投稿・いいね・削除(POST)と通知のページ | |
| 48 | + */ | |
| 49 | +class Comment extends BlogPost { | |
| 50 | + use Mods; | |
| 51 | + | |
| 52 | + private ?\stdClass $user; | |
| 53 | + | |
| 54 | + public function __construct() { | |
| 55 | + $this->user = (new Auth())->getLoggedInUser(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** | |
| 59 | + * コメントする(POST /comments) | |
| 60 | + * thread:スレッド、parent:返信先のコメント、body:本文(Markdown) | |
| 61 | + * | |
| 62 | + * @param array $params パラメータ配列 | |
| 63 | + * @return void | |
| 64 | + */ | |
| 65 | + public function post(array $params): void { | |
| 66 | + $user = $this->requireUser(); | |
| 67 | + $thread = (string)($_POST['thread'] ?? ''); | |
| 68 | + $parent = (string)($_POST['parent'] ?? ''); | |
| 69 | + $body = (string)($_POST['body'] ?? ''); | |
| 70 | + | |
| 71 | + $title = $this->commentable($thread); | |
| 72 | + if ($title === null) $this->notFound(); | |
| 73 | + | |
| 74 | + if (!$this->csrfOk()) { | |
| 75 | + $error = 'セッションの有効期限が切れました。もう一度送信して下さい。'; | |
| 76 | + } else { | |
| 77 | + $res = Comments::addLocal($thread, $title, $user, $body, $parent === '' ? null : $parent); | |
| 78 | + if ($res->isSuccess) $this->redirect('/'.$thread.'#c-'.$res->data['id']); | |
| 79 | + $error = $res->message; | |
| 80 | + } | |
| 81 | + | |
| 82 | + // 失敗:入力した内容を残して表示する | |
| 83 | + $this->render('comment', 'コメント', [ | |
| 84 | + 'error' => $error, | |
| 85 | + 'thread' => $thread, | |
| 86 | + 'title' => $title, | |
| 87 | + 'parent' => $parent, | |
| 88 | + 'body' => $body, | |
| 89 | + 'maxLength' => Comments::MAX_LENGTH, | |
| 90 | + ]); | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** | |
| 94 | + * いいね・いいねの取り消し(POST /comments/like) | |
| 95 | + * | |
| 96 | + * @param array $params パラメータ配列 | |
| 97 | + * @return void | |
| 98 | + */ | |
| 99 | + public function like(array $params): void { | |
| 100 | + [$user, $thread, $id] = $this->target(); | |
| 101 | + $res = Comments::like($thread, $id, $user); | |
| 102 | + if (!$res->isSuccess) $this->message($res->message, '/'.$thread.'#comments'); | |
| 103 | + $this->redirect('/'.$thread.'#c-'.$id); | |
| 104 | + } | |
| 105 | + | |
| 106 | + /** | |
| 107 | + * コメントを削除する(POST /comments/delete、本人又は管理者) | |
| 108 | + * | |
| 109 | + * @param array $params パラメータ配列 | |
| 110 | + * @return void | |
| 111 | + */ | |
| 112 | + public function delete(array $params): void { | |
| 113 | + [$user, $thread, $id] = $this->target(); | |
| 114 | + $res = Comments::delete($thread, $id, $user); | |
| 115 | + if (!$res->isSuccess) $this->message($res->message, '/'.$thread.'#comments'); | |
| 116 | + $this->redirect('/'.$thread.'#comments'); | |
| 117 | + } | |
| 118 | + | |
| 119 | + /** | |
| 120 | + * 通知(GET /notifications)。管理者には新しいコメント、全員には自分のコメントへの返信といいね | |
| 121 | + * | |
| 122 | + * @param array $params パラメータ配列 | |
| 123 | + * @return void | |
| 124 | + */ | |
| 125 | + public function notifications(array $params): void { | |
| 126 | + $user = $this->requireUser(); | |
| 127 | + $list = array_map(fn($n) => Comments::present($n), Comments::notifications($user->username)); | |
| 128 | + Comments::markRead($user->username); | |
| 129 | + $this->render('notifications', '通知', ['list' => $list]); | |
| 130 | + } | |
| 131 | + | |
| 132 | + ////// | |
| 133 | + | |
| 134 | + /** | |
| 135 | + * コメント出来る記事か(記事が存在し、このユーザーが読める) | |
| 136 | + * | |
| 137 | + * @param string $thread スレッド(「blog/記事」等) | |
| 138 | + * @return string|null 記事のタイトル、コメント出来なければnull | |
| 139 | + */ | |
| 140 | + private function commentable(string $thread): ?string { | |
| 141 | + if (!Comments::validThread($thread)) return null; | |
| 142 | + $parts = explode('/', $thread); | |
| 143 | + if (count($parts) !== 2 || !isset(blogs()[$parts[0]])) return null; | |
| 144 | + foreach ($this->getPosts("/{$parts[0]}/", $this->user) as $post) { | |
| 145 | + // 会員限定等で読めない記事にはコメント出来ない | |
| 146 | + if ($post['slug'] === $parts[1]) return $post['preview'] === '未許可' ? null : (string)$post['title']; | |
| 147 | + } | |
| 148 | + return null; | |
| 149 | + } | |
| 150 | + | |
| 151 | + /** いいね・削除の対象(ログイン、CSRF、記事を確認する) */ | |
| 152 | + private function target(): array { | |
| 153 | + $user = $this->requireUser(); | |
| 154 | + $thread = (string)($_POST['thread'] ?? ''); | |
| 155 | + $id = (string)($_POST['id'] ?? ''); | |
| 156 | + if ($this->commentable($thread) === null) $this->notFound(); | |
| 157 | + if (!$this->csrfOk()) $this->message('セッションの有効期限が切れました。前のページに戻って、もう一度お試し下さい。', '/'.$thread.'#comments'); | |
| 158 | + return [$user, $thread, $id]; | |
| 159 | + } | |
| 160 | + | |
| 161 | + private function requireUser(): \stdClass { | |
| 162 | + if ($this->user === null) $this->redirect('/login'); | |
| 163 | + return $this->user; | |
| 164 | + } | |
| 165 | + | |
| 166 | + private function csrfOk(): bool { | |
| 167 | + return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']); | |
| 168 | + } | |
| 169 | + | |
| 170 | + private function redirect(string $to): never { | |
| 171 | + header('Location: '.$to); | |
| 172 | + exit; | |
| 173 | + } | |
| 174 | + | |
| 175 | + private function notFound(): never { | |
| 176 | + header('Location: /404'); | |
| 177 | + exit; | |
| 178 | + } | |
| 179 | + | |
| 180 | + private function message(string $msg, string $back): never { | |
| 181 | + $this->render('comment', 'コメント', ['error' => $msg, 'back' => $back]); | |
| 182 | + exit; | |
| 183 | + } | |
| 184 | + | |
| 185 | + private function render(string $view, string $title, array $vars): void { | |
| 186 | + $tmpl = new Template('/'); | |
| 187 | + $tmpl->assign('pagetit', $title); | |
| 188 | + $tmpl->assign('curPage', 'comments'); | |
| 189 | + $tmpl->assign('custCss', false); | |
| 190 | + $tmpl->assign('menu', $this->getMenu()); | |
| 191 | + $tmpl->assign('description', SITEINFO['description']); | |
| 192 | + $tmpl->assign('user', $this->user); | |
| 193 | + // クッキーはテンプレートの出力の前に設定する | |
| 194 | + $tmpl->assign('csrf', make_csrf_token()); | |
| 195 | + foreach ($vars as $k => $v) $tmpl->assign($k, $v); | |
| 196 | + $tmpl->addCss('comments'); | |
| 197 | + $tmpl->render($view); | |
| 198 | + } | |
| 199 | +} |
| @@ -119,7 +119,8 @@ | ||
| 119 | 119 | |
| 120 | 120 | try { |
| 121 | 121 | header('Content-Type: application/activity+json'); |
| 122 | - $ap = new Activitypub(); | |
| 122 | + // 投稿はコメント(ブログの投稿への返信)の処理で必要になった時だけ読む | |
| 123 | + $ap = new Activitypub(fn() => $this->federatedPosts()); | |
| 123 | 124 | |
| 124 | 125 | if (!$ap->verifyInboxSignature($input, $activity)) { |
| 125 | 126 | // 削除されたアカウントのDeleteは鍵がもう取得出来ないので検証出来ない。拒否すると何日も再送されるので受け流す |
| @@ -134,6 +135,8 @@ | ||
| 134 | 135 | } |
| 135 | 136 | |
| 136 | 137 | logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity)); |
| 138 | + // ブログの投稿への返信は記事のコメントにする(マルチユーザーでも、ユーザー宛ての処理は続ける) | |
| 139 | + $ap->handleComments($activity); | |
| 137 | 140 | // マルチユーザーでは共有の受付ボックス(ブログのアクター宛てはInboxからActivitypubに渡す) |
| 138 | 141 | if (Fedi::enabled()) { |
| 139 | 142 | [$code, $body] = Inbox::handle($activity); |
| @@ -335,9 +338,9 @@ | ||
| 335 | 338 | |
| 336 | 339 | try { |
| 337 | 340 | header('Content-Type: application/activity+json'); |
| 338 | - $posts = $this->getPosts('/blog/', null); | |
| 341 | + $posts = $this->federatedPosts(); | |
| 339 | 342 | $ap = new Activitypub($posts); |
| 340 | - $json = $ap->getActivity($uuid); | |
| 343 | + $json = $ap->getActivity($uuid, $params['section'] ?? 'blog'); | |
| 341 | 344 | if ($json === '') { |
| 342 | 345 | header('HTTP/1.1 404 Not Found'); |
| 343 | 346 | echo json_encode(['error' => '見つかりません']); |
| @@ -359,8 +362,8 @@ | ||
| 359 | 362 | public function apobject(array $params): void { |
| 360 | 363 | try { |
| 361 | 364 | header('Content-Type: application/activity+json'); |
| 362 | - $ap = new Activitypub($this->getPosts('/blog/', null)); | |
| 363 | - $json = $ap->getObject($params['uuid'] ?? ''); | |
| 365 | + $ap = new Activitypub($this->federatedPosts()); | |
| 366 | + $json = $ap->getObject($params['uuid'] ?? '', $params['section'] ?? 'blog'); | |
| 364 | 367 | if ($json === '') { |
| 365 | 368 | header('HTTP/1.1 404 Not Found'); |
| 366 | 369 | echo json_encode(['error' => '見つかりません']); |
| @@ -382,7 +385,7 @@ | ||
| 382 | 385 | public function apoutbox(array $params): void { |
| 383 | 386 | try { |
| 384 | 387 | header('Content-Type: application/activity+json'); |
| 385 | - $posts = $this->getPosts('/blog/', null); | |
| 388 | + $posts = $this->federatedPosts(); | |
| 386 | 389 | $ap = new Activitypub($posts); |
| 387 | 390 | echo $ap->getOutbox(); |
| 388 | 391 | exit; |
| @@ -177,6 +177,7 @@ | ||
| 177 | 177 | if (isset($meta->draft) && !$isStaff) goto denyaccess; |
| 178 | 178 | |
| 179 | 179 | showpage: |
| 180 | + $this->assignComments($tmpl, 'blog', $page, $meta, $user); | |
| 180 | 181 | $tmpl->addCss('news-article'); |
| 181 | 182 | $tmpl->addCss('search'); |
| 182 | 183 | $tmpl->addCss('blogtype'); |
| @@ -38,6 +38,9 @@ | ||
| 38 | 38 | namespace Std\Lib; |
| 39 | 39 | |
| 40 | 40 | use Std\Lib\Curl; |
| 41 | +use Std\Lib\Fedi\Actor; | |
| 42 | +use Std\Lib\Fedi\Fedi; | |
| 43 | +use Std\Lib\Fedi\Remote; | |
| 41 | 44 | use LogType; |
| 42 | 45 | use Result; |
| 43 | 46 | use Uri\Rfc3986\Uri; |
| @@ -54,7 +57,8 @@ | ||
| 54 | 57 | private string $actorNick; |
| 55 | 58 | private string $desc; |
| 56 | 59 | private string $icon; |
| 57 | - private array $posts = []; | |
| 60 | + /** 投稿(必要になった時に読む場合はクロージャ) */ | |
| 61 | + private array|\Closure $posts = []; | |
| 58 | 62 | private string $https; |
| 59 | 63 | /** 秘密鍵のキャッシュ(1回の実行で何度も読まない様に) */ |
| 60 | 64 | private ?\OpenSSLAsymmetricKey $privKey = null; |
| @@ -68,9 +72,10 @@ | ||
| 68 | 72 | /** |
| 69 | 73 | * コンストラクタ |
| 70 | 74 | * |
| 71 | - * @param array $posts 投稿データの配列 | |
| 75 | + * @param array|\Closure $posts 投稿データの配列(「section」はブログのディレクトリ、無ければblog)、 | |
| 76 | + * 又は配列を返すクロージャ(受付ボックス等、投稿を使わない事が多い時) | |
| 72 | 77 | */ |
| 73 | - public function __construct(array $posts = []) { | |
| 78 | + public function __construct(array|\Closure $posts = []) { | |
| 74 | 79 | if (!ACTIVITYPUB_ENABLED) return; |
| 75 | 80 | $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://'; |
| 76 | 81 | // CLI(cron)にはHTTP_HOSTが無いので、FEDIINFOの「domain」を優先する |
| @@ -138,11 +143,12 @@ | ||
| 138 | 143 | * 特定のUUIDに対応するActivityを取得する |
| 139 | 144 | * |
| 140 | 145 | * @param string $uuid 取得するアクティビティのUUID |
| 146 | + * @param string $section ブログのディレクトリ | |
| 141 | 147 | * @return string JSONエンコードされたアクティビティデータ |
| 142 | 148 | */ |
| 143 | - public function getActivity(string $uuid): string { | |
| 149 | + public function getActivity(string $uuid, string $section = 'blog'): string { | |
| 144 | 150 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 145 | - $post = $this->findPublicPost($uuid); | |
| 151 | + $post = $this->findPublicPost($uuid, $section); | |
| 146 | 152 | if ($post === null) return ''; |
| 147 | 153 | |
| 148 | 154 | return json_encode($this->buildCreate($post), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| @@ -153,11 +159,12 @@ | ||
| 153 | 159 | * 相手のサーバーがオブジェクトのIDを取得しに来た時の為 |
| 154 | 160 | * |
| 155 | 161 | * @param string $uuid 投稿のUUID |
| 162 | + * @param string $section ブログのディレクトリ | |
| 156 | 163 | * @return string JSONエンコードされたNote、無ければ空文字 |
| 157 | 164 | */ |
| 158 | - public function getObject(string $uuid): string { | |
| 165 | + public function getObject(string $uuid, string $section = 'blog'): string { | |
| 159 | 166 | if (!ACTIVITYPUB_ENABLED) return ''; |
| 160 | - $post = $this->findPublicPost($uuid); | |
| 167 | + $post = $this->findPublicPost($uuid, $section); | |
| 161 | 168 | if ($post === null) return ''; |
| 162 | 169 | |
| 163 | 170 | $create = $this->buildCreate($post); |
| @@ -313,6 +320,50 @@ | ||
| 313 | 320 | } |
| 314 | 321 | |
| 315 | 322 | /** |
| 323 | + * ブログの投稿とコメントへのFediverseからの返信を、記事のコメントにする(署名は検証済みの事) | |
| 324 | + * 受付ボックスの処理(handleInbox又はマルチユーザーのInbox::handle)の前に呼ぶ | |
| 325 | + * | |
| 326 | + * Create:返信を追加(公開・未収載だけ)、Update:本文を更新、Delete:返信を削除(アカウントの削除なら全部) | |
| 327 | + * | |
| 328 | + * @param array $activity アクティビティ | |
| 329 | + * @return bool コメントとして処理したらtrue | |
| 330 | + */ | |
| 331 | + public function handleComments(array $activity): bool { | |
| 332 | + if (!ACTIVITYPUB_ENABLED || !Comments::enabled()) return false; | |
| 333 | + $actor = Fedi::id($activity['actor'] ?? null); | |
| 334 | + if ($actor === null || Fedi::isLocal($actor) || Actor::isDomainBlocked($actor)) return false; | |
| 335 | + | |
| 336 | + try { | |
| 337 | + switch ($activity['type'] ?? '') { | |
| 338 | + case 'Create': | |
| 339 | + return $this->commentFromNote($activity['object'] ?? null, $actor); | |
| 340 | + case 'Update': | |
| 341 | + $object = $activity['object'] ?? null; | |
| 342 | + return is_array($object) && Comments::updateRemote($object, $actor); | |
| 343 | + case 'Delete': | |
| 344 | + $id = Fedi::id($activity['object'] ?? null); | |
| 345 | + if ($id === $actor) return Comments::forgetActor($actor) > 0; | |
| 346 | + return $id !== null && Comments::deleteRemote($id, $actor); | |
| 347 | + } | |
| 348 | + } catch (\Throwable $e) { | |
| 349 | + logger(LogType::ActivityPub, 'コメントの処理に失敗:'.$e->getMessage()); | |
| 350 | + } | |
| 351 | + return false; | |
| 352 | + } | |
| 353 | + | |
| 354 | + /** | |
| 355 | + * 投稿のNoteのID(Fediverseから返信する時の宛先) | |
| 356 | + * | |
| 357 | + * @param array $post 投稿データ | |
| 358 | + * @return string|null 連合しない投稿(会員限定、未来の日付等)ならnull | |
| 359 | + */ | |
| 360 | + public function objectId(array $post): ?string { | |
| 361 | + if (!ACTIVITYPUB_ENABLED) return null; | |
| 362 | + $post = $this->findPublicPost((string)($post['uuid'] ?? ''), $post['section'] ?? 'blog'); | |
| 363 | + return $post !== null ? "{$this->domain}/ap/objects/".self::postKey($post) : null; | |
| 364 | + } | |
| 365 | + | |
| 366 | + /** | |
| 316 | 367 | * アクタープロフィールの更新アクティビティを作成する |
| 317 | 368 | * |
| 318 | 369 | * @return string JSONエンコードされた更新アクティビティ |
| @@ -359,6 +410,7 @@ | ||
| 359 | 410 | * |
| 360 | 411 | * 送信済みのUUIDはdata/ap_federated.jsonに記録する。 |
| 361 | 412 | * 初回の実行では既存の投稿を全部送信済みとして記録するだけで、何も送信しない(古い投稿を一気に流さない為)。 |
| 413 | + * 後から連合する様にしたブログ(config.phpのBLOGSのfederate)も同じく、既存の投稿は送信しない。 | |
| 362 | 414 | * 日付が未来の投稿はその日時になってから送信する。 |
| 363 | 415 | * 失敗した受付ボックスは次回以降に再試行し、FEDERATE_MAX_TRIES回で諦める。 |
| 364 | 416 | * |
| @@ -386,27 +438,41 @@ | ||
| 386 | 438 | |
| 387 | 439 | $posts = $this->federatablePosts(); |
| 388 | 440 | $zero = ['posts' => 0, 'delivered' => 0, 'failed' => 0]; |
| 441 | + // 連合するブログ(config.phpのBLOGS)。新しく追加したブログの既存の投稿は送らない | |
| 442 | + $sections = array_keys(array_filter(blogs(), fn($b) => $b['federate'])); | |
| 443 | + foreach ($posts as $p) $sections[] = $p['section'] ?? 'blog'; | |
| 389 | 444 | |
| 390 | 445 | if ($state === null) { |
| 391 | - $state = ['sent' => [], 'pending' => []]; | |
| 392 | - foreach ($posts as $p) $state['sent'][$p['uuid']] = time(); | |
| 446 | + $state = ['sent' => [], 'pending' => [], 'sections' => array_values(array_unique($sections))]; | |
| 447 | + foreach ($posts as $p) $state['sent'][self::postKey($p)] = time(); | |
| 393 | 448 | $this->writeFederateState($fp, $state); |
| 394 | 449 | return Result::Success('初回の実行なので、既存の投稿'.count($posts).'件を送信済みとして記録しました。', $zero); |
| 395 | 450 | } |
| 396 | 451 | |
| 397 | 452 | $state['sent'] = (array)($state['sent'] ?? []); |
| 398 | 453 | $state['pending'] = (array)($state['pending'] ?? []); |
| 399 | - $byUuid = array_column($posts, null, 'uuid'); | |
| 454 | + // 「sections」が無い記録はblogだけを連合していた頃の物 | |
| 455 | + $known = (array)($state['sections'] ?? ['blog']); | |
| 456 | + $byUuid = []; | |
| 457 | + foreach ($posts as $p) { | |
| 458 | + $key = self::postKey($p); | |
| 459 | + $byUuid[$key] = $p; | |
| 460 | + if (!in_array($p['section'] ?? 'blog', $known, true)) $state['sent'][$key] ??= time(); | |
| 461 | + } | |
| 462 | + $state['sections'] = array_values(array_unique(array_merge($known, $sections))); | |
| 400 | 463 | |
| 401 | 464 | // 新しい投稿を送信待ちに追加する(古い順) |
| 402 | 465 | $inboxes = null; |
| 403 | 466 | foreach ($posts as $p) { |
| 404 | - $uuid = $p['uuid']; | |
| 467 | + $uuid = self::postKey($p); | |
| 405 | 468 | if (isset($state['sent'][$uuid]) || isset($state['pending'][$uuid])) continue; |
| 406 | 469 | $inboxes ??= $this->followerInboxes(); |
| 407 | 470 | $state['pending'][$uuid] = ['inboxes' => $inboxes, 'tries' => 0]; |
| 408 | 471 | } |
| 409 | - if (empty($state['pending'])) return Result::Success('新しい投稿はありません。', $zero); | |
| 472 | + if (empty($state['pending'])) { | |
| 473 | + if ($state['sections'] !== $known) $this->writeFederateState($fp, $state); | |
| 474 | + return Result::Success('新しい投稿はありません。', $zero); | |
| 475 | + } | |
| 410 | 476 | |
| 411 | 477 | $done = $ok = $ng = 0; |
| 412 | 478 | foreach ($state['pending'] as $uuid => $job) { |
| @@ -733,14 +799,68 @@ | ||
| 733 | 799 | } |
| 734 | 800 | |
| 735 | 801 | /** |
| 802 | + * 返信(Note等)をコメントにする | |
| 803 | + * | |
| 804 | + * @param mixed $object CreateのobjectかそのID | |
| 805 | + * @param string $actor アクティビティのアクター | |
| 806 | + * @return bool コメントにしたらtrue | |
| 807 | + */ | |
| 808 | + private function commentFromNote(mixed $object, string $actor): bool { | |
| 809 | + if (is_string($object)) $object = Remote::object($object); | |
| 810 | + if (!is_array($object) || !in_array($object['type'] ?? '', ['Note', 'Article', 'Page'], true)) return false; | |
| 811 | + $parent = Fedi::id($object['inReplyTo'] ?? null); | |
| 812 | + if ($parent === null) return false; | |
| 813 | + | |
| 814 | + // 返信先:ブログの投稿か、Fediverseから受け取ったコメント | |
| 815 | + $post = $this->postByObjectId($parent); | |
| 816 | + $target = $post !== null ? [($post['section'] ?? 'blog').'/'.$post['slug'], null, (string)$post['title']] : Comments::findRemote($parent); | |
| 817 | + if ($target === null) return false; | |
| 818 | + | |
| 819 | + if (Fedi::id($object['attributedTo'] ?? null) !== $actor) return false; | |
| 820 | + $id = Fedi::id($object['id'] ?? null); | |
| 821 | + if ($id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) return false; | |
| 822 | + // DMやフォロワー限定の返信は公開しない | |
| 823 | + $vis = Fedi::visibility(Fedi::ids($object['to'] ?? []), Fedi::ids($object['cc'] ?? [])); | |
| 824 | + if (!in_array($vis, ['public', 'unlisted'], true)) return false; | |
| 825 | + | |
| 826 | + $doc = Remote::actor($actor); | |
| 827 | + if ($doc === null) return false; | |
| 828 | + $res = Comments::addRemote($target[0], $target[2], $doc, $object, $target[1]); | |
| 829 | + if (!$res->isSuccess) logger(LogType::ActivityPub, "「{$id}」をコメントに出来ませんでした:{$res->message}"); | |
| 830 | + return $res->isSuccess; | |
| 831 | + } | |
| 832 | + | |
| 833 | + /** | |
| 834 | + * NoteのID(/ap/objects/{UUID}又は/ap/objects/{ブログ}/{UUID})から公開投稿を探す | |
| 835 | + */ | |
| 836 | + private function postByObjectId(string $id): ?array { | |
| 837 | + $prefix = "{$this->domain}/ap/objects/"; | |
| 838 | + if (!str_starts_with($id, $prefix)) return null; | |
| 839 | + $key = explode('/', substr($id, strlen($prefix))); | |
| 840 | + if (count($key) === 1) return $this->findPublicPost($key[0], 'blog'); | |
| 841 | + return count($key) === 2 ? $this->findPublicPost($key[1], $key[0]) : null; | |
| 842 | + } | |
| 843 | + | |
| 844 | + /** | |
| 845 | + * 投稿のIDの部分:blogはUUIDだけ(今まで通り)、他のブログは「ブログ/UUID」 | |
| 846 | + * 翻訳した記事は同じUUIDなので、ブログ毎に違うIDにする | |
| 847 | + */ | |
| 848 | + private static function postKey(array $post): string { | |
| 849 | + $section = $post['section'] ?? 'blog'; | |
| 850 | + return $section === 'blog' ? $post['uuid'] : "{$section}/{$post['uuid']}"; | |
| 851 | + } | |
| 852 | + | |
| 853 | + /** | |
| 736 | 854 | * 投稿からCreateアクティビティを作る(getActivity・getOutbox・federateで共通) |
| 737 | 855 | * |
| 738 | 856 | * @param array $post 投稿データ |
| 739 | 857 | * @return array アクティビティ |
| 740 | 858 | */ |
| 741 | 859 | private function buildCreate(array $post): array { |
| 742 | - $uuid = $post['uuid']; | |
| 743 | - $url = "{$this->domain}/blog/".rawurlencode($post['slug']); | |
| 860 | + $uuid = self::postKey($post); | |
| 861 | + $section = $post['section'] ?? 'blog'; | |
| 862 | + $url = "{$this->domain}/{$section}/".rawurlencode($post['slug']); | |
| 863 | + $more = blogs()[$section]['more'] ?? '読み続き'; | |
| 744 | 864 | // 投稿の日付はPHPのタイムゾーンの時刻なので、UTCに変換する |
| 745 | 865 | $published = gmdate('Y-m-d\TH:i:s\Z', strtotime($post['date']) ?: time()); |
| 746 | 866 | $esc = fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| @@ -754,7 +874,7 @@ | ||
| 754 | 874 | "{$this->domain}/ap/followers", |
| 755 | 875 | ], |
| 756 | 876 | 'to' => ['https://www.w3.org/ns/activitystreams#Public'], |
| 757 | - 'content' => $esc($post['preview'])."<br /><br /><a href=\"{$esc($url)}\">読み続き</a>", | |
| 877 | + 'content' => $esc($post['preview'])."<br /><br /><a href=\"{$esc($url)}\">{$esc($more)}</a>", | |
| 758 | 878 | 'url' => $url, |
| 759 | 879 | 'published' => $published, |
| 760 | 880 | 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies", |
| @@ -808,12 +928,13 @@ | ||
| 808 | 928 | * UUIDで公開投稿を探す |
| 809 | 929 | * |
| 810 | 930 | * @param string $uuid 投稿のUUID |
| 931 | + * @param string $section ブログのディレクトリ | |
| 811 | 932 | * @return array|null 投稿データ、無い(又は非公開)場合はnull |
| 812 | 933 | */ |
| 813 | - private function findPublicPost(string $uuid): ?array { | |
| 934 | + private function findPublicPost(string $uuid, string $section = 'blog'): ?array { | |
| 814 | 935 | if ($uuid === '') return null; |
| 815 | 936 | foreach ($this->federatablePosts() as $post) { |
| 816 | - if ($post['uuid'] === $uuid) return $post; | |
| 937 | + if ($post['uuid'] === $uuid && ($post['section'] ?? 'blog') === $section) return $post; | |
| 817 | 938 | } |
| 818 | 939 | return null; |
| 819 | 940 | } |
| @@ -845,6 +966,7 @@ | ||
| 845 | 966 | * @return array 投稿データの配列 |
| 846 | 967 | */ |
| 847 | 968 | private function federatablePosts(): array { |
| 969 | + if ($this->posts instanceof \Closure) $this->posts = ($this->posts)(); | |
| 848 | 970 | $now = time(); |
| 849 | 971 | $out = []; |
| 850 | 972 |
| @@ -0,0 +1,584 @@ | ||
| 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\Remote; | |
| 43 | +use Result; | |
| 44 | +use Roles; | |
| 45 | + | |
| 46 | +/** | |
| 47 | + * 記事のコメント(ローカルのユーザーと、Fediverseからの返信) | |
| 48 | + * | |
| 49 | + * スレッドは記事毎に1つ。名前は記事のMarkdownのパスから「.md」を除いた物(「blog/記事」「tutorial/科目/記事」等)で、 | |
| 50 | + * 記事のURLと同じ。データは全部data/の下のJSONファイル: | |
| 51 | + * comments/{スレッド}.json コメント(古い順) | |
| 52 | + * comments/ap.json Fediverseの返信のID => スレッドとコメント(返信への返信・編集・削除の為) | |
| 53 | + * notifications/{ユーザー名}.json 通知(新しい順) | |
| 54 | + * | |
| 55 | + * 新しいコメントは全部の管理者に通知する。ローカルのユーザーには、自分のコメントへの返信といいねを通知する。 | |
| 56 | + */ | |
| 57 | +final class Comments { | |
| 58 | + /** コメントの最大文字数 */ | |
| 59 | + public const MAX_LENGTH = 5000; | |
| 60 | + /** 返信の字下げの最大の深さ(これより深い返信は同じ深さで表示する) */ | |
| 61 | + public const MAX_DEPTH = 5; | |
| 62 | + /** 1人の通知の最大数 */ | |
| 63 | + public const MAX_NOTIFICATIONS = 200; | |
| 64 | + | |
| 65 | + /** @var string|null 保存先(テスト用。既定はdata) */ | |
| 66 | + public static ?string $dataDir = null; | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * コメント機能が有効か(config.phpのCOMMENTS_ENABLED) | |
| 70 | + */ | |
| 71 | + public static function enabled(): bool { | |
| 72 | + return defined('COMMENTS_ENABLED') && COMMENTS_ENABLED; | |
| 73 | + } | |
| 74 | + | |
| 75 | + /** | |
| 76 | + * スレッドの名前が正しいか(「ディレクトリ/記事」又は「ディレクトリ/科目/記事」) | |
| 77 | + */ | |
| 78 | + public static function validThread(string $thread): bool { | |
| 79 | + return (bool)preg_match('#^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*){1,2}$#', $thread); | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * コメント出来るユーザーか(ログイン中、BANされていない、メールアドレス確認済み) | |
| 84 | + */ | |
| 85 | + public static function canPost(?\stdClass $user): bool { | |
| 86 | + return $user !== null && !empty($user->username) && ($user->role ?? Roles::BANNED) !== Roles::BANNED && ($user->verified ?? true) !== false; | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** | |
| 90 | + * 管理者(コメントを削除出来る)か | |
| 91 | + */ | |
| 92 | + public static function isModerator(?\stdClass $user): bool { | |
| 93 | + return $user !== null && (bool)(($user->role ?? 0) & (Roles::ADMIN | Roles::STAFF)); | |
| 94 | + } | |
| 95 | + | |
| 96 | + //////// 読む | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * スレッドのコメント(古い順、削除済みを含む) | |
| 100 | + * | |
| 101 | + * @param string $thread スレッド | |
| 102 | + * @return array ID => コメント | |
| 103 | + */ | |
| 104 | + public static function all(string $thread): array { | |
| 105 | + if (!self::validThread($thread)) return []; | |
| 106 | + return Fedi::read(self::threadPath($thread), ['comments' => []])['comments'] ?? []; | |
| 107 | + } | |
| 108 | + | |
| 109 | + /** | |
| 110 | + * 削除されていないコメントの数 | |
| 111 | + */ | |
| 112 | + public static function count(string $thread): int { | |
| 113 | + return count(array_filter(self::all($thread), fn($c) => empty($c['deleted']))); | |
| 114 | + } | |
| 115 | + | |
| 116 | + /** | |
| 117 | + * 表示用のコメント(返信は親の下、深さ付き) | |
| 118 | + * | |
| 119 | + * @param string $thread スレッド | |
| 120 | + * @param \stdClass|null $viewer 見ているユーザー | |
| 121 | + * @return array コメントの配列(id、depth、html、who、at、likes、liked、canLike、canReply、canDelete、deleted) | |
| 122 | + */ | |
| 123 | + public static function view(string $thread, ?\stdClass $viewer): array { | |
| 124 | + $all = self::all($thread); | |
| 125 | + $children = []; | |
| 126 | + foreach ($all as $id => $c) { | |
| 127 | + $parent = $c['parent'] ?? null; | |
| 128 | + // 親が無い(壊れた)返信は一番上に表示する | |
| 129 | + $children[$parent !== null && isset($all[$parent]) ? $parent : ''][] = $id; | |
| 130 | + } | |
| 131 | + | |
| 132 | + $out = []; | |
| 133 | + $canPost = self::canPost($viewer); | |
| 134 | + $walk = function(string $parent, int $depth) use (&$walk, &$out, $all, $children, $viewer, $canPost): void { | |
| 135 | + foreach ($children[$parent] ?? [] as $id) { | |
| 136 | + $c = $all[$id]; | |
| 137 | + $deleted = !empty($c['deleted']); | |
| 138 | + $likes = (array)($c['likes'] ?? []); | |
| 139 | + $mine = $viewer !== null && ($c['author']['username'] ?? null) === ($viewer->username ?? ''); | |
| 140 | + $out[] = [ | |
| 141 | + 'id' => $id, | |
| 142 | + 'depth' => min($depth, self::MAX_DEPTH), | |
| 143 | + 'deleted' => $deleted, | |
| 144 | + 'html' => $deleted ? '' : (string)($c['html'] ?? ''), | |
| 145 | + 'who' => self::who($c['author'] ?? []), | |
| 146 | + 'at' => date('Y-m-d H:i', (int)($c['at'] ?? 0)), | |
| 147 | + 'edited' => !$deleted && !empty($c['edited']), | |
| 148 | + 'likes' => count($likes), | |
| 149 | + 'liked' => $viewer !== null && isset($likes[$viewer->username ?? '']), | |
| 150 | + 'canLike' => $canPost && !$deleted && !$mine, | |
| 151 | + 'canReply' => $canPost && !$deleted, | |
| 152 | + 'canDelete' => !$deleted && $viewer !== null && ($mine || self::isModerator($viewer)), | |
| 153 | + ]; | |
| 154 | + $walk($id, $depth + 1); | |
| 155 | + } | |
| 156 | + }; | |
| 157 | + $walk('', 0); | |
| 158 | + return $out; | |
| 159 | + } | |
| 160 | + | |
| 161 | + /** | |
| 162 | + * Fediverseの返信のIDからコメントを探す | |
| 163 | + * | |
| 164 | + * @param string $apId リモートの投稿のID | |
| 165 | + * @return array|null [スレッド, コメントのID, 記事のタイトル] | |
| 166 | + */ | |
| 167 | + public static function findRemote(string $apId): ?array { | |
| 168 | + $ref = Fedi::read(self::path('comments/ap.json'))[$apId] ?? null; | |
| 169 | + if (!is_array($ref) || !isset(self::all($ref['thread'])[$ref['id']])) return null; | |
| 170 | + $title = Fedi::read(self::threadPath($ref['thread']), [])['title'] ?? ''; | |
| 171 | + return [$ref['thread'], $ref['id'], $title]; | |
| 172 | + } | |
| 173 | + | |
| 174 | + //////// 書く | |
| 175 | + | |
| 176 | + /** | |
| 177 | + * ローカルのユーザーのコメント(本文はMarkdown) | |
| 178 | + * | |
| 179 | + * @param string $thread スレッド | |
| 180 | + * @param string $title 記事のタイトル(通知用) | |
| 181 | + * @param \stdClass $user コメントするユーザー | |
| 182 | + * @param string $text 本文 | |
| 183 | + * @param string|null $parent 返信先のコメントのID | |
| 184 | + * @return Result data:id | |
| 185 | + */ | |
| 186 | + public static function addLocal(string $thread, string $title, \stdClass $user, string $text, ?string $parent = null): Result { | |
| 187 | + if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); | |
| 188 | + if (!self::canPost($user)) return Result::Error('エラー:コメントする権限がありません。'); | |
| 189 | + $text = trim(str_replace(["\r\n", "\r"], "\n", $text)); | |
| 190 | + if ($text === '') return Result::Error('エラー:コメントをご入力下さい。'); | |
| 191 | + if (mb_strlen($text) > self::MAX_LENGTH) return Result::Error('エラー:コメントは'.self::MAX_LENGTH.'文字以内でご入力下さい。'); | |
| 192 | + | |
| 193 | + return self::add($thread, $title, [ | |
| 194 | + 'author' => ['username' => $user->username], | |
| 195 | + 'source' => $text, | |
| 196 | + 'html' => Html::render($text, [], false), | |
| 197 | + ], $parent); | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** | |
| 201 | + * Fediverseからの返信(受付ボックスで検証済みの事) | |
| 202 | + * | |
| 203 | + * @param string $thread スレッド | |
| 204 | + * @param string $title 記事のタイトル | |
| 205 | + * @param array $actor 返信したアクター | |
| 206 | + * @param array $obj 返信(Note等) | |
| 207 | + * @param string|null $parent 返信先のコメントのID(記事への返信ならnull) | |
| 208 | + * @return Result data:id | |
| 209 | + */ | |
| 210 | + public static function addRemote(string $thread, string $title, array $actor, array $obj, ?string $parent = null): Result { | |
| 211 | + if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); | |
| 212 | + $apId = (string)($obj['id'] ?? ''); | |
| 213 | + if ($apId === '') return Result::Error('エラー:IDがありません。'); | |
| 214 | + // 同じ返信が2回届いても(再送等)1つにする | |
| 215 | + $known = self::findRemote($apId); | |
| 216 | + if ($known !== null) return Result::Success('', ['id' => $known[1]]); | |
| 217 | + | |
| 218 | + $res = self::add($thread, $title, [ | |
| 219 | + 'author' => self::remoteAuthor($actor), | |
| 220 | + 'html' => self::remoteHtml($obj), | |
| 221 | + 'apId' => $apId, | |
| 222 | + ], $parent); | |
| 223 | + if ($res->isSuccess) { | |
| 224 | + Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apId, $thread, $res, $actor): array { | |
| 225 | + $l[$apId] = ['thread' => $thread, 'id' => $res->data['id'], 'actor' => (string)($actor['id'] ?? '')]; | |
| 226 | + return $l; | |
| 227 | + }); | |
| 228 | + } | |
| 229 | + return $res; | |
| 230 | + } | |
| 231 | + | |
| 232 | + /** | |
| 233 | + * Fediverseで編集された返信を更新する | |
| 234 | + * | |
| 235 | + * @param array $obj 返信(Note等) | |
| 236 | + * @param string $actor アクティビティのアクター | |
| 237 | + * @return bool 更新したらtrue | |
| 238 | + */ | |
| 239 | + public static function updateRemote(array $obj, string $actor): bool { | |
| 240 | + $id = Fedi::id($obj['id'] ?? null); | |
| 241 | + $ref = $id !== null ? self::remoteRef($id) : null; | |
| 242 | + if ($ref === null || $ref['actor'] !== $actor || Fedi::id($obj['attributedTo'] ?? null) !== $actor) return false; | |
| 243 | + $html = self::remoteHtml($obj); | |
| 244 | + return self::modify($ref['thread'], function(array $t) use ($ref, $html): array { | |
| 245 | + if (isset($t['comments'][$ref['id']]) && empty($t['comments'][$ref['id']]['deleted'])) { | |
| 246 | + $t['comments'][$ref['id']]['html'] = $html; | |
| 247 | + $t['comments'][$ref['id']]['edited'] = time(); | |
| 248 | + } | |
| 249 | + return $t; | |
| 250 | + }); | |
| 251 | + } | |
| 252 | + | |
| 253 | + /** | |
| 254 | + * Fediverseで削除された返信を消す | |
| 255 | + * | |
| 256 | + * @param string $apId 返信のID | |
| 257 | + * @param string $actor アクティビティのアクター(返信した本人だけ消せる) | |
| 258 | + * @return bool 消したらtrue | |
| 259 | + */ | |
| 260 | + public static function deleteRemote(string $apId, string $actor): bool { | |
| 261 | + $ref = self::remoteRef($apId); | |
| 262 | + if ($ref === null || $ref['actor'] !== $actor) return false; | |
| 263 | + self::remove($ref['thread'], $ref['id']); | |
| 264 | + return true; | |
| 265 | + } | |
| 266 | + | |
| 267 | + /** | |
| 268 | + * 削除されたアカウントの返信を全部消す | |
| 269 | + * | |
| 270 | + * @param string $actor アクターのURL | |
| 271 | + * @return int 消した数 | |
| 272 | + */ | |
| 273 | + public static function forgetActor(string $actor): int { | |
| 274 | + $n = 0; | |
| 275 | + foreach (Fedi::read(self::path('comments/ap.json')) as $apId => $ref) { | |
| 276 | + if (($ref['actor'] ?? '') === $actor && self::deleteRemote((string)$apId, $actor)) $n++; | |
| 277 | + } | |
| 278 | + return $n; | |
| 279 | + } | |
| 280 | + | |
| 281 | + /** | |
| 282 | + * いいね(もう一度でいいねを取り消す) | |
| 283 | + * | |
| 284 | + * @param string $thread スレッド | |
| 285 | + * @param string $id コメントのID | |
| 286 | + * @param \stdClass $user いいねするユーザー | |
| 287 | + * @return Result data:liked(いいねした状態ならtrue) | |
| 288 | + */ | |
| 289 | + public static function like(string $thread, string $id, \stdClass $user): Result { | |
| 290 | + if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); | |
| 291 | + if (!self::canPost($user)) return Result::Error('エラー:いいねする権限がありません。'); | |
| 292 | + $c = self::all($thread)[$id] ?? null; | |
| 293 | + if ($c === null || !empty($c['deleted'])) return Result::Error('エラー:コメントが見つかりません。'); | |
| 294 | + $author = $c['author']['username'] ?? null; | |
| 295 | + if ($author === $user->username) return Result::Error('エラー:自分のコメントにはいいね出来ません。'); | |
| 296 | + | |
| 297 | + $liked = false; | |
| 298 | + self::modify($thread, function(array $t) use ($id, $user, &$liked): array { | |
| 299 | + if (!isset($t['comments'][$id])) return $t; | |
| 300 | + $likes = (array)($t['comments'][$id]['likes'] ?? []); | |
| 301 | + if (isset($likes[$user->username])) unset($likes[$user->username]); | |
| 302 | + else $likes[$user->username] = time(); | |
| 303 | + $liked = isset($likes[$user->username]); | |
| 304 | + $t['comments'][$id]['likes'] = $likes; | |
| 305 | + return $t; | |
| 306 | + }); | |
| 307 | + | |
| 308 | + if ($liked && $author !== null) { | |
| 309 | + $title = Fedi::read(self::threadPath($thread), [])['title'] ?? ''; | |
| 310 | + self::notify($author, 'like', $thread, $id, $title, ['username' => $user->username]); | |
| 311 | + } | |
| 312 | + return Result::Success('', ['liked' => $liked]); | |
| 313 | + } | |
| 314 | + | |
| 315 | + /** | |
| 316 | + * コメントを削除する(本人又は管理者) | |
| 317 | + * 返信があるコメントは「削除されました」として残し、返信が無ければ完全に消す | |
| 318 | + * | |
| 319 | + * @param string $thread スレッド | |
| 320 | + * @param string $id コメントのID | |
| 321 | + * @param \stdClass $user 削除するユーザー | |
| 322 | + * @return Result | |
| 323 | + */ | |
| 324 | + public static function delete(string $thread, string $id, \stdClass $user): Result { | |
| 325 | + if (!self::enabled()) return Result::Error('エラー:コメントは無効です。'); | |
| 326 | + $c = self::all($thread)[$id] ?? null; | |
| 327 | + if ($c === null || !empty($c['deleted'])) return Result::Error('エラー:コメントが見つかりません。'); | |
| 328 | + $mine = ($c['author']['username'] ?? null) === ($user->username ?? ''); | |
| 329 | + if (!$mine && !self::isModerator($user)) return Result::Error('エラー:権限がありません。'); | |
| 330 | + self::remove($thread, $id); | |
| 331 | + return Result::Success(); | |
| 332 | + } | |
| 333 | + | |
| 334 | + //////// 通知 | |
| 335 | + | |
| 336 | + /** | |
| 337 | + * 通知(新しい順) | |
| 338 | + * | |
| 339 | + * @param string $username ユーザー名 | |
| 340 | + * @return array type(comment、reply、like)、thread、comment、title、from、at、read | |
| 341 | + */ | |
| 342 | + public static function notifications(string $username): array { | |
| 343 | + if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return []; | |
| 344 | + return Fedi::read(self::path("notifications/{$username}.json")); | |
| 345 | + } | |
| 346 | + | |
| 347 | + public static function unreadCount(string $username): int { | |
| 348 | + return count(array_filter(self::notifications($username), fn($n) => empty($n['read']))); | |
| 349 | + } | |
| 350 | + | |
| 351 | + public static function markRead(string $username): void { | |
| 352 | + if (!preg_match('/^[A-Za-z0-9]+$/', $username) || self::unreadCount($username) === 0) return; | |
| 353 | + Fedi::update(self::path("notifications/{$username}.json"), fn($l) => array_map(fn($n) => ['read' => true] + $n, $l)); | |
| 354 | + } | |
| 355 | + | |
| 356 | + /** | |
| 357 | + * 通知の表示用のデータ | |
| 358 | + * | |
| 359 | + * @param array $n 通知 | |
| 360 | + * @return array who(表示名等)、label、page(コメントのURL)、excerpt、at、read | |
| 361 | + */ | |
| 362 | + public static function present(array $n): array { | |
| 363 | + $thread = (string)($n['thread'] ?? ''); | |
| 364 | + $c = self::all($thread)[$n['comment'] ?? ''] ?? null; | |
| 365 | + $title = (string)($n['title'] ?? ''); | |
| 366 | + return [ | |
| 367 | + 'type' => $n['type'] ?? '', | |
| 368 | + 'who' => self::who((array)($n['from'] ?? [])), | |
| 369 | + 'label' => match ($n['type'] ?? '') { | |
| 370 | + 'comment' => 'が「'.($title !== '' ? $title : $thread).'」にコメントしました', | |
| 371 | + 'reply' => 'があなたのコメントに返信しました', | |
| 372 | + 'like' => 'があなたのコメントにいいねしました', | |
| 373 | + default => '', | |
| 374 | + }, | |
| 375 | + 'page' => '/'.$thread.'#c-'.rawurlencode((string)($n['comment'] ?? '')), | |
| 376 | + // ブロック(コード、リスト等)の間は空ける | |
| 377 | + 'excerpt' => $c !== null && empty($c['deleted']) | |
| 378 | + ? Html::text(preg_replace('#</(?:pre|li|blockquote|summary)>#i', '$0 ', (string)($c['html'] ?? '')), 80) : '(削除されました)', | |
| 379 | + 'at' => date('Y-m-d H:i', (int)($n['at'] ?? 0)), | |
| 380 | + 'read' => !empty($n['read']), | |
| 381 | + ]; | |
| 382 | + } | |
| 383 | + | |
| 384 | + //////// 内部 | |
| 385 | + | |
| 386 | + /** | |
| 387 | + * コメントを追加し、通知する | |
| 388 | + * | |
| 389 | + * @param array $c author、html、source又はapId | |
| 390 | + * @return Result data:id | |
| 391 | + */ | |
| 392 | + private static function add(string $thread, string $title, array $c, ?string $parent): Result { | |
| 393 | + if (!self::validThread($thread)) return Result::Error('エラー:不正なスレッドです。'); | |
| 394 | + $parent = $parent === '' ? null : $parent; | |
| 395 | + $all = self::all($thread); | |
| 396 | + if ($parent !== null && (!isset($all[$parent]) || !empty($all[$parent]['deleted']))) { | |
| 397 | + return Result::Error('エラー:返信先のコメントが見つかりません。'); | |
| 398 | + } | |
| 399 | + | |
| 400 | + $id = Fedi::newId(); | |
| 401 | + $c = ['id' => $id, 'parent' => $parent, 'at' => time()] + $c + ['likes' => []]; | |
| 402 | + $ok = self::modify($thread, function(array $t) use ($id, $c, $title): array { | |
| 403 | + $t['title'] = $title !== '' ? $title : ($t['title'] ?? ''); | |
| 404 | + $t['comments'][$id] = $c; | |
| 405 | + return $t; | |
| 406 | + }); | |
| 407 | + if (!$ok) return Result::Error('エラー:コメントの保存に失敗しました。'); | |
| 408 | + | |
| 409 | + // 返信先がローカルのユーザーなら「返信」、管理者には「コメント」(両方なら返信だけ) | |
| 410 | + $from = $c['author']; | |
| 411 | + $me = $from['username'] ?? null; | |
| 412 | + $to = []; | |
| 413 | + $parentAuthor = $parent !== null ? ($all[$parent]['author']['username'] ?? null) : null; | |
| 414 | + if ($parentAuthor !== null && $parentAuthor !== $me) $to[$parentAuthor] = 'reply'; | |
| 415 | + foreach (self::admins() as $admin) { | |
| 416 | + if ($admin !== $me && !isset($to[$admin])) $to[$admin] = 'comment'; | |
| 417 | + } | |
| 418 | + foreach ($to as $u => $type) self::notify($u, $type, $thread, $id, $title, $from); | |
| 419 | + | |
| 420 | + return Result::Success('', ['id' => $id]); | |
| 421 | + } | |
| 422 | + | |
| 423 | + /** | |
| 424 | + * コメントを消す(返信があれば中身だけ消す)。中身だけ消したコメントは、返信が無くなれば完全に消す | |
| 425 | + */ | |
| 426 | + private static function remove(string $thread, string $id): void { | |
| 427 | + $apIds = []; | |
| 428 | + self::modify($thread, function(array $t) use ($id, &$apIds): array { | |
| 429 | + $all = &$t['comments']; | |
| 430 | + if (!isset($all[$id])) return $t; | |
| 431 | + $hasChildren = function(string $x) use (&$all): bool { | |
| 432 | + foreach ($all as $c) if (($c['parent'] ?? null) === $x) return true; | |
| 433 | + return false; | |
| 434 | + }; | |
| 435 | + | |
| 436 | + if ($hasChildren($id)) { | |
| 437 | + if (isset($all[$id]['apId'])) $apIds[] = $all[$id]['apId']; | |
| 438 | + $all[$id] = ['id' => $id, 'parent' => $all[$id]['parent'] ?? null, 'at' => $all[$id]['at'] ?? 0, 'author' => [], 'deleted' => true]; | |
| 439 | + return $t; | |
| 440 | + } | |
| 441 | + for ($x = $id; $x !== null && isset($all[$x]) && ($x === $id || (!empty($all[$x]['deleted']) && !$hasChildren($x)));) { | |
| 442 | + $parent = $all[$x]['parent'] ?? null; | |
| 443 | + if (isset($all[$x]['apId'])) $apIds[] = $all[$x]['apId']; | |
| 444 | + unset($all[$x]); | |
| 445 | + $x = $parent; | |
| 446 | + } | |
| 447 | + return $t; | |
| 448 | + }); | |
| 449 | + if ($apIds) { | |
| 450 | + Fedi::update(self::path('comments/ap.json'), function(array $l) use ($apIds): array { | |
| 451 | + foreach ($apIds as $a) unset($l[$a]); | |
| 452 | + return $l; | |
| 453 | + }); | |
| 454 | + } | |
| 455 | + } | |
| 456 | + | |
| 457 | + /** | |
| 458 | + * スレッドのファイルをロックして書き換える | |
| 459 | + * | |
| 460 | + * @param callable $fn スレッド(title、comments)を受け取り、新しいスレッドを返す | |
| 461 | + * @return bool 保存出来たらtrue | |
| 462 | + */ | |
| 463 | + private static function modify(string $thread, callable $fn): bool { | |
| 464 | + if (!self::validThread($thread)) return false; | |
| 465 | + $path = self::threadPath($thread); | |
| 466 | + $new = Fedi::update($path, function(array $t) use ($fn): array { | |
| 467 | + $t += ['title' => '', 'comments' => []]; | |
| 468 | + return $fn($t); | |
| 469 | + }, ['title' => '', 'comments' => []]); | |
| 470 | + return is_array($new) && is_file($path); | |
| 471 | + } | |
| 472 | + | |
| 473 | + private static function notify(string $username, string $type, string $thread, string $comment, string $title, array $from): void { | |
| 474 | + if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return; | |
| 475 | + Fedi::update(self::path("notifications/{$username}.json"), function(array $l) use ($type, $thread, $comment, $title, $from): array { | |
| 476 | + // 同じいいねを(取り消して又いいねしても)重複させない | |
| 477 | + foreach ($l as $n) { | |
| 478 | + if (empty($n['read']) && $n['type'] === $type && $n['comment'] === $comment && ($n['from'] ?? []) === $from) return $l; | |
| 479 | + } | |
| 480 | + array_unshift($l, ['type' => $type, 'thread' => $thread, 'comment' => $comment, 'title' => $title, 'from' => $from, 'at' => time(), 'read' => false]); | |
| 481 | + return array_slice($l, 0, self::MAX_NOTIFICATIONS); | |
| 482 | + }); | |
| 483 | + } | |
| 484 | + | |
| 485 | + /** | |
| 486 | + * 管理者のユーザー名 | |
| 487 | + */ | |
| 488 | + private static function admins(): array { | |
| 489 | + $dir = Fedi::$userDir ?? ROOT.'/data/user/'; | |
| 490 | + $out = []; | |
| 491 | + foreach (scandir($dir) ?: [] as $f) { | |
| 492 | + if (!preg_match('/^\d+\.([A-Za-z0-9]+)\.json$/', $f, $m)) continue; | |
| 493 | + $u = json_decode((string)file_get_contents($dir.$f)); | |
| 494 | + if ($u instanceof \stdClass && (($u->role ?? 0) & Roles::ADMIN)) $out[] = $m[1]; | |
| 495 | + } | |
| 496 | + return $out; | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** | |
| 500 | + * 表示用の名前等(ローカルのユーザー又はリモートのアクター) | |
| 501 | + * | |
| 502 | + * @param array $author username、又はactor・name・handle・url・icon | |
| 503 | + * @return array name(HTML)、handle、url、avatar、remote | |
| 504 | + */ | |
| 505 | + private static function who(array $author): array { | |
| 506 | + if (isset($author['username'])) { | |
| 507 | + $name = (string)$author['username']; | |
| 508 | + $u = Fedi::localUser($name); | |
| 509 | + return [ | |
| 510 | + 'name' => $u ? namecolor($u) : Html::esc($name), | |
| 511 | + 'handle' => '', | |
| 512 | + 'url' => AUTH_ENABLED ? '/profile/'.rawurlencode($name) : '', | |
| 513 | + 'avatar' => $u ? avatar_url($u) : '/static/user/noicon.png', | |
| 514 | + 'remote' => false, | |
| 515 | + ]; | |
| 516 | + } | |
| 517 | + if (empty($author)) return ['name' => '', 'handle' => '', 'url' => '', 'avatar' => '/static/user/noicon.png', 'remote' => false]; | |
| 518 | + $icon = (string)($author['icon'] ?? ''); | |
| 519 | + return [ | |
| 520 | + 'name' => Html::esc((string)($author['name'] ?? '')), | |
| 521 | + 'handle' => (string)($author['handle'] ?? ''), | |
| 522 | + 'url' => (string)($author['url'] ?? ''), | |
| 523 | + // リモートの画像は直接読み込まない(閲覧者のIPアドレスが渡る)。マルチユーザーではこのサイト経由で表示する | |
| 524 | + 'avatar' => $icon !== '' && Fedi::enabled() ? Remote::imageUrl($icon) : '/static/user/noicon.png', | |
| 525 | + 'remote' => true, | |
| 526 | + ]; | |
| 527 | + } | |
| 528 | + | |
| 529 | + /** | |
| 530 | + * リモートのアクターから保存する情報(表示用) | |
| 531 | + */ | |
| 532 | + private static function remoteAuthor(array $actor): array { | |
| 533 | + $url = fn(mixed $u): string => is_string($u) && preg_match('#^https?://[^\s"<>]+$#i', $u) ? $u : ''; | |
| 534 | + $icon = $actor['icon'] ?? null; | |
| 535 | + if (is_array($icon) && array_is_list($icon)) $icon = $icon[0] ?? null; | |
| 536 | + if (is_array($icon)) $icon = $icon['url'] ?? null; | |
| 537 | + if (is_array($icon)) $icon = $icon['href'] ?? null; | |
| 538 | + $id = (string)($actor['id'] ?? ''); | |
| 539 | + $profile = is_array($actor['url'] ?? null) ? Fedi::id($actor['url']) : ($actor['url'] ?? null); | |
| 540 | + return [ | |
| 541 | + 'actor' => $id, | |
| 542 | + 'name' => mb_substr(trim(strip_tags((string)(($actor['name'] ?? '') ?: ($actor['preferredUsername'] ?? '')))), 0, 100) ?: Remote::handle($actor), | |
| 543 | + 'handle' => Remote::handle($actor), | |
| 544 | + 'url' => $url($profile) ?: $url($id), | |
| 545 | + 'icon' => $url($icon), | |
| 546 | + ]; | |
| 547 | + } | |
| 548 | + | |
| 549 | + /** | |
| 550 | + * リモートの返信の本文(安全なHTML)。先頭のブログのアクターへのメンションは外す | |
| 551 | + */ | |
| 552 | + private static function remoteHtml(array $obj): string { | |
| 553 | + $content = $obj['content'] ?? ''; | |
| 554 | + if (!is_string($content) && is_array($obj['contentMap'] ?? null)) $content = (string)(array_values($obj['contentMap'])[0] ?? ''); | |
| 555 | + $html = Html::sanitize(mb_substr((string)$content, 0, 50000)); | |
| 556 | + | |
| 557 | + $blog = array_map(fn($u) => preg_quote(Html::esc($u), '#'), [Fedi::blogActorUrl(), Fedi::base(), Fedi::base().'/']); | |
| 558 | + $html = preg_replace('#^(<p>)?(?:<span class="h-card">)?<a href="(?:'.implode('|', $blog).')"[^>]*>@?(?:<span>)?[^<]*(?:</span>)?</a>(?:</span>)?\s*#u', '$1', $html) ?? $html; | |
| 559 | + $html = preg_replace('#^<p>\s*</p>#', '', $html) ?? $html; | |
| 560 | + | |
| 561 | + $summary = trim((string)($obj['summary'] ?? '')); | |
| 562 | + if ($summary !== '') $html = '<details><summary>'.Html::esc(Html::text($summary, 200)).'</summary>'.$html.'</details>'; | |
| 563 | + return $html; | |
| 564 | + } | |
| 565 | + | |
| 566 | + private static function remoteRef(string $apId): ?array { | |
| 567 | + $ref = Fedi::read(self::path('comments/ap.json'))[$apId] ?? null; | |
| 568 | + return is_array($ref) && isset($ref['thread'], $ref['id'], $ref['actor']) ? $ref : null; | |
| 569 | + } | |
| 570 | + | |
| 571 | + private static function threadPath(string $thread): string { | |
| 572 | + return self::path("comments/{$thread}.json"); | |
| 573 | + } | |
| 574 | + | |
| 575 | + /** | |
| 576 | + * data/の下のパス(ディレクトリは作る) | |
| 577 | + */ | |
| 578 | + private static function path(string $rel): string { | |
| 579 | + $path = rtrim(self::$dataDir ?? ROOT.'/data', '/').'/'.$rel; | |
| 580 | + $dir = dirname($path); | |
| 581 | + if (!is_dir($dir)) @mkdir($dir, 0700, true); | |
| 582 | + return $path; | |
| 583 | + } | |
| 584 | +} |
| @@ -92,22 +92,23 @@ | ||
| 92 | 92 | * |
| 93 | 93 | * @param string $text 本文 |
| 94 | 94 | * @param array $mentionUrls 「@user@domain」 => アクターのURL(解決出来たメンションだけリンクにする) |
| 95 | + * @param bool $hashtags ハッシュタグをマイクロブログのタグのページへのリンクにする(記事のコメント等ではfalse) | |
| 95 | 96 | * @return string HTML |
| 96 | 97 | */ |
| 97 | - public static function render(string $text, array $mentionUrls = []): string { | |
| 98 | + public static function render(string $text, array $mentionUrls = [], bool $hashtags = true): string { | |
| 98 | 99 | $text = str_replace(["\r\n", "\r", "\0"], ["\n", "\n", ''], trim($text)); |
| 99 | - return self::blocks(explode("\n", $text), $mentionUrls, 0); | |
| 100 | + return self::blocks(explode("\n", $text), $mentionUrls, 0, $hashtags); | |
| 100 | 101 | } |
| 101 | 102 | |
| 102 | 103 | /** |
| 103 | 104 | * Markdownのブロック:段落(空行で区切り、改行は<br>)、```コード```、> 引用、- と 1. のリスト |
| 104 | 105 | * 見出し・表・画像は他のサーバー(Mastodon等)で表示出来ないので扱わない |
| 105 | 106 | */ |
| 106 | - private static function blocks(array $lines, array $mentionUrls, int $depth): string { | |
| 107 | + private static function blocks(array $lines, array $mentionUrls, int $depth, bool $hashtags = true): string { | |
| 107 | 108 | $out = ''; |
| 108 | 109 | $para = []; |
| 109 | - $flush = function() use (&$para, &$out, $mentionUrls): void { | |
| 110 | - if ($para) $out .= '<p>'.implode('<br />', array_map(fn($l) => self::inline($l, $mentionUrls), $para)).'</p>'; | |
| 110 | + $flush = function() use (&$para, &$out, $mentionUrls, $hashtags): void { | |
| 111 | + if ($para) $out .= '<p>'.implode('<br />', array_map(fn($l) => self::inline($l, $mentionUrls, $hashtags), $para)).'</p>'; | |
| 111 | 112 | $para = []; |
| 112 | 113 | }; |
| 113 | 114 | $n = count($lines); |
| @@ -125,7 +126,7 @@ | ||
| 125 | 126 | $quote = []; |
| 126 | 127 | for (; $i < $n && preg_match('/^> ?(.*)$/', $lines[$i], $m); $i++) $quote[] = $m[1]; |
| 127 | 128 | $i--; |
| 128 | - $out .= '<blockquote>'.self::blocks($quote, $mentionUrls, $depth + 1).'</blockquote>'; | |
| 129 | + $out .= '<blockquote>'.self::blocks($quote, $mentionUrls, $depth + 1, $hashtags).'</blockquote>'; | |
| 129 | 130 | continue; |
| 130 | 131 | } |
| 131 | 132 | if (preg_match('/^(?:[-*+]|\d{1,9}[.)])\s+\S/', $line)) { |
| @@ -133,7 +134,7 @@ | ||
| 133 | 134 | $ordered = ctype_digit($line[0]); |
| 134 | 135 | $item = $ordered ? '/^\d{1,9}[.)]\s+(.*)$/' : '/^[-*+]\s+(.*)$/'; |
| 135 | 136 | $items = ''; |
| 136 | - for (; $i < $n && preg_match($item, $lines[$i], $m); $i++) $items .= '<li>'.self::inline($m[1], $mentionUrls).'</li>'; | |
| 137 | + for (; $i < $n && preg_match($item, $lines[$i], $m); $i++) $items .= '<li>'.self::inline($m[1], $mentionUrls, $hashtags).'</li>'; | |
| 137 | 138 | $i--; |
| 138 | 139 | $out .= $ordered ? "<ol>{$items}</ol>" : "<ul>{$items}</ul>"; |
| 139 | 140 | continue; |
| @@ -152,7 +153,7 @@ | ||
| 152 | 153 | * 1行の中:`コード`、[文字](URL)、\記号、URL・メンション・ハッシュタグ、**太字**、*斜体*、~~取り消し線~~ |
| 153 | 154 | * 変換した部分は「\0番号\0」で預かり、エスケープと強調の後で戻す |
| 154 | 155 | */ |
| 155 | - private static function inline(string $text, array $mentionUrls): string { | |
| 156 | + private static function inline(string $text, array $mentionUrls, bool $hashtags = true): string { | |
| 156 | 157 | $keep = []; |
| 157 | 158 | $hold = function(string $html) use (&$keep): string { |
| 158 | 159 | $keep[] = $html; |
| @@ -187,6 +188,8 @@ | ||
| 187 | 188 | // 解決出来なかったメンションはリンクにしない |
| 188 | 189 | $out .= $hold($url === null ? self::esc($whole) |
| 189 | 190 | : '<span class="h-card"><a href="'.self::esc($url).'" class="u-url mention">@<span>'.self::esc($m[2][0]).'</span></a></span>'); |
| 191 | + } else if (!$hashtags) { | |
| 192 | + $out .= self::esc($whole); | |
| 190 | 193 | } else { |
| 191 | 194 | $tag = $m[4][0]; |
| 192 | 195 | $out .= $hold('<a href="'.self::esc(Fedi::base().'/microblog/tag/'.rawurlencode(mb_strtolower($tag))).'" class="mention hashtag" rel="tag">#<span>'.self::esc($tag).'</span></a>'); |
| @@ -0,0 +1,265 @@ | ||
| 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\Test; | |
| 39 | + | |
| 40 | +require_once __DIR__.'/../../../autoload.php'; | |
| 41 | + | |
| 42 | +use Std\Lib\Tester; | |
| 43 | +use Std\Lib\Activitypub; | |
| 44 | +use Std\Lib\Comments; | |
| 45 | +use Std\Lib\Fedi\{Fedi, Remote}; | |
| 46 | + | |
| 47 | +$test = new Tester([ | |
| 48 | + 'colorOutput' => true, | |
| 49 | + 'verboseOutput' => true | |
| 50 | +]); | |
| 51 | + | |
| 52 | +// 本物のデータを触らない様に、一時ディレクトリを使う | |
| 53 | +$tmp = sys_get_temp_dir().'/lbcomments_'.bin2hex(random_bytes(6)); | |
| 54 | +Comments::$dataDir = "{$tmp}/data"; | |
| 55 | +Fedi::$dataDir = "{$tmp}/ap"; | |
| 56 | +Fedi::$userDir = "{$tmp}/user/"; | |
| 57 | +mkdir(Fedi::$userDir, 0700, true); | |
| 58 | +$mkuser = function(string $name, int $id, int $role = \Roles::MEMBER) use ($tmp): \stdClass { | |
| 59 | + $u = ['id' => $id, 'username' => $name, 'displayname' => '', 'namecolor' => '', 'avatar' => '', 'gender' => -1, 'role' => $role]; | |
| 60 | + file_put_contents("{$tmp}/user/{$id}.{$name}.json", json_encode($u)); | |
| 61 | + return (object)$u; | |
| 62 | +}; | |
| 63 | +$admin = $mkuser('admin1234', 1, \Roles::MEMBER | \Roles::ADMIN); | |
| 64 | +$alice = $mkuser('alice1234', 2); | |
| 65 | +$bob = $mkuser('bob12345', 3); | |
| 66 | +$banned = $mkuser('banned123', 4, \Roles::BANNED); | |
| 67 | +$types = fn(string $u) => array_map(fn($n) => $n['type'].':'.($n['from']['username'] ?? $n['from']['actor'] ?? ''), Comments::notifications($u)); | |
| 68 | +$thread = 'blog/test-post'; | |
| 69 | + | |
| 70 | +$test->describe('コメント', function($test) use ($admin, $alice, $bob, $banned, $types, $thread): void { | |
| 71 | + if (!Comments::enabled()) $test->skipAll('COMMENTS_ENABLEDが無効'); | |
| 72 | + | |
| 73 | + $test->it('不正な入力ではコメント出来ないはず', function($test) use ($alice, $banned, $thread): void { | |
| 74 | + $test->assertFalse(Comments::addLocal($thread, '記事', $alice, " \n ")->isSuccess, '空'); | |
| 75 | + $test->assertFalse(Comments::addLocal($thread, '記事', $alice, str_repeat('あ', Comments::MAX_LENGTH + 1))->isSuccess, '長過ぎる'); | |
| 76 | + $test->assertFalse(Comments::addLocal($thread, '記事', $banned, 'こんにちは')->isSuccess, 'BAN'); | |
| 77 | + $test->assertFalse(Comments::addLocal('../config/x', '記事', $alice, 'こんにちは')->isSuccess, '不正なスレッド'); | |
| 78 | + $test->assertFalse(Comments::addLocal($thread, '記事', $alice, 'こんにちは', '00000000000000000000')->isSuccess, '返信先が無い'); | |
| 79 | + $test->assertFalse(Comments::validThread('blog/../x')); | |
| 80 | + $test->assertFalse(Comments::validThread('blog')); | |
| 81 | + $test->assertTrue(Comments::validThread('tutorial/linalg/01-abc')); | |
| 82 | + $test->assertEquals(0, Comments::count($thread)); | |
| 83 | + }); | |
| 84 | + | |
| 85 | + $test->it('Markdownを安全なHTMLにして、管理者に通知するはず', function($test) use ($alice, $types, $thread): void { | |
| 86 | + $res = Comments::addLocal($thread, '記事', $alice, "**太字**と`コード` #タグ\n<script>alert(1)</script>"); | |
| 87 | + $test->assertTrue($res->isSuccess, (string)$res->message); | |
| 88 | + $c = Comments::all($thread)[$res->data['id']]; | |
| 89 | + $test->assertStringContains('<strong>太字</strong>', $c['html']); | |
| 90 | + $test->assertStringContains('<code>コード</code>', $c['html']); | |
| 91 | + $test->assertStringContains('<script>', $c['html']); | |
| 92 | + $test->assertFalse(str_contains($c['html'], '<a'), 'ハッシュタグはリンクにしない'); | |
| 93 | + $test->assertEquals(['comment:alice1234'], $types('admin1234')); | |
| 94 | + $test->assertEquals([], $types('alice1234'), '自分には通知しない'); | |
| 95 | + }); | |
| 96 | + | |
| 97 | + $test->it('返信は返信先の人に通知し、深さ付きで表示するはず', function($test) use ($admin, $alice, $bob, $types, $thread): void { | |
| 98 | + $first = array_key_first(Comments::all($thread)); | |
| 99 | + $reply = Comments::addLocal($thread, '記事', $bob, '返信です', $first)->data['id']; | |
| 100 | + $test->assertEquals(['reply:bob12345'], $types('alice1234')); | |
| 101 | + $test->assertEquals(['comment:bob12345', 'comment:alice1234'], $types('admin1234')); | |
| 102 | + | |
| 103 | + // 管理者への返信は「返信」の通知だけ | |
| 104 | + Comments::addLocal($thread, '記事', $admin, '管理者です', $reply); | |
| 105 | + $own = Comments::addLocal($thread, '記事', $admin, '自分のコメント')->data['id']; | |
| 106 | + Comments::addLocal($thread, '記事', $bob, '管理者への返信', $own); | |
| 107 | + $test->assertEquals(['reply:bob12345', 'comment:bob12345', 'comment:alice1234'], $types('admin1234')); | |
| 108 | + | |
| 109 | + $view = Comments::view($thread, $bob); | |
| 110 | + $test->assertEquals([0, 1, 2, 0, 1], array_column($view, 'depth')); | |
| 111 | + $test->assertEquals('<p>返信です</p>', $view[1]['html']); | |
| 112 | + $test->assertTrue($view[1]['canDelete'], '自分のコメントは削除出来る'); | |
| 113 | + $test->assertFalse($view[0]['canDelete'], '他人のコメントは削除出来ない'); | |
| 114 | + $test->assertFalse($view[1]['canLike'], '自分のコメントにはいいね出来ない'); | |
| 115 | + $test->assertTrue($view[0]['canLike']); | |
| 116 | + $test->assertTrue(Comments::view($thread, $admin)[0]['canDelete'], '管理者は全部削除出来る'); | |
| 117 | + $test->assertFalse(Comments::view($thread, null)[0]['canReply'], 'ログインしていなければ返信出来ない'); | |
| 118 | + $test->assertEquals(5, Comments::count($thread)); | |
| 119 | + }); | |
| 120 | + | |
| 121 | + $test->it('いいねは切り替えで、通知は重複しないはず', function($test) use ($alice, $bob, $types, $thread): void { | |
| 122 | + $first = array_key_first(Comments::all($thread)); | |
| 123 | + $test->assertTrue(Comments::like($thread, $first, $bob)->data['liked']); | |
| 124 | + $test->assertFalse(Comments::like($thread, $first, $bob)->data['liked']); | |
| 125 | + $test->assertTrue(Comments::like($thread, $first, $bob)->data['liked']); | |
| 126 | + $test->assertEquals(['like:bob12345', 'reply:bob12345'], $types('alice1234')); | |
| 127 | + $test->assertFalse(Comments::like($thread, $first, $alice)->isSuccess, '自分のコメント'); | |
| 128 | + $test->assertFalse(Comments::like($thread, 'nai', $bob)->isSuccess); | |
| 129 | + $v = Comments::view($thread, $bob)[0]; | |
| 130 | + $test->assertEquals(1, $v['likes']); | |
| 131 | + $test->assertTrue($v['liked']); | |
| 132 | + }); | |
| 133 | + | |
| 134 | + $test->it('通知を既読に出来るはず', function($test) use ($thread): void { | |
| 135 | + $test->assertEquals(2, Comments::unreadCount('alice1234')); | |
| 136 | + $n = Comments::present(Comments::notifications('alice1234')[0]); | |
| 137 | + $test->assertStringContains('いいね', $n['label']); | |
| 138 | + $test->assertStringContains("/{$thread}#c-", $n['page']); | |
| 139 | + Comments::markRead('alice1234'); | |
| 140 | + $test->assertEquals(0, Comments::unreadCount('alice1234')); | |
| 141 | + $test->assertEquals(2, count(Comments::notifications('alice1234'))); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + $test->it('返信のあるコメントは中身だけ消し、返信が無くなれば完全に消すはず', function($test) use ($admin, $alice, $bob, $thread): void { | |
| 145 | + $ids = array_keys(Comments::all($thread)); | |
| 146 | + $test->assertFalse(Comments::delete($thread, $ids[0], $bob)->isSuccess, '他人のコメントは消せない'); | |
| 147 | + $test->assertTrue(Comments::delete($thread, $ids[0], $alice)->isSuccess); | |
| 148 | + $c = Comments::all($thread)[$ids[0]]; | |
| 149 | + $test->assertTrue($c['deleted']); | |
| 150 | + $test->assertFalse(isset($c['source']) || isset($c['html']), '本文は残さない'); | |
| 151 | + $test->assertEquals(4, Comments::count($thread)); | |
| 152 | + $test->assertTrue(Comments::view($thread, null)[0]['deleted']); | |
| 153 | + $test->assertFalse(Comments::addLocal($thread, '記事', $bob, '返信', $ids[0])->isSuccess, '削除されたコメントには返信出来ない'); | |
| 154 | + | |
| 155 | + // 返信の返信(管理者)→返信(bob)の順に消すと、中身を消したaliceのコメントも消える | |
| 156 | + $test->assertTrue(Comments::delete($thread, $ids[1], $bob)->isSuccess); | |
| 157 | + $test->assertTrue(Comments::all($thread)[$ids[1]]['deleted']); | |
| 158 | + $test->assertTrue(Comments::delete($thread, $ids[2], $admin)->isSuccess); | |
| 159 | + $left = Comments::all($thread); | |
| 160 | + $test->assertFalse(isset($left[$ids[0]]) || isset($left[$ids[1]]) || isset($left[$ids[2]])); | |
| 161 | + $test->assertEquals(2, count($left)); | |
| 162 | + }); | |
| 163 | +}); | |
| 164 | + | |
| 165 | +$test->describe('Fediverseからの返信', function($test) use ($admin, $alice, $types): void { | |
| 166 | + if (!Comments::enabled() || !ACTIVITYPUB_ENABLED) $test->skipAll('COMMENTS_ENABLED又はACTIVITYPUB_ENABLEDが無効'); | |
| 167 | + | |
| 168 | + $post = ['title' => '英語の記事', 'date' => '2025-01-01 10:00', 'thumbnail' => '', 'category' => [], 'uuid' => 'c0ffee00-0000-4000-8000-000000000001', | |
| 169 | + 'preview' => '本文', 'slug' => 'english-post', 'section' => 'enblog']; | |
| 170 | + $ap = new Activitypub([$post]); | |
| 171 | + $target = (string)$ap->objectId($post); | |
| 172 | + $carol = 'https://1.1.1.1/users/carol'; | |
| 173 | + Remote::cacheActor(['id' => $carol, 'type' => 'Person', 'preferredUsername' => 'carol', 'name' => 'Carol <b>C</b>', | |
| 174 | + 'url' => 'https://1.1.1.1/@carol', 'inbox' => 'https://1.1.1.1/users/carol/inbox', | |
| 175 | + 'icon' => ['type' => 'Image', 'url' => 'https://1.1.1.1/carol.png']]); | |
| 176 | + $note = fn(string $id, string $inReplyTo, array $extra = []) => $extra + [ | |
| 177 | + 'id' => "https://1.1.1.1/users/carol/statuses/{$id}", 'type' => 'Note', 'attributedTo' => $carol, 'inReplyTo' => $inReplyTo, | |
| 178 | + 'to' => [Fedi::PUBLIC], 'cc' => [], | |
| 179 | + 'content' => '<p><span class="h-card"><a href="'.Fedi::base().'" class="u-url mention">@<span>'.FEDIINFO['actor'].'</span></a></span> 面白い記事!<script>x</script></p>', | |
| 180 | + ]; | |
| 181 | + $create = fn(array $obj) => ['type' => 'Create', 'actor' => $carol, 'object' => $obj]; | |
| 182 | + $thread = 'enblog/english-post'; | |
| 183 | + | |
| 184 | + $test->it('ブログ毎に違うNoteのIDのはず', function($test) use ($post, $target): void { | |
| 185 | + $test->assertStringContains('/ap/objects/enblog/'.$post['uuid'], $target); | |
| 186 | + $test->assertNull((new Activitypub([$post]))->objectId(['section' => 'enblog'] + ['uuid' => 'nai'] + $post)); | |
| 187 | + $blog = ['section' => 'blog'] + $post; | |
| 188 | + $test->assertStringContains('/ap/objects/'.$post['uuid'], (string)(new Activitypub([$blog]))->objectId($blog)); | |
| 189 | + }); | |
| 190 | + | |
| 191 | + $test->it('記事への返信をコメントにして、管理者に通知するはず', function($test) use ($ap, $note, $create, $target, $thread, $carol, $types): void { | |
| 192 | + $test->assertTrue($ap->handleComments($create($note('1', $target)))); | |
| 193 | + $all = Comments::all($thread); | |
| 194 | + $test->assertEquals(1, count($all)); | |
| 195 | + $c = array_values($all)[0]; | |
| 196 | + $test->assertEquals('<p>面白い記事!</p>', $c['html'], 'ブログのアクターへのメンションとscriptは消す'); | |
| 197 | + $test->assertEquals('@carol@1.1.1.1', $c['author']['handle']); | |
| 198 | + $test->assertEquals('Carol C', $c['author']['name']); | |
| 199 | + $test->assertEquals('https://1.1.1.1/@carol', $c['author']['url']); | |
| 200 | + $test->assertEquals("comment:{$carol}", $types('admin1234')[0]); | |
| 201 | + $test->assertEquals('英語の記事', Comments::notifications('admin1234')[0]['title']); | |
| 202 | + // 再送されても1つ | |
| 203 | + $test->assertTrue($ap->handleComments($create($note('1', $target)))); | |
| 204 | + $test->assertEquals(1, Comments::count($thread)); | |
| 205 | + }); | |
| 206 | + | |
| 207 | + $test->it('関係の無い・非公開・偽装した返信は無視するはず', function($test) use ($ap, $note, $create, $target, $thread, $carol): void { | |
| 208 | + $test->assertFalse($ap->handleComments($create($note('2', 'https://8.8.8.8/notes/1'))), '他の投稿への返信'); | |
| 209 | + $test->assertFalse($ap->handleComments($create($note('3', $target, ['to' => [$carol.'/followers']]))), 'フォロワー限定'); | |
| 210 | + $test->assertFalse($ap->handleComments($create($note('4', $target, ['to' => [Fedi::blogActorUrl()]]))), 'DM'); | |
| 211 | + $test->assertFalse($ap->handleComments($create($note('5', $target, ['attributedTo' => 'https://1.1.1.1/users/dave']))), '作者が違う'); | |
| 212 | + $test->assertFalse($ap->handleComments($create($note('6', $target, ['id' => 'https://8.8.8.8/notes/6']))), '他のサーバーのID'); | |
| 213 | + $test->assertFalse($ap->handleComments(['type' => 'Like', 'actor' => $carol, 'object' => $target])); | |
| 214 | + $test->assertEquals(1, Comments::count($thread)); | |
| 215 | + }); | |
| 216 | + | |
| 217 | + $test->it('リモートのコメントへの返信と、ローカルのユーザーの返信を繋げるはず', function($test) use ($ap, $note, $create, $thread, $alice, $carol, $types): void { | |
| 218 | + $first = array_key_first(Comments::all($thread)); | |
| 219 | + $mine = Comments::addLocal($thread, '英語の記事', $alice, 'Carolさんへ', $first)->data['id']; | |
| 220 | + $test->assertTrue($ap->handleComments($create($note('7', 'https://1.1.1.1/users/carol/statuses/1')))); | |
| 221 | + $view = Comments::view($thread, $alice); | |
| 222 | + $test->assertEquals([0, 1, 1], array_column($view, 'depth')); | |
| 223 | + $test->assertTrue($view[0]['who']['remote']); | |
| 224 | + // リモートの画像は直接読み込まない(マルチユーザーではこのサイト経由) | |
| 225 | + if (Fedi::enabled()) $test->assertStringContains('/microblog/media?u=', $view[0]['who']['avatar']); | |
| 226 | + else $test->assertEquals('/static/user/noicon.png', $view[0]['who']['avatar']); | |
| 227 | + $test->assertTrue($view[0]['canLike'], 'リモートのコメントにもいいね出来る'); | |
| 228 | + $test->assertFalse(in_array("reply:{$carol}", $types('alice1234'), true), 'aliceへの返信ではない'); | |
| 229 | + $test->assertEquals(["comment:{$carol}", 'comment:alice1234'], array_slice($types('admin1234'), 0, 2), '管理者には全部のコメント'); | |
| 230 | + }); | |
| 231 | + | |
| 232 | + $test->it('本人の編集と削除だけを反映するはず', function($test) use ($ap, $note, $thread, $carol): void { | |
| 233 | + $edited = $note('1', '', ['content' => '<p>編集しました</p>']); | |
| 234 | + $test->assertFalse($ap->handleComments(['type' => 'Update', 'actor' => 'https://1.1.1.1/users/dave', 'object' => $edited])); | |
| 235 | + $test->assertTrue($ap->handleComments(['type' => 'Update', 'actor' => $carol, 'object' => $edited])); | |
| 236 | + $c = array_values(Comments::all($thread))[0]; | |
| 237 | + $test->assertEquals('<p>編集しました</p>', $c['html']); | |
| 238 | + $test->assertTrue(isset($c['edited'])); | |
| 239 | + | |
| 240 | + $test->assertFalse($ap->handleComments(['type' => 'Delete', 'actor' => 'https://1.1.1.1/users/dave', 'object' => 'https://1.1.1.1/users/carol/statuses/7'])); | |
| 241 | + $test->assertTrue($ap->handleComments(['type' => 'Delete', 'actor' => $carol, 'object' => ['id' => 'https://1.1.1.1/users/carol/statuses/7', 'type' => 'Tombstone']])); | |
| 242 | + $test->assertEquals(2, Comments::count($thread)); | |
| 243 | + }); | |
| 244 | + | |
| 245 | + $test->it('アカウントの削除で全部の返信を消すはず', function($test) use ($ap, $thread, $carol): void { | |
| 246 | + $test->assertTrue($ap->handleComments(['type' => 'Delete', 'actor' => $carol, 'object' => $carol])); | |
| 247 | + $view = Comments::view($thread, null); | |
| 248 | + // 返信のあるコメントは「削除されました」として残る | |
| 249 | + $test->assertEquals([true, false], array_column($view, 'deleted')); | |
| 250 | + $test->assertNull(Comments::findRemote('https://1.1.1.1/users/carol/statuses/1')); | |
| 251 | + }); | |
| 252 | +}); | |
| 253 | + | |
| 254 | +// 一時ディレクトリを削除する | |
| 255 | +$rm = function(string $dir) use (&$rm): void { | |
| 256 | + foreach (scandir($dir) ?: [] as $f) { | |
| 257 | + if ($f === '.' || $f === '..') continue; | |
| 258 | + is_dir("{$dir}/{$f}") ? $rm("{$dir}/{$f}") : @unlink("{$dir}/{$f}"); | |
| 259 | + } | |
| 260 | + @rmdir($dir); | |
| 261 | +}; | |
| 262 | +$rm($tmp); | |
| 263 | +Comments::$dataDir = Fedi::$dataDir = Fedi::$userDir = null; | |
| 264 | + | |
| 265 | +$test->printSummary(); |
| @@ -412,6 +412,30 @@ | ||
| 412 | 412 | $test->assertStringContains('/blog/post-x1', $a['object']['url']); |
| 413 | 413 | }); |
| 414 | 414 | |
| 415 | + $test->it('ブログ毎に違うIDで送信し、後から追加したブログの既存の投稿は送らないはず', function($test) use ($make, $post, &$posts, $inboxes, $state, $read): void { | |
| 416 | + // 設定に無いブログ(testblog)は、初めて見た時に既存の投稿を送信済みにする | |
| 417 | + $posts[] = $post('tb1', '2026-01-07 10:00', ['section' => 'testblog']); | |
| 418 | + $ap = $make($posts, $inboxes); | |
| 419 | + $ap->federate($state); | |
| 420 | + $test->assertEquals([], $ap->sent); | |
| 421 | + $test->assertArrayHasKey('testblog/tb1', $read()['sent']); | |
| 422 | + $test->assertTrue(in_array('testblog', $read()['sections'], true)); | |
| 423 | + | |
| 424 | + // 同じUUID(翻訳)でもblogとは別の投稿 | |
| 425 | + $posts[] = $post('down', '2026-01-08 10:00', ['section' => 'testblog']); | |
| 426 | + $ap = $make($posts, $inboxes); | |
| 427 | + $res = $ap->federate($state); | |
| 428 | + $test->assertEquals(['posts' => 1, 'delivered' => 2, 'failed' => 0], $res->data); | |
| 429 | + $test->assertArrayHasKey('testblog/down', $read()['sent']); | |
| 430 | + | |
| 431 | + $a = json_decode($ap->getActivity('down', 'testblog'), true); | |
| 432 | + $test->assertStringContains('/ap/activities/create/testblog/down', $a['id']); | |
| 433 | + $test->assertStringContains('/ap/objects/testblog/down', $a['object']['id']); | |
| 434 | + $test->assertStringContains('/testblog/post-down', $a['object']['url']); | |
| 435 | + $test->assertStringContains('/blog/post-down', json_decode($ap->getActivity('down'), true)['object']['url']); | |
| 436 | + $test->assertEquals('', $ap->getObject('tb1'), 'blogには無い'); | |
| 437 | + }); | |
| 438 | + | |
| 415 | 439 | $test->it('(request-target)を含めて署名するはず', function($test) use ($make): void { |
| 416 | 440 | $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); |
| 417 | 441 | if ($key === false) { |
変更
util.php
| @@ -371,12 +371,13 @@ | ||
| 371 | 371 | |
| 372 | 372 | /** |
| 373 | 373 | * ブログの一覧(config.phpのBLOGS、無ければ「blog」だけ) |
| 374 | + * federateを省略した場合は「blog」だけ連合する(今まで通り) | |
| 374 | 375 | * |
| 375 | - * @return array ディレクトリ名 => ['title' => フィードの名前] | |
| 376 | + * @return array ディレクトリ名 => ['title' => フィードの名前, 'federate' => ActivityPubで配信するか] | |
| 376 | 377 | */ |
| 377 | 378 | function blogs(): array { |
| 378 | 379 | $blogs = defined('BLOGS') ? BLOGS : ['blog' => []]; |
| 379 | - foreach ($blogs as $name => $b) $blogs[$name] = $b + ['title' => SITEINFO['title']]; | |
| 380 | + foreach ($blogs as $name => $b) $blogs[$name] = $b + ['title' => SITEINFO['title'], 'federate' => $name === 'blog']; | |
| 380 | 381 | return $blogs; |
| 381 | 382 | } |
| 382 | 383 |
| @@ -18,4 +18,5 @@ | ||
| 18 | 18 | <h1 class="title">{{{ $meta->title }}}</h1> |
| 19 | 19 | {{{ $article."\n" }}} |
| 20 | 20 | </div> |
| 21 | +{@ include(common/comments) @} | |
| 21 | 22 | {@ include(common/footer) @} |
| @@ -0,0 +1,18 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | + <section class="comments"> | |
| 3 | + <div class="errormes">{{ $error }}</div> | |
| 4 | +{@ if (isset($thread) && isset($body)) @} | |
| 5 | + <h2>「{{ $title }}」へのコメント</h2> | |
| 6 | + <form class="comment-form" method="post" action="/comments"> | |
| 7 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 8 | + <input type="hidden" name="thread" value="{{ $thread }}" /> | |
| 9 | + <input type="hidden" name="parent" value="{{ $parent }}" /> | |
| 10 | + <textarea name="body" rows="8" maxlength="{{ $maxLength }}" required>{{ $body }}</textarea> | |
| 11 | + <div class="comment-form-bottom"><button>もう一度送信する</button></div> | |
| 12 | + </form> | |
| 13 | + <p><a href="/{{ $thread }}#comments">記事に戻る</a></p> | |
| 14 | +{@ else @} | |
| 15 | + <p><a href="{{ $back }}">戻る</a></p> | |
| 16 | +{@ endif @} | |
| 17 | + </section> | |
| 18 | +{@ include(common/footer) @} |
| @@ -0,0 +1,77 @@ | ||
| 1 | +{@ if (isset($comments)) @} | |
| 2 | + <section class="comments" id="comments"> | |
| 3 | + <h2>コメント({{ $comments['count'] }})</h2> | |
| 4 | +{@ if ($comments['fedi'] !== null) @} | |
| 5 | + <p class="comment-fedi">Fediverse(Mastodon、Misskey等)からもコメント出来ます:{{ $comments['actor'] }}の投稿(<a href="{{ $comments['fedi'] }}">{{ $comments['fedi'] }}</a>)を検索して返信して下さい。</p> | |
| 6 | +{@ endif @} | |
| 7 | +{@ if (empty($comments['list'])) @} | |
| 8 | + <p class="comment-none">まだコメントはありません。</p> | |
| 9 | +{@ endif @} | |
| 10 | +{@ foreach ($comments['list'] as $c) @} | |
| 11 | + <article class="comment depth-{{ $c['depth'] }}" id="c-{{ $c['id'] }}"> | |
| 12 | +{@ if ($c['deleted']) @} | |
| 13 | + <p class="comment-deleted">(このコメントは削除されました)</p> | |
| 14 | +{@ else @} | |
| 15 | + <div class="comment-head"> | |
| 16 | + <img class="comment-avatar" src="{{ $c['who']['avatar'] }}" alt="" width="32" height="32" loading="lazy" /> | |
| 17 | +{@ if ($c['who']['url'] !== '') @} | |
| 18 | + <a class="comment-name" href="{{ $c['who']['url'] }}"{{{ $c['who']['remote'] ? ' rel="nofollow ugc noopener"' : '' }}}>{{{ $c['who']['name'] }}}</a> | |
| 19 | +{@ else @} | |
| 20 | + <span class="comment-name">{{{ $c['who']['name'] }}}</span> | |
| 21 | +{@ endif @} | |
| 22 | +{@ if ($c['who']['handle'] !== '') @} | |
| 23 | + <span class="comment-handle">{{ $c['who']['handle'] }}</span> | |
| 24 | +{@ endif @} | |
| 25 | + <a class="comment-date" href="#c-{{ $c['id'] }}">{{ $c['at'] }}{{ $c['edited'] ? '(編集済み)' : '' }}</a> | |
| 26 | + </div> | |
| 27 | + <div class="comment-body">{{{ $c['html'] }}}</div> | |
| 28 | + <div class="comment-actions"> | |
| 29 | +{@ if ($c['canLike']) @} | |
| 30 | + <form method="post" action="/comments/like"> | |
| 31 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 32 | + <input type="hidden" name="thread" value="{{ $comments['thread'] }}" /> | |
| 33 | + <input type="hidden" name="id" value="{{ $c['id'] }}" /> | |
| 34 | + <button class="comment-like{{ $c['liked'] ? ' on' : '' }}" title="{{ $c['liked'] ? 'いいねを取り消す' : 'いいね' }}">{{ $c['liked'] ? '♥' : '♡' }} {{ $c['likes'] }}</button> | |
| 35 | + </form> | |
| 36 | +{@ elif ($c['likes'] > 0) @} | |
| 37 | + <span class="comment-likes">♥ {{ $c['likes'] }}</span> | |
| 38 | +{@ endif @} | |
| 39 | +{@ if ($c['canDelete']) @} | |
| 40 | + <form method="post" action="/comments/delete"> | |
| 41 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 42 | + <input type="hidden" name="thread" value="{{ $comments['thread'] }}" /> | |
| 43 | + <input type="hidden" name="id" value="{{ $c['id'] }}" /> | |
| 44 | + <button class="comment-delete">削除</button> | |
| 45 | + </form> | |
| 46 | +{@ endif @} | |
| 47 | + </div> | |
| 48 | +{@ if ($c['canReply']) @} | |
| 49 | + <details class="comment-reply"> | |
| 50 | + <summary>返信</summary> | |
| 51 | + <form class="comment-form" method="post" action="/comments"> | |
| 52 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 53 | + <input type="hidden" name="thread" value="{{ $comments['thread'] }}" /> | |
| 54 | + <input type="hidden" name="parent" value="{{ $c['id'] }}" /> | |
| 55 | + <textarea name="body" rows="4" maxlength="{{ $comments['maxLength'] }}" required></textarea> | |
| 56 | + <div class="comment-form-bottom"><button>返信する</button></div> | |
| 57 | + </form> | |
| 58 | + </details> | |
| 59 | +{@ endif @} | |
| 60 | +{@ endif @} | |
| 61 | + </article> | |
| 62 | +{@ endforeach @} | |
| 63 | +{@ if ($comments['canPost']) @} | |
| 64 | + <form class="comment-form" method="post" action="/comments"> | |
| 65 | + <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> | |
| 66 | + <input type="hidden" name="thread" value="{{ $comments['thread'] }}" /> | |
| 67 | + <textarea name="body" rows="6" maxlength="{{ $comments['maxLength'] }}" placeholder="コメントを入力して下さい" required></textarea> | |
| 68 | + <div class="comment-form-bottom"> | |
| 69 | + <span class="comment-help">Markdown:**太字**、*斜体*、~~取り消し線~~、`コード`、```コードブロック```、> 引用、- リスト、[文字](URL)</span> | |
| 70 | + <button>コメントする</button> | |
| 71 | + </div> | |
| 72 | + </form> | |
| 73 | +{@ elif (AUTH_ENABLED && !isset($user)) @} | |
| 74 | + <p class="comment-login"><a href="/login">ログイン</a>するとコメント出来ます。</p> | |
| 75 | +{@ endif @} | |
| 76 | + </section> | |
| 77 | +{@ endif @} |
| @@ -69,7 +69,12 @@ | ||
| 69 | 69 | {@ if (AUTH_ENABLED) @} |
| 70 | 70 | <div> |
| 71 | 71 | {@ if (isset($user) && isset($user->id) && $user->id > 0) @} |
| 72 | +{@ if (\Std\Lib\Comments::enabled()) @} | |
| 73 | +{$ $unreadComments = \Std\Lib\Comments::unreadCount($user->username) $} | |
| 74 | + お帰りなしゃ~い、<a style="text-decoration: none;" href="/profile/{{ $user->username }}">{{{ namecolor($user) }}}</a>! (<a href="/notifications">通知{{ $unreadComments > 0 ? '('.$unreadComments.')' : '' }}</a> | <a href="/logout">ログアウト</a>) | |
| 75 | +{@ else @} | |
| 72 | 76 | お帰りなしゃ~い、<a style="text-decoration: none;" href="/profile/{{ $user->username }}">{{{ namecolor($user) }}}</a>! (<a href="/logout">ログアウト</a>) |
| 77 | +{@ endif @} | |
| 73 | 78 | {@ else @} |
| 74 | 79 | <a href="/login">ログイン</a> |
| 75 | 80 | {@ if (AUTH_REGISTER_ENABLED) @} |
| @@ -0,0 +1,17 @@ | ||
| 1 | +{@ include(common/header) @} | |
| 2 | + <section class="comments"> | |
| 3 | + <h1>通知</h1> | |
| 4 | +{@ if (empty($list)) @} | |
| 5 | + <p class="comment-none">通知はありません。</p> | |
| 6 | +{@ endif @} | |
| 7 | +{@ foreach ($list as $n) @} | |
| 8 | + <div class="notification{{ $n['read'] ? '' : ' unread' }}"> | |
| 9 | + <img class="comment-avatar" src="{{ $n['who']['avatar'] }}" alt="" width="32" height="32" loading="lazy" /> | |
| 10 | + <div> | |
| 11 | + <span class="comment-name">{{{ $n['who']['name'] }}}</span>{{ $n['label'] }} <span class="comment-date">{{ $n['at'] }}</span><br /> | |
| 12 | + <a href="{{ $n['page'] }}">{{ $n['excerpt'] }}</a> | |
| 13 | + </div> | |
| 14 | + </div> | |
| 15 | +{@ endforeach @} | |
| 16 | + </section> | |
| 17 | +{@ include(common/footer) @} |