user = (new Auth())->getLoggedInUser(); } //////// 一覧 /** /gitler:見られるリポジトリ */ public function index(array $params): void { $q = trim((string)($_GET['q'] ?? '')); $list = []; foreach (Hosting::list() as $r) { if (Hosting::access($this->user, $r['owner'], $r['name']) === Hosting::NONE) continue; if ($q !== '' && mb_stripos($r['owner'].'/'.$r['name'].' '.$r['meta']['description'], $q) === false) continue; $list[] = $this->presentRepo($r); } $this->render('gitler/index', 'Gitler', ['list' => $list, 'q' => $q, 'heading' => 'リポジトリ']); } /** /gitler/{owner}:ユーザーのリポジトリ */ public function owner(array $params): void { $owner = (string)($params['owner'] ?? ''); $u = Hosting::validOwner($owner) ? Hosting::user($owner) : null; if ($u === null) $this->notFound(); $list = []; foreach (Hosting::list($owner) as $r) { if (Hosting::access($this->user, $r['owner'], $r['name']) !== Hosting::NONE) $list[] = $this->presentRepo($r); } $this->render('gitler/index', $owner, ['list' => $list, 'q' => '', 'heading' => ($u->displayname ?? '') !== '' ? "{$u->displayname}({$owner})" : $owner, 'ownerPage' => $owner, 'avatar' => avatar_url($u)]); } /** /gitler/-/new:リポジトリを作る */ public function create(array $params): void { if (!$this->user) $this->redirect('/login'); if (!Hosting::userCan($this->user, 'repos')) $this->message('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。'); $error = ''; $branch = (string)Hosting::config()['branch']; $in = ['name' => '', 'description' => '', 'private' => true, 'readme' => false, 'objectFormat' => (string)Hosting::config()['objectFormat'], 'branch' => $branch]; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $in = ['name' => trim((string)($_POST['name'] ?? '')), 'description' => trim((string)($_POST['description'] ?? '')), 'private' => isset($_POST['private']), 'readme' => isset($_POST['readme']), 'objectFormat' => (string)($_POST['objectFormat'] ?? 'sha1'), 'branch' => trim((string)($_POST['branch'] ?? $branch))]; if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else if (mb_strlen($in['description']) > 500) { $error = '説明は500文字までです。'; } else { $res = Hosting::create($this->user, $in['name'], $in); if ($res->isSuccess) $this->redirect('/gitler/'.$res->data['owner'].'/'.rawurlencode($res->data['name'])); $error = (string)$res->message; } } $this->render('gitler/new', '新しいリポジトリ', ['in' => $in, 'error' => $error]); } /** /gitler/-/keys:SSHの鍵とアクセストークン */ public function keys(array $params): void { if (!$this->user) $this->redirect('/login'); $me = (string)$this->user->username; $error = ''; $message = ''; $newToken = null; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $do = (string)($_POST['do'] ?? ''); if ($do === 'addKey') { $res = Keys::addKey($me, (string)($_POST['key'] ?? ''), (string)($_POST['title'] ?? '')); $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message; } else if ($do === 'removeKey') { Keys::removeKey($me, (string)($_POST['fingerprint'] ?? '')) ? $message = '鍵を削除しました。' : $error = '鍵が有りません。'; } else if ($do === 'addToken') { $res = Keys::addToken($me, (string)($_POST['name'] ?? ''), isset($_POST['write'])); if ($res->isSuccess) { $message = (string)$res->message; $newToken = $res->data['token']; } else { $error = (string)$res->message; } } else if ($do === 'removeToken') { Keys::removeToken($me, (string)($_POST['id'] ?? '')) ? $message = 'トークンを削除しました。' : $error = 'トークンが有りません。'; } else if ($do === 'email') { Keys::setEmail($me, isset($_POST['email'])) ? $message = '保存しました。' : $error = '保存出来ません。'; } } } $data = Keys::load($me); $fmt = fn($t) => $t ? date('Y-m-d H:i', (int)$t) : '-'; $this->render('gitler/keys', 'Gitlerの設定', [ 'keys' => array_map(fn($k) => $k + ['addedText' => $fmt($k['added'] ?? null), 'usedText' => $fmt($k['lastUsed'] ?? null)], $data['keys']), 'tokens' => array_map(fn($t) => $t + ['createdText' => $fmt($t['created'] ?? null), 'usedText' => $fmt($t['lastUsed'] ?? null)], $data['tokens']), 'newToken' => $newToken, 'error' => $error, 'message' => $message, 'ssh' => (string)Hosting::config()['ssh'], 'me' => $me, 'twoFactor' => !empty($this->user->twoFactorOn), 'emailOn' => $data['email'], 'mailer' => MAILER_ENABLED, 'email' => (string)($this->user->email ?? ''), ]); } //////// リポジトリ /** /gitler/{owner}/{repo}:ファイルとREADME */ public function repo(array $params): void { // ブラウザで「.git」を付けて開いた場合 if (str_ends_with((string)($params['repo'] ?? ''), '.git')) $this->redirect('/gitler/'.$params['owner'].'/'.rawurlencode(substr($params['repo'], 0, -4))); // Fediverseからはリポジトリのアクター if ($this->wantsActivity()) $this->apActor($params); header('Vary: Accept'); $this->load($params); if ($this->repo->isEmpty()) { // SSHの鍵を登録していればSSH、無ければHTTPS $clone = $this->cloneUrls(); $ssh = isset($clone['ssh']) && $this->user && !empty(Keys::load((string)$this->user->username)['keys']); $this->renderRepo('gitler/empty', '', ['pushUrl' => $ssh ? $clone['ssh'] : $clone['https'], 'pushBranch' => $this->defaultBranch()]); return; } $this->showTree($this->defaultRef(), ''); } /** …/tree?ref=&path= */ public function tree(array $params): void { $this->load($params); $this->showTree($this->ref(), $this->path()); } private function showTree(string $ref, string $path): void { $commit = $this->commitOf($ref); $entry = $this->repo->entry($commit, $path); if ($entry === null) $this->notFound(); if ($entry[0] !== Tree::DIR) $this->redirect($this->url('blob', $ref, $path)); $dirs = []; $files = []; $readme = null; foreach ($this->repo->tree($entry[2])->entries as [$mode, $name, $id]) { $p = ltrim($path.'/'.$name, '/'); if ($mode === Tree::DIR) { $dirs[] = ['name' => $name, 'url' => $this->url('tree', $ref, $p), 'icon' => '📁', 'size' => '']; } else if ($mode === Tree::SUBMODULE) { $dirs[] = ['name' => $name.' @ '.substr($id, 0, 7), 'url' => '', 'icon' => '📦', 'size' => '']; } else { $size = $this->repo->objects->info($id)[1] ?? 0; $files[] = ['name' => $name, 'url' => $this->url('blob', $ref, $p), 'icon' => $mode === Tree::LINK ? '🔗' : '📄', 'size' => $this->size($size)]; if ($readme === null && preg_match('/^readme(\.(md|markdown|txt))?$/i', $name)) $readme = [$name, $id]; } } $readmeHtml = ''; if ($readme !== null) { $text = $this->repo->blob($readme[1]); $readmeHtml = preg_match('/\.(md|markdown)$/i', $readme[0]) ? $this->markdown($text, $ref, $path) : '
'.htmlspecialchars($text, ENT_QUOTES, 'UTF-8').''; } // ルートには言語の割合 $langs = []; if ($path === '') { try { $langs = Languages::present(Languages::get($this->repo, $commit, $ref === $this->defaultRef()) ?? []); } catch (GitException) { $langs = []; } } $c = $this->repo->commit($commit); $this->renderRepo('gitler/tree', $path === '' ? '' : $path, [ 'langs' => $langs, 'entries' => array_merge($dirs, $files), 'readmeName' => $readme[0] ?? '', 'readme' => $readmeHtml, 'last' => $this->presentCommit($c), 'parentUrl' => $path === '' ? '' : $this->url('tree', $ref, dirname($path) === '.' ? '' : dirname($path)), ], $ref, $path); } /** …/blob?ref=&path=:ファイル */ public function blob(array $params): void { $this->load($params); $ref = $this->ref(); $path = $this->path(); $entry = $this->repo->entry($this->commitOf($ref), $path); if ($entry === null) $this->notFound(); if ($entry[0] === Tree::DIR) $this->redirect($this->url('tree', $ref, $path)); $size = $this->repo->objects->info($entry[2])[1] ?? 0; $vars = ['size' => $this->size($size), 'lines' => [], 'html' => '', 'kind' => 'text', 'rawUrl' => $this->url('raw', $ref, $path), 'blameUrl' => $this->url('blame', $ref, $path), 'historyUrl' => $this->url('commits', $ref, $path), 'lineCount' => 0, 'rendered' => false]; if ($entry[0] === Tree::LINK) { $vars['kind'] = 'link'; $vars['target'] = $this->repo->blob($entry[2]); } else if ($size > self::MAX_VIEW) { $vars['kind'] = 'large'; } else { $data = $this->repo->blob($entry[2]); if (Diff::isBinary($data) || !mb_check_encoding($data, 'UTF-8')) { $vars['kind'] = preg_match('/\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i', $path) ? 'image' : 'binary'; } else if (preg_match('/\.(md|markdown)$/i', $path) && !isset($_GET['plain'])) { $vars['kind'] = 'markdown'; $vars['html'] = $this->markdown($data, $ref, dirname($path) === '.' ? '' : dirname($path)); $vars['plainUrl'] = $this->url('blob', $ref, $path).'&plain=1'; } else { $vars['lines'] = $this->highlight($data, $path); $vars['lineCount'] = count($vars['lines']); if (preg_match('/\.(md|markdown)$/i', $path)) $vars['renderUrl'] = $this->url('blob', $ref, $path); } } $this->renderRepo('gitler/blob', $path, $vars, $ref, $path); } /** …/raw?ref=&path=:ファイルの中身(スクリプトは動かない様に) */ public function raw(array $params): void { $this->load($params); $path = $this->path(); $entry = $this->repo->entry($this->commitOf($this->ref()), $path); if ($entry === null || $entry[0] === Tree::DIR || $entry[0] === Tree::SUBMODULE) $this->notFound(); $data = $this->repo->blob($entry[2]); $types = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif', 'bmp' => 'image/bmp', 'ico' => 'image/x-icon', 'svg' => 'image/svg+xml', 'pdf' => 'application/pdf']; $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $type = $types[$ext] ?? (Diff::isBinary($data) || !mb_check_encoding($data, 'UTF-8') ? 'application/octet-stream' : 'text/plain; charset=UTF-8'); while (ob_get_level() > 0) ob_end_clean(); header('Content-Type: '.$type); header('Content-Length: '.strlen($data)); header("Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"); header('X-Content-Type-Options: nosniff'); if ($type === 'application/octet-stream') header("Content-Disposition: attachment; filename*=UTF-8''".rawurlencode(basename($path))); header('Cache-Control: '.($this->meta['private'] ? 'private' : 'public').', max-age=300'); echo $data; exit; } /** …/blame?ref=&path= */ public function blame(array $params): void { $this->load($params); $ref = $this->ref(); $path = $this->path(); $commit = $this->commitOf($ref); $entry = $this->repo->entry($commit, $path); if ($entry === null || $entry[0] === Tree::DIR) $this->notFound(); if (($this->repo->objects->info($entry[2])[1] ?? 0) > self::MAX_VIEW || Diff::isBinary($this->repo->blob($entry[2]))) { $this->message('このファイルのblameは表示出来ません。', $this->url('blob', $ref, $path)); } $lines = Blame::file($this->repo, $commit, $path, 2000); $groups = []; $commits = []; foreach ($lines as $i => $l) { $commits[$l['commit']] ??= $this->presentCommit($this->repo->commit($l['commit'])); if (empty($groups) || end($groups)['commit']['id'] !== $l['commit']) $groups[] = ['commit' => $commits[$l['commit']], 'lines' => []]; $groups[count($groups) - 1]['lines'][] = ['n' => $i + 1, 'text' => $l['text']]; } $this->renderRepo('gitler/blame', $path, ['groups' => $groups, 'blobUrl' => $this->url('blob', $ref, $path)], $ref, $path); } /** …/commits?ref=&path=&page= */ public function commits(array $params): void { $this->load($params); $ref = $this->ref(); $path = $this->path(); $page = max(1, (int)($_GET['page'] ?? 1)); $list = []; $more = false; foreach ($this->repo->log($this->commitOf($ref), ['path' => $path, 'skip' => ($page - 1) * self::PER_PAGE, 'limit' => self::PER_PAGE + 1]) as $c) { if (count($list) === self::PER_PAGE) { $more = true; break; } $list[] = $this->presentCommit($c); } $base = $this->url('commits', $ref, $path); $this->renderRepo('gitler/commits', 'コミット', [ 'list' => $list, 'prev' => $page > 1 ? $base.'&page='.($page - 1) : '', 'next' => $more ? $base.'&page='.($page + 1) : '', ], $ref, $path); } /** …/commit/{id}:コミットと差分 */ public function commit(array $params): void { $this->load($params); $id = $this->repo->resolve((string)($params['id'] ?? '')); $id = $id === null ? null : $this->repo->peel($id, 'commit'); if ($id === null) $this->notFound(); $c = $this->repo->commit($id); $parent = $c->parents[0] ?? null; $changes = Diff::trees($this->repo, $parent !== null ? $this->repo->commit($parent)->tree : null, $c->tree); $files = []; $add = 0; $del = 0; $budget = self::MAX_DIFF_TOTAL; foreach (array_slice($changes, 0, self::MAX_DIFF_FILES) as $ch) $files[] = $this->presentChange($ch, $id, $add, $del, $budget); $body = trim(substr($c->message, strlen(strtok($c->message, "\n") ?: ''))); $this->renderRepo('gitler/commit', $c->subject(), [ 'c' => $this->presentCommit($c), 'body' => $body, 'parents' => array_map(fn($p) => ['id' => $p, 'short' => substr($p, 0, 7), 'url' => $this->repoUrl().'/commit/'.$p], $c->parents), 'files' => $files, 'truncated' => count($changes) > self::MAX_DIFF_FILES, 'stats' => ['files' => count($changes), 'add' => $add, 'del' => $del], 'treeUrl' => $this->url('tree', $id, ''), 'signed' => $c->header('gpgsig') !== null, ], $id); } /** …/branches */ public function branches(array $params): void { $this->load($params); $default = $this->defaultBranch(); $list = []; foreach ($this->repo->refs->all('refs/heads/') as $name => $id) { $short = substr($name, 11); $c = $this->repo->commit($id); $list[] = ['name' => $short, 'url' => $this->url('tree', $short, ''), 'default' => $short === $default, 'c' => $this->presentCommit($c), 'time' => $c->committer?->time ?? 0, 'zip' => $this->url('archive', $short, '').'&format=zip']; } usort($list, fn($a, $b) => [$b['default'], $b['time']] <=> [$a['default'], $a['time']]); $this->renderRepo('gitler/branches', 'ブランチ', ['list' => $list]); } /** …/tags */ public function tags(array $params): void { $this->load($params); $list = []; foreach ($this->repo->refs->all('refs/tags/') as $name => $id) { $short = substr($name, 10); $target = $this->repo->peel($id, 'commit'); if ($target === null) continue; $c = $this->repo->commit($target); $message = ($this->repo->objects->info($id)[0] ?? '') === 'tag' ? trim(preg_replace('/-----BEGIN [A-Z ]+SIGNATURE-----.*$/s', '', $this->repo->tag($id)->message)) : ''; $list[] = ['name' => $short, 'url' => $this->url('tree', $short, ''), 'c' => $this->presentCommit($c), 'time' => $c->committer?->time ?? 0, 'message' => $message, 'zip' => $this->url('archive', $short, '').'&format=zip', 'tgz' => $this->url('archive', $short, '').'&format=tar.gz']; } usort($list, fn($a, $b) => $b['time'] <=> $a['time']); $this->renderRepo('gitler/tags', 'タグ', ['list' => $list]); } /** …/archive?ref=&format=zip|tar.gz|tar */ public function archive(array $params): void { $this->load($params); $ref = $this->ref(); $format = (string)($_GET['format'] ?? 'zip'); if (!isset(Archive::FORMATS[$format])) $this->notFound(); $commit = $this->commitOf($ref); $label = preg_replace('/[^A-Za-z0-9._-]/', '-', $this->repo->isId($ref) ? substr($ref, 0, 7) : $ref); @set_time_limit(0); while (ob_get_level() > 0) ob_end_clean(); header('Content-Type: '.Archive::FORMATS[$format]); header('Content-Disposition: attachment; filename="'.$this->name.'-'.$label.'.'.$format.'"'); header('X-Content-Type-Options: nosniff'); Archive::write($this->repo, $commit, $format, $this->name.'-'.$label.'/', function(string $b): void { echo $b; flush(); }); exit; } /** …/settings */ public function settings(array $params): void { $this->load($params); if ($this->access < Hosting::ADMIN) $this->notFound(); $error = ''; $message = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!$this->csrfOk()) { $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; } else { $do = (string)($_POST['do'] ?? ''); $meta = $this->meta; if ($do === 'general') { $desc = trim((string)($_POST['description'] ?? '')); $branch = (string)($_POST['branch'] ?? ''); if (mb_strlen($desc) > 500) { $error = '説明は500文字までです。'; } else { $meta['description'] = preg_replace('/[\x00-\x09\x0b-\x1f\x7f]/', '', $desc); $meta['private'] = isset($_POST['private']); Hosting::saveMeta($this->owner, $this->name, $meta); if ($branch !== '' && $this->repo->refs->resolve('refs/heads/'.$branch) !== null) $this->repo->refs->setSymbolic('HEAD', 'refs/heads/'.$branch); $message = '保存しました。'; } } else if ($do === 'addCollaborator') { $who = trim((string)($_POST['username'] ?? '')); $perm = ($_POST['permission'] ?? 'read') === 'write' ? 'write' : 'read'; if ($who === $this->owner || Hosting::user($who) === null) { $error = "ユーザー「{$who}」は居ません。"; } else { $meta['collaborators'][$who] = $perm; Hosting::saveMeta($this->owner, $this->name, $meta); $message = "{$who}を追加しました。"; } } else if ($do === 'removeCollaborator') { unset($meta['collaborators'][(string)($_POST['username'] ?? '')]); Hosting::saveMeta($this->owner, $this->name, $meta); $message = '削除しました。'; } else if ($do === 'rename') { $new = trim((string)($_POST['name'] ?? '')); $res = Hosting::rename($this->owner, $this->name, $new); if ($res->isSuccess) $this->redirect('/gitler/'.$this->owner.'/'.rawurlencode($new).'/settings'); $error = (string)$res->message; } else if ($do === 'delete') { if ((string)($_POST['confirm'] ?? '') !== $this->owner.'/'.$this->name) { $error = '確認の為、リポジトリの名前(所有者/名前)を正しく入力して下さい。'; } else { Hosting::delete($this->owner, $this->name); $this->redirect('/gitler/'.$this->owner); } } else if ($do === 'issues') { $meta['issues'] = isset($_POST['issues']); $meta['pulls'] = isset($_POST['pulls']); $meta['federate'] = isset($_POST['federate']); Hosting::saveMeta($this->owner, $this->name, $meta); $message = '保存しました。'; } else if ($do === 'archive' && !isset($_POST['confirm'])) { $error = '確認にチェックを入れて下さい。'; } else if ($do === 'archive' || $do === 'unarchive') { $meta['archived'] = $do === 'archive'; $meta['archivedAt'] = $meta['archived'] ? time() : null; Hosting::saveMeta($this->owner, $this->name, $meta); $message = $meta['archived'] ? 'アーカイブしました。読むだけになります。' : 'アーカイブを解除しました。'; } else if ($do === 'block' || $do === 'unblock') { $res = Federation::block($this->owner, $this->name, (string)($_POST['target'] ?? ''), $do === 'block'); $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message; } else if ($do === 'gc') { try { $r = \Std\Lib\Git\Maintenance::gc($this->repo); $message = "整理しました(オブジェクト{$r['objects']}個)。"; } catch (GitException $e) { $error = $e->getMessage(); } } $this->meta = Hosting::meta($this->owner, $this->name); } } $this->renderRepo('gitler/settings', '設定', [ 'error' => $error, 'message' => $message, 'branches' => array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/'))), 'currentBranch' => $this->defaultBranch(), 'collaborators' => $this->meta['collaborators'], 'fediAvailable' => \Std\Lib\Fedi\Fedi::enabled(), 'fediOn' => Federation::enabled($this->meta), 'fediHandle' => '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host(), 'followerCount' => count(Federation::followers($this->owner, $this->name)), 'blocks' => Federation::blocks($this->owner, $this->name), ]); } //////// Git(HTTP) /** …/info/refs、…/git-upload-pack、…/git-receive-pack */ public function git(array $params): void { $owner = (string)($params['owner'] ?? ''); $name = (string)($params['repo'] ?? ''); if (str_ends_with($name, '.git')) $name = substr($name, 0, -4); $action = (string)($params['action'] ?? ''); $write = $action === 'git-receive-pack' || ($action === 'info/refs' && ($_GET['service'] ?? '') === 'git-receive-pack'); [$user, $tokenWrite, $tried] = $this->basicAuth(); $user ??= $this->user; $exists = Hosting::validOwner($owner) && Hosting::validName($name) && Hosting::exists($owner, $name); $access = $exists ? Hosting::access($user, $owner, $name) : Hosting::NONE; // 読めてプルリクエストのロールが有ればrefs/for/(AGit)にだけプッシュ出来る $meta = $exists ? Hosting::meta($owner, $name) : []; $agit = $write && $access === Hosting::READ && $user !== null && ($meta['pulls'] ?? false) && !($meta['archived'] ?? true) && Hosting::userCan($user, 'pulls'); $need = $write && !$agit ? Hosting::WRITE : Hosting::READ; if ($write && $access >= Hosting::WRITE && Hosting::meta($owner, $name)['archived']) { http_response_code(403); echo "アーカイブされたリポジトリにはプッシュ出来ません。\n"; exit; } if ($access < $need || ($write && !$tokenWrite)) { // 認証していなければ、先に認証を求める(非公開のリポジトリが有るか分からない様に) if ($user === null || ($write && $access >= Hosting::WRITE && !$tokenWrite && !$tried)) { header('WWW-Authenticate: Basic realm="Gitler", charset="UTF-8"'); http_response_code(401); echo "認証が必要です(ユーザー名とアクセストークン)。\n"; exit; } http_response_code($exists && $access >= Hosting::READ ? 403 : 404); echo $exists && $access >= Hosting::READ ? "プッシュする権限が有りません。\n" : "リポジトリが有りません。\n"; exit; } $repo = Hosting::open($owner, $name); Http::handle($repo, $action, ['push' => $write, 'agent' => 'Gitler', 'receive' => self::receiveOptions($owner, $name, $user, $access)]); exit; } /** * プッシュの制限(書けるならブランチとタグ、AGitのrefs/for/)と、プッシュの後のプルリクエストとイシューの処理 * SSHではリポジトリが後で決まるので、所有者、名前、権限は参照で受け取る */ public static function receiveOptions(string &$owner, string &$name, ?\stdClass $user, int &$access): array { return [ 'update' => function(string $ref, string $old, string $new) use (&$owner, &$name, $user, &$access): ?string { if (str_starts_with($ref, 'refs/for/')) return Pulls::checkAgit($owner, $name, $user, $ref, $new, str_repeat('0', strlen($new))); if ($access < Hosting::WRITE) return 'only refs/for/