コメントシステム

守矢諏訪子 <suwako@076.moe>、2026-11-11 12:06
コミット b928dd14e223c28b44d1fbf28b4b5556e9ffae7d ・親 b4b752a ・ファイルを見る

21個のファイル、+1572 -36

変更 README-JP.md
@@ -120,9 +120,15 @@
120120 location "/.well-known/webfinger" {
121121 request rewrite "/index.php"
122122 }
123- location match "*.atom" {
123+ location "*.atom" {
124124 request rewrite "/index.php"
125125 }
126+ location match "/comments" {
127+ request rewrite "/index.php"
128+ }
129+ location "/notifications" {
130+ request rewrite "/index.php"
131+ }
126132 }
127133
128134 server "www.technicalsuwako.moe" {
@@ -193,6 +199,13 @@
193199 ### ブログと Atom フィード
194200 1つのサイトに複数のブログを置けます。`config/config.php` の `BLOGS` の各ディレクトリ(例:`blog`、`enblog`)がブログになり、それぞれ `/ディレクトリ.atom` の Atom フィードがあります(一覧と検索結果のページの上にリンクがあります)。\
195201 `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/` に保存します。
196209
197210 ### Cron
198211 定期的なタスク(例えば、新しいブログ投稿を ActivityPub のフォロワーに送る `apfederate`)はシステムの cron で実行します。\
変更 README.md
@@ -118,9 +118,15 @@
118118 location "/.well-known/webfinger" {
119119 request rewrite "/index.php"
120120 }
121- location match "*.atom" {
121+ location "*.atom" {
122122 request rewrite "/index.php"
123123 }
124+ location match "/comments" {
125+ request rewrite "/index.php"
126+ }
127+ location "/notifications" {
128+ request rewrite "/index.php"
129+ }
124130 }
125131
126132 server "www.technicalsuwako.moe" {
@@ -191,6 +197,13 @@
191197 ### Blogs and Atom feeds
192198 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.\
193199 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/`.
194207
195208 ### Cron
196209 Scheduled tasks (for example `apfederate`, which sends new blog posts to ActivityPub followers) are run by the system cron.\
@@ -45,6 +45,8 @@
4545 ]);
4646 // ブログ(URLのディレクトリ => 設定)。ブログ毎に「/ディレクトリ.atom」のフィードが出来る
4747 // title:フィードの名前(省略するとSITEINFOのtitle)
48+// federate:FEDIINFOのアカウントが新しい記事を投稿し、Fediverseからの返信をコメントにする(省略するとblogだけtrue)
49+// more:Fediverseの投稿の「続きを読む」リンクの文字(省略すると「読み続き」)
4850 define('BLOGS', [
4951 'blog' => [],
5052 ]);
@@ -86,5 +88,6 @@
8688 define('AUTH_REGISTER_ENABLED', false);
8789 define('AUTH_VERIFY_EMAIL', false); // 登録時にメールアドレスの確認を必須にする(MAILER_ENABLEDも必要)
8890 define('AUTH_2FA_ENABLED', false); // 二段階認証(認証アプリのコード)を使えるようにする(各ユーザーがプロフィールの編集から有効にする)
91+define('COMMENTS_ENABLED', false); // 記事のコメント(書き込みはAUTH_ENABLED、Fediverseからの返信はACTIVITYPUB_ENABLEDも必要)
8992 define('COPYRIGHT_YEAR', '2018-'.date('Y'));
9093 define('DEBUG_MODE', false);
変更 cron.php
@@ -66,7 +66,7 @@
6666
6767 if (ACTIVITYPUB_ENABLED) {
6868 // 新しいブログ投稿をフォロワーに送る
69- $cron->register('apfederate', fn() => (new Activitypub((new BlogPost())->getPosts('/blog/', null)))->federate());
69+ $cron->register('apfederate', fn() => (new Activitypub((new BlogPost())->federatedPosts()))->federate());
7070 // プロフィール(アクター)の更新をフォロワーに送る
7171 $cron->register('apupdate', fn() => (new Activitypub())->sendActorUpdate());
7272 }
@@ -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 @@
4141 use Std\Lib\Route;
4242
4343 use Site\Controller\Atom;
44+use Site\Controller\Comment;
4445 use Site\Controller\Fediverse;
4546 use Site\Controller\Home;
4647 use Site\Controller\Microblog;
@@ -74,7 +75,10 @@
7475 $routes[] = Route::add('GET', 'ap/outbox', Fediverse::class.'@apoutbox');
7576 $routes[] = Route::add('GET', 'ap/activities/create/{uuid}',
7677 Fediverse::class.'@apactivity');
78+ $routes[] = Route::add('GET', 'ap/activities/create/{section}/{uuid}',
79+ Fediverse::class.'@apactivity');
7780 $routes[] = Route::add('GET', 'ap/objects/{uuid}', Fediverse::class.'@apobject');
81+ $routes[] = Route::add('GET', 'ap/objects/{section}/{uuid}', Fediverse::class.'@apobject');
7882 $routes[] = Route::add('POST', 'ap/inbox', Fediverse::class.'@apinbox');
7983 $routes[] = Route::add('GET', 'ap/actor', Fediverse::class.'@apactor');
8084 }
@@ -128,6 +132,13 @@
128132
129133 $routes[] = Route::add('GET', 'memberonly', Page::class.'@memberonly');
130134 $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');
131142 }
132143
133144 if (ATOM_ENABLED) {
@@ -37,7 +37,11 @@
3737 */
3838 namespace Site\Controller;
3939
40+use Std\Lib\Activitypub;
41+use Std\Lib\Comments;
42+use Std\Lib\Fedi\Fedi;
4043 use Std\Lib\Markdown;
44+use Std\Lib\Template;
4145 use Roles;
4246
4347 class BlogPost {
@@ -109,5 +113,55 @@
109113 });
110114
111115 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');
112166 }
113167 }
@@ -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 @@
119119
120120 try {
121121 header('Content-Type: application/activity+json');
122- $ap = new Activitypub();
122+ // 投稿はコメント(ブログの投稿への返信)の処理で必要になった時だけ読む
123+ $ap = new Activitypub(fn() => $this->federatedPosts());
123124
124125 if (!$ap->verifyInboxSignature($input, $activity)) {
125126 // 削除されたアカウントのDeleteは鍵がもう取得出来ないので検証出来ない。拒否すると何日も再送されるので受け流す
@@ -134,6 +135,8 @@
134135 }
135136
136137 logger(\LogType::ActivityPub, "受付に入れた:".json_encode($activity));
138+ // ブログの投稿への返信は記事のコメントにする(マルチユーザーでも、ユーザー宛ての処理は続ける)
139+ $ap->handleComments($activity);
137140 // マルチユーザーでは共有の受付ボックス(ブログのアクター宛てはInboxからActivitypubに渡す)
138141 if (Fedi::enabled()) {
139142 [$code, $body] = Inbox::handle($activity);
@@ -335,9 +338,9 @@
335338
336339 try {
337340 header('Content-Type: application/activity+json');
338- $posts = $this->getPosts('/blog/', null);
341+ $posts = $this->federatedPosts();
339342 $ap = new Activitypub($posts);
340- $json = $ap->getActivity($uuid);
343+ $json = $ap->getActivity($uuid, $params['section'] ?? 'blog');
341344 if ($json === '') {
342345 header('HTTP/1.1 404 Not Found');
343346 echo json_encode(['error' => '見つかりません']);
@@ -359,8 +362,8 @@
359362 public function apobject(array $params): void {
360363 try {
361364 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');
364367 if ($json === '') {
365368 header('HTTP/1.1 404 Not Found');
366369 echo json_encode(['error' => '見つかりません']);
@@ -382,7 +385,7 @@
382385 public function apoutbox(array $params): void {
383386 try {
384387 header('Content-Type: application/activity+json');
385- $posts = $this->getPosts('/blog/', null);
388+ $posts = $this->federatedPosts();
386389 $ap = new Activitypub($posts);
387390 echo $ap->getOutbox();
388391 exit;
@@ -177,6 +177,7 @@
177177 if (isset($meta->draft) && !$isStaff) goto denyaccess;
178178
179179 showpage:
180+ $this->assignComments($tmpl, 'blog', $page, $meta, $user);
180181 $tmpl->addCss('news-article');
181182 $tmpl->addCss('search');
182183 $tmpl->addCss('blogtype');
@@ -38,6 +38,9 @@
3838 namespace Std\Lib;
3939
4040 use Std\Lib\Curl;
41+use Std\Lib\Fedi\Actor;
42+use Std\Lib\Fedi\Fedi;
43+use Std\Lib\Fedi\Remote;
4144 use LogType;
4245 use Result;
4346 use Uri\Rfc3986\Uri;
@@ -54,7 +57,8 @@
5457 private string $actorNick;
5558 private string $desc;
5659 private string $icon;
57- private array $posts = [];
60+ /** 投稿(必要になった時に読む場合はクロージャ) */
61+ private array|\Closure $posts = [];
5862 private string $https;
5963 /** 秘密鍵のキャッシュ(1回の実行で何度も読まない様に) */
6064 private ?\OpenSSLAsymmetricKey $privKey = null;
@@ -68,9 +72,10 @@
6872 /**
6973 * コンストラクタ
7074 *
71- * @param array $posts 投稿データの配列
75+ * @param array|\Closure $posts 投稿データの配列(「section」はブログのディレクトリ、無ければblog)、
76+ * 又は配列を返すクロージャ(受付ボックス等、投稿を使わない事が多い時)
7277 */
73- public function __construct(array $posts = []) {
78+ public function __construct(array|\Closure $posts = []) {
7479 if (!ACTIVITYPUB_ENABLED) return;
7580 $this->https = 'http'.((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 's' : '').'://';
7681 // CLI(cron)にはHTTP_HOSTが無いので、FEDIINFOの「domain」を優先する
@@ -138,11 +143,12 @@
138143 * 特定のUUIDに対応するActivityを取得する
139144 *
140145 * @param string $uuid 取得するアクティビティのUUID
146+ * @param string $section ブログのディレクトリ
141147 * @return string JSONエンコードされたアクティビティデータ
142148 */
143- public function getActivity(string $uuid): string {
149+ public function getActivity(string $uuid, string $section = 'blog'): string {
144150 if (!ACTIVITYPUB_ENABLED) return '';
145- $post = $this->findPublicPost($uuid);
151+ $post = $this->findPublicPost($uuid, $section);
146152 if ($post === null) return '';
147153
148154 return json_encode($this->buildCreate($post), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
@@ -153,11 +159,12 @@
153159 * 相手のサーバーがオブジェクトのIDを取得しに来た時の為
154160 *
155161 * @param string $uuid 投稿のUUID
162+ * @param string $section ブログのディレクトリ
156163 * @return string JSONエンコードされたNote、無ければ空文字
157164 */
158- public function getObject(string $uuid): string {
165+ public function getObject(string $uuid, string $section = 'blog'): string {
159166 if (!ACTIVITYPUB_ENABLED) return '';
160- $post = $this->findPublicPost($uuid);
167+ $post = $this->findPublicPost($uuid, $section);
161168 if ($post === null) return '';
162169
163170 $create = $this->buildCreate($post);
@@ -313,6 +320,50 @@
313320 }
314321
315322 /**
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+ /**
316367 * アクタープロフィールの更新アクティビティを作成する
317368 *
318369 * @return string JSONエンコードされた更新アクティビティ
@@ -359,6 +410,7 @@
359410 *
360411 * 送信済みのUUIDはdata/ap_federated.jsonに記録する。
361412 * 初回の実行では既存の投稿を全部送信済みとして記録するだけで、何も送信しない(古い投稿を一気に流さない為)。
413+ * 後から連合する様にしたブログ(config.phpのBLOGSのfederate)も同じく、既存の投稿は送信しない。
362414 * 日付が未来の投稿はその日時になってから送信する。
363415 * 失敗した受付ボックスは次回以降に再試行し、FEDERATE_MAX_TRIES回で諦める。
364416 *
@@ -386,27 +438,41 @@
386438
387439 $posts = $this->federatablePosts();
388440 $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';
389444
390445 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();
393448 $this->writeFederateState($fp, $state);
394449 return Result::Success('初回の実行なので、既存の投稿'.count($posts).'件を送信済みとして記録しました。', $zero);
395450 }
396451
397452 $state['sent'] = (array)($state['sent'] ?? []);
398453 $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)));
400463
401464 // 新しい投稿を送信待ちに追加する(古い順)
402465 $inboxes = null;
403466 foreach ($posts as $p) {
404- $uuid = $p['uuid'];
467+ $uuid = self::postKey($p);
405468 if (isset($state['sent'][$uuid]) || isset($state['pending'][$uuid])) continue;
406469 $inboxes ??= $this->followerInboxes();
407470 $state['pending'][$uuid] = ['inboxes' => $inboxes, 'tries' => 0];
408471 }
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+ }
410476
411477 $done = $ok = $ng = 0;
412478 foreach ($state['pending'] as $uuid => $job) {
@@ -733,14 +799,68 @@
733799 }
734800
735801 /**
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+ /**
736854 * 投稿からCreateアクティビティを作る(getActivity・getOutbox・federateで共通)
737855 *
738856 * @param array $post 投稿データ
739857 * @return array アクティビティ
740858 */
741859 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'] ?? '読み続き';
744864 // 投稿の日付はPHPのタイムゾーンの時刻なので、UTCに変換する
745865 $published = gmdate('Y-m-d\TH:i:s\Z', strtotime($post['date']) ?: time());
746866 $esc = fn(string $s) => htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8');
@@ -754,7 +874,7 @@
754874 "{$this->domain}/ap/followers",
755875 ],
756876 '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>",
758878 'url' => $url,
759879 'published' => $published,
760880 'replies' => "{$this->domain}/ap/objects/{$uuid}/replies",
@@ -808,12 +928,13 @@
808928 * UUIDで公開投稿を探す
809929 *
810930 * @param string $uuid 投稿のUUID
931+ * @param string $section ブログのディレクトリ
811932 * @return array|null 投稿データ、無い(又は非公開)場合はnull
812933 */
813- private function findPublicPost(string $uuid): ?array {
934+ private function findPublicPost(string $uuid, string $section = 'blog'): ?array {
814935 if ($uuid === '') return null;
815936 foreach ($this->federatablePosts() as $post) {
816- if ($post['uuid'] === $uuid) return $post;
937+ if ($post['uuid'] === $uuid && ($post['section'] ?? 'blog') === $section) return $post;
817938 }
818939 return null;
819940 }
@@ -845,6 +966,7 @@
845966 * @return array 投稿データの配列
846967 */
847968 private function federatablePosts(): array {
969+ if ($this->posts instanceof \Closure) $this->posts = ($this->posts)();
848970 $now = time();
849971 $out = [];
850972
@@ -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 @@
9292 *
9393 * @param string $text 本文
9494 * @param array $mentionUrls 「@user@domain」 => アクターのURL(解決出来たメンションだけリンクにする)
95+ * @param bool $hashtags ハッシュタグをマイクロブログのタグのページへのリンクにする(記事のコメント等ではfalse)
9596 * @return string HTML
9697 */
97- public static function render(string $text, array $mentionUrls = []): string {
98+ public static function render(string $text, array $mentionUrls = [], bool $hashtags = true): string {
9899 $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);
100101 }
101102
102103 /**
103104 * Markdownのブロック:段落(空行で区切り、改行は<br>)、```コード```、> 引用、- と 1. のリスト
104105 * 見出し・表・画像は他のサーバー(Mastodon等)で表示出来ないので扱わない
105106 */
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 {
107108 $out = '';
108109 $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>';
111112 $para = [];
112113 };
113114 $n = count($lines);
@@ -125,7 +126,7 @@
125126 $quote = [];
126127 for (; $i < $n && preg_match('/^> ?(.*)$/', $lines[$i], $m); $i++) $quote[] = $m[1];
127128 $i--;
128- $out .= '<blockquote>'.self::blocks($quote, $mentionUrls, $depth + 1).'</blockquote>';
129+ $out .= '<blockquote>'.self::blocks($quote, $mentionUrls, $depth + 1, $hashtags).'</blockquote>';
129130 continue;
130131 }
131132 if (preg_match('/^(?:[-*+]|\d{1,9}[.)])\s+\S/', $line)) {
@@ -133,7 +134,7 @@
133134 $ordered = ctype_digit($line[0]);
134135 $item = $ordered ? '/^\d{1,9}[.)]\s+(.*)$/' : '/^[-*+]\s+(.*)$/';
135136 $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>';
137138 $i--;
138139 $out .= $ordered ? "<ol>{$items}</ol>" : "<ul>{$items}</ul>";
139140 continue;
@@ -152,7 +153,7 @@
152153 * 1行の中:`コード`、[文字](URL)、\記号、URL・メンション・ハッシュタグ、**太字**、*斜体*、~~取り消し線~~
153154 * 変換した部分は「\0番号\0」で預かり、エスケープと強調の後で戻す
154155 */
155- private static function inline(string $text, array $mentionUrls): string {
156+ private static function inline(string $text, array $mentionUrls, bool $hashtags = true): string {
156157 $keep = [];
157158 $hold = function(string $html) use (&$keep): string {
158159 $keep[] = $html;
@@ -187,6 +188,8 @@
187188 // 解決出来なかったメンションはリンクにしない
188189 $out .= $hold($url === null ? self::esc($whole)
189190 : '<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);
190193 } else {
191194 $tag = $m[4][0];
192195 $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('&lt;script&gt;', $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 @@
412412 $test->assertStringContains('/blog/post-x1', $a['object']['url']);
413413 });
414414
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+
415439 $test->it('(request-target)を含めて署名するはず', function($test) use ($make): void {
416440 $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
417441 if ($key === false) {
変更 util.php
@@ -371,12 +371,13 @@
371371
372372 /**
373373 * ブログの一覧(config.phpのBLOGS、無ければ「blog」だけ)
374+ * federateを省略した場合は「blog」だけ連合する(今まで通り)
374375 *
375- * @return array ディレクトリ名 => ['title' => フィードの名前]
376+ * @return array ディレクトリ名 => ['title' => フィードの名前, 'federate' => ActivityPubで配信するか]
376377 */
377378 function blogs(): array {
378379 $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'];
380381 return $blogs;
381382 }
382383
@@ -18,4 +18,5 @@
1818 <h1 class="title">{{{ $meta->title }}}</h1>
1919 {{{ $article."\n" }}}
2020 </div>
21+{@ include(common/comments) @}
2122 {@ 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:**太字**、*斜体*、~~取り消し線~~、`コード`、```コードブロック```、&gt; 引用、- リスト、[文字](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 @@
6969 {@ if (AUTH_ENABLED) @}
7070 <div>
7171 {@ 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 @}
7276 お帰りなしゃ~い、<a style="text-decoration: none;" href="/profile/{{ $user->username }}">{{{ namecolor($user) }}}</a>! (<a href="/logout">ログアウト</a>)
77+{@ endif @}
7378 {@ else @}
7479 <a href="/login">ログイン</a>
7580 {@ 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) @}