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// (pull request)'; return str_starts_with($ref, 'refs/heads/') || str_starts_with($ref, 'refs/tags/') ? null : 'only branches and tags'; }, 'intercept' => fn(string $ref): bool => str_starts_with($ref, 'refs/for/'), 'process' => function(array $done) use (&$owner, &$name, $user): array { return $owner !== '' && $name !== '' ? Pulls::afterPush($owner, $name, $user, $done) : []; }, 'after' => function(array $done) use (&$owner, &$name): void { if ($owner !== '' && $name !== '') self::afterPush($owner, $name, $done); }, ]; } /** * Basic認証(ユーザー名とアクセストークン、又は二段階認証が無ければパスワード) * * @return array [ユーザー, プッシュ出来るか, 認証を送ったか] */ private function basicAuth(): array { $name = $_SERVER['PHP_AUTH_USER'] ?? null; $pass = $_SERVER['PHP_AUTH_PW'] ?? null; $header = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''); if ($name === null && preg_match('/^Basic\s+(\S+)$/i', $header, $m)) { $dec = (string)base64_decode($m[1], true); if (str_contains($dec, ':')) [$name, $pass] = explode(':', $dec, 2); } if ($name === null || $pass === null) return [null, $this->user !== null, false]; $name = (string)$name; if (!Hosting::validOwner($name)) return [null, false, true]; $token = Keys::checkToken($name, (string)$pass); if ($token !== null) { $u = Hosting::user($name); return [$u, $u !== null && !empty($token['write']), true]; } $u = (new Auth())->checkPassword($name, (string)$pass); return [$u, $u !== null, true]; } //////// 共通 /** リポジトリを開いて権限を確かめる */ private function load(array $params): void { $this->owner = (string)($params['owner'] ?? ''); $this->name = (string)($params['repo'] ?? ''); if (!Hosting::validOwner($this->owner) || !Hosting::validName($this->name) || !Hosting::exists($this->owner, $this->name)) $this->notFound(); $this->access = Hosting::access($this->user, $this->owner, $this->name); if ($this->access === Hosting::NONE) { if (!$this->user) $this->redirect('/login'); $this->notFound(); } $this->repo = Hosting::open($this->owner, $this->name); $this->meta = Hosting::meta($this->owner, $this->name); } private function defaultBranch(): string { $h = $this->repo->headRef(); return $h !== null && str_starts_with($h, 'refs/heads/') ? substr($h, 11) : (string)Hosting::config()['branch']; } private function defaultRef(): string { $b = $this->defaultBranch(); if ($this->repo->refs->resolve('refs/heads/'.$b) !== null) return $b; $first = array_key_first($this->repo->refs->all('refs/heads/')); return $first !== null ? substr($first, 11) : $b; } private function ref(): string { $r = trim((string)($_GET['ref'] ?? '')); return $r === '' ? $this->defaultRef() : $r; } private function path(): string { $p = trim(str_replace('\\', '/', (string)($_GET['path'] ?? '')), '/'); if (preg_match('#(^|/)\.\.?(/|$)#', $p) || str_contains($p, "\0")) $this->notFound(); return $p; } /** リビジョンのコミット(無ければ404) */ private function commitOf(string $ref): string { if ($ref === '' || str_contains($ref, ':')) $this->notFound(); $id = $this->repo->resolve($ref); $id = $id === null ? null : $this->repo->peel($id, 'commit'); if ($id === null) $this->notFound(); return $id; } private function repoUrl(): string { return '/gitler/'.$this->owner.'/'.rawurlencode($this->name); } private function url(string $page, string $ref, string $path): string { return $this->repoUrl().'/'.$page.'?ref='.rawurlencode($ref).($path !== '' ? '&path='.str_replace('%2F', '/', rawurlencode($path)) : ''); } private function presentRepo(array $r): array { return ['owner' => $r['owner'], 'name' => $r['name'], 'description' => $r['meta']['description'], 'private' => $r['meta']['private'], 'archived' => $r['meta']['archived'], 'url' => '/gitler/'.$r['owner'].'/'.rawurlencode($r['name']), 'updated' => $r['updated'] ? date('Y-m-d H:i', $r['updated']) : '-', 'lang' => Languages::primary(Hosting::path($r['owner'], $r['name']))]; } private function presentCommit(\Std\Lib\Git\Commit $c): array { $id = (string)$c->id; return ['id' => $id, 'short' => substr($id, 0, 7), 'subject' => $c->subject(), 'url' => $this->repoUrl().'/commit/'.$id, 'author' => $c->author?->name ?? '', 'email' => $c->author?->email ?? '', 'date' => $c->author ? $c->author->date()->format('Y-m-d H:i') : '', 'treeUrl' => $this->url('tree', $id, '')]; } /** 差分の1ファイル */ private function presentChange(array $ch, string $commit, int &$add, int &$del, int &$budget): array { [$om, $op, $oid] = $ch['old'] ?? [null, null, null]; [$nm, $np, $nid] = $ch['new'] ?? [null, null, null]; $status = ['A' => '追加', 'D' => '削除', 'M' => '変更', 'R' => '名前の変更', 'T' => '種類の変更'][$ch['status']]; $out = ['status' => $ch['status'], 'statusText' => $status, 'path' => $np ?? $op, 'oldPath' => $ch['status'] === 'R' ? $op : '', 'hunks' => [], 'note' => '', 'url' => $np !== null ? $this->url('blob', $commit, $np) : '']; if ($oid === $nid) return $out; $old = $oid !== null && $om !== Tree::SUBMODULE ? $this->repo->blob($oid) : ''; $new = $nid !== null && $nm !== Tree::SUBMODULE ? $this->repo->blob($nid) : ''; if ($om === Tree::SUBMODULE || $nm === Tree::SUBMODULE) { $out['note'] = 'サブモジュール:'.substr((string)$oid, 0, 7).' → '.substr((string)$nid, 0, 7); return $out; } if (Diff::isBinary($old) || Diff::isBinary($new)) { $out['note'] = 'バイナリファイル'; return $out; } if (strlen($old) + strlen($new) > Diff::MAX_BYTES) { $out['note'] = '大き過ぎるので表示しません'; return $out; } if ($budget <= 0) { $out['note'] = '差分が多過ぎるので表示しません'; return $out; } $lines = 0; foreach (Diff::hunks(Diff::split($old), Diff::split($new)) as $h) { $rows = []; $o = $h['oldStart']; $n = $h['newStart']; foreach ($h['lines'] as [$op2, $text]) { if ($op2 === '+') $add++; if ($op2 === '-') $del++; $rows[] = ['op' => $op2, 'old' => $op2 === '+' ? '' : $o++, 'new' => $op2 === '-' ? '' : $n++, 'text' => $text]; } $lines += count($rows); $budget -= count($rows); $out['hunks'][] = ['head' => "@@ -{$h['oldStart']},{$h['oldLines']} +{$h['newStart']},{$h['newLines']} @@", 'rows' => $rows]; if ($lines > self::MAX_DIFF_LINES || $budget <= 0) { $out['note'] = '長いので途中までです'; break; } } return $out; } /** 行毎のHTML(ハイライト出来る言語なら色付き) */ private function highlight(string $text, string $path): array { $text = str_replace(["\r\n", "\r"], "\n", $text); $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); $lang = $ext !== '' ? Syntax::resolve($ext) : null; $html = $lang !== null ? (new Syntax($lang))->colorize($text) : htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); $lines = explode("\n", $html); if (end($lines) === '') array_pop($lines); // 複数行のspan(コメント等)を行毎に閉じて開き直す $open = []; foreach ($lines as $i => $l) { $prefix = implode('', $open); preg_match_all('#|#', $l, $m); foreach ($m[0] as $tag) { if ($tag === '') array_pop($open); else $open[] = $tag; } $lines[$i] = $prefix.$l.str_repeat('', count($open)); } return $lines; } /** READMEのMarkdown(リンクを安全にし、相対リンクをリポジトリのファイルにする) */ private function markdown(string $text, string $ref, string $dir): string { $html = Markdown::fromString($text)->parse(); return (string)preg_replace_callback('/\s(href|src)="([^"]*)"/i', function(array $m) use ($ref, $dir): string { $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8'); if (preg_match('#^(https?:|mailto:|\#)#i', $url)) return " {$m[1]}=\"{$m[2]}\""; if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $url) || str_starts_with($url, '//')) return " {$m[1]}=\"#\""; if (str_starts_with($url, '/')) return " {$m[1]}=\"".htmlspecialchars($url, ENT_QUOTES, 'UTF-8').'"'; // 相対パス [$p, $frag] = array_pad(explode('#', $url, 2), 2, null); $parts = []; foreach (explode('/', ($dir !== '' ? $dir.'/' : '').$p) as $seg) { if ($seg === '..') array_pop($parts); else if ($seg !== '.' && $seg !== '') $parts[] = $seg; } $target = $this->url(strtolower($m[1]) === 'src' ? 'raw' : 'blob', $ref, implode('/', $parts)).($frag !== null ? '#'.$frag : ''); return " {$m[1]}=\"".htmlspecialchars($target, ENT_QUOTES, 'UTF-8').'"'; }, $html); } private function cloneUrls(): array { $base = rtrim((string)(SITEINFO['url'] ?? ''), '/'); if ($base === '') $base = ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? 'localhost'); $out = ['https' => $base.'/gitler/'.$this->owner.'/'.$this->name.'.git']; $ssh = (string)Hosting::config()['ssh']; if ($ssh !== '') { $port = (int)Hosting::config()['sshPort']; $out['ssh'] = $port === 22 ? "{$ssh}:{$this->owner}/{$this->name}.git" : "ssh://{$ssh}:{$port}/{$this->owner}/{$this->name}.git"; } return $out; } private function size(int $n): string { if ($n < 1024) return "{$n} B"; if ($n < 1048576) return round($n / 1024, 1).' KB'; return round($n / 1048576, 1).' MB'; } private function csrfOk(): bool { return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']); } private function redirect(string $to): never { header('Location: '.$to); exit; } private function notFound(): never { header('Location: /404'); exit; } private function message(string $msg, string $back = '/gitler'): never { $this->render('gitler/message', 'Gitler', ['msg' => $msg, 'back' => $back]); exit; } /** リポジトリのページ(タブとパンくず) */ private function renderRepo(string $view, string $title, array $vars, ?string $ref = null, string $path = ''): void { $ref ??= $this->repo->isEmpty() ? '' : $this->defaultRef(); $crumbs = []; $acc = ''; foreach ($path === '' ? [] : explode('/', $path) as $seg) { $acc = ltrim($acc.'/'.$seg, '/'); $crumbs[] = ['name' => $seg, 'url' => $this->url('tree', $ref, $acc)]; } $branches = array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/'))); $tags = array_map(fn($n) => substr($n, 10), array_keys($this->repo->refs->all('refs/tags/'))); $this->render($view, "{$this->owner}/{$this->name}".($title !== '' ? ":{$title}" : ''), $vars + [ 'owner' => $this->owner, 'name' => $this->name, 'repoUrl' => $this->repoUrl(), 'meta' => $this->meta, 'canAdmin' => $this->access >= Hosting::ADMIN, 'canWrite' => $this->access >= Hosting::WRITE, 'ref' => $ref, 'shortRef' => $this->repo->isId($ref) ? substr($ref, 0, 7) : $ref, 'crumbs' => $crumbs, 'path' => $path, 'branches' => $branches, 'tags' => $tags, 'clone' => $this->cloneUrls(), 'gitView' => $view, 'refUrl' => fn(string $r) => $this->url(str_starts_with($view, 'gitler/commits') ? 'commits' : (str_starts_with($view, 'gitler/blob') ? 'blob' : 'tree'), $r, $path), 'treeRoot' => $ref !== '' ? $this->url('tree', $ref, '') : $this->repoUrl(), 'commitsUrl' => $ref !== '' ? $this->url('commits', $ref, '') : '', 'zipUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=zip' : '', 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '', 'issuesOn' => $this->meta['issues'], 'openIssues' => $this->meta['issues'] ? (new Issues($this->repo))->counts()['open'] : 0, 'archived' => $this->meta['archived'], 'here' => (string)($_SERVER['REQUEST_URI'] ?? ''), 'pullsOn' => $this->meta['pulls'], 'openPulls' => $this->meta['pulls'] ? (new Issues($this->repo))->counts(true)['open'] : 0, 'forkOf' => $this->meta['forkOf'], 'forkCount' => count(Hosting::forks($this->owner, $this->name)), 'canFork' => $this->user !== null && $this->user->username !== $this->owner && Hosting::userCan($this->user, 'repos'), 'parentCompare' => $this->forkCompareUrl($ref), 'watch' => $this->user ? Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) : '', ]); } /** フォーク元にプルリクエストを作るURL(フォークでなければ空) */ private function forkCompareUrl(string $ref): string { if ($this->meta['forkOf'] === '' || !$this->user || !Hosting::userCan($this->user, 'pulls')) return ''; [$o, $n] = explode('/', $this->meta['forkOf'], 2); if (!Hosting::exists($o, $n) || !Hosting::meta($o, $n)['pulls'] || Hosting::access($this->user, $o, $n) === Hosting::NONE) return ''; $branch = $ref !== '' && $this->repo->refs->resolve('refs/heads/'.$ref) !== null ? $ref : $this->defaultBranch(); return '/gitler/'.$o.'/'.rawurlencode($n).'/compare?head='.rawurlencode("{$this->owner}/{$this->name}:{$branch}"); } private function render(string $view, string $title, array $vars): void { $tmpl = new Template('/'); $tmpl->assign('pagetit', $title); $tmpl->assign('curPage', 'gitler'); $tmpl->assign('menu', $this->getMenu()); $tmpl->assign('support', $this->getSupport()); $tmpl->assign('sns', $this->getSns()); $tmpl->assign('description', SITEINFO['description']); $tmpl->assign('user', $this->user); $tmpl->assign('canCreate', Hosting::userCan($this->user, 'repos')); // クッキーはテンプレートの出力の前に設定する $tmpl->assign('csrf', make_csrf_token()); foreach (['error' => '', 'message' => ''] as $k => $v) $tmpl->assign($k, $vars[$k] ?? $v); foreach ($vars as $k => $v) $tmpl->assign($k, $v); $tmpl->addCss('code'); $tmpl->addCss('gitler'); $tmpl->render($view); } }