'https://forgefed.org/ns#', 'Repository' => 'forgefed:Repository', 'Ticket' => 'forgefed:Ticket', 'Resolve' => 'forgefed:Resolve', 'isResolved' => 'forgefed:isResolved', 'cloneUri' => ['@id' => 'forgefed:cloneUri', '@type' => '@id'], 'ticketsTrackedBy' => ['@id' => 'forgefed:ticketsTrackedBy', '@type' => '@id'], 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable', ], ]; /** このリポジトリがFediverseに出るか */ public static function enabled(array $meta): bool { return Fedi::enabled() && empty($meta['private']) && ($meta['issues'] ?? true) && ($meta['federate'] ?? true); } public static function actorUrl(string $owner, string $name): string { return Fedi::base().'/gitler/'.$owner.'/'.rawurlencode($name); } public static function issueUrl(string $owner, string $name, int $n): string { return self::actorUrl($owner, $name).'/issues/'.$n; } /** ブラウザで見るページ(プルリクエストは…/pulls/3) */ public static function pageUrl(string $owner, string $name, array $issue): string { return self::actorUrl($owner, $name).(isset($issue['pull']) ? '/pulls/' : '/issues/').$issue['number']; } /** 「所有者.名前」(ユーザー名には「.」が無いので重ならない) */ public static function handle(string $owner, string $name): string { return $owner.'.'.$name; } //////// アクター /** * WebFinger(acct:所有者.名前@ホスト又はアクターのURL) * * @return array|null JRD、このリポジトリでなければnull */ public static function webfinger(string $resource): ?array { $found = null; if (preg_match('/^(?:acct:)?@?([A-Za-z0-9]+)\.([A-Za-z0-9._-]+)@(.+)$/', $resource, $m) && strtolower($m[3]) === strtolower(Fedi::host())) { $found = self::find($m[1], $m[2]); } else if (preg_match('#^'.preg_quote(Fedi::base(), '#').'/gitler/([A-Za-z0-9]+)/([^/?\#]+)$#', $resource, $m)) { $found = self::find($m[1], rawurldecode($m[2])); } if ($found === null) return null; [$owner, $name] = $found; $url = self::actorUrl($owner, $name); return [ 'subject' => 'acct:'.self::handle($owner, $name).'@'.Fedi::host(), 'aliases' => [$url], 'links' => [ ['rel' => 'self', 'type' => 'application/activity+json', 'href' => $url], ['rel' => 'http://webfinger.net/rel/profile-page', 'type' => 'text/html', 'href' => $url], ], ]; } /** 大文字・小文字を区別せずにFediverseに出るリポジトリを探す([所有者, 名前]) */ public static function find(string $owner, string $name): ?array { foreach (Gitler::list() as $r) { if (strcasecmp($r['owner'], $owner) === 0 && strcasecmp($r['name'], $name) === 0) return self::enabled($r['meta']) ? [$r['owner'], $r['name']] : null; } return null; } /** アクターのJSON-LD */ public static function document(string $owner, string $name, array $meta, string $cloneUrl): array { $url = self::actorUrl($owner, $name); $desc = (string)($meta['description'] ?? ''); $doc = [ '@context' => self::CONTEXT, 'id' => $url, // Mastodon等がフォロー出来る種類(ForgeFedのRepositoryはアカウントとして扱われない) 'type' => 'Service', 'preferredUsername' => self::handle($owner, $name), 'name' => "{$owner}/{$name}", 'summary' => ($desc !== '' ? '

'.Html::esc($desc).'

' : '').($meta['archived'] ?? false ? '

アーカイブされたリポジトリです(読むだけ)。

' : '

Gitlerのリポジトリ。フォローするとイシューが届き、返信するとコメント出来ます。メンションすると新しいイシューになります。

'), 'url' => $url, 'inbox' => "{$url}/inbox", 'outbox' => "{$url}/outbox", 'followers' => "{$url}/followers", 'manuallyApprovesFollowers' => false, 'discoverable' => true, 'published' => Fedi::now((int)($meta['created'] ?? 0) ?: time()), 'cloneUri' => $cloneUrl, 'ticketsTrackedBy' => $url, ]; $u = Gitler::user($owner); if ($u !== null) { $avatar = avatar_url($u); $doc['icon'] = ['type' => 'Image', 'url' => preg_match('#^https?://#i', $avatar) ? $avatar : Fedi::base().$avatar]; } $key = self::key($owner, $name); if ($key !== null) $doc['publicKey'] = ['id' => "{$url}#main-key", 'owner' => $url, 'publicKeyPem' => $key->publicPem()]; return $doc; } /** リポジトリの秘密鍵(無ければ作る、GMPが必要) */ public static function key(string $owner, string $name): ?Rsa { if (!function_exists('gmp_init') || !Gitler::exists($owner, $name)) return null; $repo = Gitler::open($owner, $name); $file = $repo->path.'/gitler/private.pem'; if (!is_file($file)) { $repo->mkdir(dirname($file)); $lock = @fopen($file.'.lock', 'c'); if ($lock) flock($lock, LOCK_EX); try { if (!is_file($file)) { $key = Rsa::generate(2048); $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; file_put_contents($tmp, $key->toPem()); @chmod($tmp, 0600); rename($tmp, $file); } } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } @unlink($file.'.lock'); } } try { $key = Rsa::fromPem((string)@file_get_contents($file)); return $key->isPrivate() ? $key : null; } catch (\InvalidArgumentException) { return null; } } /** * 配信の署名(Deliveryの「gitler:所有者/名前」) * * @return array|null [秘密鍵, keyId] */ public static function signer(string $repo): ?array { [$owner, $name] = array_pad(explode('/', $repo, 2), 2, ''); if (!Gitler::validOwner($owner) || !Gitler::validName($name)) return null; $key = self::key($owner, $name); return $key === null ? null : [$key, self::actorUrl($owner, $name).'#main-key']; } //////// フォロワーとブロック /** フォロワー(アクターのURL => inbox、sharedInbox、since) */ public static function followers(string $owner, string $name): array { return self::read($owner, $name, 'followers.json'); } /** ブロック(actors、domains) */ public static function blocks(string $owner, string $name): array { return self::read($owner, $name, 'blocks.json') + ['actors' => [], 'domains' => []]; } public static function isBlocked(string $owner, string $name, string $actor): bool { if (Actor::isDomainBlocked($actor)) return true; $b = self::blocks($owner, $name); $d = Fedi::domainOf($actor); if (in_array($actor, $b['actors'], true)) return true; foreach ($b['domains'] as $x) if ($d === $x || str_ends_with($d, '.'.$x)) return true; return false; } /** アクター(URL、@user@host)又はドメインをブロックする・外す */ public static function block(string $owner, string $name, string $target, bool $block = true): \Result { $target = trim($target); $isDomain = false; if (preg_match('/^@?([^@\s\/]+)@([^@\s\/]+)$/', $target, $m)) { $target = Remote::webfinger($m[1], $m[2]) ?? ''; if ($target === '') return \Result::Error('アクターが見つかりません。'); } else if (preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $target)) { $isDomain = true; } else if (!preg_match('#^https?://#i', $target)) { return \Result::Error('「@ユーザー@ホスト」、アクターのURL又はドメインを入力して下さい。'); } self::updateJson($owner, $name, 'blocks.json', function(array $b) use ($target, $isDomain, $block): array { $b += ['actors' => [], 'domains' => []]; $key = $isDomain ? 'domains' : 'actors'; $v = $isDomain ? strtolower($target) : $target; $b[$key] = array_values(array_diff($b[$key], [$v])); if ($block) $b[$key][] = $v; return $b; }); if ($block && !$isDomain) self::updateJson($owner, $name, 'followers.json', function(array $l) use ($target): array { unset($l[$target]); return $l; }); return \Result::Success($block ? 'ブロックしました。' : 'ブロックを外しました。'); } //////// オブジェクト /** イシューのNote(フォロワーのタイムラインに出る物) */ public static function issueNote(string $owner, string $name, array $issue): array { $url = self::issueUrl($owner, $name, $issue['number']); $repo = self::actorUrl($owner, $name); $closed = $issue['state'] === 'closed'; $pull = isset($issue['pull']); $page = self::pageUrl($owner, $name, $issue); $state = !empty($issue['pull']['merged']) ? '【マージ済み】' : ($closed ? ($pull ? '【閉じた】' : '【解決済み】') : ''); $content = '

'.$state.'#'.$issue['number'].' '.Html::esc($issue['title']).'

' .'

'.Html::esc(self::authorName($issue['author'])).'さんが'.Html::esc("{$owner}/{$name}").'に'.($pull ? 'プルリクエスト' : 'イシュー').'を作りました。

' .self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html'])); $note = [ 'id' => $url, 'type' => 'Note', 'attributedTo' => $repo, 'context' => "{$url}/ticket", 'url' => $page, 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], self::participants($owner, $name, $issue)), 'published' => Fedi::now($issue['created']), 'content' => $content, 'mediaType' => 'text/html', 'tag' => self::mentionTags(self::participants($owner, $name, $issue)), ]; if (!empty($issue['edited']) || $closed) $note['updated'] = Fedi::now($issue['updated']); return $note; } /** ForgeFedのTicket */ public static function ticket(string $owner, string $name, array $issue): array { $url = self::issueUrl($owner, $name, $issue['number']); $t = [ '@context' => self::CONTEXT, 'id' => "{$url}/ticket", 'type' => 'Ticket', 'attributedTo' => self::authorActor($owner, $name, $issue['author']), 'context' => self::actorUrl($owner, $name), 'name' => $issue['title'], 'summary' => Html::esc($issue['title']), 'content' => self::bodyHtml($owner, $name, $issue['body'], !empty($issue['html'])), 'mediaType' => 'text/html', 'published' => Fedi::now($issue['created']), 'updated' => Fedi::now($issue['updated']), 'isResolved' => $issue['state'] === 'closed', 'url' => self::pageUrl($owner, $name, $issue), ]; if (empty($issue['html'])) $t['source'] = ['content' => $issue['body'], 'mediaType' => 'text/markdown']; return $t; } /** コメントのNote */ public static function commentNote(string $owner, string $name, array $issue, array $c): array { $url = self::issueUrl($owner, $name, $issue['number']); $repo = self::actorUrl($owner, $name); $people = self::participants($owner, $name, $issue); $note = [ 'id' => "{$url}/comments/{$c['id']}", 'type' => 'Note', 'attributedTo' => $repo, 'inReplyTo' => $url, 'context' => "{$url}/ticket", 'url' => self::pageUrl($owner, $name, $issue)."#c{$c['id']}", 'to' => [Fedi::PUBLIC], 'cc' => array_merge(["{$repo}/followers"], $people), 'published' => Fedi::now($c['created']), 'mediaType' => 'text/html', 'tag' => self::mentionTags($people), 'content' => '

#'.$issue['number'].' '.Html::esc(self::authorName($c['author'])).':

' .self::bodyHtml($owner, $name, $c['body'], !empty($c['html'])), ]; if ($c['updated'] > $c['created']) $note['updated'] = Fedi::now($c['updated']); return $note; } //////// 送る public static function issueOpened(string $owner, string $name, array $meta, array $issue): void { if (!self::enabled($meta)) return; self::send($owner, $name, self::activity($owner, $name, 'Create', self::issueNote($owner, $name, $issue)), self::participants($owner, $name, $issue)); } public static function issueEdited(string $owner, string $name, array $meta, array $issue): void { if (!self::enabled($meta)) return; self::send($owner, $name, self::activity($owner, $name, 'Update', self::issueNote($owner, $name, $issue)), self::participants($owner, $name, $issue)); } /** 閉じた・開いた(ForgeFedのResolve、MastodonにはNoteの更新) */ public static function stateChanged(string $owner, string $name, array $meta, array $issue): void { if (!self::enabled($meta)) return; $people = self::participants($owner, $name, $issue); $ticket = self::issueUrl($owner, $name, $issue['number']).'/ticket'; $resolve = self::activity($owner, $name, 'Resolve', $ticket); if ($issue['state'] !== 'closed') $resolve = self::activity($owner, $name, 'Undo', array_diff_key($resolve, ['@context' => true])); self::send($owner, $name, $resolve, $people); self::send($owner, $name, self::activity($owner, $name, 'Update', self::issueNote($owner, $name, $issue)), $people); } public static function issueDeleted(string $owner, string $name, array $meta, array $issue): void { if (!self::enabled($meta)) return; $id = self::issueUrl($owner, $name, $issue['number']); self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue)); } /** ローカルのコメント(リモートのコメントは書いた人が送っている) */ public static function commentAdded(string $owner, string $name, array $meta, array $issue, array $c, string $type = 'Create'): void { if (!self::enabled($meta) || isset($c['author']['actor'])) return; self::send($owner, $name, self::activity($owner, $name, $type, self::commentNote($owner, $name, $issue, $c)), self::participants($owner, $name, $issue)); } public static function commentDeleted(string $owner, string $name, array $meta, array $issue, array $c): void { if (!self::enabled($meta) || isset($c['author']['actor'])) return; $id = self::issueUrl($owner, $name, $issue['number']).'/comments/'.$c['id']; self::send($owner, $name, self::activity($owner, $name, 'Delete', ['id' => $id, 'type' => 'Tombstone']), self::participants($owner, $name, $issue)); } //////// 受け取る /** * リポジトリの受付ボックスに届いたアクティビティ(署名は検証済み) * * @return array [HTTPステータス, 本文] */ public static function receive(string $owner, string $name, array $a): array { $meta = Gitler::meta($owner, $name); if (!self::enabled($meta)) return [404, ['error' => '見つかりません']]; $actor = Fedi::id($a['actor'] ?? null); if ($actor === null || Fedi::isLocal($actor)) return [403, ['error' => '不正なactor']]; if (self::isBlocked($owner, $name, $actor)) return [202, ['status' => '無視しました']]; // アーカイブしたリポジトリはフォローだけ if ($meta['archived'] && !in_array($a['type'] ?? '', ['Follow', 'Undo'], true)) return [202, ['status' => 'アーカイブされています']]; try { return match ((string)($a['type'] ?? '')) { 'Follow' => self::onFollow($owner, $name, $a, $actor), 'Undo' => self::onUndo($owner, $name, $meta, $a, $actor), 'Create' => self::onCreate($owner, $name, $meta, $a, $actor), 'Offer' => self::onOffer($owner, $name, $meta, $a, $actor), 'Update' => self::onUpdate($owner, $name, $a, $actor), 'Delete' => self::onDelete($owner, $name, $a, $actor), 'Resolve' => self::onResolve($owner, $name, $meta, Fedi::id($a['object'] ?? null), $actor, false), default => [202, ['status' => '未対応なアクティビティタイプ']], }; } catch (\Throwable $e) { logger(\LogType::ActivityPub, "Gitlerの受付ボックスの処理に失敗({$owner}/{$name}):".$e->getMessage()); return [500, ['error' => '処理に失敗']]; } } private static function onFollow(string $owner, string $name, array $a, string $actor): array { if (Fedi::id($a['object'] ?? null) !== self::actorUrl($owner, $name)) return [202, ['status' => '無視しました']]; $doc = Remote::actor($actor); $inbox = $doc ? Remote::inbox($doc, false) : null; if ($inbox === null) return [202, ['status' => '無視しました']]; self::updateJson($owner, $name, 'followers.json', fn(array $l) => [$actor => ['inbox' => $inbox, 'sharedInbox' => Remote::inbox($doc, true), 'since' => time()]] + $l); $me = self::actorUrl($owner, $name); Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$me}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $me, 'object' => $a], [$inbox]); return [202, ['status' => 'OK']]; } private static function onUndo(string $owner, string $name, array $meta, array $a, string $actor): array { $inner = $a['object'] ?? null; if (!is_array($inner) || Fedi::id($inner['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']]; if (($inner['type'] ?? '') === 'Follow') { self::updateJson($owner, $name, 'followers.json', function(array $l) use ($actor): array { unset($l[$actor]); return $l; }); return [202, ['status' => 'OK']]; } if (($inner['type'] ?? '') === 'Resolve' && !$meta['archived']) return self::onResolve($owner, $name, $meta, Fedi::id($inner['object'] ?? null), $actor, true); return [202, ['status' => '無視しました']]; } private static function onCreate(string $owner, string $name, array $meta, array $a, string $actor): array { $obj = $a['object'] ?? null; if (is_string($obj)) $obj = Remote::object($obj); if (!is_array($obj) || !self::isType($obj, ['Note', 'Article', 'Page', 'Question'])) return [202, ['status' => '無視しました']]; $id = Fedi::id($obj['id'] ?? null); if (Fedi::id($obj['attributedTo'] ?? null) !== $actor || $id === null || Fedi::domainOf($id) !== Fedi::domainOf($actor)) return [403, ['error' => '作者が違います']]; $issues = Issues::open($owner, $name); $author = self::remoteAuthor($actor); $html = self::cleanHtml($owner, $name, (string)($obj['content'] ?? '')); $parent = Fedi::id($obj['inReplyTo'] ?? null); // イシューかコメントへの返信はコメント if ($parent !== null) { $n = self::issueOf($owner, $name, $issues, $parent); if ($n === null) return [202, ['status' => '無視しました']]; $res = $html !== '' ? $issues->comment($n, $author, $html, ['html' => true, 'remoteId' => $id]) : null; if ($res?->isSuccess) Notify::commented($owner, $name, $meta, $issues, $res->data['issue'], $res->data['comment']); return [202, ['status' => 'OK']]; } // リポジトリへのメンションは新しいイシュー(題名は名前又は最初の行) $mentioned = false; foreach ((array)($obj['tag'] ?? []) as $t) if (is_array($t) && ($t['type'] ?? '') === 'Mention' && ($t['href'] ?? '') === self::actorUrl($owner, $name)) $mentioned = true; if (!$mentioned) return [202, ['status' => '無視しました']]; $title = is_string($obj['name'] ?? null) ? $obj['name'] : ''; if ($title === '') { $text = trim(preg_replace('/@'.preg_quote(self::handle($owner, $name), '/').'(@[A-Za-z0-9.-]+)?/i', '', Html::text($html))); $title = trim(strtok($text, "\n") ?: ''); if (mb_strlen($title) > 80) $title = mb_substr($title, 0, 79).'…'; } if ($title === '') return [202, ['status' => '無視しました']]; $res = $issues->create($author, $title, $html, ['html' => true, 'remoteId' => $id]); if ($res->isSuccess) { self::issueOpened($owner, $name, $meta, $res->data['issue']); Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']); } return [202, ['status' => 'OK']]; } /** ForgeFed:Offer{Ticket}でイシューを作り、Acceptを返す */ private static function onOffer(string $owner, string $name, array $meta, array $a, string $actor): array { $t = $a['object'] ?? null; $repo = self::actorUrl($owner, $name); if (!is_array($t) || !self::isType($t, ['Ticket'])) return [202, ['status' => '無視しました']]; if (Fedi::id($a['target'] ?? $t['context'] ?? null) !== $repo) return [202, ['status' => '無視しました']]; if (isset($t['attributedTo']) && Fedi::id($t['attributedTo']) !== $actor) return [403, ['error' => '作者が違います']]; $title = html_entity_decode(strip_tags((string)($t['name'] ?? $t['summary'] ?? '')), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $src = is_array($t['source'] ?? null) ? $t['source'] : []; $markdown = ($src['mediaType'] ?? '') === 'text/markdown' && is_string($src['content'] ?? null); $body = $markdown ? $src['content'] : self::cleanHtml($owner, $name, (string)($t['content'] ?? '')); $remoteId = Fedi::id($t['id'] ?? null) ?? Fedi::id($a['id'] ?? null); $issues = Issues::open($owner, $name); $res = $issues->create(self::remoteAuthor($actor), $title, $body, ['html' => !$markdown, 'remoteId' => $remoteId]); $n = (int)($res->data['number'] ?? 0); if ($n > 0) { $doc = Remote::actor($actor); $inbox = $doc ? Remote::inbox($doc, false) : null; if ($inbox !== null) { Delivery::enqueue('gitler:'.$owner.'/'.$name, ['@context' => self::CONTEXT, 'id' => "{$repo}#accepts/".Fedi::newId(), 'type' => 'Accept', 'actor' => $repo, 'object' => $a, 'result' => self::issueUrl($owner, $name, $n).'/ticket'], [$inbox]); } if ($res->isSuccess) { self::issueOpened($owner, $name, $meta, $res->data['issue']); Notify::issueOpened($owner, $name, $meta, $issues, $res->data['issue']); } } return [202, ['status' => 'OK']]; } private static function onUpdate(string $owner, string $name, array $a, string $actor): array { $obj = $a['object'] ?? null; if (!is_array($obj)) return [202, ['status' => '無視しました']]; if (Remote::isActor($obj)) { if (($obj['id'] ?? null) === $actor) Remote::cacheActor($obj); return [202, ['status' => 'OK']]; } $id = Fedi::id($obj['id'] ?? null); $issues = Issues::open($owner, $name); $found = $id !== null ? $issues->findRemote($id) : null; if ($found === null) return [202, ['status' => '無視しました']]; [$n, $cid] = $found; $html = self::cleanHtml($owner, $name, (string)($obj['content'] ?? '')); $issues->update($n, function(array $i) use ($cid, $actor, $html): ?array { if ($cid === null) { if (($i['author']['actor'] ?? null) !== $actor || empty($i['html'])) return null; $i['body'] = $html; $i['edited'] = time(); return $i; } foreach ($i['comments'] as $k => $c) { if ($c['id'] !== $cid || ($c['author']['actor'] ?? null) !== $actor) continue; $i['comments'][$k]['body'] = $html; $i['comments'][$k]['updated'] = time(); return $i; } return null; }); return [202, ['status' => 'OK']]; } private static function onDelete(string $owner, string $name, array $a, string $actor): array { $id = Fedi::id($a['object'] ?? null); if ($id === $actor) { // アカウントの削除 self::updateJson($owner, $name, 'followers.json', function(array $l) use ($actor): array { unset($l[$actor]); return $l; }); return [202, ['status' => 'OK']]; } $issues = Issues::open($owner, $name); $found = $id !== null ? $issues->findRemote($id) : null; if ($found === null) return [202, ['status' => '無視しました']]; [$n, $cid] = $found; $issues->update($n, function(array $i) use ($cid, $actor): ?array { if ($cid === null) { // イシューは残し、本文だけ消す if (($i['author']['actor'] ?? null) !== $actor) return null; $i['body'] = '

(削除されました)

'; $i['html'] = true; return $i; } foreach ($i['comments'] as $k => $c) { if ($c['id'] !== $cid || ($c['author']['actor'] ?? null) !== $actor) continue; array_splice($i['comments'], $k, 1); return $i; } return null; }); return [202, ['status' => 'OK']]; } /** ForgeFedのResolve(イシューを作ったリモートの人だけが閉じられる) */ private static function onResolve(string $owner, string $name, array $meta, ?string $object, string $actor, bool $undo): array { $issues = Issues::open($owner, $name); $n = $object !== null ? self::issueOf($owner, $name, $issues, $object) : null; $issue = $n !== null ? $issues->get($n) : null; if ($issue === null || ($issue['author']['actor'] ?? null) !== $actor) return [202, ['status' => '無視しました']]; $by = self::remoteAuthor($actor); $new = $issues->setState($n, $undo, $by); if ($new !== null) { self::stateChanged($owner, $name, $meta, $new); Notify::stateChanged($owner, $name, $meta, $new, $by); } return [202, ['status' => 'OK']]; } //////// 内部 /** 書いた者の表示名 */ public static function authorName(array $a): string { if (isset($a['user'])) return (string)$a['user']; if (isset($a['actor'])) return (string)(($a['name'] ?? '') !== '' ? $a['name'] : ($a['handle'] ?? $a['actor'])); return (string)($a['name'] ?? ''); } /** リモートの書いた者(アクターを取得して名前とアイコンを残す) */ public static function remoteAuthor(string $actor): array { $doc = Remote::actor($actor) ?? []; $icon = $doc['icon'] ?? null; if (is_array($icon) && array_is_list($icon)) $icon = $icon[0] ?? null; $url = Fedi::id($doc['url'] ?? null) ?? $actor; return ['actor' => $actor, 'name' => mb_substr(strip_tags((string)($doc['name'] ?? '')), 0, 100), 'handle' => $doc ? Remote::handle($doc) : $actor, 'icon' => is_array($icon) && is_string($icon['url'] ?? null) ? $icon['url'] : '', 'url' => preg_match('#^https?://#i', $url) ? $url : $actor]; } /** ForgeFedのattributedTo(Fediverseを使えるローカルのユーザーはそのアクター) */ private static function authorActor(string $owner, string $name, array $a): string { if (isset($a['actor'])) return (string)$a['actor']; if (isset($a['user']) && Fedi::localActor((string)$a['user']) !== null) return Fedi::actorUrl((string)$a['user']); return self::actorUrl($owner, $name); } /** イシューに関わったリモートの人(作った人とコメントした人) */ private static function participants(string $owner, string $name, array $issue): array { $out = []; foreach (array_merge([$issue['author']], array_column($issue['comments'], 'author')) as $a) { if (isset($a['actor']) && !self::isBlocked($owner, $name, $a['actor'])) $out[$a['actor']] = true; } return array_keys($out); } private static function mentionTags(array $actors): array { $tags = []; foreach ($actors as $url) { $doc = Remote::cachedActor($url); $tags[] = ['type' => 'Mention', 'href' => $url, 'name' => $doc ? Remote::handle($doc) : $url]; } return $tags; } /** 本文のHTML(Markdownは描画して安全に、リンクは絶対URLに) */ private static function bodyHtml(string $owner, string $name, string $body, bool $html): string { if ($html) return $body; $issues = self::actorUrl($owner, $name).'/issues'; $out = Issues::render($body, $issues); $out = (string)preg_replace('/\shref="\/(?!\/)/', ' href="'.Fedi::base().'/', $out); // 整形の改行と空の段落を外す(Mastodon等で空行にならない様に) $out = (string)preg_replace(['~>\s*\n\s*<~', '~(

|

  • )\s+~', '~\s+(

    |
  • )~'], ['><', '$1', '$1'], Html::sanitize($out)); return trim((string)preg_replace('~

    ~', '', $out)); } /** 受け取ったHTMLを安全にして、リポジトリへのメンションを外す */ private static function cleanHtml(string $owner, string $name, string $html): string { $html = Html::sanitize($html); $url = preg_quote(Html::esc(self::actorUrl($owner, $name)), '~'); $html = (string)preg_replace(['~\s*]*>.*?\s*\s*~s', '~]*>.*?\s*~s'], '', $html); return trim((string)preg_replace('~

    \s*

    ~', '', $html)); } /** オブジェクトのIDがこのリポジトリのどのイシューか */ private static function issueOf(string $owner, string $name, Issues $issues, string $id): ?int { $base = self::actorUrl($owner, $name).'/issues/'; if (str_starts_with($id, $base) && preg_match('#^(\d+)(?:/ticket|/comments/\d+)?$#', substr($id, strlen($base)), $m)) { return $issues->get((int)$m[1]) !== null ? (int)$m[1] : null; } return $issues->findRemote($id)[0] ?? null; } private static function isType(array $obj, array $types): bool { foreach ((array)($obj['type'] ?? []) as $t) if (in_array($t, $types, true)) return true; return false; } private static function activity(string $owner, string $name, string $type, array|string $object): array { $me = self::actorUrl($owner, $name); $obj = is_array($object) ? $object : null; return ['@context' => self::CONTEXT, 'id' => "{$me}#activities/".Fedi::newId(), 'type' => $type, 'actor' => $me, 'to' => $obj['to'] ?? [Fedi::PUBLIC], 'cc' => $obj['cc'] ?? ["{$me}/followers"], 'object' => $object]; } /** フォロワーと関わった人に送る(SSHのプッシュではリポジトリに溜める) */ private static function send(string $owner, string $name, array $activity, array $extra): void { if (Gitler::$deferred) { Gitler::defer($owner, $name, ['kind' => 'federate', 'activity' => $activity, 'extra' => $extra]); return; } self::deliver($owner, $name, $activity, $extra); } /** Deliveryの待ち行列に入れる */ public static function deliver(string $owner, string $name, array $activity, array $extra): void { $inboxes = []; foreach (self::followers($owner, $name) as $f) { $inbox = $f['sharedInbox'] ?? $f['inbox'] ?? null; if (is_string($inbox)) $inboxes[] = $inbox; } foreach ($extra as $actor) { $doc = Remote::actor($actor); $inbox = $doc ? Remote::inbox($doc) : null; if ($inbox !== null) $inboxes[] = $inbox; } if (!empty($inboxes)) Delivery::enqueue('gitler:'.$owner.'/'.$name, $activity, $inboxes); } private static function read(string $owner, string $name, string $file): array { $j = json_decode((string)@file_get_contents(Gitler::path($owner, $name).'/gitler/'.$file), true); return is_array($j) ? $j : []; } private static function updateJson(string $owner, string $name, string $file, callable $fn): void { $repo = Gitler::open($owner, $name); $path = $repo->path.'/gitler/'.$file; $repo->mkdir(dirname($path)); $lock = @fopen($path.'.lock', 'c'); if ($lock) flock($lock, LOCK_EX); try { $repo->writeFile($path, (string)json_encode($fn(self::read($owner, $name, $file)), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } finally { if ($lock) { flock($lock, LOCK_UN); fclose($lock); } } } }