master
ブランチ master
LittleBeast / src / Std / Lib / Gitler / Federation.php
34.2 KB、690行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, 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
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Std\Lib\Gitler;
39
40use Std\Lib\Crypto\Rsa;
41use Std\Lib\Fedi\Actor;
42use Std\Lib\Fedi\Delivery;
43use Std\Lib\Fedi\Fedi;
44use Std\Lib\Fedi\Html;
45use Std\Lib\Fedi\Remote;
46
47/**
48 * GitlerのFediverse(ActivityPub、ForgeFed)
49 *
50 * 公開のリポジトリは「@所有者.名前@ホスト」のアクター(ID:/gitler/所有者/名前)で、フォロー出来る。
51 * イシューとコメントはNoteとしてフォロワーに届く(Mastodon等で読めて、返信するとコメントになる)。
52 * リポジトリをメンションした投稿は新しいイシューになる。ForgeFedのOffer{Ticket}、Resolveにも対応。
53 *
54 * /gitler/o/r アクター(ForgeFedのcloneUri、ticketsTrackedBy付き)
55 * /gitler/o/r/issues/3 イシューのNote
56 * /gitler/o/r/issues/3/ticket ForgeFedのTicket
57 * /gitler/o/r/issues/3/comments/5 コメントのNote
58 *
59 * 鍵、フォロワー、ブロックはリポジトリの中のgitler/。配信はFediのDelivery(cronのapdeliver)。
60 * SSHのプッシュ(gitユーザー)はdata/apに書けないので、Gitler::defer()で溜めてcronのnotifyが送る。
61 * アーカイブしたリポジトリはフォロー出来るが、イシューとコメントは受け付けない。
62 */
63final class Federation {
64 public const CONTEXT = [
65 'https://www.w3.org/ns/activitystreams',
66 'https://w3id.org/security/v1',
67 [
68 'forgefed' => 'https://forgefed.org/ns#',
69 'Repository' => 'forgefed:Repository',
70 'Ticket' => 'forgefed:Ticket',
71 'Resolve' => 'forgefed:Resolve',
72 'isResolved' => 'forgefed:isResolved',
73 'cloneUri' => ['@id' => 'forgefed:cloneUri', '@type' => '@id'],
74 'ticketsTrackedBy' => ['@id' => 'forgefed:ticketsTrackedBy', '@type' => '@id'],
75 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
76 'toot' => 'http://joinmastodon.org/ns#',
77 'discoverable' => 'toot:discoverable',
78 ],
79 ];
80
81 /** このリポジトリがFediverseに出るか */
82 public static function enabled(array $meta): bool {
83 return Fedi::enabled() && empty($meta['private']) && ($meta['issues'] ?? true) && ($meta['federate'] ?? true);
84 }
85
86 public static function actorUrl(string $owner, string $name): string {
87 return Fedi::base().'/gitler/'.$owner.'/'.rawurlencode($name);
88 }
89
90 public static function issueUrl(string $owner, string $name, int $n): string {
91 return self::actorUrl($owner, $name).'/issues/'.$n;
92 }
93
94 /** ブラウザで見るページ(プルリクエストは…/pulls/3) */
95 public static function pageUrl(string $owner, string $name, array $issue): string {
96 return self::actorUrl($owner, $name).(isset($issue['pull']) ? '/pulls/' : '/issues/').$issue['number'];
97 }
98
99 /** 「所有者.名前」(ユーザー名には「.」が無いので重ならない) */
100 public static function handle(string $owner, string $name): string {
101 return $owner.'.'.$name;
102 }
103
104 //////// アクター
105
106 /**
107 * WebFinger(acct:所有者.名前@ホスト又はアクターのURL)
108 *
109 * @return array|null JRD、このリポジトリでなければnull
110 */
111 public static function webfinger(string $resource): ?array {
112 $found = null;
113 if (preg_match('/^(?:acct:)?@?([A-Za-z0-9]+)\.([A-Za-z0-9._-]+)@(.+)$/', $resource, $m) && strtolower($m[3]) === strtolower(Fedi::host())) {
114 $found = self::find($m[1], $m[2]);
115 } else if (preg_match('#^'.preg_quote(Fedi::base(), '#').'/gitler/([A-Za-z0-9]+)/([^/?\#]+)$#', $resource, $m)) {
116 $found = self::find($m[1], rawurldecode($m[2]));
117 }
118 if ($found === null) return null;
119 [$owner, $name] = $found;
120 $url = self::actorUrl($owner, $name);
121 return [
122 'subject' => 'acct:'.self::handle($owner, $name).'@'.Fedi::host(),
123 'aliases' => [$url],
124 'links' => [
125 ['rel' => 'self', 'type' => 'application/activity+json', 'href' => $url],
126 ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => $url],
127 ],
128 ];
129 }
130
131 /** 大文字・小文字を区別せずにFediverseに出るリポジトリを探す([所有者, 名前]) */
132 public static function find(string $owner, string $name): ?array {
133 foreach (Gitler::list() as $r) {
134 if (strcasecmp($r['owner'], $owner) === 0 && strcasecmp($r['name'], $name) === 0) return self::enabled($r['meta']) ? [$r['owner'], $r['name']] : null;
135 }
136 return null;
137 }
138
139 /** アクターのJSON-LD */
140 public static function document(string $owner, string $name, array $meta, string $cloneUrl): array {
141 $url = self::actorUrl($owner, $name);
142 $desc = (string)($meta['description'] ?? '');
143 $doc = [
144 '@context' => self::CONTEXT,
145 'id' => $url,
146 // Mastodon等がフォロー出来る種類(ForgeFedのRepositoryはアカウントとして扱われない)
147 'type' => 'Service',
148 'preferredUsername' => self::handle($owner, $name),
149 'name' => "{$owner}/{$name}",
150 'summary' => ($desc !== '' ? '<p>'.Html::esc($desc).'</p>' : '').($meta['archived'] ?? false ? '<p>アーカイブされたリポジトリです(読むだけ)。</p>'
151 : '<p>Gitlerのリポジトリ。フォローするとイシューが届き、返信するとコメント出来ます。メンションすると新しいイシューになります。</p>'),
152 'url' => $url,
153 'inbox' => "{$url}/inbox",
154 'outbox' => "{$url}/outbox",
155 'followers' => "{$url}/followers",
156 'manuallyApprovesFollowers' => false,
157 'discoverable' => true,
158 'published' => Fedi::now((int)($meta['created'] ?? 0) ?: time()),
159 'cloneUri' => $cloneUrl,
160 'ticketsTrackedBy' => $url,
161 ];
162 $u = Gitler::user($owner);
163 if ($u !== null) {
164 $avatar = avatar_url($u);
165 $doc['icon'] = ['type' => 'Image', 'url' => preg_match('#^https?://#i', $avatar) ? $avatar : Fedi::base().$avatar];
166 }
167 $key = self::key($owner, $name);
168 if ($key !== null) $doc['publicKey'] = ['id' => "{$url}#main-key", 'owner' => $url, 'publicKeyPem' => $key->publicPem()];
169 return $doc;
170 }
171
172 /** リポジトリの秘密鍵(無ければ作る、GMPが必要) */
173 public static function key(string $owner, string $name): ?Rsa {
174 if (!function_exists('gmp_init') || !Gitler::exists($owner, $name)) return null;
175 $repo = Gitler::open($owner, $name);
176 $file = $repo->path.'/gitler/private.pem';
177 if (!is_file($file)) {
178 $repo->mkdir(dirname($file));
179 $lock = @fopen($file.'.lock', 'c');
180 if ($lock) flock($lock, LOCK_EX);
181 try {
182 if (!is_file($file)) {
183 $key = Rsa::generate(2048);
184 $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp';
185 file_put_contents($tmp, $key->toPem());
186 @chmod($tmp, 0600);
187 rename($tmp, $file);
188 }
189 } finally {
190 if ($lock) {
191 flock($lock, LOCK_UN);
192 fclose($lock);
193 }
194 @unlink($file.'.lock');
195 }
196 }
197 try {
198 $key = Rsa::fromPem((string)@file_get_contents($file));
199 return $key->isPrivate() ? $key : null;
200 } catch (\InvalidArgumentException) {
201 return null;
202 }
203 }
204
205 /**
206 * 配信の署名(Deliveryの「gitler:所有者/名前」)
207 *
208 * @return array|null [秘密鍵, keyId]
209 */
210 public static function signer(string $repo): ?array {
211 [$owner, $name] = array_pad(explode('/', $repo, 2), 2, '');
212 if (!Gitler::validOwner($owner) || !Gitler::validName($name)) return null;
213 $key = self::key($owner, $name);
214 return $key === null ? null : [$key, self::actorUrl($owner, $name).'#main-key'];
215 }
216
217 //////// フォロワーとブロック
218
219 /** フォロワー(アクターのURL => inbox、sharedInbox、since) */
220 public static function followers(string $owner, string $name): array {
221 return self::read($owner, $name, 'followers.json');
222 }
223
224 /** ブロック(actors、domains) */
225 public static function blocks(string $owner, string $name): array {
226 return self::read($owner, $name, 'blocks.json') + ['actors' => [], 'domains' => []];
227 }
228
229 public static function isBlocked(string $owner, string $name, string $actor): bool {
230 if (Actor::isDomainBlocked($actor)) return true;
231 $b = self::blocks($owner, $name);
232 $d = Fedi::domainOf($actor);
233 if (in_array($actor, $b['actors'], true)) return true;
234 foreach ($b['domains'] as $x) if ($d === $x || str_ends_with($d, '.'.$x)) return true;
235 return false;
236 }
237
238 /** アクター(URL、@user@host)又はドメインをブロックする・外す */
239 public static function block(string $owner, string $name, string $target, bool $block = true): \Result {
240 $target = trim($target);
241 $isDomain = false;
242 if (preg_match('/^@?([^@\s\/]+)@([^@\s\/]+)$/', $target, $m)) {
243 $target = Remote::webfinger($m[1], $m[2]) ?? '';
244 if ($target === '') return \Result::Error('アクターが見つかりません。');
245 } else if (preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $target)) {
246 $isDomain = true;
247 } else if (!preg_match('#^https?://#i', $target)) {
248 return \Result::Error('「@ユーザー@ホスト」、アクターのURL又はドメインを入力して下さい。');
249 }
250 self::updateJson($owner, $name, 'blocks.json', function(array $b) use ($target, $isDomain, $block): array {
251 $b += ['actors' => [], 'domains' => []];
252 $key = $isDomain ? 'domains' : 'actors';
253 $v = $isDomain ? strtolower($target) : $target;
254 $b[$key] = array_values(array_diff($b[$key], [$v]));
255 if ($block) $b[$key][] = $v;
256 return $b;
257 });
258 if ($block && !$isDomain) self::updateJson($owner, $name, 'followers.json', function(array $l) use ($target): array {
259 unset($l[$target]);
260 return $l;
261 });
262 return \Result::Success($block ? 'ブロックしました。' : 'ブロックを外しました。');
263 }
264
265 //////// オブジェクト
266
267 /** イシューのNote(フォロワーのタイムラインに出る物) */
268 public static function issueNote(string $owner, string $name, array $issue): array {
269 $url = self::issueUrl($owner, $name, $issue['number']);
270 $repo = self::actorUrl($owner, $name);
271 $closed = $issue['state'] === 'closed';
272 $pull = isset($issue['pull']);
273 $page = self::pageUrl($owner, $name, $issue);
274 $state = !empty($issue['pull']['merged']) ? '【マージ済み】' : ($closed ? ($pull ? '【閉じた】' : '【解決済み】') : '');
275 $content = '<p>'.$state.'<a href="'.Html::esc($page).'">#'.$issue['number'].' '.Html::esc($issue['title']).'</a></p>'
276 .'<p>'.Html::esc(self::authorName($issue['author'])).'さんが'.Html::esc("{$owner}/{$name}").'に'.($pull ? 'プルリクエスト' : 'イシュー').'を作りました。</p>'
277 .self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html']));
278 $note = [
279 'id' => $url, 'type' => 'Note', 'attributedTo' => $repo, 'context' => "{$url}/ticket", 'url' => $page,
280 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], self::participants($owner, $name, $issue)),
281 'published' => Fedi::now($issue['created']), 'content' => $content, 'mediaType' => 'text/html',
282 'tag' => self::mentionTags(self::participants($owner, $name, $issue)),
283 ];
284 if (!empty($issue['edited']) || $closed) $note['updated'] = Fedi::now($issue['updated']);
285 return $note;
286 }
287
288 /** ForgeFedのTicket */
289 public static function ticket(string $owner, string $name, array $issue): array {
290 $url = self::issueUrl($owner, $name, $issue['number']);
291 $t = [
292 '@context' => self::CONTEXT, 'id' => "{$url}/ticket", 'type' => 'Ticket', 'attributedTo' => self::authorActor($owner, $name, $issue['author']),
293 'context' => self::actorUrl($owner, $name), 'name' => $issue['title'], 'summary' => Html::esc($issue['title']),
294 'content' => self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html'])), 'mediaType' => 'text/html',
295 'published' => Fedi::now($issue['created']), 'updated' => Fedi::now($issue['updated']), 'isResolved' => $issue['state'] === 'closed', 'url' => self::pageUrl($owner, $name, $issue),
296 ];
297 if (empty($issue['html'])) $t['source'] = ['content' => $issue['body'], 'mediaType' => 'text/markdown'];
298 return $t;
299 }
300
301 /** コメントのNote */
302 public static function commentNote(string $owner, string $name, array $issue, array $c): array {
303 $url = self::issueUrl($owner, $name, $issue['number']);
304 $repo = self::actorUrl($owner, $name);
305 $people = self::participants($owner, $name, $issue);
306 $note = [
307 'id' => "{$url}/comments/{$c['id']}", 'type' => 'Note', 'attributedTo' => $repo, 'inReplyTo' => $url, 'context' => "{$url}/ticket",
308 'url' => self::pageUrl($owner, $name, $issue)."#c{$c['id']}", 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], $people),
309 'published' => Fedi::now($c['created']), 'mediaType' => 'text/html', 'tag' => self::mentionTags($people),
310 'content' => '<p><a href="'.Html::esc($url).'">#'.$issue['number'].'</a> '.Html::esc(self::authorName($c['author'])).':</p>'
311 .self::bodyHtml($owner, $name, $c['body'], !empty($c['html'])),
312 ];
313 if ($c['updated'] > $c['created']) $note['updated'] = Fedi::now($c['updated']);
314 return $note;
315 }
316
317 //////// 送る
318
319 public static function issueOpened(string $owner, string $name, array $meta, array $issue): void {
320 if (!self::enabled($meta)) return;
321 self::send($owner, $name, self::activity($owner, $name, 'Create', self::issueNote($owner, $name, $issue)), self::participants($owner, $name, $issue));
322 }
323
324 public static function issueEdited(string $owner, string $name, array $meta, array $issue): void {
325 if (!self::enabled($meta)) return;
326 self::send($owner, $name, self::activity($owner, $name, 'Update', self::issueNote($owner, $name, $issue)), self::participants($owner, $name, $issue));
327 }
328
329 /** 閉じた・開いた(ForgeFedのResolve、MastodonにはNoteの更新) */
330 public static function stateChanged(string $owner, string $name, array $meta, array $issue): void {
331 if (!self::enabled($meta)) return;
332 $people = self::participants($owner, $name, $issue);
333 $ticket = self::issueUrl($owner, $name, $issue['number']).'/ticket';
334 $resolve = self::activity($owner, $name, 'Resolve', $ticket);
335 if ($issue['state'] !== 'closed') $resolve = self::activity($owner, $name, 'Undo', array_diff_key($resolve, ['@context' => true]));
336 self::send($owner, $name, $resolve, $people);
337 self::send($owner, $name, self::activity($owner, $name, 'Update', self::issueNote($owner, $name, $issue)), $people);
338 }
339
340 public static function issueDeleted(string $owner, string $name, array $meta, array $issue): void {
341 if (!self::enabled($meta)) return;
342 $id = self::issueUrl($owner, $name, $issue['number']);
343 self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue));
344 }
345
346 /** ローカルのコメント(リモートのコメントは書いた人が送っている) */
347 public static function commentAdded(string $owner, string $name, array $meta, array $issue, array $c, string $type = 'Create'): void {
348 if (!self::enabled($meta) || isset($c['author']['actor'])) return;
349 self::send($owner, $name, self::activity($owner, $name, $type, self::commentNote($owner, $name, $issue, $c)), self::participants($owner, $name, $issue));
350 }
351
352 public static function commentDeleted(string $owner, string $name, array $meta, array $issue, array $c): void {
353 if (!self::enabled($meta) || isset($c['author']['actor'])) return;
354 $id = self::issueUrl($owner, $name, $issue['number']).'/comments/'.$c['id'];
355 self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue));
356 }
357
358 //////// 受け取る
359
360 /**
361 * リポジトリの受付ボックスに届いたアクティビティ(署名は検証済み)
362 *
363 * @return array [HTTPステータス, 本文]
364 */
365 public static function receive(string $owner, string $name, array $a): array {
366 $meta = Gitler::meta($owner, $name);
367 if (!self::enabled($meta)) return [404, ['error' => '見つかりません']];
368 $actor = Fedi::id($a['actor'] ?? null);
369 if ($actor === null || Fedi::isLocal($actor)) return [403, ['error' => '不正なactor']];
370 if (self::isBlocked($owner, $name, $actor)) return [202, ['status' => '無視しました']];
371 // アーカイブしたリポジトリはフォローだけ
372 if ($meta['archived'] && !in_array($a['type'] ?? '', ['Follow', 'Undo'], true)) return [202, ['status' => 'アーカイブされています']];
373 try {
374 return match ((string)($a['type'] ?? '')) {
375 'Follow' => self::onFollow($owner, $name, $a, $actor),
376 'Undo' => self::onUndo($owner, $name, $meta, $a, $actor),
377 'Create' => self::onCreate($owner, $name, $meta, $a, $actor),
378 'Offer' => self::onOffer($owner, $name, $meta, $a, $actor),
379 'Update' => self::onUpdate($owner, $name, $a, $actor),
380 'Delete' => self::onDelete($owner, $name, $a, $actor),
381 'Resolve' => self::onResolve($owner, $name, $meta, Fedi::id($a['object'] ?? null), $actor, false),
382 default => [202, ['status' => '未対応なアクティビティタイプ']],
383 };
384 } catch (\Throwable $e) {
385 logger(\LogType::ActivityPub, "Gitlerの受付ボックスの処理に失敗({$owner}/{$name}):".$e->getMessage());
386 return [500, ['error' => '処理に失敗']];
387 }
388 }
389
390 private static function onFollow(string $owner, string $name, array $a, string $actor): array {
391 if (Fedi::id($a['object'] ?? null) !== self::actorUrl($owner, $name)) return [202, ['status' => '無視しました']];
392 $doc = Remote::actor($actor);
393 $inbox = $doc ? Remote::inbox($doc, false) : null;
394 if ($inbox === null) return [202, ['status' => '無視しました']];
395 self::updateJson($owner, $name, 'followers.json', fn(array $l) => [$actor => ['inbox' => $inbox, 'sharedInbox' => Remote::inbox($doc, true), 'since' => time()]] + $l);
396 $me = self::actorUrl($owner, $name);
397 Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$me}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $me, 'object' => $a], [$inbox]);
398 return [202, ['status' => 'OK']];
399 }
400
401 private static function onUndo(string $owner, string $name, array $meta, array $a, string $actor): array {
402 $inner = $a['object'] ?? null;
403 if (!is_array($inner) || Fedi::id($inner['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']];
404 if (($inner['type'] ?? '') === 'Follow') {
405 self::updateJson($owner, $name, 'followers.json', function(array $l) use ($actor): array {
406 unset($l[$actor]);
407 return $l;
408 });
409 return [202, ['status' => 'OK']];
410 }
411 if (($inner['type'] ?? '') === 'Resolve' && !$meta['archived']) return self::onResolve($owner, $name, $meta, Fedi::id($inner['object'] ?? null), $actor, true);
412 return [202, ['status' => '無視しました']];
413 }
414
415 private static function onCreate(string $owner, string $name, array $meta, array $a, string $actor): array {
416 $obj = $a['object'] ?? null;
417 if (is_string($obj)) $obj = Remote::object($obj);
418 if (!is_array($obj) || !self::isType($obj, ['Note', 'Article', 'Page', 'Question'])) return [202, ['status' => '無視しました']];
419 $id = Fedi::id($obj['id'] ?? null);
420 if (Fedi::id($obj['attributedTo'] ?? null) !== $actor || $id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) return [403, ['error' => '作者が違います']];
421 $issues = Issues::open($owner, $name);
422 $author = self::remoteAuthor($actor);
423 $html = self::cleanHtml($owner, $name, (string)($obj['content'] ?? ''));
424 $parent = Fedi::id($obj['inReplyTo'] ?? null);
425
426 // イシューかコメントへの返信はコメント
427 if ($parent !== null) {
428 $n = self::issueOf($owner, $name, $issues, $parent);
429 if ($n === null) return [202, ['status' => '無視しました']];
430 $res = $html !== '' ? $issues->comment($n, $author, $html, ['html' => true, 'remoteId' => $id]) : null;
431 if ($res?->isSuccess) Notify::commented($owner, $name, $meta, $issues, $res->data['issue'], $res->data['comment']);
432 return [202, ['status' => 'OK']];
433 }
434
435 // リポジトリへのメンションは新しいイシュー(題名は名前又は最初の行)
436 $mentioned = false;
437 foreach ((array)($obj['tag'] ?? []) as $t) if (is_array($t) && ($t['type'] ?? '') === 'Mention' && ($t['href'] ?? '') === self::actorUrl($owner, $name)) $mentioned = true;
438 if (!$mentioned) return [202, ['status' => '無視しました']];
439 $title = is_string($obj['name'] ?? null) ? $obj['name'] : '';
440 if ($title === '') {
441 $text = trim(preg_replace('/@'.preg_quote(self::handle($owner, $name), '/').'(@[A-Za-z0-9.-]+)?/i', '', Html::text($html)));
442 $title = trim(strtok($text, "\n") ?: '');
443 if (mb_strlen($title) > 80) $title = mb_substr($title, 0, 79).'…';
444 }
445 if ($title === '') return [202, ['status' => '無視しました']];
446 $res = $issues->create($author, $title, $html, ['html' => true, 'remoteId' => $id]);
447 if ($res->isSuccess) {
448 self::issueOpened($owner, $name, $meta, $res->data['issue']);
449 Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']);
450 }
451 return [202, ['status' => 'OK']];
452 }
453
454 /** ForgeFed:Offer{Ticket}でイシューを作り、Acceptを返す */
455 private static function onOffer(string $owner, string $name, array $meta, array $a, string $actor): array {
456 $t = $a['object'] ?? null;
457 $repo = self::actorUrl($owner, $name);
458 if (!is_array($t) || !self::isType($t, ['Ticket'])) return [202, ['status' => '無視しました']];
459 if (Fedi::id($a['target'] ?? $t['context'] ?? null) !== $repo) return [202, ['status' => '無視しました']];
460 if (isset($t['attributedTo']) && Fedi::id($t['attributedTo']) !== $actor) return [403, ['error' => '作者が違います']];
461 $title = html_entity_decode(strip_tags((string)($t['name'] ?? $t['summary'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8');
462 $src = is_array($t['source'] ?? null) ? $t['source'] : [];
463 $markdown = ($src['mediaType'] ?? '') === 'text/markdown' && is_string($src['content'] ?? null);
464 $body = $markdown ? $src['content'] : self::cleanHtml($owner, $name, (string)($t['content'] ?? ''));
465 $remoteId = Fedi::id($t['id'] ?? null) ?? Fedi::id($a['id'] ?? null);
466 $issues = Issues::open($owner, $name);
467 $res = $issues->create(self::remoteAuthor($actor), $title, $body, ['html' => !$markdown, 'remoteId' => $remoteId]);
468 $n = (int)($res->data['number'] ?? 0);
469 if ($n > 0) {
470 $doc = Remote::actor($actor);
471 $inbox = $doc ? Remote::inbox($doc, false) : null;
472 if ($inbox !== null) {
473 Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$repo}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $repo,
474 'object' => $a, 'result' => self::issueUrl($owner, $name, $n).'/ticket'], [$inbox]);
475 }
476 if ($res->isSuccess) {
477 self::issueOpened($owner, $name, $meta, $res->data['issue']);
478 Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']);
479 }
480 }
481 return [202, ['status' => 'OK']];
482 }
483
484 private static function onUpdate(string $owner, string $name, array $a, string $actor): array {
485 $obj = $a['object'] ?? null;
486 if (!is_array($obj)) return [202, ['status' => '無視しました']];
487 if (Remote::isActor($obj)) {
488 if (($obj['id'] ?? null) === $actor) Remote::cacheActor($obj);
489 return [202, ['status' => 'OK']];
490 }
491 $id = Fedi::id($obj['id'] ?? null);
492 $issues = Issues::open($owner, $name);
493 $found = $id !== null ? $issues->findRemote($id) : null;
494 if ($found === null) return [202, ['status' => '無視しました']];
495 [$n, $cid] = $found;
496 $html = self::cleanHtml($owner, $name, (string)($obj['content'] ?? ''));
497 $issues->update($n, function(array $i) use ($cid, $actor, $html): ?array {
498 if ($cid === null) {
499 if (($i['author']['actor'] ?? null) !== $actor || empty($i['html'])) return null;
500 $i['body'] = $html;
501 $i['edited'] = time();
502 return $i;
503 }
504 foreach ($i['comments'] as $k => $c) {
505 if ($c['id'] !== $cid || ($c['author']['actor'] ?? null) !== $actor) continue;
506 $i['comments'][$k]['body'] = $html;
507 $i['comments'][$k]['updated'] = time();
508 return $i;
509 }
510 return null;
511 });
512 return [202, ['status' => 'OK']];
513 }
514
515 private static function onDelete(string $owner, string $name, array $a, string $actor): array {
516 $id = Fedi::id($a['object'] ?? null);
517 if ($id === $actor) {
518 // アカウントの削除
519 self::updateJson($owner, $name, 'followers.json', function(array $l) use ($actor): array {
520 unset($l[$actor]);
521 return $l;
522 });
523 return [202, ['status' => 'OK']];
524 }
525 $issues = Issues::open($owner, $name);
526 $found = $id !== null ? $issues->findRemote($id) : null;
527 if ($found === null) return [202, ['status' => '無視しました']];
528 [$n, $cid] = $found;
529 $issues->update($n, function(array $i) use ($cid, $actor): ?array {
530 if ($cid === null) {
531 // イシューは残し、本文だけ消す
532 if (($i['author']['actor'] ?? null) !== $actor) return null;
533 $i['body'] = '<p>(削除されました)</p>';
534 $i['html'] = true;
535 return $i;
536 }
537 foreach ($i['comments'] as $k => $c) {
538 if ($c['id'] !== $cid || ($c['author']['actor'] ?? null) !== $actor) continue;
539 array_splice($i['comments'], $k, 1);
540 return $i;
541 }
542 return null;
543 });
544 return [202, ['status' => 'OK']];
545 }
546
547 /** ForgeFedのResolve(イシューを作ったリモートの人だけが閉じられる) */
548 private static function onResolve(string $owner, string $name, array $meta, ?string $object, string $actor, bool $undo): array {
549 $issues = Issues::open($owner, $name);
550 $n = $object !== null ? self::issueOf($owner, $name, $issues, $object) : null;
551 $issue = $n !== null ? $issues->get($n) : null;
552 if ($issue === null || ($issue['author']['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']];
553 $by = self::remoteAuthor($actor);
554 $new = $issues->setState($n, $undo, $by);
555 if ($new !== null) {
556 self::stateChanged($owner, $name, $meta, $new);
557 Notify::stateChanged($owner, $name, $meta, $new, $by);
558 }
559 return [202, ['status' => 'OK']];
560 }
561
562 //////// 内部
563
564 /** 書いた者の表示名 */
565 public static function authorName(array $a): string {
566 if (isset($a['user'])) return (string)$a['user'];
567 if (isset($a['actor'])) return (string)(($a['name'] ?? '') !== '' ? $a['name'] : ($a['handle'] ?? $a['actor']));
568 return (string)($a['name'] ?? '');
569 }
570
571 /** リモートの書いた者(アクターを取得して名前とアイコンを残す) */
572 public static function remoteAuthor(string $actor): array {
573 $doc = Remote::actor($actor) ?? [];
574 $icon = $doc['icon'] ?? null;
575 if (is_array($icon) && array_is_list($icon)) $icon = $icon[0] ?? null;
576 $url = Fedi::id($doc['url'] ?? null) ?? $actor;
577 return ['actor' => $actor, 'name' => mb_substr(strip_tags((string)($doc['name'] ?? '')), 0, 100), 'handle' => $doc ? Remote::handle($doc) : $actor,
578 'icon' => is_array($icon) && is_string($icon['url'] ?? null) ? $icon['url'] : '', 'url' => preg_match('#^https?://#i', $url) ? $url : $actor];
579 }
580
581 /** ForgeFedのattributedTo(Fediverseを使えるローカルのユーザーはそのアクター) */
582 private static function authorActor(string $owner, string $name, array $a): string {
583 if (isset($a['actor'])) return (string)$a['actor'];
584 if (isset($a['user']) && Fedi::localActor((string)$a['user']) !== null) return Fedi::actorUrl((string)$a['user']);
585 return self::actorUrl($owner, $name);
586 }
587
588 /** イシューに関わったリモートの人(作った人とコメントした人) */
589 private static function participants(string $owner, string $name, array $issue): array {
590 $out = [];
591 foreach (array_merge([$issue['author']], array_column($issue['comments'], 'author')) as $a) {
592 if (isset($a['actor']) && !self::isBlocked($owner, $name, $a['actor'])) $out[$a['actor']] = true;
593 }
594 return array_keys($out);
595 }
596
597 private static function mentionTags(array $actors): array {
598 $tags = [];
599 foreach ($actors as $url) {
600 $doc = Remote::cachedActor($url);
601 $tags[] = ['type' => 'Mention', 'href' => $url, 'name' => $doc ? Remote::handle($doc) : $url];
602 }
603 return $tags;
604 }
605
606 /** 本文のHTML(Markdownは描画して安全に、リンクは絶対URLに) */
607 private static function bodyHtml(string $owner, string $name, string $body, bool $html): string {
608 if ($html) return $body;
609 $issues = self::actorUrl($owner, $name).'/issues';
610 $out = Issues::render($body, $issues);
611 $out = (string)preg_replace('/\shref="\/(?!\/)/', ' href="'.Fedi::base().'/', $out);
612 // 整形の改行と空の段落を外す(Mastodon等で空行にならない様に)
613 $out = (string)preg_replace(['~>\s*\n\s*<~', '~(<p>|<li>)\s+~', '~\s+(</p>|</li>)~'], ['><', '$1', '$1'], Html::sanitize($out));
614 return trim((string)preg_replace('~<p></p>~', '', $out));
615 }
616
617 /** 受け取ったHTMLを安全にして、リポジトリへのメンションを外す */
618 private static function cleanHtml(string $owner, string $name, string $html): string {
619 $html = Html::sanitize($html);
620 $url = preg_quote(Html::esc(self::actorUrl($owner, $name)), '~');
621 $html = (string)preg_replace(['~<span class="h-card">\s*<a href="'.$url.'"[^>]*>.*?</a>\s*</span>\s*~s', '~<a href="'.$url.'"[^>]*>.*?</a>\s*~s'], '', $html);
622 return trim((string)preg_replace('~<p>\s*</p>~', '', $html));
623 }
624
625 /** オブジェクトのIDがこのリポジトリのどのイシューか */
626 private static function issueOf(string $owner, string $name, Issues $issues, string $id): ?int {
627 $base = self::actorUrl($owner, $name).'/issues/';
628 if (str_starts_with($id, $base) && preg_match('#^(\d+)(?:/ticket|/comments/\d+)?$#', substr($id, strlen($base)), $m)) {
629 return $issues->get((int)$m[1]) !== null ? (int)$m[1] : null;
630 }
631 return $issues->findRemote($id)[0] ?? null;
632 }
633
634 private static function isType(array $obj, array $types): bool {
635 foreach ((array)($obj['type'] ?? []) as $t) if (in_array($t, $types, true)) return true;
636 return false;
637 }
638
639 private static function activity(string $owner, string $name, string $type, array|string $object): array {
640 $me = self::actorUrl($owner, $name);
641 $obj = is_array($object) ? $object : null;
642 return ['@context' => self::CONTEXT, 'id' => "{$me}#activities/".Fedi::newId(), 'type' => $type, 'actor' => $me,
643 'to' => $obj['to'] ?? [Fedi::PUBLIC], 'cc' => $obj['cc'] ?? ["{$me}/followers"], 'object' => $object];
644 }
645
646 /** フォロワーと関わった人に送る(SSHのプッシュではリポジトリに溜める) */
647 private static function send(string $owner, string $name, array $activity, array $extra): void {
648 if (Gitler::$deferred) {
649 Gitler::defer($owner, $name, ['kind' => 'federate', 'activity' => $activity, 'extra' => $extra]);
650 return;
651 }
652 self::deliver($owner, $name, $activity, $extra);
653 }
654
655 /** Deliveryの待ち行列に入れる */
656 public static function deliver(string $owner, string $name, array $activity, array $extra): void {
657 $inboxes = [];
658 foreach (self::followers($owner, $name) as $f) {
659 $inbox = $f['sharedInbox'] ?? $f['inbox'] ?? null;
660 if (is_string($inbox)) $inboxes[] = $inbox;
661 }
662 foreach ($extra as $actor) {
663 $doc = Remote::actor($actor);
664 $inbox = $doc ? Remote::inbox($doc) : null;
665 if ($inbox !== null) $inboxes[] = $inbox;
666 }
667 if (!empty($inboxes)) Delivery::enqueue('gitler:'.$owner.'/'.$name, $activity, $inboxes);
668 }
669
670 private static function read(string $owner, string $name, string $file): array {
671 $j = json_decode((string)@file_get_contents(Gitler::path($owner, $name).'/gitler/'.$file), true);
672 return is_array($j) ? $j : [];
673 }
674
675 private static function updateJson(string $owner, string $name, string $file, callable $fn): void {
676 $repo = Gitler::open($owner, $name);
677 $path = $repo->path.'/gitler/'.$file;
678 $repo->mkdir(dirname($path));
679 $lock = @fopen($path.'.lock', 'c');
680 if ($lock) flock($lock, LOCK_EX);
681 try {
682 $repo->writeFile($path, (string)json_encode($fn(self::read($owner, $name, $file)), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
683 } finally {
684 if ($lock) {
685 flock($lock, LOCK_UN);
686 fclose($lock);
687 }
688 }
689 }
690}