修正
守矢諏訪子 <suwako@076.moe>、2026-11-13 12:45
コミット eb7ddda46ad9a860f2b146a2324e205fd48ab4a3
・親 9cc43d4
・ファイルを見る
8個のファイル、+70 -32
変更
README-JP.md
| @@ -281,7 +281,7 @@ | ||
| 281 | 281 | |
| 282 | 282 | ### Gitler(Git のホスティング) |
| 283 | 283 | Gitler(`/gitler`)は上の Git のライブラリでサイトに Git のリポジトリをホストします:リポジトリのページ(ファイル、README、履歴、差分、blame、ブランチ、タグ、ZIP・TAR.GZ のダウンロード)、非公開のリポジトリ、共同作業者(読むだけ又は読み書き)、HTTPS と SSH のクローン・プッシュ。JavaScript は不要です。 |
| 284 | -* `config/config.php` で `GITLER_ENABLED` を `true`(`AUTH_ENABLED` も必要)にして、`GITINFO` を設定して下さい:`root` はリポジトリのディレクトリ(php-fpm の chroot の中と外で同じ設定を使える様に、サイトのディレクトリからの相対パス。`../git` は `/var/www/git`)、`ssh` はクローンの為に表示する SSH のアドレス(例:`git@example.com`、空なら HTTPS だけ)、`sshPort`、`php` は chroot の外の PHP のコマンド、`objectFormat` は新しいリポジトリの形式(`sha1` 又は `sha256`)、`maxRepos` は1人の最大の数。 | |
| 284 | +* `config/config.php` で `GITLER_ENABLED` を `true`(`AUTH_ENABLED` も必要)にして、`GITINFO` を設定して下さい:`root` はリポジトリのディレクトリ(php-fpm の chroot の中と外で同じ設定を使える様に、サイトのディレクトリからの相対パス。`/var/www/git` なら、サイトが `/var/www/htdocs` の場合は `../git`、`/var/www/htdocs/example.com/www` の場合は `../../../git`)、`ssh` はクローンの為に表示する SSH のアドレス(例:`git@example.com`、空なら HTTPS だけ)、`sshPort`、`php` は chroot の外の PHP のコマンド、`objectFormat` と `branch`(既定は `master`)は新しいリポジトリの形式とブランチ、`maxRepos` は1人の最大の数。 | |
| 285 | 285 | * ロール:「Gitのリポジトリ」(自分のリポジトリを作る・管理する。管理者が付けるまで誰も持っていない)、「Gitのイシュー」(既定は全員)、「Gitのプルリクエスト」(既定は無し)。イシューとプルリクエストは今後。管理者は全部のリポジトリを見て変更出来ます。 |
| 286 | 286 | * HTTPS:`git clone https://example.com/gitler/user/repo.git`。公開のリポジトリはログイン無しでクローン出来ます。非公開のリポジトリとプッシュは、ユーザー名と `/gitler/-/keys` で作ったアクセストークン(読むだけ又は読み書き)を聞きます。二段階認証が無いユーザーはパスワードも使えます。プッシュ出来るのはブランチとタグだけです。OpenBSD の httpd が受け付けるリクエストの本文は既定で 1MB までなので、プッシュの為に `server` のブロックで増やして下さい(例:`connection max request body 536870912`)。 |
| 287 | 287 | * SSH:ユーザーは `/gitler/-/keys` で公開鍵を追加します。chroot の中にホームの有る `git` ユーザーを作り、`www` をそのグループに入れ、sshd が Gitler に鍵を聞く様にします: |
変更
README.md
| @@ -279,7 +279,7 @@ | ||
| 279 | 279 | |
| 280 | 280 | ### Gitler (Git hosting) |
| 281 | 281 | Gitler (`/gitler`) hosts Git repositories on the site with the Git library above: repository pages (files, README, history, diffs, blame, branches, tags, ZIP/TAR.GZ downloads), private repositories, collaborators (read or read/write), and clone/push over HTTPS and SSH. It needs no JavaScript. |
| 282 | -* Set `GITLER_ENABLED` to `true` (it needs `AUTH_ENABLED`) and `GITINFO` in `config/config.php`: `root` is the directory of the repositories (relative to the site root, so that the same setting works inside and outside the php-fpm chroot; `../git` is `/var/www/git`), `ssh` the SSH address shown for cloning (for example `git@example.com`, empty for HTTPS only), `sshPort`, `php` the PHP command outside the chroot, `objectFormat` for new repositories (`sha1` or `sha256`) and `maxRepos` per user. | |
| 282 | +* Set `GITLER_ENABLED` to `true` (it needs `AUTH_ENABLED`) and `GITINFO` in `config/config.php`: `root` is the directory of the repositories (relative to the site root, so that the same setting works inside and outside the php-fpm chroot; for `/var/www/git`, `../git` when the site is in `/var/www/htdocs`, or `../../../git` when it is in `/var/www/htdocs/example.com/www`), `ssh` the SSH address shown for cloning (for example `git@example.com`, empty for HTTPS only), `sshPort`, `php` the PHP command outside the chroot, `objectFormat` and `branch` (`master` by default) for new repositories and `maxRepos` per user. | |
| 283 | 283 | * Roles: 「Gitのリポジトリ」 (create and manage your own repositories; nobody has it until an admin gives it), 「Gitのイシュー」 (everybody by default) and 「Gitのプルリクエスト」 (nobody by default). The issue tracker and pull requests come later. Admins can see and change every repository. |
| 284 | 284 | * HTTPS: `git clone https://example.com/gitler/user/repo.git`. Public repositories can be cloned without signing in; private ones and pushes ask for the user name and an access token made at `/gitler/-/keys` (read-only or read/write). Users without two-factor authentication can also use their password. Pushes are limited to branches and tags. OpenBSD's httpd accepts request bodies up to 1 MB by default, so raise it for pushes in the `server` block, for example `connection max request body 536870912`. |
| 285 | 285 | * SSH: users add their public keys at `/gitler/-/keys`. Create the `git` user with its home inside the chroot, put `www` in its group, and let sshd ask Gitler for the keys: |
| @@ -95,14 +95,16 @@ | ||
| 95 | 95 | 'debug' => false, |
| 96 | 96 | ]); |
| 97 | 97 | // Gitler(Gitのホスティング、GITLER_ENABLEDが必要)。root:リポジトリのディレクトリ(相対パスはROOTから。 |
| 98 | -// OpenBSDではphp-fpmのchrootの中、例:../git=/var/www/git)。ssh:SSHのホスト(git@example.com、空ならSSHは無し)、 | |
| 99 | -// sshPort、php:SSHで使うPHPのコマンド(chrootの外のパス)、objectFormat:新しいリポジトリの形式(sha1、sha256)、maxRepos:1人の最大 | |
| 98 | +// OpenBSDではphp-fpmのchrootの中、例:サイトが/var/www/htdocsなら../git=/var/www/git、/var/www/htdocs/example.com/wwwなら../../../git)。 | |
| 99 | +// ssh:SSHのホスト(git@example.com、空ならSSHは無し)、sshPort、php:SSHで使うPHPのコマンド(chrootの外のパス)、 | |
| 100 | +// objectFormat:新しいリポジトリの形式(sha1、sha256)、branch:新しいリポジトリのブランチ、maxRepos:1人の最大 | |
| 100 | 101 | define('GITINFO', [ |
| 101 | 102 | 'root' => '../git', |
| 102 | 103 | 'ssh' => '', |
| 103 | 104 | 'sshPort' => 22, |
| 104 | 105 | 'php' => '/usr/local/bin/php-8.5', |
| 105 | 106 | 'objectFormat' => 'sha1', |
| 107 | + 'branch' => 'master', | |
| 106 | 108 | 'maxRepos' => 100, |
| 107 | 109 | ]); |
| 108 | 110 | define('OPENPROVIDER', [ |
| @@ -57,7 +57,7 @@ | ||
| 57 | 57 | /** |
| 58 | 58 | * Gitler(/gitler):Gitのリポジトリのホスティング。JavaScriptなし |
| 59 | 59 | * |
| 60 | - * リポジトリのページはクエリでリビジョンとパスを渡す(例:/gitler/user/repo/blob?ref=main&path=src/a.php)。 | |
| 60 | + * リポジトリのページはクエリでリビジョンとパスを渡す(例:/gitler/user/repo/blob?ref=master&path=src/a.php)。 | |
| 61 | 61 | * git clone https://…/gitler/user/repo.git と SSH(gitler.php)に対応。 |
| 62 | 62 | */ |
| 63 | 63 | class Gitler { |
| @@ -119,10 +119,11 @@ | ||
| 119 | 119 | if (!$this->user) $this->redirect('/login'); |
| 120 | 120 | if (!Hosting::userCan($this->user, 'repos')) $this->message('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。'); |
| 121 | 121 | $error = ''; |
| 122 | - $in = ['name' => '', 'description' => '', 'private' => false, 'readme' => true, 'objectFormat' => (string)Hosting::config()['objectFormat'], 'branch' => 'main']; | |
| 122 | + $branch = (string)Hosting::config()['branch']; | |
| 123 | + $in = ['name' => '', 'description' => '', 'private' => true, 'readme' => false, 'objectFormat' => (string)Hosting::config()['objectFormat'], 'branch' => $branch]; | |
| 123 | 124 | if ($_SERVER['REQUEST_METHOD'] === 'POST') { |
| 124 | 125 | $in = ['name' => trim((string)($_POST['name'] ?? '')), 'description' => trim((string)($_POST['description'] ?? '')), 'private' => isset($_POST['private']), |
| 125 | - 'readme' => isset($_POST['readme']), 'objectFormat' => (string)($_POST['objectFormat'] ?? 'sha1'), 'branch' => trim((string)($_POST['branch'] ?? 'main'))]; | |
| 126 | + 'readme' => isset($_POST['readme']), 'objectFormat' => (string)($_POST['objectFormat'] ?? 'sha1'), 'branch' => trim((string)($_POST['branch'] ?? $branch))]; | |
| 126 | 127 | if (!$this->csrfOk()) { |
| 127 | 128 | $error = 'セッションの有効期限が切れました。もう一度お試し下さい。'; |
| 128 | 129 | } else if (mb_strlen($in['description']) > 500) { |
| @@ -184,7 +185,10 @@ | ||
| 184 | 185 | if (str_ends_with((string)($params['repo'] ?? ''), '.git')) $this->redirect('/gitler/'.$params['owner'].'/'.rawurlencode(substr($params['repo'], 0, -4))); |
| 185 | 186 | $this->load($params); |
| 186 | 187 | if ($this->repo->isEmpty()) { |
| 187 | - $this->renderRepo('gitler/empty', '', []); | |
| 188 | + // SSHの鍵を登録していればSSH、無ければHTTPS | |
| 189 | + $clone = $this->cloneUrls(); | |
| 190 | + $ssh = isset($clone['ssh']) && $this->user && !empty(Keys::load((string)$this->user->username)['keys']); | |
| 191 | + $this->renderRepo('gitler/empty', '', ['pushUrl' => $ssh ? $clone['ssh'] : $clone['https'], 'pushBranch' => $this->defaultBranch()]); | |
| 188 | 192 | return; |
| 189 | 193 | } |
| 190 | 194 | $this->showTree($this->defaultRef(), ''); |
| @@ -560,7 +564,7 @@ | ||
| 560 | 564 | |
| 561 | 565 | private function defaultBranch(): string { |
| 562 | 566 | $h = $this->repo->headRef(); |
| 563 | - return $h !== null && str_starts_with($h, 'refs/heads/') ? substr($h, 11) : 'main'; | |
| 567 | + return $h !== null && str_starts_with($h, 'refs/heads/') ? substr($h, 11) : (string)Hosting::config()['branch']; | |
| 564 | 568 | } |
| 565 | 569 | |
| 566 | 570 | private function defaultRef(): string { |
| @@ -70,19 +70,30 @@ | ||
| 70 | 70 | |
| 71 | 71 | /** GITINFOと既定値 */ |
| 72 | 72 | public static function config(): array { |
| 73 | - return (defined('GITINFO') ? GITINFO : []) + ['root' => '../git', 'ssh' => '', 'sshPort' => 22, 'php' => '/usr/local/bin/php-8.5', 'objectFormat' => 'sha1', 'maxRepos' => 100]; | |
| 73 | + return (defined('GITINFO') ? GITINFO : []) + ['root' => '../git', 'ssh' => '', 'sshPort' => 22, 'php' => '/usr/local/bin/php-8.5', 'objectFormat' => 'sha1', 'branch' => 'master', 'maxRepos' => 100]; | |
| 74 | 74 | } |
| 75 | 75 | |
| 76 | 76 | /** リポジトリのディレクトリ(相対パスはROOTから) */ |
| 77 | 77 | public static function root(): string { |
| 78 | 78 | if (self::$root !== null) return rtrim(self::$root, '/\\'); |
| 79 | - $r = str_replace('\\', '/', (string)self::config()['root']); | |
| 80 | - if (!preg_match('#^(/|[A-Za-z]:/)#', $r)) $r = ROOT.'/'.$r; | |
| 81 | - // 「..」を解決する(chrootの中と外で同じ設定を使える様に、realpathは使わない) | |
| 79 | + return self::resolvePath((string)self::config()['root']); | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * パスを絶対パスにする(相対パスはROOTから) | |
| 84 | + * | |
| 85 | + * 「..」は文字列として解決する(chrootの中と外で同じ設定を使える様に、realpathは使わない)。 | |
| 86 | + */ | |
| 87 | + public static function resolvePath(string $path): string { | |
| 88 | + $r = str_replace('\\', '/', $path); | |
| 89 | + if (!preg_match('#^(/|[A-Za-z]:/)#', $r)) $r = str_replace('\\', '/', ROOT).'/'.$r; | |
| 82 | 90 | $parts = []; |
| 83 | 91 | foreach (explode('/', $r) as $i => $p) { |
| 84 | - if ($p === '..') array_pop($parts); | |
| 85 | - else if ($p !== '.' && ($p !== '' || $i === 0)) $parts[] = $p; | |
| 92 | + if ($p === '..') { | |
| 93 | + if (count($parts) > 1) array_pop($parts); | |
| 94 | + } else if ($p !== '.' && ($p !== '' || $i === 0)) { | |
| 95 | + $parts[] = $p; | |
| 96 | + } | |
| 86 | 97 | } |
| 87 | 98 | return implode('/', $parts) ?: '/'; |
| 88 | 99 | } |
| @@ -195,7 +206,7 @@ | ||
| 195 | 206 | $max = (int)self::config()['maxRepos']; |
| 196 | 207 | if ($max > 0 && count(self::list($owner)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。"); |
| 197 | 208 | $format = (string)($opt['objectFormat'] ?? self::config()['objectFormat']); |
| 198 | - $branch = trim((string)($opt['branch'] ?? '')) ?: 'main'; | |
| 209 | + $branch = trim((string)($opt['branch'] ?? '')) ?: (string)self::config()['branch']; | |
| 199 | 210 | try { |
| 200 | 211 | $repo = Repository::init(self::path($owner, $name), in_array($format, ['sha1', 'sha256'], true) ? $format : 'sha1', $branch, 'group', (string)($opt['description'] ?? '')); |
| 201 | 212 | } catch (GitException $e) { |
| @@ -115,7 +115,12 @@ | ||
| 115 | 115 | foreach (['', '.', '..', 'a..b', 'x.git', 'X.GIT', '-x', '.x', 'a/b', 'a b', str_repeat('a', 101), "a\0"] as $bad) $test->assertFalse(Gitler::validName($bad), $bad); |
| 116 | 116 | $test->assertFalse(Gitler::validOwner('a-b')); |
| 117 | 117 | Gitler::$root = null; |
| 118 | - $test->assertSame(dirname(ROOT).'/git', Gitler::root(), '既定は../git(ROOTから)'); | |
| 118 | + $root = str_replace('\\', '/', ROOT); | |
| 119 | + $test->assertSame(dirname($root).'/git', Gitler::root(), '既定は../git(ROOTから)'); | |
| 120 | + $test->assertSame(dirname($root, 3).'/git', Gitler::resolvePath('../../../git')); | |
| 121 | + $test->assertSame('/var/www/git', Gitler::resolvePath('/var/www/htdocs/example.com/www/../../../git')); | |
| 122 | + $test->assertSame('/git', Gitler::resolvePath('/htdocs/example.com/www/../../../git'), 'chrootの中'); | |
| 123 | + $test->assertSame('/a/b', Gitler::resolvePath('/a/./b/')); | |
| 119 | 124 | }); |
| 120 | 125 | }); |
| 121 | 126 | |
| @@ -128,8 +133,8 @@ | ||
| 128 | 133 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 129 | 134 | $test->assertTrue(Gitler::exists('owner001', 'public1')); |
| 130 | 135 | $repo = Gitler::open('owner001', 'public1'); |
| 131 | - $test->assertSame("# public1\n\n公開\n", $repo->blob($repo->resolve('main:README.md'))); | |
| 132 | - $test->assertSame('owner001', $repo->commit($repo->resolve('main'))->author->name); | |
| 136 | + $test->assertSame("# public1\n\n公開\n", $repo->blob($repo->resolve('master:README.md'))); | |
| 137 | + $test->assertSame('owner001', $repo->commit($repo->resolve('master'))->author->name); | |
| 133 | 138 | $test->assertSame('group', $repo->config->get('core.sharedrepository')); |
| 134 | 139 | $test->assertSame("公開\n", file_get_contents($repo->path.'/description')); |
| 135 | 140 | $test->assertFalse(Gitler::create($owner, 'public1')->isSuccess, '同じ名前'); |
| @@ -139,6 +144,7 @@ | ||
| 139 | 144 | $repo = Gitler::open('owner001', 'secret'); |
| 140 | 145 | $test->assertSame('sha256', $repo->hashAlgo); |
| 141 | 146 | $test->assertSame('refs/heads/trunk', $repo->headRef()); |
| 147 | + $test->assertSame('refs/heads/master', Gitler::open('owner001', 'public1')->headRef(), '既定のブランチはmaster'); | |
| 142 | 148 | $test->assertTrue($repo->isEmpty()); |
| 143 | 149 | $names = array_map(fn($r) => $r['name'], Gitler::list('owner001')); |
| 144 | 150 | sort($names); |
| @@ -183,7 +189,7 @@ | ||
| 183 | 189 | $test->assertTrue($res->isSuccess, (string)$res->message); |
| 184 | 190 | $repo = Gitler::open('owner001', 'public1'); |
| 185 | 191 | $test->assertCount(1, $repo->objects->packs(true)); |
| 186 | - $test->assertStringContains('公開', $repo->blob($repo->resolve('main:README.md'))); | |
| 192 | + $test->assertStringContains('公開', $repo->blob($repo->resolve('master:README.md'))); | |
| 187 | 193 | }); |
| 188 | 194 | }); |
| 189 | 195 |
変更
util.php
| @@ -520,17 +520,32 @@ | ||
| 520 | 520 | return ass_url("user/{$name}/".rawurlencode($avatar)); |
| 521 | 521 | } |
| 522 | 522 | |
| 523 | +/** | |
| 524 | + * CSRFのトークン(クッキーとフォームの両方に入れる) | |
| 525 | + * | |
| 526 | + * ページを開く度に有効期限(1日)を延ばす。以前は最初に作ってから5分で切れたので、 | |
| 527 | + * 開いたままのフォームや、少し前に開いたページから送ると失敗した。 | |
| 528 | + * | |
| 529 | + * @param bool|null $force 新しいトークンにする | |
| 530 | + * @return string | |
| 531 | + */ | |
| 523 | 532 | function make_csrf_token(?bool $force = false): string { |
| 524 | - if (null !== getcookie('csrf_token') && !$force) return getcookie('csrf_token'); | |
| 525 | - $token = bin2hex(random_bytes(32)); | |
| 526 | - setcookie('csrf_token', $token, [ | |
| 527 | - 'expires' => time() + 300, // 5分 | |
| 528 | - 'path' => '/', | |
| 529 | - 'domain' => $_SERVER['HTTP_HOST'], | |
| 530 | - 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), | |
| 531 | - 'httponly' => true, | |
| 532 | - 'samesite' => 'Strict' | |
| 533 | - ]); | |
| 533 | + static $sent = null; | |
| 534 | + $token = getcookie('csrf_token'); | |
| 535 | + if ($force || !is_string($token) || !preg_match('/^[0-9a-f]{64}$/', $token)) $token = bin2hex(random_bytes(32)); | |
| 536 | + if ($sent !== $token && !headers_sent()) { | |
| 537 | + $sent = $token; | |
| 538 | + setcookie('csrf_token', $token, [ | |
| 539 | + 'expires' => time() + 86400, // 1日(使う度に延ばす) | |
| 540 | + 'path' => '/', | |
| 541 | + 'domain' => $_SERVER['HTTP_HOST'] ?? '', | |
| 542 | + 'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'), | |
| 543 | + 'httponly' => true, | |
| 544 | + 'samesite' => 'Strict' | |
| 545 | + ]); | |
| 546 | + } | |
| 547 | + // 同じリクエストでもう一度呼ばれても同じトークン(以前は別のトークンになり、最初のフォームが失敗した) | |
| 548 | + $_COOKIE['csrf_token'] = $token; | |
| 534 | 549 | return $token; |
| 535 | 550 | } |
| 536 | 551 |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {@ include(gitler/clone) @} |
| 4 | 4 | <p class="paragraph">このリポジトリは空です。</p> |
| 5 | 5 | {@ if ($canWrite) @} |
| 6 | - <pre class="gl-pre">git remote add origin {{ $clone['ssh'] ?? $clone['https'] }}{{ "\n" }} | |
| 7 | -git push -u origin main</pre> | |
| 6 | + <pre class="gl-pre">git remote add origin {{ $pushUrl }}{{ "\n" }} | |
| 7 | +git push -u origin {{ $pushBranch }}</pre> | |
| 8 | 8 | {@ endif @} |
| 9 | 9 | {@ include(common/footer) @} |