通知・アーカイブ

守矢諏訪子 <suwako@076.moe>、2026-11-13 15:37
コミット 0748c3b6d92a2c1863c14847847f1ec311588418 ・親 b501fc9 ・ファイルを見る

25個のファイル、+767 -131

変更 README-JP.md
@@ -303,7 +303,9 @@
303303 * リポジトリを整理する為に Cron のジョブ `gitlergc` を追加するか(例:`php cron.php add "@daily" gitlergc`)、`php gitler.php gc` を実行して下さい。
304304 * リポジトリのページには GitHub の様に言語の割合が出ます(バイト数で、ファイル名・拡張子・`#!` で決めます)。数えるのはプログラミングとマークアップの言語だけで、データ(JSON、YAML 等)、文章(Markdown 等)、外部のコード(`vendor/`、`node_modules/` 等)、ドキュメント(`docs/`、`examples/` 等)、生成されたファイルは数えません。ルートの `.gitattributes` の `linguist-language`、`linguist-vendored`、`linguist-generated`、`linguist-documentation`、`linguist-detectable` で変えられます(例:`*.h linguist-language=C++`)。結果はリポジトリの中の `gitler-languages.json` にツリー毎に保存し、一覧には既定のブランチの一番多い言語が出ます。
305305 * イシュー:ラベル、担当者、マイルストーン、Markdown の本文とコメント(「#3」は他のイシューへのリンク)。リポジトリを読めて「Gitのイシュー」のロールの有るユーザーが作ってコメント出来、書き込み権限の有る人がラベル等を付けて閉じます(作った人も閉じられます)。既定のブランチにプッシュしたコミットのメッセージの「fixes #3」「closes #3」「resolves #3」でイシューを閉じ、「#3」はイシューに記録します。設定でリポジトリ毎に無効に出来ます。データはリポジトリの中の `gitler/` です。
306-* Fediverse(マルチユーザーの ActivityPub が有効な場合):公開のリポジトリは `@所有者.名前@ホスト` のアカウントになり、Mastodon 等からフォローするとイシューとコメントが届き、返信するとコメント、リポジトリをメンションした投稿は新しいイシューになります。ForgeFed の `Offer{Ticket}` と `Resolve` にも対応します(イシューの Ticket は `…/issues/3/ticket`)。リポジトリの設定でオフにしたり、アカウントやドメインをブロック出来ます(サイト全体のドメインのブロックも効きます)。配信には cron の `apdeliver` を使い、SSH のプッシュ(`git` ユーザー)で閉じたイシューは `gitler/spool/` に溜めて `apdeliver` が送ります。鍵の作成に GMP が必要です。
306+* Fediverse(マルチユーザーの ActivityPub が有効な場合):公開のリポジトリは `@所有者.名前@ホスト` のアカウントになり、Mastodon 等からフォローするとイシューとコメントが届き、返信するとコメント、リポジトリをメンションした投稿は新しいイシューになります。ForgeFed の `Offer{Ticket}` と `Resolve` にも対応します(イシューの Ticket は `…/issues/3/ticket`)。リポジトリの設定でオフにしたり、アカウントやドメインをブロック出来ます(サイト全体のドメインのブロックも効きます)。配信には cron の `apdeliver` を使い、SSH のプッシュ(`git` ユーザー)で閉じたイシューは `gitler/spool/` に溜めて cron の `notify` が送ります。鍵の作成に GMP が必要です。
307+* 通知:所有者と共同作業者は最初からリポジトリをウォッチし(新しいイシューと全部の動き)、他の人は作った・コメントした・担当の・メンション(`@ユーザー`)されたイシューの動きを受け取ります。リポジトリのページの「通知」でウォッチ・参加しているイシューだけ・無視を、イシューのページで受け取る・止めるを選べます。通知はサイトの「通知」に届き、`MAILER_ENABLED` ならメールでも届きます(`/gitler/-/keys` で止められます)。メールと SSH のプッシュの通知は cron の `notify` が送るので追加して下さい:`php cron.php add "* * * * *" notify`。
308+* アーカイブ:リポジトリの設定でアーカイブすると読むだけになります(プッシュ、イシュー・コメント・ラベル・マイルストーンの変更、Fediverse からのコメントを止めます)。何時でも解除出来ます。
307309
308310 ### TLS 1.3・1.2(php_openssl は不要)
309311 HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。
@@ -345,6 +347,7 @@
345347 * `authpurge`:7日以内にメールアドレスを確認しなかったアカウントを削除する(`AUTH_VERIFY_EMAIL` が有効の場合)
346348 * `apdeliver`:マイクロブログのアクティビティを他のサーバーに配信する(マルチユーザーモード、毎分実行)
347349 * `apcleanup`:90日より古いリモートの投稿とキャッシュを削除する(マルチユーザーモード、1日1回実行)
350+* `notify`:通知のメールを送り、SSH のプッシュで Gitler のリポジトリに溜めた通知と配信を処理する(`AUTH_ENABLED` が有効の場合、毎分実行)
348351 * `gitlergc`:Gitler のリポジトリを整理する(`GITLER_ENABLED` が有効の場合、1日1回実行)
349352
350353 ### マイクロブログ(マルチユーザーの Fediverse)
変更 README.md
@@ -301,7 +301,9 @@
301301 * Add the Cron job `gitlergc` (for example `php cron.php add "@daily" gitlergc`) to pack the repositories, or run `php gitler.php gc`.
302302 * The repository page shows the share of each language like GitHub (by bytes; file names, extensions and `#!`). Only programming and markup languages count: data (JSON, YAML…), prose (Markdown…), vendored code (`vendor/`, `node_modules/`…), documentation (`docs/`, `examples/`…) and generated files are left out. Change it with `linguist-language`, `linguist-vendored`, `linguist-generated`, `linguist-documentation` and `linguist-detectable` in the root `.gitattributes` (for example `*.h linguist-language=C++`). Results are stored per tree in `gitler-languages.json` inside the repository, and lists show the main language of the default branch.
303303 * Issues: labels, assignees, milestones, Markdown bodies and comments (`#3` links to another issue). Users who can read the repository and have the "Git issues" role open issues and comment; people with write access label and close them (authors can close their own). Commit messages pushed to the default branch close issues with `fixes #3`, `closes #3` or `resolves #3`, and any `#3` is recorded on the issue. Issues can be turned off per repository in its settings. The data lives in `gitler/` inside the repository.
304-* Fediverse (when multi-user ActivityPub is on): public repositories become `@owner.name@host` accounts. Follow one from Mastodon and the like to receive issues and comments; replies become comments, and posts mentioning the repository open new issues. ForgeFed `Offer{Ticket}` and `Resolve` are supported too (the issue's Ticket is at `…/issues/3/ticket`). Repository settings can turn it off and block accounts or domains (site-wide domain blocks apply as well). Delivery uses the `apdeliver` cron job; issues closed by SSH pushes (the `git` user) are queued in `gitler/spool/` and sent by `apdeliver`. Generating the keys needs GMP.
304+* Fediverse (when multi-user ActivityPub is on): public repositories become `@owner.name@host` accounts. Follow one from Mastodon and the like to receive issues and comments; replies become comments, and posts mentioning the repository open new issues. ForgeFed `Offer{Ticket}` and `Resolve` are supported too (the issue's Ticket is at `…/issues/3/ticket`). Repository settings can turn it off and block accounts or domains (site-wide domain blocks apply as well). Delivery uses the `apdeliver` cron job; issues closed by SSH pushes (the `git` user) are queued in `gitler/spool/` and sent by the `notify` cron job. Generating the keys needs GMP.
305+* Notifications: owners and collaborators watch their repositories from the start (new issues and all activity); others get activity on issues they opened, commented on, are assigned to or are mentioned in (`@user`). The repository page's "通知" menu switches between watching, participating only and ignoring, and each issue page can subscribe or unsubscribe. Notifications appear on the site's notifications page, and by email too when `MAILER_ENABLED` is on (users can turn email off at `/gitler/-/keys`). Emails and notifications from SSH pushes are sent by the `notify` cron job, so add it: `php cron.php add "* * * * *" notify`.
306+* Archiving: archiving a repository in its settings makes it read-only (no pushes, no changes to issues, comments, labels or milestones, no comments from the Fediverse). It can be undone at any time.
305307
306308 ### TLS 1.3 and 1.2 (no php_openssl)
307309 HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working.
@@ -343,6 +345,7 @@
343345 * `authpurge`: deletes accounts whose email address was not verified within 7 days (when `AUTH_VERIFY_EMAIL` is enabled)
344346 * `apdeliver`: delivers microblog activities to other servers (multi-user mode, run every minute)
345347 * `apcleanup`: deletes remote posts and caches older than 90 days (multi-user mode, run daily)
348+* `notify`: sends notification emails and processes the notifications and deliveries queued in Gitler repositories by SSH pushes (when `AUTH_ENABLED` is enabled, run every minute)
346349 * `gitlergc`: packs the Gitler repositories (when `GITLER_ENABLED` is enabled, run daily)
347350
348351 ### Microblog (multi-user Fediverse)
変更 gitler.php
@@ -56,7 +56,6 @@
5656 use Site\Controller\Gitler as GitlerController;
5757 use Std\Lib\Git\Repository;
5858 use Std\Lib\Git\Ssh;
59-use Std\Lib\Gitler\Federation;
6059 use Std\Lib\Gitler\Gitler;
6160 use Std\Lib\Gitler\Keys;
6261
@@ -110,14 +109,18 @@
110109 fwrite(STDERR, "こんにちは、{$name}さん!Gitlerにはシェルが有りません。\n");
111110 exit(1);
112111 }
113- // gitユーザーはdata/apに書けないので、Fediverseへの配信はリポジトリに溜める(cronのapdeliverが送る)
114- Federation::$spool = true;
112+ // gitユーザーはdata/に書けないので、通知とFediverseへの配信はリポジトリに溜める(cronのnotifyが送る)
113+ Gitler::$deferred = true;
115114 $owner = $repoName = '';
116115 exit(Ssh::handle($command, function(string $path, bool $write) use ($user, &$owner, &$repoName): ?Repository {
117116 // 「所有者/名前.git」又は「所有者/名前」(前に「gitler/」が有っても良い)
118117 if (!preg_match('#^(?:gitler/)?([A-Za-z0-9]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$#', $path, $m)) return null;
119118 $access = Gitler::access($user, $m[1], $m[2]);
120119 if ($access < ($write ? Gitler::WRITE : Gitler::READ)) return null;
120+ if ($write && Gitler::meta($m[1], $m[2])['archived']) {
121+ fwrite(STDERR, "アーカイブされたリポジトリにはプッシュ出来ません。\n");
122+ return null;
123+ }
121124 [$owner, $repoName] = [$m[1], $m[2]];
122125 return Gitler::open($m[1], $m[2]);
123126 }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions($owner, $repoName)]));
@@ -595,6 +595,28 @@
595595 margin-top: 16px;
596596 }
597597
598+.gl-watch {
599+ float: right;
600+ font-size: 0.9em;
601+}
602+
603+.gl-watch form {
604+ display: flex;
605+ flex-direction: column;
606+ align-items: stretch;
607+ gap: 4px;
608+ margin-top: 4px;
609+}
610+
611+.gl-watch button.active {
612+ border-color: #2c980c;
613+}
614+
615+.gl-archived {
616+ border-left: 4px solid #f1e05a;
617+ padding: 2px 8px;
618+}
619+
598620 button.danger {
599621 border-color: #ee4030;
600622 color: #ee4030;
変更 route.php
@@ -179,7 +179,7 @@
179179 foreach (['issues', 'labels', 'milestones', 'outbox', 'followers'] as $page) {
180180 $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page);
181181 }
182- foreach (['labels', 'milestones', 'inbox'] as $page) {
182+ foreach (['labels', 'milestones', 'inbox', 'watch'] as $page) {
183183 $routes[] = Route::add('POST', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page);
184184 }
185185 foreach (['tree', 'blob', 'raw', 'blame', 'commits', 'branches', 'tags', 'archive', 'settings'] as $page) {
@@ -194,6 +194,10 @@
194194 $routes[] = Route::add('POST', 'comments', Comment::class.'@post');
195195 $routes[] = Route::add('POST', 'comments/like', Comment::class.'@like');
196196 $routes[] = Route::add('POST', 'comments/delete', Comment::class.'@delete');
197+}
198+
199+// 通知(記事のコメント、Gitler等)
200+if (AUTH_ENABLED) {
197201 $routes[] = Route::add('GET', 'notifications', Comment::class.'@notifications');
198202 }
199203
@@ -125,8 +125,8 @@
125125 */
126126 public function notifications(array $params): void {
127127 $user = $this->requireUser();
128- $list = array_map(fn($n) => Comments::present($n), Comments::notifications($user->username));
129- Comments::markRead($user->username);
128+ $list = array_map(fn($n) => Comments::present($n), \Std\Lib\Notifications::list($user->username));
129+ \Std\Lib\Notifications::markRead($user->username);
130130 $this->render('notifications', '通知', ['list' => $list]);
131131 }
132132
@@ -51,6 +51,7 @@
5151 use Std\Lib\Gitler\Gitler as Hosting;
5252 use Std\Lib\Gitler\Issues;
5353 use Std\Lib\Gitler\Keys;
54+use Std\Lib\Gitler\Notify;
5455 use Std\Lib\Gitler\Languages;
5556 use Std\Lib\Markdown;
5657 use Std\Lib\Syntax;
@@ -168,16 +169,18 @@
168169 }
169170 } else if ($do === 'removeToken') {
170171 Keys::removeToken($me, (string)($_POST['id'] ?? '')) ? $message = 'トークンを削除しました。' : $error = 'トークンが有りません。';
172+ } else if ($do === 'email') {
173+ Keys::setEmail($me, isset($_POST['email'])) ? $message = '保存しました。' : $error = '保存出来ません。';
171174 }
172175 }
173176 }
174177 $data = Keys::load($me);
175178 $fmt = fn($t) => $t ? date('Y-m-d H:i', (int)$t) : '-';
176- $this->render('gitler/keys', 'SSHの鍵とトークン', [
179+ $this->render('gitler/keys', 'Gitlerの設定', [
177180 'keys' => array_map(fn($k) => $k + ['addedText' => $fmt($k['added'] ?? null), 'usedText' => $fmt($k['lastUsed'] ?? null)], $data['keys']),
178181 'tokens' => array_map(fn($t) => $t + ['createdText' => $fmt($t['created'] ?? null), 'usedText' => $fmt($t['lastUsed'] ?? null)], $data['tokens']),
179182 'newToken' => $newToken, 'error' => $error, 'message' => $message, 'ssh' => (string)Hosting::config()['ssh'], 'me' => $me,
180- 'twoFactor' => !empty($this->user->twoFactorOn),
183+ 'twoFactor' => !empty($this->user->twoFactorOn), 'emailOn' => $data['email'], 'mailer' => MAILER_ENABLED, 'email' => (string)($this->user->email ?? ''),
181184 ]);
182185 }
183186
@@ -486,6 +489,13 @@
486489 $meta['federate'] = isset($_POST['federate']);
487490 Hosting::saveMeta($this->owner, $this->name, $meta);
488491 $message = '保存しました。';
492+ } else if ($do === 'archive' && !isset($_POST['confirm'])) {
493+ $error = '確認にチェックを入れて下さい。';
494+ } else if ($do === 'archive' || $do === 'unarchive') {
495+ $meta['archived'] = $do === 'archive';
496+ $meta['archivedAt'] = $meta['archived'] ? time() : null;
497+ Hosting::saveMeta($this->owner, $this->name, $meta);
498+ $message = $meta['archived'] ? 'アーカイブしました。読むだけになります。' : 'アーカイブを解除しました。';
489499 } else if ($do === 'block' || $do === 'unblock') {
490500 $res = Federation::block($this->owner, $this->name, (string)($_POST['target'] ?? ''), $do === 'block');
491501 $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message;
@@ -525,6 +535,11 @@
525535 $exists = Hosting::validOwner($owner) && Hosting::validName($name) && Hosting::exists($owner, $name);
526536 $access = $exists ? Hosting::access($user, $owner, $name) : Hosting::NONE;
527537 $need = $write ? Hosting::WRITE : Hosting::READ;
538+ if ($write && $access >= Hosting::WRITE && Hosting::meta($owner, $name)['archived']) {
539+ http_response_code(403);
540+ echo "アーカイブされたリポジトリにはプッシュ出来ません。\n";
541+ exit;
542+ }
528543 if ($access < $need || ($write && !$tokenWrite)) {
529544 // 認証していなければ、先に認証を求める(非公開のリポジトリが有るか分からない様に)
530545 if ($user === null || ($write && $access >= Hosting::WRITE && !$tokenWrite && !$tried)) {
@@ -637,7 +652,7 @@
637652 }
638653
639654 private function presentRepo(array $r): array {
640- return ['owner' => $r['owner'], 'name' => $r['name'], 'description' => $r['meta']['description'], 'private' => $r['meta']['private'],
655+ return ['owner' => $r['owner'], 'name' => $r['name'], 'description' => $r['meta']['description'], 'private' => $r['meta']['private'], 'archived' => $r['meta']['archived'],
641656 'url' => '/gitler/'.$r['owner'].'/'.rawurlencode($r['name']), 'updated' => $r['updated'] ? date('Y-m-d H:i', $r['updated']) : '-',
642657 'lang' => Languages::primary(Hosting::path($r['owner'], $r['name']))];
643658 }
@@ -797,6 +812,8 @@
797812 'zipUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=zip' : '',
798813 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '',
799814 'issuesOn' => $this->meta['issues'], 'openIssues' => $this->meta['issues'] ? (new Issues($this->repo))->counts()['open'] : 0,
815+ 'archived' => $this->meta['archived'], 'here' => (string)($_SERVER['REQUEST_URI'] ?? ''),
816+ 'watch' => $this->user ? Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) : '',
800817 ]);
801818 }
802819
@@ -42,9 +42,10 @@
4242 use Std\Lib\Gitler\Federation;
4343 use Std\Lib\Gitler\Gitler as Hosting;
4444 use Std\Lib\Gitler\Issues;
45+use Std\Lib\Gitler\Notify;
4546
4647 /**
47- * Gitlerのイシュー、ラベル、マイルストーンのページと、リポジトリのFediverse(アクター、受付ボックス等)
48+ * Gitlerのイシュー、ラベル、マイルストーンのページ、ウォッチ、リポジトリのFediverse(アクター、受付ボックス等)
4849 *
4950 * Gitlerコントローラーで使う(リポジトリを開く等はGitlerの物)。
5051 */
@@ -83,6 +84,7 @@
8384 public function newIssue(array $params): void {
8485 $this->loadIssues($params);
8586 if (!$this->user) $this->redirect('/login');
87+ if ($this->meta['archived']) $this->message('アーカイブされたリポジトリです(読むだけ)。', $this->repoUrl().'/issues');
8688 if (!$this->canIssue()) $this->message('イシューを作る権限が有りません。', $this->repoUrl().'/issues');
8789 $in = ['title' => '', 'body' => '', 'labels' => [], 'assignees' => [], 'milestone' => 0];
8890 $error = '';
@@ -96,6 +98,7 @@
9698 $res = $this->issueDb->create($this->me(), $in['title'], $in['body'], $opt);
9799 if ($res->isSuccess) {
98100 Federation::issueOpened($this->owner, $this->name, $this->meta, $res->data['issue']);
101+ Notify::issueOpened($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue']);
99102 $this->redirect($this->repoUrl().'/issues/'.$res->data['number']);
100103 }
101104 $error = (string)$res->message;
@@ -125,30 +128,37 @@
125128 }
126129 header('Vary: Accept');
127130 $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username;
131+ $archived = $this->meta['archived'];
128132 $labels = $this->issueDb->labels();
129133 $milestones = $this->issueDb->milestones();
130134 $timeline = [];
131135 foreach ($issue['comments'] as $c) {
132136 $mine = $this->user && ($c['author']['user'] ?? null) === $this->user->username;
133137 $timeline[] = ['kind' => 'comment', 'at' => $c['created'], 'id' => $c['id'], 'who' => $this->who($c['author']), 'html' => $this->bodyHtml($c['body'], !empty($c['html'])),
134- 'date' => date('Y-m-d H:i', $c['created']), 'edited' => $c['updated'] > $c['created'], 'body' => $c['body'], 'canEdit' => $mine && empty($c['html']),
135- 'canDelete' => $mine || $this->access >= Hosting::ADMIN, 'actor' => $c['author']['actor'] ?? '',
138+ 'date' => date('Y-m-d H:i', $c['created']), 'edited' => $c['updated'] > $c['created'], 'body' => $c['body'], 'canEdit' => !$archived && $mine && empty($c['html']),
139+ 'canDelete' => !$archived && ($mine || $this->access >= Hosting::ADMIN), 'actor' => $c['author']['actor'] ?? '',
136140 'remote' => preg_match('#^https?://#i', (string)($c['remoteId'] ?? '')) ? $c['remoteId'] : ''];
137141 }
138142 foreach ($issue['events'] as $e) $timeline[] = ['kind' => 'event', 'at' => $e['at'], 'html' => $this->eventHtml($e, $labels, $milestones), 'date' => date('Y-m-d H:i', $e['at'])];
139143 usort($timeline, fn($a, $b) => $a['at'] <=> $b['at']);
140- $fedi = Federation::enabled($this->meta);
144+ $fedi = Federation::enabled($this->meta) && !$this->meta['archived'];
145+ // 通知:受け取っている(true)、止めた(false)、ウォッチで受け取る、受け取っていない
146+ $sub = $this->user ? ($issue['subscribers'][$this->user->username] ?? null) : null;
147+ $watching = $this->user && Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) === Notify::WATCHING;
141148 $this->renderRepo('gitler/issue', '#'.$n.' '.$issue['title'], [
142149 'issue' => $issue, 'who' => $this->who($issue['author']), 'bodyHtml' => $this->bodyHtml($issue['body'], !empty($issue['html'])),
143150 'date' => date('Y-m-d H:i', $issue['created']), 'timeline' => $timeline, 'error' => $error,
144- 'canIssue' => $this->canIssue(), 'canEdit' => ($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE, 'canClose' => $isAuthor || $this->access >= Hosting::WRITE,
145- 'canTriage' => $this->access >= Hosting::WRITE, 'canAdmin' => $this->access >= Hosting::ADMIN,
151+ 'canIssue' => $this->canIssue(), 'canEdit' => !$archived && (($isAuthor && empty($issue['html'])) || $this->access >= Hosting::WRITE),
152+ 'canClose' => !$archived && ($isAuthor || $this->access >= Hosting::WRITE), 'canTriage' => !$archived && $this->access >= Hosting::WRITE,
153+ 'canAdmin' => !$archived && $this->access >= Hosting::ADMIN,
146154 'labels' => $this->presentLabels($labels, $issue['labels']), 'issueLabels' => $this->presentLabels(array_intersect_key($labels, array_flip($issue['labels']))),
147155 'milestones' => $milestones, 'milestone' => $issue['milestone'] !== null ? ($milestones[$issue['milestone']]['title'] ?? '') : '',
148156 'people' => array_map(fn($p) => ['name' => $p, 'checked' => in_array($p, $issue['assignees'], true)], array_unique(array_merge($this->assignable(), $issue['assignees']))),
149157 'issueUrl' => $this->repoUrl().'/issues/'.$n, 'fedi' => $fedi,
150158 'fediHandle' => $fedi ? '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host() : '',
151159 'fediUrl' => $fedi ? Federation::issueUrl($this->owner, $this->name, $n) : '',
160+ 'subscribed' => $sub === true || ($sub === null && $watching),
161+ 'subscribeNote' => $sub === true ? '通知を受け取っています。' : ($sub === false ? '通知を止めています。' : ($watching ? 'リポジトリをウォッチしているので通知を受け取ります。' : '通知を受け取っていません。')),
152162 ]);
153163 }
154164
@@ -181,6 +191,12 @@
181191 $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? ''));
182192 if (!$this->user) return 'ログインして下さい。';
183193
194+ if ($do === 'subscribe' || $do === 'unsubscribe') {
195+ $this->issueDb->subscribe($n, [(string)$this->user->username], $do === 'subscribe');
196+ $this->redirect($url);
197+ }
198+ if ($this->meta['archived']) return 'アーカイブされたリポジトリです(読むだけ)。';
199+
184200 if (in_array($do, ['comment', 'commentClose', 'commentReopen', 'close', 'reopen'], true)) {
185201 if (!$this->canIssue()) return 'コメントする権限が有りません。';
186202 $anchor = '';
@@ -188,11 +204,15 @@
188204 $res = $this->issueDb->comment($n, $me, $body);
189205 if (!$res->isSuccess) return (string)$res->message;
190206 Federation::commentAdded($this->owner, $this->name, $this->meta, $res->data['issue'], $res->data['comment']);
207+ Notify::commented($this->owner, $this->name, $this->meta, $this->issueDb, $res->data['issue'], $res->data['comment']);
191208 $anchor = '#c'.$res->data['comment']['id'];
192209 }
193210 if ($do !== 'comment' && $canClose) {
194211 $new = $this->issueDb->setState($n, in_array($do, ['reopen', 'commentReopen'], true), $me);
195- if ($new !== null) Federation::stateChanged($this->owner, $this->name, $this->meta, $new);
212+ if ($new !== null) {
213+ Federation::stateChanged($this->owner, $this->name, $this->meta, $new);
214+ Notify::stateChanged($this->owner, $this->name, $this->meta, $new, $me);
215+ }
196216 }
197217 $this->redirect($url.$anchor);
198218 }
@@ -206,7 +226,9 @@
206226 if ($do === 'triage') {
207227 if ($this->access < Hosting::WRITE) return '権限が有りません。';
208228 $people = array_merge($this->assignable(), $issue['assignees']);
209- $this->issueDb->triage($n, $me, (array)($_POST['labels'] ?? []), array_values(array_intersect((array)($_POST['assignees'] ?? []), $people)), (int)($_POST['milestone'] ?? 0));
229+ $new = $this->issueDb->triage($n, $me, (array)($_POST['labels'] ?? []), array_values(array_intersect((array)($_POST['assignees'] ?? []), $people)), (int)($_POST['milestone'] ?? 0));
230+ $added = $new !== null ? array_values(array_diff($new['assignees'], $issue['assignees'])) : [];
231+ if (!empty($added)) Notify::assigned($this->owner, $this->name, $this->meta, $new, $added, $me);
210232 $this->redirect($url);
211233 }
212234 if ($do === 'editComment' || $do === 'deleteComment') {
@@ -242,6 +264,17 @@
242264 return '';
243265 }
244266
267+ //////// ウォッチ
268+
269+ /** …/watch(POST:state=watching、participating、ignoring) */
270+ public function watch(array $params): void {
271+ $this->load($params);
272+ if (!$this->user) $this->redirect('/login');
273+ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $this->csrfOk()) Notify::setWatch($this->owner, $this->name, (string)$this->user->username, (string)($_POST['state'] ?? ''));
274+ $back = (string)($_POST['back'] ?? '');
275+ $this->redirect(str_starts_with($back, $this->repoUrl()) ? $back : $this->repoUrl());
276+ }
277+
245278 //////// ラベルとマイルストーン
246279
247280 /** …/labels */
@@ -251,8 +284,8 @@
251284 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
252285 if (!$this->csrfOk()) {
253286 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
254- } else if ($this->access < Hosting::WRITE) {
255- $error = '権限が有りません。';
287+ } else if ($this->access < Hosting::WRITE || $this->meta['archived']) {
288+ $error = $this->meta['archived'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。';
256289 } else if (($_POST['do'] ?? '') === 'delete') {
257290 $this->issueDb->deleteLabel((int)($_POST['id'] ?? 0));
258291 $this->redirect($this->repoUrl().'/labels');
@@ -267,7 +300,7 @@
267300 foreach ($this->issueDb->search(['state' => 'open']) as $s) foreach ($s['labels'] as $l) $counts[$l] = ($counts[$l] ?? 0) + 1;
268301 $list = [];
269302 foreach ($this->presentLabels($this->issueDb->labels()) as $l) $list[] = $l + ['open' => $counts[$l['id']] ?? 0, 'url' => $this->repoUrl().'/issues?label='.$l['id']];
270- $this->renderRepo('gitler/labels', 'ラベル', ['list' => $list, 'error' => $error, 'canTriage' => $this->access >= Hosting::WRITE]);
303+ $this->renderRepo('gitler/labels', 'ラベル', ['list' => $list, 'error' => $error, 'canTriage' => $this->access >= Hosting::WRITE && !$this->meta['archived']]);
271304 }
272305
273306 /** …/milestones */
@@ -280,8 +313,8 @@
280313 $all = $this->issueDb->milestones();
281314 if (!$this->csrfOk()) {
282315 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
283- } else if ($this->access < Hosting::WRITE) {
284- $error = '権限が有りません。';
316+ } else if ($this->access < Hosting::WRITE || $this->meta['archived']) {
317+ $error = $this->meta['archived'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。';
285318 } else if ($do === 'delete') {
286319 $this->issueDb->deleteMilestone($id);
287320 $this->redirect($this->repoUrl().'/milestones');
@@ -312,7 +345,8 @@
312345 'url' => $this->repoUrl().'/issues?state=all&milestone='.$id];
313346 }
314347 usort($list, fn($a, $b) => [$a['due'] === '' ? '9999' : $a['due'], $a['id']] <=> [$b['due'] === '' ? '9999' : $b['due'], $b['id']]);
315- $this->renderRepo('gitler/milestones', 'マイルストーン', ['list' => $list, 'error' => $error, 'state' => $state, 'counts' => $counts, 'canTriage' => $this->access >= Hosting::WRITE]);
348+ $this->renderRepo('gitler/milestones', 'マイルストーン', ['list' => $list, 'error' => $error, 'state' => $state, 'counts' => $counts,
349+ 'canTriage' => $this->access >= Hosting::WRITE && !$this->meta['archived']]);
316350 }
317351
318352 //////// ActivityPub(リポジトリのアクター)
@@ -407,7 +441,9 @@
407441 $head = $issues->repo->headRef() ?? 'refs/heads/'.Hosting::config()['branch'];
408442 foreach ($issues->processPush($done, $head) as $n) {
409443 $issue = $issues->get($n);
410- if ($issue !== null) Federation::stateChanged($owner, $name, $meta, $issue);
444+ if ($issue === null) continue;
445+ Federation::stateChanged($owner, $name, $meta, $issue);
446+ Notify::stateChanged($owner, $name, $meta, $issue, end($issue['events'])['by'] ?? []);
411447 }
412448 } catch (\Throwable $e) {
413449 // プッシュ自体は終わっている
@@ -425,7 +461,7 @@
425461
426462 /** イシューを作ってコメント出来るか */
427463 private function canIssue(): bool {
428- return $this->user !== null && $this->meta['issues'] && Hosting::userCan($this->user, 'issues');
464+ return $this->user !== null && $this->meta['issues'] && !$this->meta['archived'] && Hosting::userCan($this->user, 'issues');
429465 }
430466
431467 private function me(): array {
@@ -52,7 +52,7 @@
5252 * 記事のURLと同じ。データは全部data/の下のJSONファイル:
5353 * comments/{スレッド}.json コメント(古い順)
5454 * comments/ap.json Fediverseの返信のID => スレッドとコメント(返信への返信・編集・削除の為)
55- * notifications/{ユーザー名}.json 通知(新しい順)
55+ * 通知はNotifications(data/notifications/)。
5656 *
5757 * 新しいコメントは全部の管理者に通知する。ローカルのユーザーには、自分のコメントへの返信といいねを通知する。
5858 * マルチユーザーモードでFediverseのロールを持つユーザーのコメントは、連合する記事なら本人のアカウントの投稿
@@ -64,8 +64,6 @@
6464 public const MAX_LENGTH = 5000;
6565 /** 返信の字下げの最大の深さ(これより深い返信は同じ深さで表示する) */
6666 public const MAX_DEPTH = 5;
67- /** 1人の通知の最大数 */
68- public const MAX_NOTIFICATIONS = 200;
6967
7068 /** @var string|null 保存先(テスト用。既定はdata) */
7169 public static ?string $dataDir = null;
@@ -363,32 +361,17 @@
363361 //////// 通知
364362
365363 /**
366- * 通知(新しい順)
364+ * 通知(Notifications)の表示用のデータ
367365 *
368- * @param string $username ユーザー名
369- * @return array type(comment、reply、like)、thread、comment、title、from、at、read
370- */
371- public static function notifications(string $username): array {
372- if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return [];
373- return Fedi::read(self::path("notifications/{$username}.json"));
374- }
375-
376- public static function unreadCount(string $username): int {
377- return count(array_filter(self::notifications($username), fn($n) => empty($n['read'])));
378- }
379-
380- public static function markRead(string $username): void {
381- if (!preg_match('/^[A-Za-z0-9]+$/', $username) || self::unreadCount($username) === 0) return;
382- Fedi::update(self::path("notifications/{$username}.json"), fn($l) => array_map(fn($n) => ['read' => true] + $n, $l));
383- }
384-
385- /**
386- * 通知の表示用のデータ
387- *
388- * @param array $n 通知
366+ * @param array $n 通知(コメントはtype:comment、reply、like、thread、comment、title。他はlabel・page・excerpt)
389367 * @return array who(表示名等)、label、page(コメントのURL)、excerpt、at、read
390368 */
391369 public static function present(array $n): array {
370+ // コメント以外(Gitler等)は追加した時の文
371+ if (isset($n['label'], $n['page'])) {
372+ return ['type' => $n['type'] ?? '', 'who' => self::who((array)($n['from'] ?? [])), 'label' => (string)$n['label'], 'page' => (string)$n['page'],
373+ 'excerpt' => (string)($n['excerpt'] ?? ''), 'at' => date('Y-m-d H:i', (int)($n['at'] ?? 0)), 'read' => !empty($n['read'])];
374+ }
392375 $thread = (string)($n['thread'] ?? '');
393376 $c = self::all($thread)[$n['comment'] ?? ''] ?? null;
394377 $title = (string)($n['title'] ?? '');
@@ -536,15 +519,9 @@
536519 }
537520
538521 private static function notify(string $username, string $type, string $thread, string $comment, string $title, array $from): void {
539- if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return;
540- Fedi::update(self::path("notifications/{$username}.json"), function(array $l) use ($type, $thread, $comment, $title, $from): array {
541- // 同じいいねを(取り消して又いいねしても)重複させない
542- foreach ($l as $n) {
543- if (empty($n['read']) && $n['type'] === $type && $n['comment'] === $comment && ($n['from'] ?? []) === $from) return $l;
544- }
545- array_unshift($l, ['type' => $type, 'thread' => $thread, 'comment' => $comment, 'title' => $title, 'from' => $from, 'at' => time(), 'read' => false]);
546- return array_slice($l, 0, self::MAX_NOTIFICATIONS);
547- });
522+ // 同じいいねを(取り消して又いいねしても)重複させない
523+ Notifications::add($username, ['type' => $type, 'thread' => $thread, 'comment' => $comment, 'title' => $title, 'from' => $from],
524+ fn(array $n) => ($n['type'] ?? '') === $type && ($n['comment'] ?? '') === $comment && ($n['from'] ?? []) === $from);
548525 }
549526
550527 /**
@@ -57,7 +57,8 @@
5757 * /gitler/o/r/issues/3/comments/5 コメントのNote
5858 *
5959 * 鍵、フォロワー、ブロックはリポジトリの中のgitler/。配信はFediのDelivery(cronのapdeliver)。
60- * SSHのプッシュ(gitユーザー)はdata/apに書けないので、gitler/spool/に溜めてapdeliverが送る。
60+ * SSHのプッシュ(gitユーザー)はdata/apに書けないので、Gitler::defer()で溜めてcronのnotifyが送る。
61+ * アーカイブしたリポジトリはフォロー出来るが、イシューとコメントは受け付けない。
6162 */
6263 final class Federation {
6364 public const CONTEXT = [
@@ -77,9 +78,6 @@
7778 ],
7879 ];
7980
80- /** SSHのプッシュでは配信をリポジトリに溜める */
81- public static bool $spool = false;
82-
8381 /** このリポジトリがFediverseに出るか */
8482 public static function enabled(array $meta): bool {
8583 return Fedi::enabled() && empty($meta['private']) && ($meta['issues'] ?? true) && ($meta['federate'] ?? true);
@@ -144,7 +142,8 @@
144142 'type' => 'Service',
145143 'preferredUsername' => self::handle($owner, $name),
146144 'name' => "{$owner}/{$name}",
147- 'summary' => ($desc !== '' ? '<p>'.Html::esc($desc).'</p>' : '').'<p>Gitlerのリポジトリ。フォローするとイシューが届き、返信するとコメント出来ます。メンションすると新しいイシューになります。</p>',
145+ 'summary' => ($desc !== '' ? '<p>'.Html::esc($desc).'</p>' : '').($meta['archived'] ?? false ? '<p>アーカイブされたリポジトリです(読むだけ)。</p>'
146+ : '<p>Gitlerのリポジトリ。フォローするとイシューが届き、返信するとコメント出来ます。メンションすると新しいイシューになります。</p>'),
148147 'url' => $url,
149148 'inbox' => "{$url}/inbox",
150149 'outbox' => "{$url}/outbox",
@@ -348,22 +347,6 @@
348347 self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue));
349348 }
350349
351- /** 溜めた配信を送る(cronのapdeliver) */
352- public static function flush(): int {
353- $n = 0;
354- foreach (Gitler::list() as $r) {
355- $dir = Gitler::path($r['owner'], $r['name']).'/gitler/spool';
356- foreach (glob($dir.'/*.json') ?: [] as $f) {
357- $job = json_decode((string)@file_get_contents($f), true);
358- @unlink($f);
359- if (!is_array($job) || !is_array($job['activity'] ?? null) || !self::enabled($r['meta'])) continue;
360- self::send($r['owner'], $r['name'], $job['activity'], (array)($job['extra'] ?? []), true);
361- $n++;
362- }
363- }
364- return $n;
365- }
366-
367350 //////// 受け取る
368351
369352 /**
@@ -377,6 +360,8 @@
377360 $actor = Fedi::id($a['actor'] ?? null);
378361 if ($actor === null || Fedi::isLocal($actor)) return [403, ['error' => '不正なactor']];
379362 if (self::isBlocked($owner, $name, $actor)) return [202, ['status' => '無視しました']];
363+ // アーカイブしたリポジトリはフォローだけ
364+ if ($meta['archived'] && !in_array($a['type'] ?? '', ['Follow', 'Undo'], true)) return [202, ['status' => 'アーカイブされています']];
380365 try {
381366 return match ((string)($a['type'] ?? '')) {
382367 'Follow' => self::onFollow($owner, $name, $a, $actor),
@@ -415,7 +400,7 @@
415400 });
416401 return [202, ['status' => 'OK']];
417402 }
418- if (($inner['type'] ?? '') === 'Resolve') return self::onResolve($owner, $name, $meta, Fedi::id($inner['object'] ?? null), $actor, true);
403+ if (($inner['type'] ?? '') === 'Resolve' && !$meta['archived']) return self::onResolve($owner, $name, $meta, Fedi::id($inner['object'] ?? null), $actor, true);
419404 return [202, ['status' => '無視しました']];
420405 }
421406
@@ -434,7 +419,8 @@
434419 if ($parent !== null) {
435420 $n = self::issueOf($owner, $name, $issues, $parent);
436421 if ($n === null) return [202, ['status' => '無視しました']];
437- if ($html !== '') $issues->comment($n, $author, $html, ['html' => true, 'remoteId' => $id]);
422+ $res = $html !== '' ? $issues->comment($n, $author, $html, ['html' => true, 'remoteId' => $id]) : null;
423+ if ($res?->isSuccess) Notify::commented($owner, $name, $meta, $issues, $res->data['issue'], $res->data['comment']);
438424 return [202, ['status' => 'OK']];
439425 }
440426
@@ -450,7 +436,10 @@
450436 }
451437 if ($title === '') return [202, ['status' => '無視しました']];
452438 $res = $issues->create($author, $title, $html, ['html' => true, 'remoteId' => $id]);
453- if ($res->isSuccess) self::issueOpened($owner, $name, $meta, $res->data['issue']);
439+ if ($res->isSuccess) {
440+ self::issueOpened($owner, $name, $meta, $res->data['issue']);
441+ Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']);
442+ }
454443 return [202, ['status' => 'OK']];
455444 }
456445
@@ -476,7 +465,10 @@
476465 Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$repo}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $repo,
477466 'object' => $a, 'result' => self::issueUrl($owner, $name, $n).'/ticket'], [$inbox]);
478467 }
479- if ($res->isSuccess) self::issueOpened($owner, $name, $meta, $res->data['issue']);
468+ if ($res->isSuccess) {
469+ self::issueOpened($owner, $name, $meta, $res->data['issue']);
470+ Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']);
471+ }
480472 }
481473 return [202, ['status' => 'OK']];
482474 }
@@ -550,8 +542,12 @@
550542 $n = $object !== null ? self::issueOf($owner, $name, $issues, $object) : null;
551543 $issue = $n !== null ? $issues->get($n) : null;
552544 if ($issue === null || ($issue['author']['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']];
553- $new = $issues->setState($n, $undo, self::remoteAuthor($actor));
554- if ($new !== null) self::stateChanged($owner, $name, $meta, $new);
545+ $by = self::remoteAuthor($actor);
546+ $new = $issues->setState($n, $undo, $by);
547+ if ($new !== null) {
548+ self::stateChanged($owner, $name, $meta, $new);
549+ Notify::stateChanged($owner, $name, $meta, $new, $by);
550+ }
555551 return [202, ['status' => 'OK']];
556552 }
557553
@@ -639,16 +635,17 @@
639635 'to' => $obj['to'] ?? [Fedi::PUBLIC], 'cc' => $obj['cc'] ?? ["{$me}/followers"], 'object' => $object];
640636 }
641637
642- /**
643- * フォロワーと関わった人に送る(SSHのプッシュではリポジトリに溜める)
644- */
645- private static function send(string $owner, string $name, array $activity, array $extra, bool $now = false): void {
646- if (self::$spool && !$now) {
647- $repo = Gitler::open($owner, $name);
648- $repo->mkdir($repo->path.'/gitler/spool');
649- $repo->writeFile($repo->path.'/gitler/spool/'.Fedi::newId().'.json', (string)json_encode(['activity' => $activity, 'extra' => $extra], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
638+ /** フォロワーと関わった人に送る(SSHのプッシュではリポジトリに溜める) */
639+ private static function send(string $owner, string $name, array $activity, array $extra): void {
640+ if (Gitler::$deferred) {
641+ Gitler::defer($owner, $name, ['kind' => 'federate', 'activity' => $activity, 'extra' => $extra]);
650642 return;
651643 }
644+ self::deliver($owner, $name, $activity, $extra);
645+ }
646+
647+ /** Deliveryの待ち行列に入れる */
648+ public static function deliver(string $owner, string $name, array $activity, array $extra): void {
652649 $inboxes = [];
653650 foreach (self::followers($owner, $name) as $f) {
654651 $inbox = $f['sharedInbox'] ?? $f['inbox'] ?? null;
@@ -52,6 +52,7 @@
5252 *
5353 * リポジトリは{root}/{所有者}/{名前}.git(GITINFOのroot)。説明・非公開・共同作業者はリポジトリの中のgitler.json。
5454 * 権限:NONE(見えない)、READ(読める)、WRITE(プッシュ出来る)、ADMIN(設定出来る)。
55+ * アーカイブしたリポジトリは読むだけ(プッシュ、イシュー等の変更は出来ない)。
5556 */
5657 final class Gitler {
5758 public const NONE = 0;
@@ -63,6 +64,11 @@
6364 public static ?string $root = null;
6465 /** ユーザーのデータ(テスト用、既定はdata/user/) */
6566 public static ?string $userDir = null;
67+ /**
68+ * SSHのプッシュ(gitユーザー)ではdata/に書けないので、通知とFediverseへの配信をリポジトリのgitler/spool/に溜める。
69+ * cronのnotifyが送る
70+ */
71+ public static bool $deferred = false;
6672
6773 public static function enabled(): bool {
6874 return defined('GITLER_ENABLED') && GITLER_ENABLED && AUTH_ENABLED;
@@ -124,13 +130,13 @@
124130 * リポジトリの情報
125131 *
126132 * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、
127- * issues(イシューを使う)、federate(公開ならFediverseに出す)
133+ * issues(イシューを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ)
128134 */
129135 public static function meta(string $owner, string $name): array {
130136 $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true);
131137 $j = is_array($j) ? $j : [];
132- $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'federate' => true];
133- foreach (['private', 'issues', 'federate'] as $k) $j[$k] = (bool)$j[$k];
138+ $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'federate' => true, 'archived' => false];
139+ foreach (['private', 'issues', 'federate', 'archived'] as $k) $j[$k] = (bool)$j[$k];
134140 $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : [];
135141 return $j;
136142 }
@@ -275,6 +281,41 @@
275281 }
276282 usort($out, fn($a, $b) => $b['updated'] <=> $a['updated'] ?: strcmp($a['owner'].'/'.$a['name'], $b['owner'].'/'.$b['name']));
277283 return $out;
284+ }
285+
286+ //////// 後で処理する物
287+
288+ /** SSHのプッシュの通知・配信をリポジトリに溜める(kind:notify、federate) */
289+ public static function defer(string $owner, string $name, array $job): void {
290+ $repo = self::open($owner, $name);
291+ $dir = $repo->path.'/gitler/spool';
292+ $repo->mkdir($dir);
293+ $repo->writeFile($dir.'/'.Fedi::newId().'.json', (string)json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
294+ }
295+
296+ /**
297+ * 溜めた通知と配信を処理する(cronのnotify)
298+ *
299+ * @return int 処理した数
300+ */
301+ public static function flushDeferred(): int {
302+ $n = 0;
303+ foreach (self::list() as $r) {
304+ foreach (glob(self::path($r['owner'], $r['name']).'/gitler/spool/*.json') ?: [] as $f) {
305+ // 同時に動いた別のcronと重ならない様に、名前を変えて取る
306+ if (!@rename($f, $f.'.work')) continue;
307+ $job = json_decode((string)@file_get_contents($f.'.work'), true);
308+ @unlink($f.'.work');
309+ if (!is_array($job)) continue;
310+ if (($job['kind'] ?? 'federate') === 'notify') {
311+ Notify::deliver((string)($job['user'] ?? ''), (array)($job['record'] ?? []), $job['mail'] ?? null);
312+ } else if (is_array($job['activity'] ?? null) && Federation::enabled($r['meta'])) {
313+ Federation::deliver($r['owner'], $r['name'], $job['activity'], (array)($job['extra'] ?? []));
314+ }
315+ $n++;
316+ }
317+ }
318+ return $n;
278319 }
279320
280321 /** 全部のリポジトリを整理する(cronのgitlergc) */
@@ -47,7 +47,7 @@
4747 * Gitlerのイシュー(リポジトリの中のgitler/)
4848 *
4949 * issues.json 索引(次の番号、イシュー毎の概要、リモートのID => [番号, コメント])
50- * issues/{番号}.json イシュー(本文、コメント、出来事)
50+ * issues/{番号}.json イシュー(本文、コメント、出来事、通知を受け取るユーザー(subscribers:ユーザー名 => bool))
5151 * labels.json、milestones.json
5252 *
5353 * 書く者:local({user})、remote({actor, name, handle, icon, url}、Fediverse)、commit({commit, name})。
@@ -147,8 +147,10 @@
147147 'number' => $n, 'title' => $title, 'body' => $body, 'html' => !empty($opt['html']), 'author' => $author, 'state' => 'open',
148148 'created' => $now, 'updated' => $now, 'closed' => null, 'labels' => $this->validLabels($opt['labels'] ?? []),
149149 'assignees' => array_values(array_unique(array_map('strval', $opt['assignees'] ?? []))), 'milestone' => $this->validMilestone($opt['milestone'] ?? null),
150- 'remoteId' => $opt['remoteId'] ?? null, 'comments' => [], 'events' => [], 'seq' => 1,
150+ 'remoteId' => $opt['remoteId'] ?? null, 'comments' => [], 'events' => [], 'seq' => 1, 'subscribers' => [],
151151 ];
152+ // 作った人と担当者は通知を受け取る
153+ foreach (array_merge(isset($author['user']) ? [$author['user']] : [], $issue['assignees']) as $u) $issue['subscribers'][$u] = true;
152154 $idx['next'] = $n + 1;
153155 if ($issue['remoteId'] !== null) $idx['remote'][$issue['remoteId']] = [$n, null];
154156 $this->save($issue, $idx);
@@ -208,6 +210,8 @@
208210 'remoteId' => $opt['remoteId'] ?? null];
209211 $i['seq'] = (int)$i['seq'] + 1;
210212 $i['comments'][] = $added;
213+ // コメントした人は通知を受け取る(止めていても)
214+ if (isset($author['user'])) $i['subscribers'][$author['user']] = true;
211215 return $i;
212216 });
213217 if ($issue === null || $added === null) return Result::Error('コメント出来ません。');
@@ -263,6 +267,7 @@
263267 $add = array_values(array_diff($assignees, $i['assignees']));
264268 $remove = array_values(array_diff($i['assignees'], $assignees));
265269 if ($add || $remove) $i['events'][] = ['type' => 'assignees', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove];
270+ foreach ($add as $u) $i['subscribers'][$u] = true;
266271 $i['assignees'] = $assignees;
267272 }
268273 if ($milestone !== false) {
@@ -274,6 +279,14 @@
274279 });
275280 }
276281
282+ /** 通知を受け取る・止める(明示的に止めた人はtrueに戻るまで受け取らない) */
283+ public function subscribe(int $n, array $users, bool $on): ?array {
284+ return $this->update($n, function(array $i) use ($users, $on): array {
285+ foreach ($users as $u) $i['subscribers'][(string)$u] = $on;
286+ return $i;
287+ });
288+ }
289+
277290 /** イシューを消す(番号は使い回さない) */
278291 public function delete(int $n): bool {
279292 return $this->locked(function() use ($n): bool {
@@ -423,7 +436,7 @@
423436 return self::linkify($html, $issuesUrl);
424437 }
425438
426- /** HTMLの文字の部分(a、code、preの外)の「#3」とURLをリンクにする */
439+ /** HTMLの文字の部分(a、code、preの外)の「#3」、「@ユーザー」(居れば)とURLをリンクにする */
427440 public static function linkify(string $html, string $issuesUrl): string {
428441 $parts = preg_split('/(<[^>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
429442 $depth = 0;
@@ -434,7 +447,8 @@
434447 continue;
435448 }
436449 if ($depth > 0) continue;
437- $parts[$k] = (string)preg_replace_callback('~(https?://[^\s<>"]+[^\s<>".,;:!?)\]」。、])|(?<![\w&#/])#(\d+)\b~u', function(array $m) use ($issuesUrl): string {
450+ $parts[$k] = (string)preg_replace_callback('~(https?://[^\s<>"]+[^\s<>".,;:!?)\]」。、])|(?<![\w&#/])#(\d+)\b|(?<![\w@/&.])@([A-Za-z0-9]+)(?![\w@]|\.\w)~u', function(array $m) use ($issuesUrl): string {
451+ if (($m[3] ?? '') !== '') return Gitler::user($m[3]) !== null ? '<a href="/gitler/'.$m[3].'">@'.$m[3].'</a>' : $m[0];
438452 if (($m[2] ?? '') !== '') return '<a href="'.htmlspecialchars($issuesUrl, ENT_QUOTES, 'UTF-8').'/'.$m[2].'">#'.$m[2].'</a>';
439453 return '<a href="'.$m[1].'" rel="nofollow noopener noreferrer">'.$m[1].'</a>';
440454 }, $p);
@@ -40,7 +40,7 @@
4040 use Result;
4141
4242 /**
43- * GitlerのSSHの鍵とアクセストークン(data/gitler/users/{ユーザー名}.json)
43+ * GitlerのSSHの鍵、アクセストークン、通知のメールの設定(data/gitler/users/{ユーザー名}.json)
4444 *
4545 * SSH:sshdのAuthorizedKeysCommand(gitler.php keys)が鍵からユーザーを探し、command=でgitler.php serveを実行させる。
4646 * HTTPS:ユーザー名とアクセストークン(又は二段階認証が無ければパスワード)。
@@ -60,11 +60,11 @@
6060 return self::dir()."/{$user}.json";
6161 }
6262
63- /** @return array keys、tokens */
63+ /** @return array keys、tokens、email(通知をメールでも受け取る) */
6464 public static function load(string $user): array {
6565 $j = json_decode((string)@file_get_contents(self::file($user)), true);
6666 $j = is_array($j) ? $j : [];
67- return ['keys' => (array)($j['keys'] ?? []), 'tokens' => (array)($j['tokens'] ?? [])];
67+ return ['keys' => (array)($j['keys'] ?? []), 'tokens' => (array)($j['tokens'] ?? []), 'email' => (bool)($j['email'] ?? true)];
6868 }
6969
7070 private static function save(string $user, array $data): bool {
@@ -220,6 +220,13 @@
220220 *
221221 * @return array|null トークン(write等)
222222 */
223+ /** Gitlerの通知をメールでも受け取るか(既定は受け取る) */
224+ public static function setEmail(string $user, bool $on): bool {
225+ $data = self::load($user);
226+ $data['email'] = $on;
227+ return self::save($user, $data);
228+ }
229+
223230 public static function checkToken(string $user, string $token): ?array {
224231 if (!preg_match('/^[A-Za-z0-9]+$/', $user) || !str_starts_with($token, 'lbg_')) return null;
225232 $hash = hash('sha256', $token);
@@ -0,0 +1,216 @@
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\Gitler;
39+
40+use Std\Lib\Fedi\Html;
41+use Std\Lib\Notifications;
42+
43+/**
44+ * Gitlerの通知(サイトの「通知」とメール)
45+ *
46+ * リポジトリのウォッチ(gitler/watch.json:ユーザー名 => watching、participating、ignoring):
47+ * watching 新しいイシューと全部のイシューの動き(所有者と共同作業者の既定)
48+ * participating 通知を受け取るイシューだけ(他の人の既定)
49+ * ignoring 何も受け取らない
50+ * イシュー毎の受け取り(subscribers):作った人、コメントした人、担当者、メンションされた人。止める事も出来る。
51+ * メールはKeysの設定(既定は受け取る)で、cronのnotifyが送る。自分の操作は通知しない。
52+ */
53+final class Notify {
54+ public const WATCHING = 'watching';
55+ public const PARTICIPATING = 'participating';
56+ public const IGNORING = 'ignoring';
57+ /** 「@ユーザー」(「@user@host」と「@所有者.名前」は除く) */
58+ public const MENTION_RE = '/(?<![\w@\/&.])@([A-Za-z0-9]+)(?![\w@]|\.\w)/';
59+
60+ //////// ウォッチ
61+
62+ public static function watchState(string $owner, string $name, string $user, ?array $meta = null): string {
63+ $w = self::watchList($owner, $name)[$user] ?? null;
64+ if (in_array($w, [self::WATCHING, self::PARTICIPATING, self::IGNORING], true)) return $w;
65+ $meta ??= Gitler::meta($owner, $name);
66+ return $user === $owner || isset($meta['collaborators'][$user]) ? self::WATCHING : self::PARTICIPATING;
67+ }
68+
69+ public static function setWatch(string $owner, string $name, string $user, string $state): void {
70+ if (!in_array($state, [self::WATCHING, self::PARTICIPATING, self::IGNORING], true)) return;
71+ $repo = Gitler::open($owner, $name);
72+ $file = $repo->path.'/gitler/watch.json';
73+ $repo->mkdir(dirname($file));
74+ $lock = @fopen($file.'.lock', 'c');
75+ if ($lock) flock($lock, LOCK_EX);
76+ try {
77+ $list = self::watchList($owner, $name);
78+ $list[$user] = $state;
79+ $repo->writeFile($file, (string)json_encode($list, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
80+ } finally {
81+ if ($lock) {
82+ flock($lock, LOCK_UN);
83+ fclose($lock);
84+ }
85+ }
86+ }
87+
88+ /** ウォッチしている人(既定の所有者と共同作業者を含む) */
89+ public static function watchers(string $owner, string $name, array $meta): array {
90+ $out = [];
91+ foreach (array_unique(array_merge([$owner], array_map('strval', array_keys($meta['collaborators'])), array_keys(self::watchList($owner, $name)))) as $u) {
92+ if (self::watchState($owner, $name, (string)$u, $meta) === self::WATCHING) $out[] = (string)$u;
93+ }
94+ return $out;
95+ }
96+
97+ //////// イシュー
98+
99+ /** Markdownの本文の「@ユーザー」(コードの中は除く) */
100+ public static function mentions(string $text): array {
101+ $text = (string)preg_replace(['/^(```|~~~).*?^\1/ms', '/`[^`\n]*`/'], '', $text);
102+ preg_match_all(self::MENTION_RE, $text, $m);
103+ return array_values(array_unique($m[1]));
104+ }
105+
106+ public static function issueOpened(string $owner, string $name, array $meta, Issues $db, array $issue): void {
107+ $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $issue['body'], !empty($issue['html']));
108+ $to = array_fill_keys(self::watchers($owner, $name, $meta), 'opened') + array_fill_keys($issue['assignees'], 'assigned');
109+ foreach ($mentioned as $u) $to[$u] = 'mention';
110+ self::send($owner, $name, $meta, $db->get($issue['number']) ?? $issue, $issue['author'], $to, $issue['body'], !empty($issue['html']), '');
111+ }
112+
113+ public static function commented(string $owner, string $name, array $meta, Issues $db, array $issue, array $c): void {
114+ $mentioned = self::mentioned($owner, $name, $db, $issue['number'], $c['body'], !empty($c['html']));
115+ $issue = $db->get($issue['number']) ?? $issue;
116+ $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), 'comment');
117+ foreach ($mentioned as $u) $to[$u] = 'mention';
118+ self::send($owner, $name, $meta, $issue, $c['author'], $to, $c['body'], !empty($c['html']), '#c'.$c['id']);
119+ }
120+
121+ /** 閉じた・開き直した($byはコミット等も) */
122+ public static function stateChanged(string $owner, string $name, array $meta, array $issue, array $by): void {
123+ $type = $issue['state'] === 'closed' ? (isset($by['commit']) ? 'closedByCommit' : 'closed') : 'reopened';
124+ $to = array_fill_keys(array_merge(self::watchers($owner, $name, $meta), self::subscribed($issue)), $type);
125+ self::send($owner, $name, $meta, $issue, $by, $to, '', false, '');
126+ }
127+
128+ /** 担当者に追加した */
129+ public static function assigned(string $owner, string $name, array $meta, array $issue, array $users, array $by): void {
130+ self::send($owner, $name, $meta, $issue, $by, array_fill_keys($users, 'assigned'), '', false, '');
131+ }
132+
133+ //////// 送る
134+
135+ /**
136+ * 通知を追加し、メールを待ち行列に入れる(SSHのプッシュではリポジトリに溜める)
137+ *
138+ * @param array|null $mail to、subject、body、name
139+ */
140+ public static function deliver(string $user, array $record, ?array $mail): void {
141+ if ($user === '' || empty($record)) return;
142+ Notifications::add($user, $record);
143+ if (is_array($mail)) Notifications::queueMail((string)$mail['to'], (string)$mail['subject'], (string)$mail['body'], (string)($mail['name'] ?? ''));
144+ }
145+
146+ /**
147+ * @param array $to ユーザー名 => 種類(opened、comment、mention、closed、closedByCommit、reopened、assigned)
148+ * @param string $anchor コメントの「#c5」等
149+ */
150+ private static function send(string $owner, string $name, array $meta, array $issue, array $by, array $to, string $body, bool $html, string $anchor): void {
151+ if (empty($to) || $meta['archived']) return;
152+ $n = $issue['number'];
153+ $repoName = "{$owner}/{$name}";
154+ $title = "{$repoName}#{$n}「{$issue['title']}」";
155+ $commit = isset($by['commit']) ? substr((string)$by['commit'], 0, 7) : '';
156+ $path = '/gitler/'.$owner.'/'.rawurlencode($name).'/issues/'.$n.$anchor;
157+ $excerpt = $body !== '' ? Html::text($html ? $body : Issues::render($body, '/gitler/'.$owner.'/'.rawurlencode($name).'/issues')) : '';
158+ // Markdownの整形の空白を除く
159+ $excerpt = trim((string)preg_replace(['/[ \t]*\n[ \t]*/', '/\n{3,}/'], ["\n", "\n\n"], $excerpt));
160+ $from = isset($by['user']) ? ['username' => (string)$by['user']] : (isset($by['actor']) ? $by : ['name' => (string)($by['name'] ?? '')]);
161+ $fromName = Federation::authorName($by);
162+ $base = rtrim((string)(SITEINFO['url'] ?? ''), '/') ?: \Std\Lib\Fedi\Fedi::base();
163+ foreach ($to as $user => $type) {
164+ $user = (string)$user;
165+ if ($user === ($by['user'] ?? null)) continue;
166+ // 止めた人(メンションと担当は届ける)、無視している人、読めない人には送らない
167+ if (($issue['subscribers'][$user] ?? null) === false && !in_array($type, ['mention', 'assigned'], true)) continue;
168+ if (self::watchState($owner, $name, $user, $meta) === self::IGNORING) continue;
169+ $u = Gitler::user($user);
170+ if ($u === null || Gitler::access($u, $owner, $name) < Gitler::READ) continue;
171+ $label = match ($type) {
172+ 'opened' => "が{$repoName}にイシュー#{$n}「{$issue['title']}」を作りました",
173+ 'comment' => "が{$title}にコメントしました",
174+ 'mention' => "が{$title}であなたをメンションしました",
175+ 'closed' => "が{$title}を閉じました",
176+ 'closedByCommit' => "のコミット{$commit}が{$title}を閉じました",
177+ 'reopened' => "が{$title}を開き直しました",
178+ 'assigned' => "が{$title}の担当者にあなたを追加しました",
179+ default => '',
180+ };
181+ $record = ['type' => 'gitler', 'from' => $from, 'label' => $label, 'page' => $path, 'excerpt' => mb_strimwidth((string)preg_replace('/\s+/u', ' ', $excerpt), 0, 160, '…')];
182+ $mail = null;
183+ if (($u->email ?? '') !== '' && Keys::load($user)['email']) {
184+ $mail = ['to' => (string)$u->email, 'name' => $user, 'subject' => "[{$repoName}] {$issue['title']} (#{$n})",
185+ 'body' => $fromName.$label."\n\n".($excerpt !== '' ? mb_strimwidth($excerpt, 0, 4000, '…')."\n\n" : '').$base.$path."\n\n--\n"
186+ ."このリポジトリの通知(ウォッチ):{$base}/gitler/{$owner}/".rawurlencode($name)."\nメールの通知を止める:{$base}/gitler/-/keys\n"];
187+ }
188+ if (Gitler::$deferred) {
189+ Gitler::defer($owner, $name, ['kind' => 'notify', 'user' => $user, 'record' => $record, 'mail' => $mail]);
190+ } else {
191+ self::deliver($user, $record, $mail);
192+ }
193+ }
194+ }
195+
196+ /** 本文でメンションされた、リポジトリを読めるユーザー(通知を受け取る様にする) */
197+ private static function mentioned(string $owner, string $name, Issues $db, int $n, string $body, bool $html): array {
198+ $out = [];
199+ foreach (self::mentions($html ? Html::text($body) : $body) as $u) {
200+ $user = Gitler::user($u);
201+ if ($user !== null && Gitler::access($user, $owner, $name) >= Gitler::READ) $out[] = (string)$user->username;
202+ }
203+ if (!empty($out)) $db->subscribe($n, $out, true);
204+ return $out;
205+ }
206+
207+ /** イシューの通知を受け取る人 */
208+ private static function subscribed(array $issue): array {
209+ return array_map('strval', array_keys(array_filter($issue['subscribers'] ?? [], fn($on) => $on === true)));
210+ }
211+
212+ private static function watchList(string $owner, string $name): array {
213+ $j = json_decode((string)@file_get_contents(Gitler::path($owner, $name).'/gitler/watch.json'), true);
214+ return is_array($j) ? $j : [];
215+ }
216+}
@@ -0,0 +1,143 @@
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 Result;
42+
43+/**
44+ * サイトの通知(ヘッダーの「通知」)とメールの待ち行列
45+ *
46+ * データはdata/notifications/:
47+ * {ユーザー名}.json 通知(新しい順、MAX件まで)
48+ * mail/{ID}.json 送るメール(cronのnotifyが送る)
49+ *
50+ * 記事のコメントの通知(Comments)は表示する時に中身を探す。他の物(Gitler等)はlabel・page・excerptを入れて追加する。
51+ */
52+final class Notifications {
53+ /** 1人の通知の最大数 */
54+ public const MAX = 200;
55+ /** メールの再試行の回数 */
56+ private const MAIL_TRIES = 5;
57+
58+ /** @var string|null 保存先(テスト用。既定はdata) */
59+ public static ?string $dataDir = null;
60+
61+ /**
62+ * 通知(新しい順)
63+ *
64+ * @return array type、from、at、read等
65+ */
66+ public static function list(string $username): array {
67+ if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return [];
68+ return Fedi::read(self::path("notifications/{$username}.json"));
69+ }
70+
71+ public static function unreadCount(string $username): int {
72+ return count(array_filter(self::list($username), fn($n) => empty($n['read'])));
73+ }
74+
75+ public static function markRead(string $username): void {
76+ if (self::unreadCount($username) === 0) return;
77+ Fedi::update(self::path("notifications/{$username}.json"), fn($l) => array_map(fn($n) => ['read' => true] + $n, $l));
78+ }
79+
80+ /**
81+ * 通知を追加する
82+ *
83+ * @param array $n type、from(username、又はactor・name・handle・url・icon)等。label・page・excerptが有れば表示にそのまま使う
84+ * @param callable|null $same 未読の同じ通知が有れば追加しない(fn(array $old): bool)
85+ */
86+ public static function add(string $username, array $n, ?callable $same = null): void {
87+ if (!preg_match('/^[A-Za-z0-9]+$/', $username)) return;
88+ Fedi::update(self::path("notifications/{$username}.json"), function(array $l) use ($n, $same): array {
89+ if ($same !== null) foreach ($l as $old) if (empty($old['read']) && $same($old)) return $l;
90+ array_unshift($l, $n + ['at' => time(), 'read' => false]);
91+ return array_slice($l, 0, self::MAX);
92+ });
93+ }
94+
95+ //////// メール
96+
97+ /** メールを待ち行列に入れる(MAILER_ENABLEDでなければ何もしない) */
98+ public static function queueMail(string $to, string $subject, string $body, string $toName = ''): void {
99+ if (!MAILER_ENABLED || !filter_var($to, FILTER_VALIDATE_EMAIL)) return;
100+ Fedi::write(self::path('notifications/mail/'.Fedi::newId().'.json'), ['to' => $to, 'subject' => $subject, 'body' => $body, 'name' => $toName, 'tries' => 0]);
101+ }
102+
103+ /**
104+ * 待っているメールを送る(cronのnotify)
105+ *
106+ * @param int $budget この秒数を超えたら次に回す
107+ */
108+ public static function sendMail(int $budget = 50): Result {
109+ if (!MAILER_ENABLED) return Result::Success('メールは無効です。');
110+ $start = time();
111+ $sent = $failed = 0;
112+ $files = glob(self::path('notifications/mail/').'*.json') ?: [];
113+ sort($files);
114+ foreach ($files as $file) {
115+ if (time() - $start > $budget) break;
116+ // 同時に動いた別のcronと重ならない様に、名前を変えて取る
117+ $work = $file.'.work';
118+ if (!@rename($file, $work)) continue;
119+ $m = Fedi::read($work, null);
120+ if (!is_array($m) || !is_string($m['to'] ?? null)) {
121+ @unlink($work);
122+ continue;
123+ }
124+ if (Mailer::deliver($m['to'], (string)$m['subject'], (string)$m['body'], (string)($m['name'] ?? '') ?: null)->isSuccess) {
125+ @unlink($work);
126+ $sent++;
127+ continue;
128+ }
129+ $failed++;
130+ $m['tries'] = (int)($m['tries'] ?? 0) + 1;
131+ if ($m['tries'] < self::MAIL_TRIES) Fedi::write($file, $m);
132+ @unlink($work);
133+ }
134+ return Result::Success("メール:送信{$sent}件、失敗{$failed}件", ['sent' => $sent, 'failed' => $failed]);
135+ }
136+
137+ private static function path(string $rel): string {
138+ $path = rtrim(self::$dataDir ?? ROOT.'/data', '/').'/'.$rel;
139+ $dir = dirname($path);
140+ if (!is_dir($dir)) @mkdir($dir, 0700, true);
141+ return $path;
142+ }
143+}
@@ -42,6 +42,7 @@
4242 use Std\Lib\Tester;
4343 use Std\Lib\Activitypub;
4444 use Std\Lib\Comments;
45+use Std\Lib\Notifications;
4546 use Std\Lib\Fedi\{Fedi, Notes, Remote, Social};
4647
4748 $test = new Tester([
@@ -51,7 +52,7 @@
5152
5253 // 本物のデータを触らない様に、一時ディレクトリを使う
5354 $tmp = sys_get_temp_dir().'/lbcomments_'.bin2hex(random_bytes(6));
54-Comments::$dataDir = "{$tmp}/data";
55+Comments::$dataDir = Notifications::$dataDir = "{$tmp}/data";
5556 Fedi::$dataDir = "{$tmp}/ap";
5657 Fedi::$userDir = "{$tmp}/user/";
5758 mkdir(Fedi::$userDir, 0700, true);
@@ -64,7 +65,7 @@
6465 $alice = $mkuser('alice1234', 2);
6566 $bob = $mkuser('bob12345', 3);
6667 $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+$types = fn(string $u) => array_map(fn($n) => $n['type'].':'.($n['from']['username'] ?? $n['from']['actor'] ?? ''), Notifications::list($u));
6869 $thread = 'blog/test-post';
6970
7071 $test->describe('コメント', function($test) use ($admin, $alice, $bob, $banned, $types, $thread): void {
@@ -132,13 +133,13 @@
132133 });
133134
134135 $test->it('通知を既読に出来るはず', function($test) use ($thread): void {
135- $test->assertEquals(2, Comments::unreadCount('alice1234'));
136- $n = Comments::present(Comments::notifications('alice1234')[0]);
136+ $test->assertEquals(2, Notifications::unreadCount('alice1234'));
137+ $n = Comments::present(Notifications::list('alice1234')[0]);
137138 $test->assertStringContains('いいね', $n['label']);
138139 $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')));
140+ Notifications::markRead('alice1234');
141+ $test->assertEquals(0, Notifications::unreadCount('alice1234'));
142+ $test->assertEquals(2, count(Notifications::list('alice1234')));
142143 });
143144
144145 $test->it('返信のあるコメントは中身だけ消し、返信が無くなれば完全に消すはず', function($test) use ($admin, $alice, $bob, $thread): void {
@@ -198,7 +199,7 @@
198199 $test->assertEquals('Carol C', $c['author']['name']);
199200 $test->assertEquals('https://1.1.1.1/@carol', $c['author']['url']);
200201 $test->assertEquals("comment:{$carol}", $types('admin1234')[0]);
201- $test->assertEquals('英語の記事', Comments::notifications('admin1234')[0]['title']);
202+ $test->assertEquals('英語の記事', Notifications::list('admin1234')[0]['title']);
202203 // 再送されても1つ
203204 $test->assertTrue($ap->handleComments($create($note('1', $target))));
204205 $test->assertEquals(1, Comments::count($thread));
@@ -342,6 +343,6 @@
342343 @rmdir($dir);
343344 };
344345 $rm($tmp);
345-Comments::$dataDir = Fedi::$dataDir = Fedi::$userDir = null;
346+Comments::$dataDir = Notifications::$dataDir = Fedi::$dataDir = Fedi::$userDir = null;
346347
347348 $test->printSummary();
@@ -52,6 +52,8 @@
5252 use Std\Lib\Gitler\Gitler;
5353 use Std\Lib\Gitler\Issues;
5454 use Std\Lib\Gitler\Keys;
55+use Std\Lib\Gitler\Notify;
56+use Std\Lib\Notifications;
5557 use Std\Lib\Gitler\Languages;
5658 use Roles;
5759
@@ -490,6 +492,10 @@
490492 $test->assertTrue(str_contains($h, '<a href="https://example.com/a?b=1&amp;c=2"'));
491493 $test->assertTrue(str_contains($h, '<code>#4</code>'), 'コードの中はそのまま');
492494 $test->assertFalse(str_contains($h, 'issues/5'), 'リンクの中はそのまま');
495+ $h = Issues::render('@owner001 と @nobody と a@owner001.example', '/x');
496+ $test->assertTrue(str_contains($h, '<a href="/gitler/owner001">@owner001</a>'));
497+ $test->assertFalse(str_contains($h, '/gitler/nobody'), '居ないユーザー');
498+ $test->assertFalse(str_contains($h, 'gitler/owner001.example'));
493499 });
494500 });
495501
@@ -646,18 +652,114 @@
646652 Federation::block('owner001', 'fedi1', $bob, false);
647653 $test->assertFalse(Federation::isBlocked('owner001', 'fedi1', $bob));
648654 $queue();
649- Federation::$spool = true;
655+ Gitler::$deferred = true;
650656 $issue = Issues::open('owner001', 'fedi1')->get(1);
651657 Federation::stateChanged('owner001', 'fedi1', Gitler::meta('owner001', 'fedi1'), $issue);
652- Federation::$spool = false;
658+ Gitler::$deferred = false;
653659 $test->assertSame([], $queue());
654660 $test->assertSame(2, count(glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json')));
655- $test->assertSame(2, Federation::flush());
661+ $test->assertSame(2, Gitler::flushDeferred());
656662 $test->assertSame([], glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json'));
657663 $test->assertTrue(count($queue()) > 0);
658664 });
659665 Fedi::$dataDir = null;
660666 Fedi::$baseUrl = null;
667+});
668+
669+$test->describe('Gitler(通知とアーカイブ)', function($test) use ($base, $owner, $other, $plain, $admin): void {
670+ Notifications::$dataDir = "{$base}/data";
671+ Gitler::create($owner, 'notify1');
672+ $meta = Gitler::meta('owner001', 'notify1');
673+ $meta['collaborators'] = ['other001' => 'write'];
674+ Gitler::saveMeta('owner001', 'notify1', $meta);
675+ $meta = Gitler::meta('owner001', 'notify1');
676+ $db = Issues::open('owner001', 'notify1');
677+ $labels = fn(string $u) => array_map(fn($n) => $n['label'], Notifications::list($u));
678+ $clear = function(): void {
679+ foreach (glob(Notifications::$dataDir.'/notifications/*.json') ?: [] as $f) unlink($f);
680+ };
681+
682+ $test->it('「@ユーザー」を見つけるはず(コード、リモート、リポジトリは除く)', function($test): void {
683+ $test->assertSame(['alice', 'bob'], Notify::mentions("@alice さん、@bob。\n`@code` @x@example.com @owner.repo mail@example.com\n```\n@block\n```\n"));
684+ });
685+
686+ $test->it('所有者と共同作業者はウォッチし、作った人は自分の通知を受け取らないはず', function($test) use ($meta, $db, $labels, $clear): void {
687+ $clear();
688+ $test->assertSame(Notify::WATCHING, Notify::watchState('owner001', 'notify1', 'owner001'));
689+ $test->assertSame(Notify::PARTICIPATING, Notify::watchState('owner001', 'notify1', 'plain001'));
690+ $test->assertSame(['owner001', 'other001'], Notify::watchers('owner001', 'notify1', $meta));
691+ $issue = $db->create(['user' => 'plain001'], '動かない', '@admin001 見て下さい')->data['issue'];
692+ Notify::issueOpened('owner001', 'notify1', $meta, $db, $issue);
693+ $test->assertSame(['がowner001/notify1にイシュー#1「動かない」を作りました'], $labels('owner001'));
694+ $test->assertSame(1, count($labels('other001')));
695+ $test->assertSame([], $labels('plain001'), '自分の操作');
696+ $test->assertSame(['がowner001/notify1#1「動かない」であなたをメンションしました'], $labels('admin001'));
697+ $test->assertSame(true, $db->get(1)['subscribers']['admin001'], 'メンションされた人は受け取る');
698+ $n = Notifications::list('owner001')[0];
699+ $test->assertSame(['username' => 'plain001'], $n['from']);
700+ $test->assertSame('/gitler/owner001/notify1/issues/1', $n['page']);
701+ $test->assertSame('@admin001 見て下さい', $n['excerpt']);
702+ $test->assertSame(1, Notifications::unreadCount('owner001'));
703+ Notifications::markRead('owner001');
704+ $test->assertSame(0, Notifications::unreadCount('owner001'));
705+ });
706+
707+ $test->it('コメントは受け取る人に届き、止めた人と無視した人には届かないはず', function($test) use ($meta, $db, $labels, $clear): void {
708+ $clear();
709+ Notify::setWatch('owner001', 'notify1', 'other001', Notify::IGNORING);
710+ $db->subscribe(1, ['admin001'], false);
711+ $res = $db->comment(1, ['user' => 'owner001'], '直します');
712+ Notify::commented('owner001', 'notify1', $meta, $db, $res->data['issue'], $res->data['comment']);
713+ $test->assertSame(['がowner001/notify1#1「動かない」にコメントしました'], $labels('plain001'));
714+ $test->assertSame('/gitler/owner001/notify1/issues/1#c1', Notifications::list('plain001')[0]['page']);
715+ $test->assertSame([], $labels('other001'), '無視');
716+ $test->assertSame([], $labels('admin001'), '止めた');
717+ $test->assertSame([], $labels('owner001'), '自分');
718+ // コミットで閉じる
719+ $clear();
720+ $issue = $db->setState(1, false, ['commit' => str_repeat('a', 40), 'name' => '諏訪子']);
721+ Notify::stateChanged('owner001', 'notify1', $meta, $issue, ['commit' => str_repeat('a', 40), 'name' => '諏訪子']);
722+ $test->assertSame(['のコミットaaaaaaaがowner001/notify1#1「動かない」を閉じました'], $labels('owner001'));
723+ $test->assertSame(['name' => '諏訪子'], Notifications::list('owner001')[0]['from']);
724+ $test->assertSame(1, count($labels('plain001')));
725+ // 担当者
726+ $clear();
727+ $new = $db->triage(1, ['user' => 'owner001'], null, ['other001'], false);
728+ Notify::assigned('owner001', 'notify1', $meta, $new, ['other001'], ['user' => 'owner001']);
729+ $test->assertSame([], $labels('other001'), '無視しているので担当も届かない');
730+ Notify::setWatch('owner001', 'notify1', 'other001', Notify::PARTICIPATING);
731+ Notify::assigned('owner001', 'notify1', $meta, $new, ['other001'], ['user' => 'owner001']);
732+ $test->assertSame(['がowner001/notify1#1「動かない」の担当者にあなたを追加しました'], $labels('other001'));
733+ });
734+
735+ $test->it('SSHのプッシュでは通知を溜めて後で届けるはず', function($test) use ($meta, $db, $labels, $clear): void {
736+ $clear();
737+ Gitler::$deferred = true;
738+ $issue = $db->setState(1, true, ['user' => 'owner001']);
739+ Notify::stateChanged('owner001', 'notify1', $meta, $issue, ['user' => 'owner001']);
740+ Gitler::$deferred = false;
741+ $test->assertSame([], $labels('plain001'));
742+ $test->assertTrue(Gitler::flushDeferred() >= 1);
743+ $test->assertSame(['がowner001/notify1#1「動かない」を開き直しました'], $labels('plain001'));
744+ });
745+
746+ $test->it('メールの設定を保存し、アーカイブではイシューとFediverseのコメントを受け付けないはず', function($test) use ($meta, $db): void {
747+ $test->assertTrue(Keys::load('plain001')['email']);
748+ $test->assertTrue(Keys::setEmail('plain001', false));
749+ $test->assertFalse(Keys::load('plain001')['email']);
750+ $test->assertSame([], Keys::load('plain001')['keys'], '鍵は消えない');
751+ $test->assertFalse($meta['archived']);
752+ $meta['archived'] = true;
753+ Gitler::saveMeta('owner001', 'notify1', $meta);
754+ $test->assertTrue(Gitler::meta('owner001', 'notify1')['archived']);
755+ if (Fedi::enabled()) {
756+ $res = Federation::receive('owner001', 'notify1', ['type' => 'Create', 'actor' => 'https://8.8.8.8/users/alice', 'object' => ['id' => 'https://8.8.8.8/notes/x', 'type' => 'Note',
757+ 'attributedTo' => 'https://8.8.8.8/users/alice', 'inReplyTo' => 'https://lb.test/gitler/owner001/notify1/issues/1', 'content' => '<p>x</p>']]);
758+ $test->assertSame('アーカイブされています', $res[1]['status']);
759+ }
760+ $test->assertSame(1, count($db->get(1)['comments']));
761+ });
762+ Notifications::$dataDir = null;
661763 });
662764
663765 $test->describe('Gitler(HTTPのパスワード)', function($test) use ($base): void {
変更 util.php
@@ -293,11 +293,8 @@
293293 }
294294
295295 if (ACTIVITYPUB_MULTIUSER) {
296- // マイクロブログとGitlerの配信(毎分。SSHのプッシュでGitlerのリポジトリに溜めた物も)
297- $cron->register('apdeliver', function(): \Result {
298- if (\Std\Lib\Gitler\Gitler::enabled()) \Std\Lib\Gitler\Federation::flush();
299- return \Std\Lib\Fedi\Delivery::run();
300- });
296+ // マイクロブログの配信(毎分)
297+ $cron->register('apdeliver', fn() => \Std\Lib\Fedi\Delivery::run());
301298 // 90日より古いリモートの投稿とキャッシュを消す(1日1回)
302299 $cron->register('apcleanup', fn() => \Std\Lib\Fedi\Timeline::cleanup());
303300 }
@@ -305,6 +302,15 @@
305302 if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) {
306303 // メールアドレスを7日以内に確認しなかったアカウントを削除する
307304 $cron->register('authpurge', fn() => (new \Std\Lib\Auth())->purgeUnverified());
305+ }
306+
307+ if (AUTH_ENABLED) {
308+ // 通知のメールを送り、SSHのプッシュでGitlerのリポジトリに溜めた通知と配信を処理する(毎分)
309+ $cron->register('notify', function(): \Result {
310+ $n = \Std\Lib\Gitler\Gitler::enabled() ? \Std\Lib\Gitler\Gitler::flushDeferred() : 0;
311+ $res = \Std\Lib\Notifications::sendMail();
312+ return \Result::Success("Gitler:{$n}件、".$res->message, $res->data + ['gitler' => $n]);
313+ });
308314 }
309315
310316 if (\Std\Lib\Gitler\Gitler::enabled()) {
@@ -69,12 +69,8 @@
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) $}
72+{$ $unreadComments = \Std\Lib\Notifications::unreadCount($user->username) $}
7473 お帰りなしゃ~い、<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 @}
76- お帰りなしゃ~い、<a style="text-decoration: none;" href="/profile/{{ $user->username }}">{{{ namecolor($user) }}}</a>! (<a href="/logout">ログアウト</a>)
77-{@ endif @}
7874 {@ else @}
7975 <a href="/login">ログイン</a>
8076 {@ if (AUTH_REGISTER_ENABLED) @}
@@ -7,7 +7,7 @@
77 <ul class="gl-repos">
88 {@ foreach ($list as $r) @}
99 <li>
10- <a href="{{ $r['url'] }}"><b>{{ $r['owner'] }}/{{ $r['name'] }}</b></a>{{{ $r['private'] ? ' <span class="gl-badge">非公開</span>' : '' }}}
10+ <a href="{{ $r['url'] }}"><b>{{ $r['owner'] }}/{{ $r['name'] }}</b></a>{{{ $r['private'] ? ' <span class="gl-badge">非公開</span>' : '' }}}{{{ $r['archived'] ? ' <span class="gl-badge">アーカイブ</span>' : '' }}}
1111 {@ if ($r['description'] !== '') @}
1212 <p>{{ $r['description'] }}</p>
1313 {@ endif @}
@@ -120,6 +120,14 @@
120120 <p>{{ !empty($issue['assignees']) ? implode('、', $issue['assignees']) : '無し' }}</p>
121121 <h3>マイルストーン</h3>
122122 <p>{{ $milestone !== '' ? $milestone : '無し' }}</p>
123+{@ if ($user) @}
124+ <h3>通知</h3>
125+ <form method="post" action="{{ $issueUrl }}">
126+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
127+ <p><small>{{ $subscribeNote }}</small></p>
128+ <button name="do" value="{{ $subscribed ? 'unsubscribe' : 'subscribe' }}">{{ $subscribed ? '通知を止める' : '通知を受け取る' }}</button>
129+ </form>
130+{@ endif @}
123131 {@ if ($canTriage) @}
124132 <details class="gl-edit">
125133 <summary>変える</summary>
@@ -1,5 +1,14 @@
11 {@ include(common/header) @}
22 {@ include(gitler/nav) @}
3+ <h1 class="paragraph">通知</h1>
4+ <p class="paragraph">ウォッチしているリポジトリ(自分と共同作業者のリポジトリは最初からウォッチ)と、作った・コメントした・担当の・メンションされたイシューの動きはサイトの「通知」に届きます。リポジトリ毎の設定はリポジトリのページの「通知」から変えられます。</p>
5+{@ if ($mailer) @}
6+ <form method="post" action="/gitler/-/keys" class="gl-form">
7+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
8+ <label><input type="checkbox" name="email" value="1"{{{ $emailOn ? ' checked="checked"' : '' }}} /> メールでも受け取る{{ $email !== '' ? '('.$email.')' : '' }}</label>
9+ <button name="do" value="email">保存</button>
10+ </form>
11+{@ endif @}
312 <h1 class="paragraph">SSHの鍵</h1>
413 {@ if ($ssh === '') @}
514 <p class="paragraph">このサイトではSSHは使えません(HTTPSとアクセストークンを使って下さい)。</p>
@@ -9,7 +9,7 @@
99 {@ endif @}
1010 {@ if (isset($user) && $user) @}
1111 <a href="/gitler/{{ $user->username }}">自分のリポジトリ</a>
12- <a href="/gitler/-/keys">SSHの鍵・トークン</a>
12+ <a href="/gitler/-/keys">設定(鍵・トークン・通知)</a>
1313 {@ endif @}
1414 </span>
1515 </div>
@@ -1,6 +1,21 @@
11 {@ include(gitler/nav) @}
22 <div class="gl-repohead">
3- <h1><a href="/gitler/{{ $owner }}">{{ $owner }}</a> / <a href="{{ $repoUrl }}">{{ $name }}</a>{{{ $meta['private'] ? ' <span class="gl-badge">非公開</span>' : '' }}}</h1>
3+ <h1><a href="/gitler/{{ $owner }}">{{ $owner }}</a> / <a href="{{ $repoUrl }}">{{ $name }}</a>{{{ $meta['private'] ? ' <span class="gl-badge">非公開</span>' : '' }}}{{{ $archived ? ' <span class="gl-badge">アーカイブ</span>' : '' }}}</h1>
4+{@ if ($watch !== '') @}
5+ <details class="gl-watch">
6+ <summary>通知:{{ $watch === 'watching' ? 'ウォッチ中' : ($watch === 'ignoring' ? '無視' : '参加しているイシューだけ') }}</summary>
7+ <form method="post" action="{{ $repoUrl }}/watch">
8+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
9+ <input type="hidden" name="back" value="{{ $here }}" />
10+ <button name="state" value="participating"{{{ $watch === 'participating' ? ' class="active"' : '' }}}>参加しているイシューだけ</button>
11+ <button name="state" value="watching"{{{ $watch === 'watching' ? ' class="active"' : '' }}}>ウォッチ(新しいイシューと全部の動き)</button>
12+ <button name="state" value="ignoring"{{{ $watch === 'ignoring' ? ' class="active"' : '' }}}>無視(何も受け取らない)</button>
13+ </form>
14+ </details>
15+{@ endif @}
16+{@ if ($archived) @}
17+ <p class="gl-archived">このリポジトリはアーカイブされています。読むだけで、プッシュやイシューの変更は出来ません。</p>
18+{@ endif @}
419 {@ if ($meta['description'] !== '') @}
520 <p class="gl-desc">{{ $meta['description'] }}</p>
621 {@ endif @}
@@ -74,6 +74,22 @@
7474 </form>
7575 {@ endif @}
7676
77+ <h2 class="paragraph">アーカイブ</h2>
78+{@ if ($archived) @}
79+ <p class="paragraph">アーカイブされています。解除するとプッシュやイシューの変更が出来る様になります。</p>
80+ <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
81+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
82+ <button name="do" value="unarchive">アーカイブを解除</button>
83+ </form>
84+{@ else @}
85+ <p class="paragraph">読むだけにします(プッシュ、イシュー・コメント・ラベル等の変更、Fediverseからのコメントを止める)。何時でも解除出来ます。</p>
86+ <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
87+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
88+ <label><input type="checkbox" name="confirm" value="1" required="required" /> 確認</label>
89+ <button name="do" value="archive" class="danger">アーカイブする</button>
90+ </form>
91+{@ endif @}
92+
7793 <h2 class="paragraph">整理</h2>
7894 <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
7995 <input type="hidden" name="csrf_token" value="{{ $csrf }}" />