通知・アーカイブ
守矢諏訪子 <suwako@076.moe>、2026-11-13 15:37
コミット 0748c3b6d92a2c1863c14847847f1ec311588418
・親 b501fc9
・ファイルを見る
25個のファイル、+767 -131
変更
README-JP.md
| @@ -303,7 +303,9 @@ | ||
| 303 | 303 | * リポジトリを整理する為に Cron のジョブ `gitlergc` を追加するか(例:`php cron.php add "@daily" gitlergc`)、`php gitler.php gc` を実行して下さい。 |
| 304 | 304 | * リポジトリのページには 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` にツリー毎に保存し、一覧には既定のブランチの一番多い言語が出ます。 |
| 305 | 305 | * イシュー:ラベル、担当者、マイルストーン、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 からのコメントを止めます)。何時でも解除出来ます。 | |
| 307 | 309 | |
| 308 | 310 | ### TLS 1.3・1.2(php_openssl は不要) |
| 309 | 311 | 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 @@ | ||
| 345 | 347 | * `authpurge`:7日以内にメールアドレスを確認しなかったアカウントを削除する(`AUTH_VERIFY_EMAIL` が有効の場合) |
| 346 | 348 | * `apdeliver`:マイクロブログのアクティビティを他のサーバーに配信する(マルチユーザーモード、毎分実行) |
| 347 | 349 | * `apcleanup`:90日より古いリモートの投稿とキャッシュを削除する(マルチユーザーモード、1日1回実行) |
| 350 | +* `notify`:通知のメールを送り、SSH のプッシュで Gitler のリポジトリに溜めた通知と配信を処理する(`AUTH_ENABLED` が有効の場合、毎分実行) | |
| 348 | 351 | * `gitlergc`:Gitler のリポジトリを整理する(`GITLER_ENABLED` が有効の場合、1日1回実行) |
| 349 | 352 | |
| 350 | 353 | ### マイクロブログ(マルチユーザーの Fediverse) |
変更
README.md
| @@ -301,7 +301,9 @@ | ||
| 301 | 301 | * Add the Cron job `gitlergc` (for example `php cron.php add "@daily" gitlergc`) to pack the repositories, or run `php gitler.php gc`. |
| 302 | 302 | * 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. |
| 303 | 303 | * 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. | |
| 305 | 307 | |
| 306 | 308 | ### TLS 1.3 and 1.2 (no php_openssl) |
| 307 | 309 | 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 @@ | ||
| 343 | 345 | * `authpurge`: deletes accounts whose email address was not verified within 7 days (when `AUTH_VERIFY_EMAIL` is enabled) |
| 344 | 346 | * `apdeliver`: delivers microblog activities to other servers (multi-user mode, run every minute) |
| 345 | 347 | * `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) | |
| 346 | 349 | * `gitlergc`: packs the Gitler repositories (when `GITLER_ENABLED` is enabled, run daily) |
| 347 | 350 | |
| 348 | 351 | ### Microblog (multi-user Fediverse) |
変更
gitler.php
| @@ -56,7 +56,6 @@ | ||
| 56 | 56 | use Site\Controller\Gitler as GitlerController; |
| 57 | 57 | use Std\Lib\Git\Repository; |
| 58 | 58 | use Std\Lib\Git\Ssh; |
| 59 | -use Std\Lib\Gitler\Federation; | |
| 60 | 59 | use Std\Lib\Gitler\Gitler; |
| 61 | 60 | use Std\Lib\Gitler\Keys; |
| 62 | 61 | |
| @@ -110,14 +109,18 @@ | ||
| 110 | 109 | fwrite(STDERR, "こんにちは、{$name}さん!Gitlerにはシェルが有りません。\n"); |
| 111 | 110 | exit(1); |
| 112 | 111 | } |
| 113 | - // gitユーザーはdata/apに書けないので、Fediverseへの配信はリポジトリに溜める(cronのapdeliverが送る) | |
| 114 | - Federation::$spool = true; | |
| 112 | + // gitユーザーはdata/に書けないので、通知とFediverseへの配信はリポジトリに溜める(cronのnotifyが送る) | |
| 113 | + Gitler::$deferred = true; | |
| 115 | 114 | $owner = $repoName = ''; |
| 116 | 115 | exit(Ssh::handle($command, function(string $path, bool $write) use ($user, &$owner, &$repoName): ?Repository { |
| 117 | 116 | // 「所有者/名前.git」又は「所有者/名前」(前に「gitler/」が有っても良い) |
| 118 | 117 | if (!preg_match('#^(?:gitler/)?([A-Za-z0-9]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$#', $path, $m)) return null; |
| 119 | 118 | $access = Gitler::access($user, $m[1], $m[2]); |
| 120 | 119 | 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 | + } | |
| 121 | 124 | [$owner, $repoName] = [$m[1], $m[2]]; |
| 122 | 125 | return Gitler::open($m[1], $m[2]); |
| 123 | 126 | }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions($owner, $repoName)])); |
| @@ -595,6 +595,28 @@ | ||
| 595 | 595 | margin-top: 16px; |
| 596 | 596 | } |
| 597 | 597 | |
| 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 | + | |
| 598 | 620 | button.danger { |
| 599 | 621 | border-color: #ee4030; |
| 600 | 622 | color: #ee4030; |
変更
route.php
| @@ -179,7 +179,7 @@ | ||
| 179 | 179 | foreach (['issues', 'labels', 'milestones', 'outbox', 'followers'] as $page) { |
| 180 | 180 | $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page); |
| 181 | 181 | } |
| 182 | - foreach (['labels', 'milestones', 'inbox'] as $page) { | |
| 182 | + foreach (['labels', 'milestones', 'inbox', 'watch'] as $page) { | |
| 183 | 183 | $routes[] = Route::add('POST', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page); |
| 184 | 184 | } |
| 185 | 185 | foreach (['tree', 'blob', 'raw', 'blame', 'commits', 'branches', 'tags', 'archive', 'settings'] as $page) { |
| @@ -194,6 +194,10 @@ | ||
| 194 | 194 | $routes[] = Route::add('POST', 'comments', Comment::class.'@post'); |
| 195 | 195 | $routes[] = Route::add('POST', 'comments/like', Comment::class.'@like'); |
| 196 | 196 | $routes[] = Route::add('POST', 'comments/delete', Comment::class.'@delete'); |
| 197 | +} | |
| 198 | + | |
| 199 | +// 通知(記事のコメント、Gitler等) | |
| 200 | +if (AUTH_ENABLED) { | |
| 197 | 201 | $routes[] = Route::add('GET', 'notifications', Comment::class.'@notifications'); |
| 198 | 202 | } |
| 199 | 203 |
| @@ -125,8 +125,8 @@ | ||
| 125 | 125 | */ |
| 126 | 126 | public function notifications(array $params): void { |
| 127 | 127 | $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); | |
| 130 | 130 | $this->render('notifications', '通知', ['list' => $list]); |
| 131 | 131 | } |
| 132 | 132 |
| @@ -51,6 +51,7 @@ | ||
| 51 | 51 | use Std\Lib\Gitler\Gitler as Hosting; |
| 52 | 52 | use Std\Lib\Gitler\Issues; |
| 53 | 53 | use Std\Lib\Gitler\Keys; |
| 54 | +use Std\Lib\Gitler\Notify; | |
| 54 | 55 | use Std\Lib\Gitler\Languages; |
| 55 | 56 | use Std\Lib\Markdown; |
| 56 | 57 | use Std\Lib\Syntax; |
| @@ -168,16 +169,18 @@ | ||
| 168 | 169 | } |
| 169 | 170 | } else if ($do === 'removeToken') { |
| 170 | 171 | Keys::removeToken($me, (string)($_POST['id'] ?? '')) ? $message = 'トークンを削除しました。' : $error = 'トークンが有りません。'; |
| 172 | + } else if ($do === 'email') { | |
| 173 | + Keys::setEmail($me, isset($_POST['email'])) ? $message = '保存しました。' : $error = '保存出来ません。'; | |
| 171 | 174 | } |
| 172 | 175 | } |
| 173 | 176 | } |
| 174 | 177 | $data = Keys::load($me); |
| 175 | 178 | $fmt = fn($t) => $t ? date('Y-m-d H:i', (int)$t) : '-'; |
| 176 | - $this->render('gitler/keys', 'SSHの鍵とトークン', [ | |
| 179 | + $this->render('gitler/keys', 'Gitlerの設定', [ | |
| 177 | 180 | 'keys' => array_map(fn($k) => $k + ['addedText' => $fmt($k['added'] ?? null), 'usedText' => $fmt($k['lastUsed'] ?? null)], $data['keys']), |
| 178 | 181 | 'tokens' => array_map(fn($t) => $t + ['createdText' => $fmt($t['created'] ?? null), 'usedText' => $fmt($t['lastUsed'] ?? null)], $data['tokens']), |
| 179 | 182 | '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 ?? ''), | |
| 181 | 184 | ]); |
| 182 | 185 | } |
| 183 | 186 | |
| @@ -486,6 +489,13 @@ | ||
| 486 | 489 | $meta['federate'] = isset($_POST['federate']); |
| 487 | 490 | Hosting::saveMeta($this->owner, $this->name, $meta); |
| 488 | 491 | $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'] ? 'アーカイブしました。読むだけになります。' : 'アーカイブを解除しました。'; | |
| 489 | 499 | } else if ($do === 'block' || $do === 'unblock') { |
| 490 | 500 | $res = Federation::block($this->owner, $this->name, (string)($_POST['target'] ?? ''), $do === 'block'); |
| 491 | 501 | $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message; |
| @@ -525,6 +535,11 @@ | ||
| 525 | 535 | $exists = Hosting::validOwner($owner) && Hosting::validName($name) && Hosting::exists($owner, $name); |
| 526 | 536 | $access = $exists ? Hosting::access($user, $owner, $name) : Hosting::NONE; |
| 527 | 537 | $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 | + } | |
| 528 | 543 | if ($access < $need || ($write && !$tokenWrite)) { |
| 529 | 544 | // 認証していなければ、先に認証を求める(非公開のリポジトリが有るか分からない様に) |
| 530 | 545 | if ($user === null || ($write && $access >= Hosting::WRITE && !$tokenWrite && !$tried)) { |
| @@ -637,7 +652,7 @@ | ||
| 637 | 652 | } |
| 638 | 653 | |
| 639 | 654 | 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'], | |
| 641 | 656 | 'url' => '/gitler/'.$r['owner'].'/'.rawurlencode($r['name']), 'updated' => $r['updated'] ? date('Y-m-d H:i', $r['updated']) : '-', |
| 642 | 657 | 'lang' => Languages::primary(Hosting::path($r['owner'], $r['name']))]; |
| 643 | 658 | } |
| @@ -797,6 +812,8 @@ | ||
| 797 | 812 | 'zipUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=zip' : '', |
| 798 | 813 | 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '', |
| 799 | 814 | '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) : '', | |
| 800 | 817 | ]); |
| 801 | 818 | } |
| 802 | 819 |
| @@ -42,9 +42,10 @@ | ||
| 42 | 42 | use Std\Lib\Gitler\Federation; |
| 43 | 43 | use Std\Lib\Gitler\Gitler as Hosting; |
| 44 | 44 | use Std\Lib\Gitler\Issues; |
| 45 | +use Std\Lib\Gitler\Notify; | |
| 45 | 46 | |
| 46 | 47 | /** |
| 47 | - * Gitlerのイシュー、ラベル、マイルストーンのページと、リポジトリのFediverse(アクター、受付ボックス等) | |
| 48 | + * Gitlerのイシュー、ラベル、マイルストーンのページ、ウォッチ、リポジトリのFediverse(アクター、受付ボックス等) | |
| 48 | 49 | * |
| 49 | 50 | * Gitlerコントローラーで使う(リポジトリを開く等はGitlerの物)。 |
| 50 | 51 | */ |
| @@ -83,6 +84,7 @@ | ||
| 83 | 84 | public function newIssue(array $params): void { |
| 84 | 85 | $this->loadIssues($params); |
| 85 | 86 | if (!$this->user) $this->redirect('/login'); |
| 87 | + if ($this->meta['archived']) $this->message('アーカイブされたリポジトリです(読むだけ)。', $this->repoUrl().'/issues'); | |
| 86 | 88 | if (!$this->canIssue()) $this->message('イシューを作る権限が有りません。', $this->repoUrl().'/issues'); |
| 87 | 89 | $in = ['title' => '', 'body' => '', 'labels' => [], 'assignees' => [], 'milestone' => 0]; |
| 88 | 90 | $error = ''; |
| @@ -96,6 +98,7 @@ | ||
| 96 | 98 | $res = $this->issueDb->create($this->me(), $in['title'], $in['body'], $opt); |
| 97 | 99 | if ($res->isSuccess) { |
| 98 | 100 | 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']); | |
| 99 | 102 | $this->redirect($this->repoUrl().'/issues/'.$res->data['number']); |
| 100 | 103 | } |
| 101 | 104 | $error = (string)$res->message; |
| @@ -125,30 +128,37 @@ | ||
| 125 | 128 | } |
| 126 | 129 | header('Vary: Accept'); |
| 127 | 130 | $isAuthor = $this->user && ($issue['author']['user'] ?? null) === $this->user->username; |
| 131 | + $archived = $this->meta['archived']; | |
| 128 | 132 | $labels = $this->issueDb->labels(); |
| 129 | 133 | $milestones = $this->issueDb->milestones(); |
| 130 | 134 | $timeline = []; |
| 131 | 135 | foreach ($issue['comments'] as $c) { |
| 132 | 136 | $mine = $this->user && ($c['author']['user'] ?? null) === $this->user->username; |
| 133 | 137 | $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'] ?? '', | |
| 136 | 140 | 'remote' => preg_match('#^https?://#i', (string)($c['remoteId'] ?? '')) ? $c['remoteId'] : '']; |
| 137 | 141 | } |
| 138 | 142 | 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'])]; |
| 139 | 143 | 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; | |
| 141 | 148 | $this->renderRepo('gitler/issue', '#'.$n.' '.$issue['title'], [ |
| 142 | 149 | 'issue' => $issue, 'who' => $this->who($issue['author']), 'bodyHtml' => $this->bodyHtml($issue['body'], !empty($issue['html'])), |
| 143 | 150 | '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, | |
| 146 | 154 | 'labels' => $this->presentLabels($labels, $issue['labels']), 'issueLabels' => $this->presentLabels(array_intersect_key($labels, array_flip($issue['labels']))), |
| 147 | 155 | 'milestones' => $milestones, 'milestone' => $issue['milestone'] !== null ? ($milestones[$issue['milestone']]['title'] ?? '') : '', |
| 148 | 156 | 'people' => array_map(fn($p) => ['name' => $p, 'checked' => in_array($p, $issue['assignees'], true)], array_unique(array_merge($this->assignable(), $issue['assignees']))), |
| 149 | 157 | 'issueUrl' => $this->repoUrl().'/issues/'.$n, 'fedi' => $fedi, |
| 150 | 158 | 'fediHandle' => $fedi ? '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host() : '', |
| 151 | 159 | 'fediUrl' => $fedi ? Federation::issueUrl($this->owner, $this->name, $n) : '', |
| 160 | + 'subscribed' => $sub === true || ($sub === null && $watching), | |
| 161 | + 'subscribeNote' => $sub === true ? '通知を受け取っています。' : ($sub === false ? '通知を止めています。' : ($watching ? 'リポジトリをウォッチしているので通知を受け取ります。' : '通知を受け取っていません。')), | |
| 152 | 162 | ]); |
| 153 | 163 | } |
| 154 | 164 | |
| @@ -181,6 +191,12 @@ | ||
| 181 | 191 | $body = str_replace("\r\n", "\n", (string)($_POST['body'] ?? '')); |
| 182 | 192 | if (!$this->user) return 'ログインして下さい。'; |
| 183 | 193 | |
| 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 | + | |
| 184 | 200 | if (in_array($do, ['comment', 'commentClose', 'commentReopen', 'close', 'reopen'], true)) { |
| 185 | 201 | if (!$this->canIssue()) return 'コメントする権限が有りません。'; |
| 186 | 202 | $anchor = ''; |
| @@ -188,11 +204,15 @@ | ||
| 188 | 204 | $res = $this->issueDb->comment($n, $me, $body); |
| 189 | 205 | if (!$res->isSuccess) return (string)$res->message; |
| 190 | 206 | 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']); | |
| 191 | 208 | $anchor = '#c'.$res->data['comment']['id']; |
| 192 | 209 | } |
| 193 | 210 | if ($do !== 'comment' && $canClose) { |
| 194 | 211 | $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 | + } | |
| 196 | 216 | } |
| 197 | 217 | $this->redirect($url.$anchor); |
| 198 | 218 | } |
| @@ -206,7 +226,9 @@ | ||
| 206 | 226 | if ($do === 'triage') { |
| 207 | 227 | if ($this->access < Hosting::WRITE) return '権限が有りません。'; |
| 208 | 228 | $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); | |
| 210 | 232 | $this->redirect($url); |
| 211 | 233 | } |
| 212 | 234 | if ($do === 'editComment' || $do === 'deleteComment') { |
| @@ -242,6 +264,17 @@ | ||
| 242 | 264 | return ''; |
| 243 | 265 | } |
| 244 | 266 | |
| 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 | + | |
| 245 | 278 | //////// ラベルとマイルストーン |
| 246 | 279 | |
| 247 | 280 | /** …/labels */ |
| @@ -251,8 +284,8 @@ | ||
| 251 | 284 | if ($_SERVER['REQUEST_METHOD'] === 'POST') { |
| 252 | 285 | if (!$this->csrfOk()) { |
| 253 | 286 | $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'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。'; | |
| 256 | 289 | } else if (($_POST['do'] ?? '') === 'delete') { |
| 257 | 290 | $this->issueDb->deleteLabel((int)($_POST['id'] ?? 0)); |
| 258 | 291 | $this->redirect($this->repoUrl().'/labels'); |
| @@ -267,7 +300,7 @@ | ||
| 267 | 300 | foreach ($this->issueDb->search(['state' => 'open']) as $s) foreach ($s['labels'] as $l) $counts[$l] = ($counts[$l] ?? 0) + 1; |
| 268 | 301 | $list = []; |
| 269 | 302 | 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']]); | |
| 271 | 304 | } |
| 272 | 305 | |
| 273 | 306 | /** …/milestones */ |
| @@ -280,8 +313,8 @@ | ||
| 280 | 313 | $all = $this->issueDb->milestones(); |
| 281 | 314 | if (!$this->csrfOk()) { |
| 282 | 315 | $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'] ? 'アーカイブされたリポジトリです(読むだけ)。' : '権限が有りません。'; | |
| 285 | 318 | } else if ($do === 'delete') { |
| 286 | 319 | $this->issueDb->deleteMilestone($id); |
| 287 | 320 | $this->redirect($this->repoUrl().'/milestones'); |
| @@ -312,7 +345,8 @@ | ||
| 312 | 345 | 'url' => $this->repoUrl().'/issues?state=all&milestone='.$id]; |
| 313 | 346 | } |
| 314 | 347 | 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']]); | |
| 316 | 350 | } |
| 317 | 351 | |
| 318 | 352 | //////// ActivityPub(リポジトリのアクター) |
| @@ -407,7 +441,9 @@ | ||
| 407 | 441 | $head = $issues->repo->headRef() ?? 'refs/heads/'.Hosting::config()['branch']; |
| 408 | 442 | foreach ($issues->processPush($done, $head) as $n) { |
| 409 | 443 | $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'] ?? []); | |
| 411 | 447 | } |
| 412 | 448 | } catch (\Throwable $e) { |
| 413 | 449 | // プッシュ自体は終わっている |
| @@ -425,7 +461,7 @@ | ||
| 425 | 461 | |
| 426 | 462 | /** イシューを作ってコメント出来るか */ |
| 427 | 463 | 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'); | |
| 429 | 465 | } |
| 430 | 466 | |
| 431 | 467 | private function me(): array { |
| @@ -52,7 +52,7 @@ | ||
| 52 | 52 | * 記事のURLと同じ。データは全部data/の下のJSONファイル: |
| 53 | 53 | * comments/{スレッド}.json コメント(古い順) |
| 54 | 54 | * comments/ap.json Fediverseの返信のID => スレッドとコメント(返信への返信・編集・削除の為) |
| 55 | - * notifications/{ユーザー名}.json 通知(新しい順) | |
| 55 | + * 通知はNotifications(data/notifications/)。 | |
| 56 | 56 | * |
| 57 | 57 | * 新しいコメントは全部の管理者に通知する。ローカルのユーザーには、自分のコメントへの返信といいねを通知する。 |
| 58 | 58 | * マルチユーザーモードでFediverseのロールを持つユーザーのコメントは、連合する記事なら本人のアカウントの投稿 |
| @@ -64,8 +64,6 @@ | ||
| 64 | 64 | public const MAX_LENGTH = 5000; |
| 65 | 65 | /** 返信の字下げの最大の深さ(これより深い返信は同じ深さで表示する) */ |
| 66 | 66 | public const MAX_DEPTH = 5; |
| 67 | - /** 1人の通知の最大数 */ | |
| 68 | - public const MAX_NOTIFICATIONS = 200; | |
| 69 | 67 | |
| 70 | 68 | /** @var string|null 保存先(テスト用。既定はdata) */ |
| 71 | 69 | public static ?string $dataDir = null; |
| @@ -363,32 +361,17 @@ | ||
| 363 | 361 | //////// 通知 |
| 364 | 362 | |
| 365 | 363 | /** |
| 366 | - * 通知(新しい順) | |
| 364 | + * 通知(Notifications)の表示用のデータ | |
| 367 | 365 | * |
| 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) | |
| 389 | 367 | * @return array who(表示名等)、label、page(コメントのURL)、excerpt、at、read |
| 390 | 368 | */ |
| 391 | 369 | public static function present(array $n): array { |
| 370 | + // コメント以外(Gitler等)は追加した時の文 | |
| 371 | + if (isset($n['label'], $n['page'])) { | |
| 372 | + return ['type' => $n['type'] ?? '', 'who' => self::who((array)($n['from'] ?? [])), 'label' => (string)$n['label'], 'page' => (string)$n['page'], | |
| 373 | + 'excerpt' => (string)($n['excerpt'] ?? ''), 'at' => date('Y-m-d H:i', (int)($n['at'] ?? 0)), 'read' => !empty($n['read'])]; | |
| 374 | + } | |
| 392 | 375 | $thread = (string)($n['thread'] ?? ''); |
| 393 | 376 | $c = self::all($thread)[$n['comment'] ?? ''] ?? null; |
| 394 | 377 | $title = (string)($n['title'] ?? ''); |
| @@ -536,15 +519,9 @@ | ||
| 536 | 519 | } |
| 537 | 520 | |
| 538 | 521 | 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); | |
| 548 | 525 | } |
| 549 | 526 | |
| 550 | 527 | /** |
| @@ -57,7 +57,8 @@ | ||
| 57 | 57 | * /gitler/o/r/issues/3/comments/5 コメントのNote |
| 58 | 58 | * |
| 59 | 59 | * 鍵、フォロワー、ブロックはリポジトリの中のgitler/。配信はFediのDelivery(cronのapdeliver)。 |
| 60 | - * SSHのプッシュ(gitユーザー)はdata/apに書けないので、gitler/spool/に溜めてapdeliverが送る。 | |
| 60 | + * SSHのプッシュ(gitユーザー)はdata/apに書けないので、Gitler::defer()で溜めてcronのnotifyが送る。 | |
| 61 | + * アーカイブしたリポジトリはフォロー出来るが、イシューとコメントは受け付けない。 | |
| 61 | 62 | */ |
| 62 | 63 | final class Federation { |
| 63 | 64 | public const CONTEXT = [ |
| @@ -77,9 +78,6 @@ | ||
| 77 | 78 | ], |
| 78 | 79 | ]; |
| 79 | 80 | |
| 80 | - /** SSHのプッシュでは配信をリポジトリに溜める */ | |
| 81 | - public static bool $spool = false; | |
| 82 | - | |
| 83 | 81 | /** このリポジトリがFediverseに出るか */ |
| 84 | 82 | public static function enabled(array $meta): bool { |
| 85 | 83 | return Fedi::enabled() && empty($meta['private']) && ($meta['issues'] ?? true) && ($meta['federate'] ?? true); |
| @@ -144,7 +142,8 @@ | ||
| 144 | 142 | 'type' => 'Service', |
| 145 | 143 | 'preferredUsername' => self::handle($owner, $name), |
| 146 | 144 | '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>'), | |
| 148 | 147 | 'url' => $url, |
| 149 | 148 | 'inbox' => "{$url}/inbox", |
| 150 | 149 | 'outbox' => "{$url}/outbox", |
| @@ -348,22 +347,6 @@ | ||
| 348 | 347 | self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue)); |
| 349 | 348 | } |
| 350 | 349 | |
| 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 | - | |
| 367 | 350 | //////// 受け取る |
| 368 | 351 | |
| 369 | 352 | /** |
| @@ -377,6 +360,8 @@ | ||
| 377 | 360 | $actor = Fedi::id($a['actor'] ?? null); |
| 378 | 361 | if ($actor === null || Fedi::isLocal($actor)) return [403, ['error' => '不正なactor']]; |
| 379 | 362 | if (self::isBlocked($owner, $name, $actor)) return [202, ['status' => '無視しました']]; |
| 363 | + // アーカイブしたリポジトリはフォローだけ | |
| 364 | + if ($meta['archived'] && !in_array($a['type'] ?? '', ['Follow', 'Undo'], true)) return [202, ['status' => 'アーカイブされています']]; | |
| 380 | 365 | try { |
| 381 | 366 | return match ((string)($a['type'] ?? '')) { |
| 382 | 367 | 'Follow' => self::onFollow($owner, $name, $a, $actor), |
| @@ -415,7 +400,7 @@ | ||
| 415 | 400 | }); |
| 416 | 401 | return [202, ['status' => 'OK']]; |
| 417 | 402 | } |
| 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); | |
| 419 | 404 | return [202, ['status' => '無視しました']]; |
| 420 | 405 | } |
| 421 | 406 | |
| @@ -434,7 +419,8 @@ | ||
| 434 | 419 | if ($parent !== null) { |
| 435 | 420 | $n = self::issueOf($owner, $name, $issues, $parent); |
| 436 | 421 | 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']); | |
| 438 | 424 | return [202, ['status' => 'OK']]; |
| 439 | 425 | } |
| 440 | 426 | |
| @@ -450,7 +436,10 @@ | ||
| 450 | 436 | } |
| 451 | 437 | if ($title === '') return [202, ['status' => '無視しました']]; |
| 452 | 438 | $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 | + } | |
| 454 | 443 | return [202, ['status' => 'OK']]; |
| 455 | 444 | } |
| 456 | 445 | |
| @@ -476,7 +465,10 @@ | ||
| 476 | 465 | Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$repo}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $repo, |
| 477 | 466 | 'object' => $a, 'result' => self::issueUrl($owner, $name, $n).'/ticket'], [$inbox]); |
| 478 | 467 | } |
| 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 | + } | |
| 480 | 472 | } |
| 481 | 473 | return [202, ['status' => 'OK']]; |
| 482 | 474 | } |
| @@ -550,8 +542,12 @@ | ||
| 550 | 542 | $n = $object !== null ? self::issueOf($owner, $name, $issues, $object) : null; |
| 551 | 543 | $issue = $n !== null ? $issues->get($n) : null; |
| 552 | 544 | 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 | + } | |
| 555 | 551 | return [202, ['status' => 'OK']]; |
| 556 | 552 | } |
| 557 | 553 | |
| @@ -639,16 +635,17 @@ | ||
| 639 | 635 | 'to' => $obj['to'] ?? [Fedi::PUBLIC], 'cc' => $obj['cc'] ?? ["{$me}/followers"], 'object' => $object]; |
| 640 | 636 | } |
| 641 | 637 | |
| 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]); | |
| 650 | 642 | return; |
| 651 | 643 | } |
| 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 { | |
| 652 | 649 | $inboxes = []; |
| 653 | 650 | foreach (self::followers($owner, $name) as $f) { |
| 654 | 651 | $inbox = $f['sharedInbox'] ?? $f['inbox'] ?? null; |
| @@ -52,6 +52,7 @@ | ||
| 52 | 52 | * |
| 53 | 53 | * リポジトリは{root}/{所有者}/{名前}.git(GITINFOのroot)。説明・非公開・共同作業者はリポジトリの中のgitler.json。 |
| 54 | 54 | * 権限:NONE(見えない)、READ(読める)、WRITE(プッシュ出来る)、ADMIN(設定出来る)。 |
| 55 | + * アーカイブしたリポジトリは読むだけ(プッシュ、イシュー等の変更は出来ない)。 | |
| 55 | 56 | */ |
| 56 | 57 | final class Gitler { |
| 57 | 58 | public const NONE = 0; |
| @@ -63,6 +64,11 @@ | ||
| 63 | 64 | public static ?string $root = null; |
| 64 | 65 | /** ユーザーのデータ(テスト用、既定はdata/user/) */ |
| 65 | 66 | public static ?string $userDir = null; |
| 67 | + /** | |
| 68 | + * SSHのプッシュ(gitユーザー)ではdata/に書けないので、通知とFediverseへの配信をリポジトリのgitler/spool/に溜める。 | |
| 69 | + * cronのnotifyが送る | |
| 70 | + */ | |
| 71 | + public static bool $deferred = false; | |
| 66 | 72 | |
| 67 | 73 | public static function enabled(): bool { |
| 68 | 74 | return defined('GITLER_ENABLED') && GITLER_ENABLED && AUTH_ENABLED; |
| @@ -124,13 +130,13 @@ | ||
| 124 | 130 | * リポジトリの情報 |
| 125 | 131 | * |
| 126 | 132 | * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、 |
| 127 | - * issues(イシューを使う)、federate(公開ならFediverseに出す) | |
| 133 | + * issues(イシューを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ) | |
| 128 | 134 | */ |
| 129 | 135 | public static function meta(string $owner, string $name): array { |
| 130 | 136 | $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true); |
| 131 | 137 | $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]; | |
| 134 | 140 | $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : []; |
| 135 | 141 | return $j; |
| 136 | 142 | } |
| @@ -275,6 +281,41 @@ | ||
| 275 | 281 | } |
| 276 | 282 | usort($out, fn($a, $b) => $b['updated'] <=> $a['updated'] ?: strcmp($a['owner'].'/'.$a['name'], $b['owner'].'/'.$b['name'])); |
| 277 | 283 | 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; | |
| 278 | 319 | } |
| 279 | 320 | |
| 280 | 321 | /** 全部のリポジトリを整理する(cronのgitlergc) */ |
| @@ -47,7 +47,7 @@ | ||
| 47 | 47 | * Gitlerのイシュー(リポジトリの中のgitler/) |
| 48 | 48 | * |
| 49 | 49 | * issues.json 索引(次の番号、イシュー毎の概要、リモートのID => [番号, コメント]) |
| 50 | - * issues/{番号}.json イシュー(本文、コメント、出来事) | |
| 50 | + * issues/{番号}.json イシュー(本文、コメント、出来事、通知を受け取るユーザー(subscribers:ユーザー名 => bool)) | |
| 51 | 51 | * labels.json、milestones.json |
| 52 | 52 | * |
| 53 | 53 | * 書く者:local({user})、remote({actor, name, handle, icon, url}、Fediverse)、commit({commit, name})。 |
| @@ -147,8 +147,10 @@ | ||
| 147 | 147 | 'number' => $n, 'title' => $title, 'body' => $body, 'html' => !empty($opt['html']), 'author' => $author, 'state' => 'open', |
| 148 | 148 | 'created' => $now, 'updated' => $now, 'closed' => null, 'labels' => $this->validLabels($opt['labels'] ?? []), |
| 149 | 149 | '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' => [], | |
| 151 | 151 | ]; |
| 152 | + // 作った人と担当者は通知を受け取る | |
| 153 | + foreach (array_merge(isset($author['user']) ? [$author['user']] : [], $issue['assignees']) as $u) $issue['subscribers'][$u] = true; | |
| 152 | 154 | $idx['next'] = $n + 1; |
| 153 | 155 | if ($issue['remoteId'] !== null) $idx['remote'][$issue['remoteId']] = [$n, null]; |
| 154 | 156 | $this->save($issue, $idx); |
| @@ -208,6 +210,8 @@ | ||
| 208 | 210 | 'remoteId' => $opt['remoteId'] ?? null]; |
| 209 | 211 | $i['seq'] = (int)$i['seq'] + 1; |
| 210 | 212 | $i['comments'][] = $added; |
| 213 | + // コメントした人は通知を受け取る(止めていても) | |
| 214 | + if (isset($author['user'])) $i['subscribers'][$author['user']] = true; | |
| 211 | 215 | return $i; |
| 212 | 216 | }); |
| 213 | 217 | if ($issue === null || $added === null) return Result::Error('コメント出来ません。'); |
| @@ -263,6 +267,7 @@ | ||
| 263 | 267 | $add = array_values(array_diff($assignees, $i['assignees'])); |
| 264 | 268 | $remove = array_values(array_diff($i['assignees'], $assignees)); |
| 265 | 269 | if ($add || $remove) $i['events'][] = ['type' => 'assignees', 'by' => $by, 'at' => $at, 'add' => $add, 'remove' => $remove]; |
| 270 | + foreach ($add as $u) $i['subscribers'][$u] = true; | |
| 266 | 271 | $i['assignees'] = $assignees; |
| 267 | 272 | } |
| 268 | 273 | if ($milestone !== false) { |
| @@ -274,6 +279,14 @@ | ||
| 274 | 279 | }); |
| 275 | 280 | } |
| 276 | 281 | |
| 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 | + | |
| 277 | 290 | /** イシューを消す(番号は使い回さない) */ |
| 278 | 291 | public function delete(int $n): bool { |
| 279 | 292 | return $this->locked(function() use ($n): bool { |
| @@ -423,7 +436,7 @@ | ||
| 423 | 436 | return self::linkify($html, $issuesUrl); |
| 424 | 437 | } |
| 425 | 438 | |
| 426 | - /** HTMLの文字の部分(a、code、preの外)の「#3」とURLをリンクにする */ | |
| 439 | + /** HTMLの文字の部分(a、code、preの外)の「#3」、「@ユーザー」(居れば)とURLをリンクにする */ | |
| 427 | 440 | public static function linkify(string $html, string $issuesUrl): string { |
| 428 | 441 | $parts = preg_split('/(<[^>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE); |
| 429 | 442 | $depth = 0; |
| @@ -434,7 +447,8 @@ | ||
| 434 | 447 | continue; |
| 435 | 448 | } |
| 436 | 449 | 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]; | |
| 438 | 452 | if (($m[2] ?? '') !== '') return '<a href="'.htmlspecialchars($issuesUrl, ENT_QUOTES, 'UTF-8').'/'.$m[2].'">#'.$m[2].'</a>'; |
| 439 | 453 | return '<a href="'.$m[1].'" rel="nofollow noopener noreferrer">'.$m[1].'</a>'; |
| 440 | 454 | }, $p); |
| @@ -40,7 +40,7 @@ | ||
| 40 | 40 | use Result; |
| 41 | 41 | |
| 42 | 42 | /** |
| 43 | - * GitlerのSSHの鍵とアクセストークン(data/gitler/users/{ユーザー名}.json) | |
| 43 | + * GitlerのSSHの鍵、アクセストークン、通知のメールの設定(data/gitler/users/{ユーザー名}.json) | |
| 44 | 44 | * |
| 45 | 45 | * SSH:sshdのAuthorizedKeysCommand(gitler.php keys)が鍵からユーザーを探し、command=でgitler.php serveを実行させる。 |
| 46 | 46 | * HTTPS:ユーザー名とアクセストークン(又は二段階認証が無ければパスワード)。 |
| @@ -60,11 +60,11 @@ | ||
| 60 | 60 | return self::dir()."/{$user}.json"; |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | - /** @return array keys、tokens */ | |
| 63 | + /** @return array keys、tokens、email(通知をメールでも受け取る) */ | |
| 64 | 64 | public static function load(string $user): array { |
| 65 | 65 | $j = json_decode((string)@file_get_contents(self::file($user)), true); |
| 66 | 66 | $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)]; | |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | 70 | private static function save(string $user, array $data): bool { |
| @@ -220,6 +220,13 @@ | ||
| 220 | 220 | * |
| 221 | 221 | * @return array|null トークン(write等) |
| 222 | 222 | */ |
| 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 | + | |
| 223 | 230 | public static function checkToken(string $user, string $token): ?array { |
| 224 | 231 | if (!preg_match('/^[A-Za-z0-9]+$/', $user) || !str_starts_with($token, 'lbg_')) return null; |
| 225 | 232 | $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 @@ | ||
| 42 | 42 | use Std\Lib\Tester; |
| 43 | 43 | use Std\Lib\Activitypub; |
| 44 | 44 | use Std\Lib\Comments; |
| 45 | +use Std\Lib\Notifications; | |
| 45 | 46 | use Std\Lib\Fedi\{Fedi, Notes, Remote, Social}; |
| 46 | 47 | |
| 47 | 48 | $test = new Tester([ |
| @@ -51,7 +52,7 @@ | ||
| 51 | 52 | |
| 52 | 53 | // 本物のデータを触らない様に、一時ディレクトリを使う |
| 53 | 54 | $tmp = sys_get_temp_dir().'/lbcomments_'.bin2hex(random_bytes(6)); |
| 54 | -Comments::$dataDir = "{$tmp}/data"; | |
| 55 | +Comments::$dataDir = Notifications::$dataDir = "{$tmp}/data"; | |
| 55 | 56 | Fedi::$dataDir = "{$tmp}/ap"; |
| 56 | 57 | Fedi::$userDir = "{$tmp}/user/"; |
| 57 | 58 | mkdir(Fedi::$userDir, 0700, true); |
| @@ -64,7 +65,7 @@ | ||
| 64 | 65 | $alice = $mkuser('alice1234', 2); |
| 65 | 66 | $bob = $mkuser('bob12345', 3); |
| 66 | 67 | $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)); | |
| 68 | 69 | $thread = 'blog/test-post'; |
| 69 | 70 | |
| 70 | 71 | $test->describe('コメント', function($test) use ($admin, $alice, $bob, $banned, $types, $thread): void { |
| @@ -132,13 +133,13 @@ | ||
| 132 | 133 | }); |
| 133 | 134 | |
| 134 | 135 | $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]); | |
| 137 | 138 | $test->assertStringContains('いいね', $n['label']); |
| 138 | 139 | $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'))); | |
| 142 | 143 | }); |
| 143 | 144 | |
| 144 | 145 | $test->it('返信のあるコメントは中身だけ消し、返信が無くなれば完全に消すはず', function($test) use ($admin, $alice, $bob, $thread): void { |
| @@ -198,7 +199,7 @@ | ||
| 198 | 199 | $test->assertEquals('Carol C', $c['author']['name']); |
| 199 | 200 | $test->assertEquals('https://1.1.1.1/@carol', $c['author']['url']); |
| 200 | 201 | $test->assertEquals("comment:{$carol}", $types('admin1234')[0]); |
| 201 | - $test->assertEquals('英語の記事', Comments::notifications('admin1234')[0]['title']); | |
| 202 | + $test->assertEquals('英語の記事', Notifications::list('admin1234')[0]['title']); | |
| 202 | 203 | // 再送されても1つ |
| 203 | 204 | $test->assertTrue($ap->handleComments($create($note('1', $target)))); |
| 204 | 205 | $test->assertEquals(1, Comments::count($thread)); |
| @@ -342,6 +343,6 @@ | ||
| 342 | 343 | @rmdir($dir); |
| 343 | 344 | }; |
| 344 | 345 | $rm($tmp); |
| 345 | -Comments::$dataDir = Fedi::$dataDir = Fedi::$userDir = null; | |
| 346 | +Comments::$dataDir = Notifications::$dataDir = Fedi::$dataDir = Fedi::$userDir = null; | |
| 346 | 347 | |
| 347 | 348 | $test->printSummary(); |
| @@ -52,6 +52,8 @@ | ||
| 52 | 52 | use Std\Lib\Gitler\Gitler; |
| 53 | 53 | use Std\Lib\Gitler\Issues; |
| 54 | 54 | use Std\Lib\Gitler\Keys; |
| 55 | +use Std\Lib\Gitler\Notify; | |
| 56 | +use Std\Lib\Notifications; | |
| 55 | 57 | use Std\Lib\Gitler\Languages; |
| 56 | 58 | use Roles; |
| 57 | 59 | |
| @@ -490,6 +492,10 @@ | ||
| 490 | 492 | $test->assertTrue(str_contains($h, '<a href="https://example.com/a?b=1&c=2"')); |
| 491 | 493 | $test->assertTrue(str_contains($h, '<code>#4</code>'), 'コードの中はそのまま'); |
| 492 | 494 | $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')); | |
| 493 | 499 | }); |
| 494 | 500 | }); |
| 495 | 501 | |
| @@ -646,18 +652,114 @@ | ||
| 646 | 652 | Federation::block('owner001', 'fedi1', $bob, false); |
| 647 | 653 | $test->assertFalse(Federation::isBlocked('owner001', 'fedi1', $bob)); |
| 648 | 654 | $queue(); |
| 649 | - Federation::$spool = true; | |
| 655 | + Gitler::$deferred = true; | |
| 650 | 656 | $issue = Issues::open('owner001', 'fedi1')->get(1); |
| 651 | 657 | Federation::stateChanged('owner001', 'fedi1', Gitler::meta('owner001', 'fedi1'), $issue); |
| 652 | - Federation::$spool = false; | |
| 658 | + Gitler::$deferred = false; | |
| 653 | 659 | $test->assertSame([], $queue()); |
| 654 | 660 | $test->assertSame(2, count(glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json'))); |
| 655 | - $test->assertSame(2, Federation::flush()); | |
| 661 | + $test->assertSame(2, Gitler::flushDeferred()); | |
| 656 | 662 | $test->assertSame([], glob(Gitler::path('owner001', 'fedi1').'/gitler/spool/*.json')); |
| 657 | 663 | $test->assertTrue(count($queue()) > 0); |
| 658 | 664 | }); |
| 659 | 665 | Fedi::$dataDir = null; |
| 660 | 666 | 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; | |
| 661 | 763 | }); |
| 662 | 764 | |
| 663 | 765 | $test->describe('Gitler(HTTPのパスワード)', function($test) use ($base): void { |
変更
util.php
| @@ -293,11 +293,8 @@ | ||
| 293 | 293 | } |
| 294 | 294 | |
| 295 | 295 | 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()); | |
| 301 | 298 | // 90日より古いリモートの投稿とキャッシュを消す(1日1回) |
| 302 | 299 | $cron->register('apcleanup', fn() => \Std\Lib\Fedi\Timeline::cleanup()); |
| 303 | 300 | } |
| @@ -305,6 +302,15 @@ | ||
| 305 | 302 | if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) { |
| 306 | 303 | // メールアドレスを7日以内に確認しなかったアカウントを削除する |
| 307 | 304 | $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 | + }); | |
| 308 | 314 | } |
| 309 | 315 | |
| 310 | 316 | if (\Std\Lib\Gitler\Gitler::enabled()) { |
| @@ -69,12 +69,8 @@ | ||
| 69 | 69 | {@ if (AUTH_ENABLED) @} |
| 70 | 70 | <div> |
| 71 | 71 | {@ if (isset($user) && isset($user->id) && $user->id > 0) @} |
| 72 | -{@ if (\Std\Lib\Comments::enabled()) @} | |
| 73 | -{$ $unreadComments = \Std\Lib\Comments::unreadCount($user->username) $} | |
| 72 | +{$ $unreadComments = \Std\Lib\Notifications::unreadCount($user->username) $} | |
| 74 | 73 | お帰りなしゃ~い、<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 @} | |
| 78 | 74 | {@ else @} |
| 79 | 75 | <a href="/login">ログイン</a> |
| 80 | 76 | {@ if (AUTH_REGISTER_ENABLED) @} |
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | <ul class="gl-repos"> |
| 8 | 8 | {@ foreach ($list as $r) @} |
| 9 | 9 | <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>' : '' }}} | |
| 11 | 11 | {@ if ($r['description'] !== '') @} |
| 12 | 12 | <p>{{ $r['description'] }}</p> |
| 13 | 13 | {@ endif @} |
| @@ -120,6 +120,14 @@ | ||
| 120 | 120 | <p>{{ !empty($issue['assignees']) ? implode('、', $issue['assignees']) : '無し' }}</p> |
| 121 | 121 | <h3>マイルストーン</h3> |
| 122 | 122 | <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 @} | |
| 123 | 131 | {@ if ($canTriage) @} |
| 124 | 132 | <details class="gl-edit"> |
| 125 | 133 | <summary>変える</summary> |
| @@ -1,5 +1,14 @@ | ||
| 1 | 1 | {@ include(common/header) @} |
| 2 | 2 | {@ 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 @} | |
| 3 | 12 | <h1 class="paragraph">SSHの鍵</h1> |
| 4 | 13 | {@ if ($ssh === '') @} |
| 5 | 14 | <p class="paragraph">このサイトではSSHは使えません(HTTPSとアクセストークンを使って下さい)。</p> |
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | {@ endif @} |
| 10 | 10 | {@ if (isset($user) && $user) @} |
| 11 | 11 | <a href="/gitler/{{ $user->username }}">自分のリポジトリ</a> |
| 12 | - <a href="/gitler/-/keys">SSHの鍵・トークン</a> | |
| 12 | + <a href="/gitler/-/keys">設定(鍵・トークン・通知)</a> | |
| 13 | 13 | {@ endif @} |
| 14 | 14 | </span> |
| 15 | 15 | </div> |
| @@ -1,6 +1,21 @@ | ||
| 1 | 1 | {@ include(gitler/nav) @} |
| 2 | 2 | <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 @} | |
| 4 | 19 | {@ if ($meta['description'] !== '') @} |
| 5 | 20 | <p class="gl-desc">{{ $meta['description'] }}</p> |
| 6 | 21 | {@ endif @} |
| @@ -74,6 +74,22 @@ | ||
| 74 | 74 | </form> |
| 75 | 75 | {@ endif @} |
| 76 | 76 | |
| 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 | + | |
| 77 | 93 | <h2 class="paragraph">整理</h2> |
| 78 | 94 | <form method="post" action="{{ $repoUrl }}/settings" class="gl-row"> |
| 79 | 95 | <input type="hidden" name="csrf_token" value="{{ $csrf }}" /> |