Gitler

守矢諏訪子 <suwako@076.moe>、2026-11-13 12:18
コミット 9cc43d4ddd7bd66ae73707b9100581c040decf9c ・親 d68bcbb ・ファイルを見る

33個のファイル、+2893 -3

変更 README-JP.md
@@ -279,6 +279,29 @@
279279 * OpenBSD では php-fpm は `/var/www` に chroot されるので、リポジトリはその中に置き(例:`git` ユーザーのホームを `/var/www/git` に)、`$shared = 'group'` で作り、`www` を `git` グループに入れると、サイトと SSH の両方から書けます。SSH でプロトコル v2 を使うには、`sshd_config` に `AcceptEnv GIT_PROTOCOL` を追加して下さい。
280280 * 未対応:新しい差分の作成(ルーズで受け取ったオブジェクトは差分無しでパックにする)、シンパック、部分クローン(`--filter`)、`--shallow-since`・`--shallow-exclude`、`git archive --remote`、LFS、commit-graph・multi-pack-index の書き込み。
281281
282+### Gitler(Git のホスティング)
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人の最大の数。
285+* ロール:「Gitのリポジトリ」(自分のリポジトリを作る・管理する。管理者が付けるまで誰も持っていない)、「Gitのイシュー」(既定は全員)、「Gitのプルリクエスト」(既定は無し)。イシューとプルリクエストは今後。管理者は全部のリポジトリを見て変更出来ます。
286+* HTTPS:`git clone https://example.com/gitler/user/repo.git`。公開のリポジトリはログイン無しでクローン出来ます。非公開のリポジトリとプッシュは、ユーザー名と `/gitler/-/keys` で作ったアクセストークン(読むだけ又は読み書き)を聞きます。二段階認証が無いユーザーはパスワードも使えます。プッシュ出来るのはブランチとタグだけです。OpenBSD の httpd が受け付けるリクエストの本文は既定で 1MB までなので、プッシュの為に `server` のブロックで増やして下さい(例:`connection max request body 536870912`)。
287+* SSH:ユーザーは `/gitler/-/keys` で公開鍵を追加します。chroot の中にホームの有る `git` ユーザーを作り、`www` をそのグループに入れ、sshd が Gitler に鍵を聞く様にします:
288+```sh
289+useradd -m -d /var/www/git -s /bin/sh git
290+usermod -G git www # php-fpmがリポジトリに書ける
291+usermod -G www git # gitler.phpがconfig/config.phpとdata/を読める
292+chmod 2775 /var/www/git
293+```
294+```
295+# /etc/ssh/sshd_config
296+Match User git
297+ AuthorizedKeysCommand /usr/local/bin/php-8.5 /var/www/htdocs/gitler.php keys %u %t %k
298+ AuthorizedKeysCommandUser git
299+ AcceptEnv GIT_PROTOCOL
300+ PasswordAuthentication no
301+```
302+ 鍵毎に `gitler.php serve <ユーザー>` を実行するので、ユーザーが出来るのは `git clone`・`git push`(`git@example.com:user/repo.git`)だけです。`AuthorizedKeysCommand` を使わない場合は、`php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys`(例えば cron から)で同じ行を書けます。
303+* リポジトリを整理する為に Cron のジョブ `gitlergc` を追加するか(例:`php cron.php add "@daily" gitlergc`)、`php gitler.php gc` を実行して下さい。
304+
282305 ### TLS 1.3・1.2(php_openssl は不要)
283306 HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。
284307 * TLS 1.3 を優先し、TLS 1.2 にしか対応していないサーバーとは安全な暗号スイートだけで TLS 1.2 を使います(ECDHE と AES-GCM 又は ChaCha20-Poly1305、サーバーが対応していれば Extended Master Secret、再ネゴシエーションはしない)。CBC、RSA の鍵交換、DHE、TLS 1.1 以前は未対応です。TLS 1.3 に対応しているサーバーとの通信を攻撃者が TLS 1.2 に下げた場合は分かります。TLS 1.2 を拒否するには、オプションの `'min_version' => '1.3'` を使って下さい。
@@ -319,6 +342,7 @@
319342 * `authpurge`:7日以内にメールアドレスを確認しなかったアカウントを削除する(`AUTH_VERIFY_EMAIL` が有効の場合)
320343 * `apdeliver`:マイクロブログのアクティビティを他のサーバーに配信する(マルチユーザーモード、毎分実行)
321344 * `apcleanup`:90日より古いリモートの投稿とキャッシュを削除する(マルチユーザーモード、1日1回実行)
345+* `gitlergc`:Gitler のリポジトリを整理する(`GITLER_ENABLED` が有効の場合、1日1回実行)
322346
323347 ### マイクロブログ(マルチユーザーの Fediverse)
324348 既定では ActivityPub はシングルユーザーで、ブログのアクター(`/ap/actor`)が新しいブログ投稿をフォロワーに送ります。\
変更 README.md
@@ -277,6 +277,29 @@
277277 * On OpenBSD, php-fpm runs chrooted in `/var/www`, so keep the repositories inside it (for example `/var/www/git` as the home of the `git` user) and create them with `$shared = 'group'`, with `www` in the `git` group, so that both the website and SSH can write. For protocol v2 over SSH, add `AcceptEnv GIT_PROTOCOL` to `sshd_config`.
278278 * Not supported: creating new deltas (objects pushed as loose files are packed without them), thin packs, partial clone (`--filter`), `--shallow-since`/`--shallow-exclude`, `git archive --remote`, LFS, and writing commit-graph or multi-pack-index files.
279279
280+### Gitler (Git hosting)
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.
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+* 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+* 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:
286+```sh
287+useradd -m -d /var/www/git -s /bin/sh git
288+usermod -G git www # php-fpm can write the repositories
289+usermod -G www git # gitler.php can read config/config.php and data/
290+chmod 2775 /var/www/git
291+```
292+```
293+# /etc/ssh/sshd_config
294+Match User git
295+ AuthorizedKeysCommand /usr/local/bin/php-8.5 /var/www/htdocs/gitler.php keys %u %t %k
296+ AuthorizedKeysCommandUser git
297+ AcceptEnv GIT_PROTOCOL
298+ PasswordAuthentication no
299+```
300+ Each key runs `gitler.php serve <user>`, so the user only gets `git clone`/`git push` (`git@example.com:user/repo.git`). Without `AuthorizedKeysCommand`, `php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys` (for example from cron) writes the same lines.
301+* Add the Cron job `gitlergc` (for example `php cron.php add "@daily" gitlergc`) to pack the repositories, or run `php gitler.php gc`.
302+
280303 ### TLS 1.3 and 1.2 (no php_openssl)
281304 HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working.
282305 * TLS 1.3 is preferred. Servers that only speak TLS 1.2 get TLS 1.2 with secure cipher suites only (ECDHE with AES-GCM or ChaCha20-Poly1305, Extended Master Secret when the server supports it, no renegotiation); CBC, RSA key exchange and DHE are not supported, and neither is TLS 1.1 or older. A TLS 1.3 server that is pushed down to TLS 1.2 by an attacker is detected. Use the option `'min_version' => '1.3'` to refuse TLS 1.2.
@@ -317,6 +340,7 @@
317340 * `authpurge`: deletes accounts whose email address was not verified within 7 days (when `AUTH_VERIFY_EMAIL` is enabled)
318341 * `apdeliver`: delivers microblog activities to other servers (multi-user mode, run every minute)
319342 * `apcleanup`: deletes remote posts and caches older than 90 days (multi-user mode, run daily)
343+* `gitlergc`: packs the Gitler repositories (when `GITLER_ENABLED` is enabled, run daily)
320344
321345 ### Microblog (multi-user Fediverse)
322346 By default ActivityPub is single-user: the blog actor (`/ap/actor`) sends new blog posts to its followers.\
@@ -94,6 +94,17 @@
9494 'tls' => false,
9595 'debug' => false,
9696 ]);
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人の最大
100+define('GITINFO', [
101+ 'root' => '../git',
102+ 'ssh' => '',
103+ 'sshPort' => 22,
104+ 'php' => '/usr/local/bin/php-8.5',
105+ 'objectFormat' => 'sha1',
106+ 'maxRepos' => 100,
107+]);
97108 define('OPENPROVIDER', [
98109 'username' => '',
99110 'password' => '',
@@ -116,5 +127,6 @@
116127 define('COMMENTS_ENABLED', false); // 記事のコメント(書き込みはAUTH_ENABLED、Fediverseからの返信はACTIVITYPUB_ENABLEDも必要)
117128 define('ADMIN_ENABLED', false); // 管理画面(/admin、管理者のロールとAUTH_ENABLEDが必要)。ユーザー、ログ、Cron、キャッシュ、データベースの変換
118129 define('ADMIN_SETUP_CODE', ''); // 管理者が居ない時に、/admin/setupで最初の管理者を作る為のコード(長くて推測されにくい物、作った後は空に)
130+define('GITLER_ENABLED', false); // Gitler(/gitler、Gitのリポジトリ、AUTH_ENABLEDも必要。GITINFOを参照)
119131 define('COPYRIGHT_YEAR', '2018-'.date('Y'));
120132 define('DEBUG_MODE', false);
追加 gitler.php
@@ -0,0 +1,128 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+
39+// Gitler(Gitのホスティング)のコマンド。SSHのgitユーザーから使う(chrootの外で動く)
40+// sshd_config:
41+// Match User git
42+// AuthorizedKeysCommand /usr/local/bin/php-8.5 /var/www/htdocs/gitler.php keys %u %t %k
43+// AuthorizedKeysCommandUser git
44+// AcceptEnv GIT_PROTOCOL
45+if (PHP_SAPI !== 'cli') {
46+ http_response_code(404);
47+ exit;
48+}
49+
50+require_once __DIR__.DIRECTORY_SEPARATOR.'/autoload.php';
51+define('ROOT', realpath(__DIR__));
52+require_once ROOT.'/config/config.php';
53+require_once ROOT.'/util.php';
54+mb_internal_encoding('UTF-8');
55+
56+use Site\Controller\Gitler as GitlerController;
57+use Std\Lib\Git\Repository;
58+use Std\Lib\Git\Ssh;
59+use Std\Lib\Gitler\Gitler;
60+use Std\Lib\Gitler\Keys;
61+
62+$usage = <<<TXT
63+使い方: php gitler.php [コマンド]
64+
65+ keys <ユーザー> <種類> <鍵> sshdのAuthorizedKeysCommand(%u %t %k)。鍵の持ち主のauthorized_keysの行
66+ authorized-keys 全部の鍵のauthorized_keys(AuthorizedKeysCommandを使わない場合)
67+ serve <ユーザー> [指紋] SSHの強制コマンド(SSH_ORIGINAL_COMMANDのgit-upload-pack等を処理する)
68+ gc 全部のリポジトリを整理する
69+
70+TXT;
71+
72+if (!Gitler::enabled()) {
73+ fwrite(STDERR, "Gitlerは無効です(config.phpのGITLER_ENABLED)。\n");
74+ exit(1);
75+}
76+
77+$args = array_slice($argv, 1);
78+switch ($args[0] ?? '') {
79+case 'keys':
80+ // %u(ログインのユーザー)、%t(鍵の種類)、%k(base64の鍵)
81+ [, $login, $type, $key] = $args + [null, '', '', ''];
82+ $sshUser = explode('@', (string)Gitler::config()['ssh'])[0] ?: 'git';
83+ if ($login !== $sshUser) exit(0);
84+ $found = Keys::find((string)$type, (string)$key);
85+ if ($found !== null) {
86+ $u = Gitler::user($found[0]);
87+ if ($u !== null && (int)($u->role ?? 0) !== Roles::BANNED) echo Keys::authorizedKey($found[0], $found[1]), "\n";
88+ }
89+ exit(0);
90+
91+case 'authorized-keys':
92+ foreach (Keys::all() as $user => $keys) {
93+ $u = Gitler::user($user);
94+ if ($u === null || (int)($u->role ?? 0) === Roles::BANNED) continue;
95+ foreach ($keys as $k) echo Keys::authorizedKey($user, $k), "\n";
96+ }
97+ exit(0);
98+
99+case 'serve':
100+ $name = (string)($args[1] ?? '');
101+ $user = Gitler::user($name);
102+ if ($user === null || (int)($user->role ?? 0) === Roles::BANNED) {
103+ fwrite(STDERR, "このユーザーは使えません。\n");
104+ exit(1);
105+ }
106+ if (isset($args[2])) Keys::touchKey($name, (string)$args[2]);
107+ $command = (string)getenv('SSH_ORIGINAL_COMMAND');
108+ if ($command === '') {
109+ fwrite(STDERR, "こんにちは、{$name}さん!Gitlerにはシェルが有りません。\n");
110+ exit(1);
111+ }
112+ exit(Ssh::handle($command, function(string $path, bool $write) use ($user): ?Repository {
113+ // 「所有者/名前.git」又は「所有者/名前」(前に「gitler/」が有っても良い)
114+ if (!preg_match('#^(?:gitler/)?([A-Za-z0-9]+)/([A-Za-z0-9._-]+?)(?:\.git)?/?$#', $path, $m)) return null;
115+ $access = Gitler::access($user, $m[1], $m[2]);
116+ if ($access < ($write ? Gitler::WRITE : Gitler::READ)) return null;
117+ return Gitler::open($m[1], $m[2]);
118+ }, ['agent' => 'Gitler', 'receive' => GitlerController::receiveOptions()]));
119+
120+case 'gc':
121+ $res = Gitler::gcAll();
122+ fwrite($res->isSuccess ? STDOUT : STDERR, $res->message."\n");
123+ exit($res->isSuccess ? 0 : 1);
124+
125+default:
126+ fwrite(STDERR, $usage);
127+ exit(2);
128+}
@@ -0,0 +1,354 @@
1+/* Gitler(/gitler)。JavaScriptなし */
2+.gl-top {
3+ display: flex;
4+ flex-wrap: wrap;
5+ gap: 8px 16px;
6+ align-items: center;
7+ border-bottom: 1px solid #c016c6;
8+ padding-bottom: 8px;
9+ margin-bottom: 8px;
10+}
11+
12+.gl-logo {
13+ font-weight: bold;
14+ font-size: 1.2em;
15+ text-decoration: none;
16+}
17+
18+.gl-search input {
19+ width: 16em;
20+ max-width: 50vw;
21+}
22+
23+.gl-links {
24+ display: flex;
25+ flex-wrap: wrap;
26+ gap: 12px;
27+ margin-left: auto;
28+}
29+
30+.gl-message {
31+ border: 1px solid #2c980c;
32+ border-radius: 2px;
33+ padding: 4px 8px;
34+}
35+
36+.gl-badge {
37+ display: inline-block;
38+ font-size: 0.8em;
39+ padding: 0 6px;
40+ border: 1px solid #746c75;
41+ border-radius: 10px;
42+ vertical-align: middle;
43+}
44+
45+.gl-avatar {
46+ width: 48px;
47+ height: 48px;
48+ vertical-align: middle;
49+ object-fit: cover;
50+}
51+
52+.gl-repos, .gl-commits, .gl-keys {
53+ list-style: none;
54+ padding: 0;
55+}
56+
57+.gl-repos li, .gl-commits li, .gl-keys li {
58+ border-bottom: 1px solid #550f75;
59+ padding: 8px 0;
60+}
61+
62+.gl-repos p {
63+ margin: 4px 0;
64+}
65+
66+.gl-keys form {
67+ display: inline;
68+}
69+
70+.gl-repohead h1 {
71+ margin: 8px 0;
72+ overflow-wrap: anywhere;
73+}
74+
75+.gl-desc {
76+ margin: 0 0 8px;
77+}
78+
79+.gl-tabs {
80+ display: flex;
81+ flex-wrap: wrap;
82+ gap: 4px;
83+ border-bottom: 1px solid #c016c6;
84+ margin-bottom: 8px;
85+}
86+
87+.gl-tabs a {
88+ padding: 4px 10px;
89+ text-decoration: none;
90+}
91+
92+.gl-tabs a.active {
93+ border: 1px solid #ea79d8;
94+ border-bottom: 0;
95+ border-radius: 6px 6px 0 0;
96+ background-color: #550f75;
97+}
98+
99+.gl-refbar {
100+ display: flex;
101+ flex-wrap: wrap;
102+ gap: 8px;
103+ align-items: flex-start;
104+ margin: 8px 0;
105+}
106+
107+.gl-refs summary, .gl-clone summary {
108+ cursor: pointer;
109+ border: 1px solid #ea79d8;
110+ border-radius: 4px;
111+ padding: 2px 8px;
112+ display: inline-block;
113+}
114+
115+.gl-refs div {
116+ display: flex;
117+ flex-direction: column;
118+ max-height: 300px;
119+ overflow-y: auto;
120+ border: 1px solid #746c75;
121+ padding: 4px 8px;
122+ margin-top: 4px;
123+}
124+
125+.gl-refs a.active {
126+ font-weight: bold;
127+}
128+
129+.gl-crumbs {
130+ overflow-wrap: anywhere;
131+ padding-top: 2px;
132+}
133+
134+.gl-clone {
135+ margin: 8px 0;
136+}
137+
138+.gl-clone div {
139+ display: flex;
140+ flex-direction: column;
141+ gap: 4px;
142+ margin-top: 4px;
143+}
144+
145+.gl-clone input {
146+ width: 100%;
147+ max-width: 40em;
148+ box-sizing: border-box;
149+ font-family: monospace;
150+}
151+
152+.gl-last, .gl-filebar {
153+ display: flex;
154+ flex-wrap: wrap;
155+ justify-content: space-between;
156+ gap: 8px;
157+ border: 1px solid #746c75;
158+ border-bottom: 0;
159+ padding: 6px 8px;
160+}
161+
162+.gl-filebar span {
163+ display: flex;
164+ gap: 10px;
165+}
166+
167+.gl-files {
168+ width: 100%;
169+ max-width: none;
170+ margin: 0 0 16px;
171+}
172+
173+.gl-files th, .gl-files td, .gl-lines td {
174+ width: auto;
175+ padding: 3px 8px;
176+}
177+
178+.gl-size {
179+ text-align: right !important;
180+ white-space: nowrap;
181+}
182+
183+.gl-readme {
184+ border: 1px solid #746c75;
185+ padding: 8px 16px;
186+ margin: 8px 0;
187+ overflow-x: auto;
188+}
189+
190+.gl-readme img {
191+ max-width: 100%;
192+}
193+
194+.gl-readme-name {
195+ font-weight: bold;
196+ border-bottom: 1px solid #746c75;
197+ margin: 0 -16px 8px;
198+ padding: 0 16px 6px;
199+}
200+
201+.gl-code {
202+ overflow-x: auto;
203+ border: 1px solid #746c75;
204+ margin-bottom: 16px;
205+}
206+
207+.gl-lines {
208+ width: 100%;
209+ max-width: none;
210+ margin: 0;
211+ border: 0;
212+ border-collapse: collapse;
213+ font-family: monospace;
214+ font-size: 0.9em;
215+}
216+
217+.gl-lines td {
218+ border: 0;
219+ padding: 0 8px;
220+ vertical-align: top;
221+}
222+
223+.gl-ln {
224+ width: 1% !important;
225+ text-align: right !important;
226+ color: #746c75;
227+ user-select: none;
228+ white-space: nowrap;
229+}
230+
231+.gl-ln a {
232+ color: inherit;
233+ text-decoration: none;
234+}
235+
236+.gl-src {
237+ white-space: pre;
238+}
239+
240+.gl-lines tr:target td {
241+ background-color: #550f75;
242+}
243+
244+.gl-blame-start td {
245+ border-top: 1px solid #550f75;
246+}
247+
248+.gl-blame-info {
249+ width: 14em !important;
250+ min-width: 14em;
251+ font-family: sans-serif;
252+ white-space: normal;
253+}
254+
255+.gl-line-add td {
256+ background-color: rgba(44, 152, 12, 0.25);
257+}
258+
259+.gl-line-del td {
260+ background-color: rgba(188, 23, 41, 0.25);
261+}
262+
263+.gl-hunk td {
264+ color: #1cbcd0;
265+}
266+
267+.gl-add {
268+ color: #2c980c;
269+}
270+
271+.gl-del {
272+ color: #ee4030;
273+}
274+
275+.gl-diff-head {
276+ border: 1px solid #746c75;
277+ border-bottom: 0;
278+ padding: 6px 8px;
279+ overflow-wrap: anywhere;
280+}
281+
282+.gl-st {
283+ display: inline-block;
284+ padding: 0 6px;
285+ border-radius: 4px;
286+ font-size: 0.8em;
287+ border: 1px solid #746c75;
288+}
289+
290+.gl-st-A {
291+ border-color: #2c980c;
292+}
293+
294+.gl-st-D {
295+ border-color: #ee4030;
296+}
297+
298+.gl-note {
299+ border: 1px solid #746c75;
300+ margin: 0 0 16px;
301+ padding: 6px 8px;
302+}
303+
304+.gl-body, .gl-pre {
305+ white-space: pre-wrap;
306+ overflow-wrap: anywhere;
307+}
308+
309+.gl-commit-links {
310+ float: right;
311+ display: flex;
312+ gap: 8px;
313+}
314+
315+.gl-pager {
316+ display: flex;
317+ justify-content: space-between;
318+}
319+
320+.gl-image img {
321+ max-width: 100%;
322+}
323+
324+.gl-form {
325+ display: flex;
326+ flex-direction: column;
327+ align-items: flex-start;
328+ gap: 6px;
329+ margin: 8px 0;
330+}
331+
332+.gl-form textarea, .gl-form input[type="text"] {
333+ width: 40em;
334+ max-width: 100%;
335+ box-sizing: border-box;
336+}
337+
338+.gl-row {
339+ display: flex;
340+ flex-wrap: wrap;
341+ gap: 4px;
342+ align-items: center;
343+ margin: 4px 0;
344+}
345+
346+.gl-token {
347+ word-break: break-all;
348+ font-size: 1.1em;
349+}
350+
351+button.danger {
352+ border-color: #ee4030;
353+ color: #ee4030;
354+}
変更 route.php
@@ -44,6 +44,7 @@
4444 use Site\Controller\Atom;
4545 use Site\Controller\Comment;
4646 use Site\Controller\Fediverse;
47+use Site\Controller\Gitler;
4748 use Site\Controller\Home;
4849 use Site\Controller\Microblog;
4950 use Site\Controller\Notfound;
@@ -155,6 +156,25 @@
155156 $routes[] = Route::add('POST', 'admin/cache', Admin::class.'@cache');
156157 $routes[] = Route::add('GET', 'admin/convert', Admin::class.'@convert');
157158 $routes[] = Route::add('POST', 'admin/convert', Admin::class.'@convert');
159+}
160+
161+// Gitler(Gitのホスティング)。具体的なパスを先に
162+if (\Std\Lib\Gitler\Gitler::enabled()) {
163+ $routes[] = Route::add('GET', 'gitler', Gitler::class.'@index');
164+ $routes[] = Route::add('GET', 'gitler/-/new', Gitler::class.'@create');
165+ $routes[] = Route::add('POST', 'gitler/-/new', Gitler::class.'@create');
166+ $routes[] = Route::add('GET', 'gitler/-/keys', Gitler::class.'@keys');
167+ $routes[] = Route::add('POST', 'gitler/-/keys', Gitler::class.'@keys');
168+ $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/info/refs', Gitler::class.'@git', ['action' => 'info/refs']);
169+ $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/git-upload-pack', Gitler::class.'@git', ['action' => 'git-upload-pack']);
170+ $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/git-receive-pack', Gitler::class.'@git', ['action' => 'git-receive-pack']);
171+ $routes[] = Route::add('GET', 'gitler/{owner}/{repo}/commit/{id}', Gitler::class.'@commit');
172+ foreach (['tree', 'blob', 'raw', 'blame', 'commits', 'branches', 'tags', 'archive', 'settings'] as $page) {
173+ $routes[] = Route::add('GET', "gitler/{owner}/{repo}/{$page}", Gitler::class.'@'.$page);
174+ }
175+ $routes[] = Route::add('POST', 'gitler/{owner}/{repo}/settings', Gitler::class.'@settings');
176+ $routes[] = Route::add('GET', 'gitler/{owner}/{repo}', Gitler::class.'@repo');
177+ $routes[] = Route::add('GET', 'gitler/{owner}', Gitler::class.'@owner');
158178 }
159179
160180 if (AUTH_ENABLED && \Std\Lib\Comments::enabled()) {
@@ -0,0 +1,781 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Site\Controller;
39+
40+use Site\Controller\Mods;
41+use Std\Lib\Auth;
42+use Std\Lib\Git\Blame;
43+use Std\Lib\Git\Diff;
44+use Std\Lib\Git\GitException;
45+use Std\Lib\Git\Http;
46+use Std\Lib\Git\Refs;
47+use Std\Lib\Git\Repository;
48+use Std\Lib\Git\Tree;
49+use Std\Lib\Gitler\Archive;
50+use Std\Lib\Gitler\Gitler as Hosting;
51+use Std\Lib\Gitler\Keys;
52+use Std\Lib\Markdown;
53+use Std\Lib\Syntax;
54+use Std\Lib\Template;
55+use Roles;
56+
57+/**
58+ * Gitler(/gitler):Gitのリポジトリのホスティング。JavaScriptなし
59+ *
60+ * リポジトリのページはクエリでリビジョンとパスを渡す(例:/gitler/user/repo/blob?ref=main&path=src/a.php)。
61+ * git clone https://…/gitler/user/repo.git と SSH(gitler.php)に対応。
62+ */
63+class Gitler {
64+ use Mods;
65+
66+ /** 1ページのコミットの数 */
67+ private const PER_PAGE = 30;
68+ /** これより大きいファイルは表示しない */
69+ private const MAX_VIEW = 1048576;
70+ /** 差分を表示する最大のファイル数と行数 */
71+ private const MAX_DIFF_FILES = 300;
72+ private const MAX_DIFF_LINES = 3000;
73+ private const MAX_DIFF_TOTAL = 20000;
74+
75+ private ?\stdClass $user;
76+ private ?Repository $repo = null;
77+ private string $owner = '';
78+ private string $name = '';
79+ private array $meta = [];
80+ private int $access = Hosting::NONE;
81+
82+ public function __construct() {
83+ if (!Hosting::enabled()) {
84+ header('Location: /404');
85+ exit;
86+ }
87+ $this->user = (new Auth())->getLoggedInUser();
88+ }
89+
90+ //////// 一覧
91+
92+ /** /gitler:見られるリポジトリ */
93+ public function index(array $params): void {
94+ $q = trim((string)($_GET['q'] ?? ''));
95+ $list = [];
96+ foreach (Hosting::list() as $r) {
97+ if (Hosting::access($this->user, $r['owner'], $r['name']) === Hosting::NONE) continue;
98+ if ($q !== '' && mb_stripos($r['owner'].'/'.$r['name'].' '.$r['meta']['description'], $q) === false) continue;
99+ $list[] = $this->presentRepo($r);
100+ }
101+ $this->render('gitler/index', 'Gitler', ['list' => $list, 'q' => $q, 'heading' => 'リポジトリ']);
102+ }
103+
104+ /** /gitler/{owner}:ユーザーのリポジトリ */
105+ public function owner(array $params): void {
106+ $owner = (string)($params['owner'] ?? '');
107+ $u = Hosting::validOwner($owner) ? Hosting::user($owner) : null;
108+ if ($u === null) $this->notFound();
109+ $list = [];
110+ foreach (Hosting::list($owner) as $r) {
111+ if (Hosting::access($this->user, $r['owner'], $r['name']) !== Hosting::NONE) $list[] = $this->presentRepo($r);
112+ }
113+ $this->render('gitler/index', $owner, ['list' => $list, 'q' => '', 'heading' => ($u->displayname ?? '') !== '' ? "{$u->displayname}({$owner})" : $owner,
114+ 'ownerPage' => $owner, 'avatar' => avatar_url($u)]);
115+ }
116+
117+ /** /gitler/-/new:リポジトリを作る */
118+ public function create(array $params): void {
119+ if (!$this->user) $this->redirect('/login');
120+ if (!Hosting::userCan($this->user, 'repos')) $this->message('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。');
121+ $error = '';
122+ $in = ['name' => '', 'description' => '', 'private' => false, 'readme' => true, 'objectFormat' => (string)Hosting::config()['objectFormat'], 'branch' => 'main'];
123+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
124+ $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+ if (!$this->csrfOk()) {
127+ $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
128+ } else if (mb_strlen($in['description']) > 500) {
129+ $error = '説明は500文字までです。';
130+ } else {
131+ $res = Hosting::create($this->user, $in['name'], $in);
132+ if ($res->isSuccess) $this->redirect('/gitler/'.$res->data['owner'].'/'.rawurlencode($res->data['name']));
133+ $error = (string)$res->message;
134+ }
135+ }
136+ $this->render('gitler/new', '新しいリポジトリ', ['in' => $in, 'error' => $error]);
137+ }
138+
139+ /** /gitler/-/keys:SSHの鍵とアクセストークン */
140+ public function keys(array $params): void {
141+ if (!$this->user) $this->redirect('/login');
142+ $me = (string)$this->user->username;
143+ $error = '';
144+ $message = '';
145+ $newToken = null;
146+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
147+ if (!$this->csrfOk()) {
148+ $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
149+ } else {
150+ $do = (string)($_POST['do'] ?? '');
151+ if ($do === 'addKey') {
152+ $res = Keys::addKey($me, (string)($_POST['key'] ?? ''), (string)($_POST['title'] ?? ''));
153+ $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message;
154+ } else if ($do === 'removeKey') {
155+ Keys::removeKey($me, (string)($_POST['fingerprint'] ?? '')) ? $message = '鍵を削除しました。' : $error = '鍵が有りません。';
156+ } else if ($do === 'addToken') {
157+ $res = Keys::addToken($me, (string)($_POST['name'] ?? ''), isset($_POST['write']));
158+ if ($res->isSuccess) {
159+ $message = (string)$res->message;
160+ $newToken = $res->data['token'];
161+ } else {
162+ $error = (string)$res->message;
163+ }
164+ } else if ($do === 'removeToken') {
165+ Keys::removeToken($me, (string)($_POST['id'] ?? '')) ? $message = 'トークンを削除しました。' : $error = 'トークンが有りません。';
166+ }
167+ }
168+ }
169+ $data = Keys::load($me);
170+ $fmt = fn($t) => $t ? date('Y-m-d H:i', (int)$t) : '-';
171+ $this->render('gitler/keys', 'SSHの鍵とトークン', [
172+ 'keys' => array_map(fn($k) => $k + ['addedText' => $fmt($k['added'] ?? null), 'usedText' => $fmt($k['lastUsed'] ?? null)], $data['keys']),
173+ 'tokens' => array_map(fn($t) => $t + ['createdText' => $fmt($t['created'] ?? null), 'usedText' => $fmt($t['lastUsed'] ?? null)], $data['tokens']),
174+ 'newToken' => $newToken, 'error' => $error, 'message' => $message, 'ssh' => (string)Hosting::config()['ssh'], 'me' => $me,
175+ 'twoFactor' => !empty($this->user->twoFactorOn),
176+ ]);
177+ }
178+
179+ //////// リポジトリ
180+
181+ /** /gitler/{owner}/{repo}:ファイルとREADME */
182+ public function repo(array $params): void {
183+ // ブラウザで「.git」を付けて開いた場合
184+ if (str_ends_with((string)($params['repo'] ?? ''), '.git')) $this->redirect('/gitler/'.$params['owner'].'/'.rawurlencode(substr($params['repo'], 0, -4)));
185+ $this->load($params);
186+ if ($this->repo->isEmpty()) {
187+ $this->renderRepo('gitler/empty', '', []);
188+ return;
189+ }
190+ $this->showTree($this->defaultRef(), '');
191+ }
192+
193+ /** …/tree?ref=&path= */
194+ public function tree(array $params): void {
195+ $this->load($params);
196+ $this->showTree($this->ref(), $this->path());
197+ }
198+
199+ private function showTree(string $ref, string $path): void {
200+ $commit = $this->commitOf($ref);
201+ $entry = $this->repo->entry($commit, $path);
202+ if ($entry === null) $this->notFound();
203+ if ($entry[0] !== Tree::DIR) $this->redirect($this->url('blob', $ref, $path));
204+ $dirs = [];
205+ $files = [];
206+ $readme = null;
207+ foreach ($this->repo->tree($entry[2])->entries as [$mode, $name, $id]) {
208+ $p = ltrim($path.'/'.$name, '/');
209+ if ($mode === Tree::DIR) {
210+ $dirs[] = ['name' => $name, 'url' => $this->url('tree', $ref, $p), 'icon' => '📁', 'size' => ''];
211+ } else if ($mode === Tree::SUBMODULE) {
212+ $dirs[] = ['name' => $name.' @ '.substr($id, 0, 7), 'url' => '', 'icon' => '📦', 'size' => ''];
213+ } else {
214+ $size = $this->repo->objects->info($id)[1] ?? 0;
215+ $files[] = ['name' => $name, 'url' => $this->url('blob', $ref, $p), 'icon' => $mode === Tree::LINK ? '🔗' : '📄', 'size' => $this->size($size)];
216+ if ($readme === null && preg_match('/^readme(\.(md|markdown|txt))?$/i', $name)) $readme = [$name, $id];
217+ }
218+ }
219+ $readmeHtml = '';
220+ if ($readme !== null) {
221+ $text = $this->repo->blob($readme[1]);
222+ $readmeHtml = preg_match('/\.(md|markdown)$/i', $readme[0]) ? $this->markdown($text, $ref, $path) : '<pre>'.htmlspecialchars($text, ENT_QUOTES, 'UTF-8').'</pre>';
223+ }
224+ $c = $this->repo->commit($commit);
225+ $this->renderRepo('gitler/tree', $path === '' ? '' : $path, [
226+ 'entries' => array_merge($dirs, $files),
227+ 'readmeName' => $readme[0] ?? '',
228+ 'readme' => $readmeHtml,
229+ 'last' => $this->presentCommit($c),
230+ 'parentUrl' => $path === '' ? '' : $this->url('tree', $ref, dirname($path) === '.' ? '' : dirname($path)),
231+ ], $ref, $path);
232+ }
233+
234+ /** …/blob?ref=&path=:ファイル */
235+ public function blob(array $params): void {
236+ $this->load($params);
237+ $ref = $this->ref();
238+ $path = $this->path();
239+ $entry = $this->repo->entry($this->commitOf($ref), $path);
240+ if ($entry === null) $this->notFound();
241+ if ($entry[0] === Tree::DIR) $this->redirect($this->url('tree', $ref, $path));
242+ $size = $this->repo->objects->info($entry[2])[1] ?? 0;
243+ $vars = ['size' => $this->size($size), 'lines' => [], 'html' => '', 'kind' => 'text', 'rawUrl' => $this->url('raw', $ref, $path),
244+ 'blameUrl' => $this->url('blame', $ref, $path), 'historyUrl' => $this->url('commits', $ref, $path), 'lineCount' => 0, 'rendered' => false];
245+ if ($entry[0] === Tree::LINK) {
246+ $vars['kind'] = 'link';
247+ $vars['target'] = $this->repo->blob($entry[2]);
248+ } else if ($size > self::MAX_VIEW) {
249+ $vars['kind'] = 'large';
250+ } else {
251+ $data = $this->repo->blob($entry[2]);
252+ if (Diff::isBinary($data) || !mb_check_encoding($data, 'UTF-8')) {
253+ $vars['kind'] = preg_match('/\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i', $path) ? 'image' : 'binary';
254+ } else if (preg_match('/\.(md|markdown)$/i', $path) && !isset($_GET['plain'])) {
255+ $vars['kind'] = 'markdown';
256+ $vars['html'] = $this->markdown($data, $ref, dirname($path) === '.' ? '' : dirname($path));
257+ $vars['plainUrl'] = $this->url('blob', $ref, $path).'&plain=1';
258+ } else {
259+ $vars['lines'] = $this->highlight($data, $path);
260+ $vars['lineCount'] = count($vars['lines']);
261+ if (preg_match('/\.(md|markdown)$/i', $path)) $vars['renderUrl'] = $this->url('blob', $ref, $path);
262+ }
263+ }
264+ $this->renderRepo('gitler/blob', $path, $vars, $ref, $path);
265+ }
266+
267+ /** …/raw?ref=&path=:ファイルの中身(スクリプトは動かない様に) */
268+ public function raw(array $params): void {
269+ $this->load($params);
270+ $path = $this->path();
271+ $entry = $this->repo->entry($this->commitOf($this->ref()), $path);
272+ if ($entry === null || $entry[0] === Tree::DIR || $entry[0] === Tree::SUBMODULE) $this->notFound();
273+ $data = $this->repo->blob($entry[2]);
274+ $types = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif',
275+ 'bmp' => 'image/bmp', 'ico' => 'image/x-icon', 'svg' => 'image/svg+xml', 'pdf' => 'application/pdf'];
276+ $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
277+ $type = $types[$ext] ?? (Diff::isBinary($data) || !mb_check_encoding($data, 'UTF-8') ? 'application/octet-stream' : 'text/plain; charset=UTF-8');
278+ while (ob_get_level() > 0) ob_end_clean();
279+ header('Content-Type: '.$type);
280+ header('Content-Length: '.strlen($data));
281+ header("Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox");
282+ header('X-Content-Type-Options: nosniff');
283+ if ($type === 'application/octet-stream') header("Content-Disposition: attachment; filename*=UTF-8''".rawurlencode(basename($path)));
284+ header('Cache-Control: '.($this->meta['private'] ? 'private' : 'public').', max-age=300');
285+ echo $data;
286+ exit;
287+ }
288+
289+ /** …/blame?ref=&path= */
290+ public function blame(array $params): void {
291+ $this->load($params);
292+ $ref = $this->ref();
293+ $path = $this->path();
294+ $commit = $this->commitOf($ref);
295+ $entry = $this->repo->entry($commit, $path);
296+ if ($entry === null || $entry[0] === Tree::DIR) $this->notFound();
297+ if (($this->repo->objects->info($entry[2])[1] ?? 0) > self::MAX_VIEW || Diff::isBinary($this->repo->blob($entry[2]))) {
298+ $this->message('このファイルのblameは表示出来ません。', $this->url('blob', $ref, $path));
299+ }
300+ $lines = Blame::file($this->repo, $commit, $path, 2000);
301+ $groups = [];
302+ $commits = [];
303+ foreach ($lines as $i => $l) {
304+ $commits[$l['commit']] ??= $this->presentCommit($this->repo->commit($l['commit']));
305+ if (empty($groups) || end($groups)['commit']['id'] !== $l['commit']) $groups[] = ['commit' => $commits[$l['commit']], 'lines' => []];
306+ $groups[count($groups) - 1]['lines'][] = ['n' => $i + 1, 'text' => $l['text']];
307+ }
308+ $this->renderRepo('gitler/blame', $path, ['groups' => $groups, 'blobUrl' => $this->url('blob', $ref, $path)], $ref, $path);
309+ }
310+
311+ /** …/commits?ref=&path=&page= */
312+ public function commits(array $params): void {
313+ $this->load($params);
314+ $ref = $this->ref();
315+ $path = $this->path();
316+ $page = max(1, (int)($_GET['page'] ?? 1));
317+ $list = [];
318+ $more = false;
319+ foreach ($this->repo->log($this->commitOf($ref), ['path' => $path, 'skip' => ($page - 1) * self::PER_PAGE, 'limit' => self::PER_PAGE + 1]) as $c) {
320+ if (count($list) === self::PER_PAGE) {
321+ $more = true;
322+ break;
323+ }
324+ $list[] = $this->presentCommit($c);
325+ }
326+ $base = $this->url('commits', $ref, $path);
327+ $this->renderRepo('gitler/commits', 'コミット', [
328+ 'list' => $list,
329+ 'prev' => $page > 1 ? $base.'&page='.($page - 1) : '',
330+ 'next' => $more ? $base.'&page='.($page + 1) : '',
331+ ], $ref, $path);
332+ }
333+
334+ /** …/commit/{id}:コミットと差分 */
335+ public function commit(array $params): void {
336+ $this->load($params);
337+ $id = $this->repo->resolve((string)($params['id'] ?? ''));
338+ $id = $id === null ? null : $this->repo->peel($id, 'commit');
339+ if ($id === null) $this->notFound();
340+ $c = $this->repo->commit($id);
341+ $parent = $c->parents[0] ?? null;
342+ $changes = Diff::trees($this->repo, $parent !== null ? $this->repo->commit($parent)->tree : null, $c->tree);
343+ $files = [];
344+ $add = 0;
345+ $del = 0;
346+ $budget = self::MAX_DIFF_TOTAL;
347+ foreach (array_slice($changes, 0, self::MAX_DIFF_FILES) as $ch) $files[] = $this->presentChange($ch, $id, $add, $del, $budget);
348+ $body = trim(substr($c->message, strlen(strtok($c->message, "\n") ?: '')));
349+ $this->renderRepo('gitler/commit', $c->subject(), [
350+ 'c' => $this->presentCommit($c),
351+ 'body' => $body,
352+ 'parents' => array_map(fn($p) => ['id' => $p, 'short' => substr($p, 0, 7), 'url' => $this->repoUrl().'/commit/'.$p], $c->parents),
353+ 'files' => $files,
354+ 'truncated' => count($changes) > self::MAX_DIFF_FILES,
355+ 'stats' => ['files' => count($changes), 'add' => $add, 'del' => $del],
356+ 'treeUrl' => $this->url('tree', $id, ''),
357+ 'signed' => $c->header('gpgsig') !== null,
358+ ], $id);
359+ }
360+
361+ /** …/branches */
362+ public function branches(array $params): void {
363+ $this->load($params);
364+ $default = $this->defaultBranch();
365+ $list = [];
366+ foreach ($this->repo->refs->all('refs/heads/') as $name => $id) {
367+ $short = substr($name, 11);
368+ $c = $this->repo->commit($id);
369+ $list[] = ['name' => $short, 'url' => $this->url('tree', $short, ''), 'default' => $short === $default, 'c' => $this->presentCommit($c), 'time' => $c->committer?->time ?? 0,
370+ 'zip' => $this->url('archive', $short, '').'&format=zip'];
371+ }
372+ usort($list, fn($a, $b) => [$b['default'], $b['time']] <=> [$a['default'], $a['time']]);
373+ $this->renderRepo('gitler/branches', 'ブランチ', ['list' => $list]);
374+ }
375+
376+ /** …/tags */
377+ public function tags(array $params): void {
378+ $this->load($params);
379+ $list = [];
380+ foreach ($this->repo->refs->all('refs/tags/') as $name => $id) {
381+ $short = substr($name, 10);
382+ $target = $this->repo->peel($id, 'commit');
383+ if ($target === null) continue;
384+ $c = $this->repo->commit($target);
385+ $message = ($this->repo->objects->info($id)[0] ?? '') === 'tag' ? trim(preg_replace('/-----BEGIN [A-Z ]+SIGNATURE-----.*$/s', '', $this->repo->tag($id)->message)) : '';
386+ $list[] = ['name' => $short, 'url' => $this->url('tree', $short, ''), 'c' => $this->presentCommit($c), 'time' => $c->committer?->time ?? 0, 'message' => $message,
387+ 'zip' => $this->url('archive', $short, '').'&format=zip', 'tgz' => $this->url('archive', $short, '').'&format=tar.gz'];
388+ }
389+ usort($list, fn($a, $b) => $b['time'] <=> $a['time']);
390+ $this->renderRepo('gitler/tags', 'タグ', ['list' => $list]);
391+ }
392+
393+ /** …/archive?ref=&format=zip|tar.gz|tar */
394+ public function archive(array $params): void {
395+ $this->load($params);
396+ $ref = $this->ref();
397+ $format = (string)($_GET['format'] ?? 'zip');
398+ if (!isset(Archive::FORMATS[$format])) $this->notFound();
399+ $commit = $this->commitOf($ref);
400+ $label = preg_replace('/[^A-Za-z0-9._-]/', '-', $this->repo->isId($ref) ? substr($ref, 0, 7) : $ref);
401+ @set_time_limit(0);
402+ while (ob_get_level() > 0) ob_end_clean();
403+ header('Content-Type: '.Archive::FORMATS[$format]);
404+ header('Content-Disposition: attachment; filename="'.$this->name.'-'.$label.'.'.$format.'"');
405+ header('X-Content-Type-Options: nosniff');
406+ Archive::write($this->repo, $commit, $format, $this->name.'-'.$label.'/', function(string $b): void {
407+ echo $b;
408+ flush();
409+ });
410+ exit;
411+ }
412+
413+ /** …/settings */
414+ public function settings(array $params): void {
415+ $this->load($params);
416+ if ($this->access < Hosting::ADMIN) $this->notFound();
417+ $error = '';
418+ $message = '';
419+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
420+ if (!$this->csrfOk()) {
421+ $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
422+ } else {
423+ $do = (string)($_POST['do'] ?? '');
424+ $meta = $this->meta;
425+ if ($do === 'general') {
426+ $desc = trim((string)($_POST['description'] ?? ''));
427+ $branch = (string)($_POST['branch'] ?? '');
428+ if (mb_strlen($desc) > 500) {
429+ $error = '説明は500文字までです。';
430+ } else {
431+ $meta['description'] = preg_replace('/[\x00-\x09\x0b-\x1f\x7f]/', '', $desc);
432+ $meta['private'] = isset($_POST['private']);
433+ Hosting::saveMeta($this->owner, $this->name, $meta);
434+ if ($branch !== '' && $this->repo->refs->resolve('refs/heads/'.$branch) !== null) $this->repo->refs->setSymbolic('HEAD', 'refs/heads/'.$branch);
435+ $message = '保存しました。';
436+ }
437+ } else if ($do === 'addCollaborator') {
438+ $who = trim((string)($_POST['username'] ?? ''));
439+ $perm = ($_POST['permission'] ?? 'read') === 'write' ? 'write' : 'read';
440+ if ($who === $this->owner || Hosting::user($who) === null) {
441+ $error = "ユーザー「{$who}」は居ません。";
442+ } else {
443+ $meta['collaborators'][$who] = $perm;
444+ Hosting::saveMeta($this->owner, $this->name, $meta);
445+ $message = "{$who}を追加しました。";
446+ }
447+ } else if ($do === 'removeCollaborator') {
448+ unset($meta['collaborators'][(string)($_POST['username'] ?? '')]);
449+ Hosting::saveMeta($this->owner, $this->name, $meta);
450+ $message = '削除しました。';
451+ } else if ($do === 'rename') {
452+ $new = trim((string)($_POST['name'] ?? ''));
453+ $res = Hosting::rename($this->owner, $this->name, $new);
454+ if ($res->isSuccess) $this->redirect('/gitler/'.$this->owner.'/'.rawurlencode($new).'/settings');
455+ $error = (string)$res->message;
456+ } else if ($do === 'delete') {
457+ if ((string)($_POST['confirm'] ?? '') !== $this->owner.'/'.$this->name) {
458+ $error = '確認の為、リポジトリの名前(所有者/名前)を正しく入力して下さい。';
459+ } else {
460+ Hosting::delete($this->owner, $this->name);
461+ $this->redirect('/gitler/'.$this->owner);
462+ }
463+ } else if ($do === 'gc') {
464+ try {
465+ $r = \Std\Lib\Git\Maintenance::gc($this->repo);
466+ $message = "整理しました(オブジェクト{$r['objects']}個)。";
467+ } catch (GitException $e) {
468+ $error = $e->getMessage();
469+ }
470+ }
471+ $this->meta = Hosting::meta($this->owner, $this->name);
472+ }
473+ }
474+ $this->renderRepo('gitler/settings', '設定', [
475+ 'error' => $error, 'message' => $message,
476+ 'branches' => array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/'))),
477+ 'currentBranch' => $this->defaultBranch(),
478+ 'collaborators' => $this->meta['collaborators'],
479+ ]);
480+ }
481+
482+ //////// Git(HTTP)
483+
484+ /** …/info/refs、…/git-upload-pack、…/git-receive-pack */
485+ public function git(array $params): void {
486+ $owner = (string)($params['owner'] ?? '');
487+ $name = (string)($params['repo'] ?? '');
488+ if (str_ends_with($name, '.git')) $name = substr($name, 0, -4);
489+ $action = (string)($params['action'] ?? '');
490+ $write = $action === 'git-receive-pack' || ($action === 'info/refs' && ($_GET['service'] ?? '') === 'git-receive-pack');
491+ [$user, $tokenWrite, $tried] = $this->basicAuth();
492+ $user ??= $this->user;
493+ $exists = Hosting::validOwner($owner) && Hosting::validName($name) && Hosting::exists($owner, $name);
494+ $access = $exists ? Hosting::access($user, $owner, $name) : Hosting::NONE;
495+ $need = $write ? Hosting::WRITE : Hosting::READ;
496+ if ($access < $need || ($write && !$tokenWrite)) {
497+ // 認証していなければ、先に認証を求める(非公開のリポジトリが有るか分からない様に)
498+ if ($user === null || ($write && $access >= Hosting::WRITE && !$tokenWrite && !$tried)) {
499+ header('WWW-Authenticate: Basic realm="Gitler", charset="UTF-8"');
500+ http_response_code(401);
501+ echo "認証が必要です(ユーザー名とアクセストークン)。\n";
502+ exit;
503+ }
504+ http_response_code($exists && $access >= Hosting::READ ? 403 : 404);
505+ echo $exists && $access >= Hosting::READ ? "プッシュする権限が有りません。\n" : "リポジトリが有りません。\n";
506+ exit;
507+ }
508+ $repo = Hosting::open($owner, $name);
509+ Http::handle($repo, $action, ['push' => $write, 'agent' => 'Gitler', 'receive' => self::receiveOptions()]);
510+ exit;
511+ }
512+
513+ /** プッシュの制限(ブランチとタグだけ) */
514+ public static function receiveOptions(): array {
515+ return [
516+ 'update' => fn(string $ref, string $old, string $new): ?string => str_starts_with($ref, 'refs/heads/') || str_starts_with($ref, 'refs/tags/') ? null : 'only branches and tags',
517+ ];
518+ }
519+
520+ /**
521+ * Basic認証(ユーザー名とアクセストークン、又は二段階認証が無ければパスワード)
522+ *
523+ * @return array [ユーザー, プッシュ出来るか, 認証を送ったか]
524+ */
525+ private function basicAuth(): array {
526+ $name = $_SERVER['PHP_AUTH_USER'] ?? null;
527+ $pass = $_SERVER['PHP_AUTH_PW'] ?? null;
528+ $header = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
529+ if ($name === null && preg_match('/^Basic\s+(\S+)$/i', $header, $m)) {
530+ $dec = (string)base64_decode($m[1], true);
531+ if (str_contains($dec, ':')) [$name, $pass] = explode(':', $dec, 2);
532+ }
533+ if ($name === null || $pass === null) return [null, $this->user !== null, false];
534+ $name = (string)$name;
535+ if (!Hosting::validOwner($name)) return [null, false, true];
536+ $token = Keys::checkToken($name, (string)$pass);
537+ if ($token !== null) {
538+ $u = Hosting::user($name);
539+ return [$u, $u !== null && !empty($token['write']), true];
540+ }
541+ $u = (new Auth())->checkPassword($name, (string)$pass);
542+ return [$u, $u !== null, true];
543+ }
544+
545+ //////// 共通
546+
547+ /** リポジトリを開いて権限を確かめる */
548+ private function load(array $params): void {
549+ $this->owner = (string)($params['owner'] ?? '');
550+ $this->name = (string)($params['repo'] ?? '');
551+ if (!Hosting::validOwner($this->owner) || !Hosting::validName($this->name) || !Hosting::exists($this->owner, $this->name)) $this->notFound();
552+ $this->access = Hosting::access($this->user, $this->owner, $this->name);
553+ if ($this->access === Hosting::NONE) {
554+ if (!$this->user) $this->redirect('/login');
555+ $this->notFound();
556+ }
557+ $this->repo = Hosting::open($this->owner, $this->name);
558+ $this->meta = Hosting::meta($this->owner, $this->name);
559+ }
560+
561+ private function defaultBranch(): string {
562+ $h = $this->repo->headRef();
563+ return $h !== null && str_starts_with($h, 'refs/heads/') ? substr($h, 11) : 'main';
564+ }
565+
566+ private function defaultRef(): string {
567+ $b = $this->defaultBranch();
568+ if ($this->repo->refs->resolve('refs/heads/'.$b) !== null) return $b;
569+ $first = array_key_first($this->repo->refs->all('refs/heads/'));
570+ return $first !== null ? substr($first, 11) : $b;
571+ }
572+
573+ private function ref(): string {
574+ $r = trim((string)($_GET['ref'] ?? ''));
575+ return $r === '' ? $this->defaultRef() : $r;
576+ }
577+
578+ private function path(): string {
579+ $p = trim(str_replace('\\', '/', (string)($_GET['path'] ?? '')), '/');
580+ if (preg_match('#(^|/)\.\.?(/|$)#', $p) || str_contains($p, "\0")) $this->notFound();
581+ return $p;
582+ }
583+
584+ /** リビジョンのコミット(無ければ404) */
585+ private function commitOf(string $ref): string {
586+ if ($ref === '' || str_contains($ref, ':')) $this->notFound();
587+ $id = $this->repo->resolve($ref);
588+ $id = $id === null ? null : $this->repo->peel($id, 'commit');
589+ if ($id === null) $this->notFound();
590+ return $id;
591+ }
592+
593+ private function repoUrl(): string {
594+ return '/gitler/'.$this->owner.'/'.rawurlencode($this->name);
595+ }
596+
597+ private function url(string $page, string $ref, string $path): string {
598+ return $this->repoUrl().'/'.$page.'?ref='.rawurlencode($ref).($path !== '' ? '&path='.str_replace('%2F', '/', rawurlencode($path)) : '');
599+ }
600+
601+ private function presentRepo(array $r): array {
602+ return ['owner' => $r['owner'], 'name' => $r['name'], 'description' => $r['meta']['description'], 'private' => $r['meta']['private'],
603+ 'url' => '/gitler/'.$r['owner'].'/'.rawurlencode($r['name']), 'updated' => $r['updated'] ? date('Y-m-d H:i', $r['updated']) : '-'];
604+ }
605+
606+ private function presentCommit(\Std\Lib\Git\Commit $c): array {
607+ $id = (string)$c->id;
608+ return ['id' => $id, 'short' => substr($id, 0, 7), 'subject' => $c->subject(), 'url' => $this->repoUrl().'/commit/'.$id,
609+ 'author' => $c->author?->name ?? '', 'email' => $c->author?->email ?? '', 'date' => $c->author ? $c->author->date()->format('Y-m-d H:i') : '',
610+ 'treeUrl' => $this->url('tree', $id, '')];
611+ }
612+
613+ /** 差分の1ファイル */
614+ private function presentChange(array $ch, string $commit, int &$add, int &$del, int &$budget): array {
615+ [$om, $op, $oid] = $ch['old'] ?? [null, null, null];
616+ [$nm, $np, $nid] = $ch['new'] ?? [null, null, null];
617+ $status = ['A' => '追加', 'D' => '削除', 'M' => '変更', 'R' => '名前の変更', 'T' => '種類の変更'][$ch['status']];
618+ $out = ['status' => $ch['status'], 'statusText' => $status, 'path' => $np ?? $op, 'oldPath' => $ch['status'] === 'R' ? $op : '', 'hunks' => [], 'note' => '',
619+ 'url' => $np !== null ? $this->url('blob', $commit, $np) : ''];
620+ if ($oid === $nid) return $out;
621+ $old = $oid !== null && $om !== Tree::SUBMODULE ? $this->repo->blob($oid) : '';
622+ $new = $nid !== null && $nm !== Tree::SUBMODULE ? $this->repo->blob($nid) : '';
623+ if ($om === Tree::SUBMODULE || $nm === Tree::SUBMODULE) {
624+ $out['note'] = 'サブモジュール:'.substr((string)$oid, 0, 7).' → '.substr((string)$nid, 0, 7);
625+ return $out;
626+ }
627+ if (Diff::isBinary($old) || Diff::isBinary($new)) {
628+ $out['note'] = 'バイナリファイル';
629+ return $out;
630+ }
631+ if (strlen($old) + strlen($new) > Diff::MAX_BYTES) {
632+ $out['note'] = '大き過ぎるので表示しません';
633+ return $out;
634+ }
635+ if ($budget <= 0) {
636+ $out['note'] = '差分が多過ぎるので表示しません';
637+ return $out;
638+ }
639+ $lines = 0;
640+ foreach (Diff::hunks(Diff::split($old), Diff::split($new)) as $h) {
641+ $rows = [];
642+ $o = $h['oldStart'];
643+ $n = $h['newStart'];
644+ foreach ($h['lines'] as [$op2, $text]) {
645+ if ($op2 === '+') $add++;
646+ if ($op2 === '-') $del++;
647+ $rows[] = ['op' => $op2, 'old' => $op2 === '+' ? '' : $o++, 'new' => $op2 === '-' ? '' : $n++, 'text' => $text];
648+ }
649+ $lines += count($rows);
650+ $budget -= count($rows);
651+ $out['hunks'][] = ['head' => "@@ -{$h['oldStart']},{$h['oldLines']} +{$h['newStart']},{$h['newLines']} @@", 'rows' => $rows];
652+ if ($lines > self::MAX_DIFF_LINES || $budget <= 0) {
653+ $out['note'] = '長いので途中までです';
654+ break;
655+ }
656+ }
657+ return $out;
658+ }
659+
660+ /** 行毎のHTML(ハイライト出来る言語なら色付き) */
661+ private function highlight(string $text, string $path): array {
662+ $text = str_replace(["\r\n", "\r"], "\n", $text);
663+ $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
664+ $lang = $ext !== '' ? Syntax::resolve($ext) : null;
665+ $html = $lang !== null ? (new Syntax($lang))->colorize($text) : htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
666+ $lines = explode("\n", $html);
667+ if (end($lines) === '') array_pop($lines);
668+ // 複数行のspan(コメント等)を行毎に閉じて開き直す
669+ $open = [];
670+ foreach ($lines as $i => $l) {
671+ $prefix = implode('', $open);
672+ preg_match_all('#<span class="[^"]*">|</span>#', $l, $m);
673+ foreach ($m[0] as $tag) {
674+ if ($tag === '</span>') array_pop($open);
675+ else $open[] = $tag;
676+ }
677+ $lines[$i] = $prefix.$l.str_repeat('</span>', count($open));
678+ }
679+ return $lines;
680+ }
681+
682+ /** READMEのMarkdown(リンクを安全にし、相対リンクをリポジトリのファイルにする) */
683+ private function markdown(string $text, string $ref, string $dir): string {
684+ $html = Markdown::fromString($text)->parse();
685+ return (string)preg_replace_callback('/\s(href|src)="([^"]*)"/i', function(array $m) use ($ref, $dir): string {
686+ $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
687+ if (preg_match('#^(https?:|mailto:|\#)#i', $url)) return " {$m[1]}=\"{$m[2]}\"";
688+ if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $url) || str_starts_with($url, '//')) return " {$m[1]}=\"#\"";
689+ if (str_starts_with($url, '/')) return " {$m[1]}=\"".htmlspecialchars($url, ENT_QUOTES, 'UTF-8').'"';
690+ // 相対パス
691+ [$p, $frag] = array_pad(explode('#', $url, 2), 2, null);
692+ $parts = [];
693+ foreach (explode('/', ($dir !== '' ? $dir.'/' : '').$p) as $seg) {
694+ if ($seg === '..') array_pop($parts);
695+ else if ($seg !== '.' && $seg !== '') $parts[] = $seg;
696+ }
697+ $target = $this->url(strtolower($m[1]) === 'src' ? 'raw' : 'blob', $ref, implode('/', $parts)).($frag !== null ? '#'.$frag : '');
698+ return " {$m[1]}=\"".htmlspecialchars($target, ENT_QUOTES, 'UTF-8').'"';
699+ }, $html);
700+ }
701+
702+ private function cloneUrls(): array {
703+ $base = rtrim((string)(SITEINFO['url'] ?? ''), '/');
704+ if ($base === '') $base = ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? 'localhost');
705+ $out = ['https' => $base.'/gitler/'.$this->owner.'/'.$this->name.'.git'];
706+ $ssh = (string)Hosting::config()['ssh'];
707+ if ($ssh !== '') {
708+ $port = (int)Hosting::config()['sshPort'];
709+ $out['ssh'] = $port === 22 ? "{$ssh}:{$this->owner}/{$this->name}.git" : "ssh://{$ssh}:{$port}/{$this->owner}/{$this->name}.git";
710+ }
711+ return $out;
712+ }
713+
714+ private function size(int $n): string {
715+ if ($n < 1024) return "{$n} B";
716+ if ($n < 1048576) return round($n / 1024, 1).' KB';
717+ return round($n / 1048576, 1).' MB';
718+ }
719+
720+ private function csrfOk(): bool {
721+ return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']);
722+ }
723+
724+ private function redirect(string $to): never {
725+ header('Location: '.$to);
726+ exit;
727+ }
728+
729+ private function notFound(): never {
730+ header('Location: /404');
731+ exit;
732+ }
733+
734+ private function message(string $msg, string $back = '/gitler'): never {
735+ $this->render('gitler/message', 'Gitler', ['msg' => $msg, 'back' => $back]);
736+ exit;
737+ }
738+
739+ /** リポジトリのページ(タブとパンくず) */
740+ private function renderRepo(string $view, string $title, array $vars, ?string $ref = null, string $path = ''): void {
741+ $ref ??= $this->repo->isEmpty() ? '' : $this->defaultRef();
742+ $crumbs = [];
743+ $acc = '';
744+ foreach ($path === '' ? [] : explode('/', $path) as $seg) {
745+ $acc = ltrim($acc.'/'.$seg, '/');
746+ $crumbs[] = ['name' => $seg, 'url' => $this->url('tree', $ref, $acc)];
747+ }
748+ $branches = array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/')));
749+ $tags = array_map(fn($n) => substr($n, 10), array_keys($this->repo->refs->all('refs/tags/')));
750+ $this->render($view, "{$this->owner}/{$this->name}".($title !== '' ? ":{$title}" : ''), $vars + [
751+ 'owner' => $this->owner, 'name' => $this->name, 'repoUrl' => $this->repoUrl(), 'meta' => $this->meta,
752+ 'canAdmin' => $this->access >= Hosting::ADMIN, 'canWrite' => $this->access >= Hosting::WRITE,
753+ 'ref' => $ref, 'shortRef' => $this->repo->isId($ref) ? substr($ref, 0, 7) : $ref, 'crumbs' => $crumbs, 'path' => $path,
754+ 'branches' => $branches, 'tags' => $tags, 'clone' => $this->cloneUrls(), 'gitView' => $view,
755+ 'refUrl' => fn(string $r) => $this->url(str_starts_with($view, 'gitler/commits') ? 'commits' : (str_starts_with($view, 'gitler/blob') ? 'blob' : 'tree'), $r, $path),
756+ 'treeRoot' => $ref !== '' ? $this->url('tree', $ref, '') : $this->repoUrl(),
757+ 'commitsUrl' => $ref !== '' ? $this->url('commits', $ref, '') : '',
758+ 'zipUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=zip' : '',
759+ 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '',
760+ ]);
761+ }
762+
763+ private function render(string $view, string $title, array $vars): void {
764+ $tmpl = new Template('/');
765+ $tmpl->assign('pagetit', $title);
766+ $tmpl->assign('curPage', 'gitler');
767+ $tmpl->assign('menu', $this->getMenu());
768+ $tmpl->assign('support', $this->getSupport());
769+ $tmpl->assign('sns', $this->getSns());
770+ $tmpl->assign('description', SITEINFO['description']);
771+ $tmpl->assign('user', $this->user);
772+ $tmpl->assign('canCreate', Hosting::userCan($this->user, 'repos'));
773+ // クッキーはテンプレートの出力の前に設定する
774+ $tmpl->assign('csrf', make_csrf_token());
775+ foreach (['error' => '', 'message' => ''] as $k => $v) $tmpl->assign($k, $vars[$k] ?? $v);
776+ foreach ($vars as $k => $v) $tmpl->assign($k, $v);
777+ $tmpl->addCss('code');
778+ $tmpl->addCss('gitler');
779+ $tmpl->render($view);
780+ }
781+}
@@ -94,6 +94,16 @@
9494 ];
9595 }
9696
97+ if (\Std\Lib\Gitler\Gitler::enabled()) {
98+ $menu[] = [
99+ 'class' => 'menu-item',
100+ 'href' => '/gitler',
101+ 'page' => 'gitler',
102+ 'text' => 'Gitler',
103+ 'show' => true,
104+ ];
105+ }
106+
97107 static $isAdmin = null;
98108
99109 if (OPENPROVIDER_ENABLED) {
@@ -782,6 +782,27 @@
782782 return Result::Success('未確認のアカウント'.count($deleted).'件を削除しました。', ['deleted' => $deleted]);
783783 }
784784
785+ /**
786+ * パスワードだけを確かめる(Gitのプッシュ等、ログインはしない)
787+ *
788+ * 二段階認証が有効なユーザー、未確認・BANのユーザーはnull(Gitler等ではアクセストークンを使う)。
789+ *
790+ * @return \stdClass|null ユーザー(パスワード等を除く)
791+ */
792+ public function checkPassword(string $username, #[SensitiveParameter] string $password): ?\stdClass {
793+ if (!AUTH_ENABLED || $password === '' || $this->isLoginThrottled($username)) return null;
794+ $path = $this->userPath($username);
795+ $user = $path !== null ? json_decode((string)file_get_contents($path)) : null;
796+ if (!$user instanceof \stdClass || !password_verify($password, (string)($user->password ?? ''))) {
797+ $this->recordLoginFailure($username);
798+ return null;
799+ }
800+ if (($user->verified ?? true) === false || (int)($user->role ?? 0) === Roles::BANNED) return null;
801+ if ($this->twoFactorAvailable() && !empty($user->twoFactor->secret)) return null;
802+ unset($user->password, $user->tokens, $user->verifyToken, $user->twoFactor, $user->twoFactorPending, $user->twoFactorTicket);
803+ return $user;
804+ }
805+
785806 //////////////////// 管理画面
786807
787808 /**
@@ -0,0 +1,170 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib\Gitler;
39+
40+use Std\Lib\Git\GitException;
41+use Std\Lib\Git\Repository;
42+use Std\Lib\Git\Tree;
43+
44+/**
45+ * リポジトリのアーカイブ(zip、tar、tar.gz)。git archiveと同じく、前に「名前-リビジョン/」を付ける
46+ */
47+final class Archive {
48+ public const FORMATS = ['zip' => 'application/zip', 'tar.gz' => 'application/gzip', 'tar' => 'application/x-tar'];
49+
50+ /**
51+ * @param string $treeish コミット(時刻に使う)又はツリー
52+ * @param string $format zip、tar、tar.gz
53+ * @param string $prefix 例:「repo-main/」
54+ * @param callable $out fn(string $bytes)
55+ */
56+ public static function write(Repository $repo, string $treeish, string $format, string $prefix, callable $out): void {
57+ if (!isset(self::FORMATS[$format])) throw new GitException("未対応の形式:{$format}");
58+ $tree = $repo->treeOf($treeish) ?? throw new GitException("リビジョンが有りません:{$treeish}");
59+ $commit = $repo->peel($repo->isId($treeish) ? $treeish : ($repo->resolve($treeish) ?? ''), 'commit');
60+ $mtime = $commit !== null ? (int)$repo->commit($commit)->committer?->time : time();
61+ $entries = [];
62+ self::collect($repo, $tree, $prefix, $entries);
63+ if ($format === 'zip') {
64+ self::zip($entries, $mtime, $out, $repo);
65+ return;
66+ }
67+ if ($format === 'tar.gz') {
68+ $ctx = deflate_init(ZLIB_ENCODING_GZIP, ['level' => 6]);
69+ $raw = $out;
70+ $out = function(string $b) use ($ctx, $raw): void {
71+ $z = deflate_add($ctx, $b, ZLIB_NO_FLUSH);
72+ if ($z !== '') $raw($z);
73+ };
74+ self::tar($entries, $mtime, $out, $repo);
75+ $raw(deflate_add($ctx, '', ZLIB_FINISH));
76+ return;
77+ }
78+ self::tar($entries, $mtime, $out, $repo);
79+ }
80+
81+ /** [mode, パス, ID](ディレクトリは最後が「/」) */
82+ private static function collect(Repository $repo, string $tree, string $prefix, array &$entries): void {
83+ if ($prefix !== '') $entries[] = [Tree::DIR, $prefix, $tree];
84+ foreach ($repo->tree($tree)->entries as [$mode, $name, $id]) {
85+ if ($mode === Tree::DIR) self::collect($repo, $id, $prefix.$name.'/', $entries);
86+ else if ($mode !== Tree::SUBMODULE) $entries[] = [$mode, $prefix.$name, $id];
87+ else $entries[] = [Tree::DIR, $prefix.$name.'/', $id];
88+ }
89+ }
90+
91+ private static function tar(array $entries, int $mtime, callable $out, Repository $repo): void {
92+ foreach ($entries as [$mode, $path, $id]) {
93+ $isDir = $mode === Tree::DIR;
94+ $isLink = $mode === Tree::LINK;
95+ $data = $isDir ? '' : $repo->blob($id);
96+ $perm = $isDir || $mode === Tree::EXEC ? 0775 : ($isLink ? 0777 : 0664);
97+ $link = $isLink ? $data : '';
98+ $size = $isDir || $isLink ? 0 : strlen($data);
99+ // 長い名前はpaxの拡張ヘッダー
100+ [$name, $pre] = self::splitName($path);
101+ if ($name === null || strlen($link) > 100) {
102+ $pax = self::paxRecord('path', $path).($isLink && strlen($link) > 100 ? self::paxRecord('linkpath', $link) : '');
103+ $out(self::tarHeader('././@PaxHeader', '', 0644, strlen($pax), $mtime, 'x', '').$pax.str_repeat("\0", (512 - strlen($pax) % 512) % 512));
104+ [$name, $pre] = [substr(basename($path), 0, 100), ''];
105+ }
106+ $out(self::tarHeader($name, $pre, $perm, $size, $mtime, $isDir ? '5' : ($isLink ? '2' : '0'), substr($link, 0, 100)));
107+ if ($size > 0) $out($data.str_repeat("\0", (512 - $size % 512) % 512));
108+ }
109+ $out(str_repeat("\0", 1024));
110+ }
111+
112+ /** ustarの名前(100)と前(155)に分ける。入らなければ[null, null] */
113+ private static function splitName(string $path): array {
114+ if (strlen($path) <= 100) return [$path, ''];
115+ for ($i = strlen($path) - 1; $i > 0; $i--) {
116+ if ($path[$i] !== '/' || $i > 155) continue;
117+ if (strlen($path) - $i - 1 <= 100 && strlen($path) - $i - 1 > 0) return [substr($path, $i + 1), substr($path, 0, $i)];
118+ }
119+ return [null, null];
120+ }
121+
122+ private static function paxRecord(string $key, string $value): string {
123+ $body = " {$key}={$value}\n";
124+ $len = strlen($body);
125+ $n = $len + strlen((string)$len);
126+ if (strlen((string)$n) !== strlen((string)$len)) $n++;
127+ return $n.$body;
128+ }
129+
130+ private static function tarHeader(string $name, string $prefix, int $mode, int $size, int $mtime, string $type, string $link): string {
131+ $h = str_pad($name, 100, "\0").sprintf('%07o', $mode)."\0".sprintf('%07o', 0)."\0".sprintf('%07o', 0)."\0"
132+ .sprintf('%011o', $size)."\0".sprintf('%011o', $mtime)."\0".' '.$type.str_pad($link, 100, "\0")
133+ ."ustar\0".'00'.str_pad('git', 32, "\0").str_pad('git', 32, "\0").sprintf('%07o', 0)."\0".sprintf('%07o', 0)."\0"
134+ .str_pad($prefix, 155, "\0");
135+ $h = str_pad($h, 512, "\0");
136+ $sum = array_sum(array_map('ord', str_split($h)));
137+ return substr($h, 0, 148).sprintf('%06o', $sum)."\0 ".substr($h, 156);
138+ }
139+
140+ private static function zip(array $entries, int $mtime, callable $out, Repository $repo): void {
141+ $d = getdate($mtime);
142+ $dosTime = ($d['hours'] << 11) | ($d['minutes'] << 5) | intdiv($d['seconds'], 2);
143+ $dosDate = ((max(1980, $d['year']) - 1980) << 9) | ($d['mon'] << 5) | $d['mday'];
144+ $central = '';
145+ $offset = 0;
146+ $count = 0;
147+ foreach ($entries as [$mode, $path, $id]) {
148+ $isDir = $mode === Tree::DIR;
149+ $data = $isDir ? '' : $repo->blob($id);
150+ $crc = $isDir ? 0 : crc32($data);
151+ $method = $isDir || strlen($data) < 64 ? 0 : 8;
152+ $body = $method === 8 ? (string)gzdeflate($data, 6) : $data;
153+ if ($method === 8 && strlen($body) >= strlen($data)) {
154+ $method = 0;
155+ $body = $data;
156+ }
157+ if ($offset > 0xffffffff || strlen($data) > 0xffffffff) throw new GitException('アーカイブが大き過ぎます(4GB)');
158+ $unix = $isDir ? 040755 : ($mode === Tree::LINK ? 0120777 : ($mode === Tree::EXEC ? 0100755 : 0100644));
159+ // UTF-8の名前(ビット11)
160+ $local = pack('VvvvvvVVVvv', 0x04034b50, 20, 0x0800, $method, $dosTime, $dosDate, $crc, strlen($body), strlen($data), strlen($path), 0).$path;
161+ $central .= pack('VvvvvvvVVVvvvvvVV', 0x02014b50, (3 << 8) | 20, 20, 0x0800, $method, $dosTime, $dosDate, $crc, strlen($body), strlen($data), strlen($path), 0, 0, 0, 0,
162+ ($unix << 16) | ($isDir ? 0x10 : 0), $offset).$path;
163+ $out($local.$body);
164+ $offset += strlen($local) + strlen($body);
165+ $count++;
166+ }
167+ if ($count > 0xffff) throw new GitException('ファイルが多過ぎます(65535)');
168+ $out($central.pack('VvvvvVVv', 0x06054b50, 0, 0, $count, $count, strlen($central), $offset, 0));
169+ }
170+}
@@ -0,0 +1,282 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib\Gitler;
39+
40+use Std\Lib\Fedi\Fedi;
41+use Std\Lib\Git\Commit;
42+use Std\Lib\Git\GitException;
43+use Std\Lib\Git\Maintenance;
44+use Std\Lib\Git\Person;
45+use Std\Lib\Git\Repository;
46+use Std\Lib\Git\Tree;
47+use Result;
48+use Roles;
49+
50+/**
51+ * Gitler(Gitのホスティング):リポジトリと権限
52+ *
53+ * リポジトリは{root}/{所有者}/{名前}.git(GITINFOのroot)。説明・非公開・共同作業者はリポジトリの中のgitler.json。
54+ * 権限:NONE(見えない)、READ(読める)、WRITE(プッシュ出来る)、ADMIN(設定出来る)。
55+ */
56+final class Gitler {
57+ public const NONE = 0;
58+ public const READ = 1;
59+ public const WRITE = 2;
60+ public const ADMIN = 3;
61+
62+ /** リポジトリのディレクトリ(テスト用) */
63+ public static ?string $root = null;
64+ /** ユーザーのデータ(テスト用、既定はdata/user/) */
65+ public static ?string $userDir = null;
66+
67+ public static function enabled(): bool {
68+ return defined('GITLER_ENABLED') && GITLER_ENABLED && AUTH_ENABLED;
69+ }
70+
71+ /** GITINFOと既定値 */
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];
74+ }
75+
76+ /** リポジトリのディレクトリ(相対パスはROOTから) */
77+ public static function root(): string {
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は使わない)
82+ $parts = [];
83+ foreach (explode('/', $r) as $i => $p) {
84+ if ($p === '..') array_pop($parts);
85+ else if ($p !== '.' && ($p !== '' || $i === 0)) $parts[] = $p;
86+ }
87+ return implode('/', $parts) ?: '/';
88+ }
89+
90+ /** リポジトリの名前(英数字・「.」「_」「-」、「.git」で終わらない) */
91+ public static function validName(string $name): bool {
92+ return (bool)preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]{0,99}$/', $name) && !str_ends_with(strtolower($name), '.git') && !str_contains($name, '..');
93+ }
94+
95+ public static function validOwner(string $owner): bool {
96+ return (bool)preg_match('/^[A-Za-z0-9]+$/', $owner);
97+ }
98+
99+ public static function path(string $owner, string $name): string {
100+ if (!self::validOwner($owner) || !self::validName($name)) throw new GitException("不正なリポジトリ:{$owner}/{$name}");
101+ return self::root()."/{$owner}/{$name}.git";
102+ }
103+
104+ public static function exists(string $owner, string $name): bool {
105+ return self::validOwner($owner) && self::validName($name) && Repository::isRepository(self::path($owner, $name));
106+ }
107+
108+ public static function open(string $owner, string $name): Repository {
109+ return Repository::open(self::path($owner, $name));
110+ }
111+
112+ /**
113+ * リポジトリの情報
114+ *
115+ * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch
116+ */
117+ public static function meta(string $owner, string $name): array {
118+ $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true);
119+ $j = is_array($j) ? $j : [];
120+ $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => []];
121+ $j['private'] = (bool)$j['private'];
122+ $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : [];
123+ return $j;
124+ }
125+
126+ public static function saveMeta(string $owner, string $name, array $meta): void {
127+ $repo = self::open($owner, $name);
128+ $repo->writeFile($repo->path.'/gitler.json', (string)json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
129+ $repo->writeFile($repo->path.'/description', ($meta['description'] ?? '') !== '' ? $meta['description']."\n" : '');
130+ }
131+
132+ //////// ユーザーと権限
133+
134+ /** ユーザー(パスワード等を除く)。無ければnull */
135+ public static function user(string $name): ?\stdClass {
136+ if (!self::validOwner($name)) return null;
137+ $dir = rtrim(self::$userDir ?? Fedi::$userDir ?? ROOT.'/data/user', '/').'/';
138+ foreach (glob($dir.'*.'.$name.'.json') ?: [] as $f) {
139+ if (!preg_match('/^\d+\.'.$name.'\.json$/', basename($f))) continue;
140+ $u = json_decode((string)file_get_contents($f));
141+ if (!$u instanceof \stdClass || ($u->username ?? '') !== $name) continue;
142+ unset($u->password, $u->tokens, $u->verifyToken, $u->twoFactor, $u->twoFactorPending, $u->twoFactorTicket);
143+ return $u;
144+ }
145+ return null;
146+ }
147+
148+ /**
149+ * ユーザーがGitlerの機能を使えるか
150+ *
151+ * @param string $what repos(自分のリポジトリ、既定は無し)、issues(イシュー、既定は全員)、pulls(プルリクエスト、既定は無し)
152+ */
153+ public static function userCan(?\stdClass $user, string $what): bool {
154+ if ($user === null || !isset($user->username)) return false;
155+ $role = (int)($user->role ?? 0);
156+ if ($role === Roles::BANNED || ($user->verified ?? true) === false) return false;
157+ if ($role & Roles::ADMIN) return true;
158+ return match ($what) {
159+ 'repos' => (bool)($role & Roles::GIT_REPOS),
160+ 'pulls' => (bool)($role & Roles::GIT_PULLS),
161+ // 管理者がロールを変えていなければ全員(Fediverseと同じ)
162+ 'issues' => (bool)($role & Roles::GIT_ISSUES) || !isset($user->fediDefault),
163+ default => false,
164+ };
165+ }
166+
167+ /** リポジトリの権限(NONE・READ・WRITE・ADMIN) */
168+ public static function access(?\stdClass $user, string $owner, string $name): int {
169+ if (!self::exists($owner, $name)) return self::NONE;
170+ $meta = self::meta($owner, $name);
171+ $public = $meta['private'] ? self::NONE : self::READ;
172+ if ($user === null || !isset($user->username)) return $public;
173+ $role = (int)($user->role ?? 0);
174+ if ($role === Roles::BANNED || ($user->verified ?? true) === false) return $public;
175+ if ($role & Roles::ADMIN) return self::ADMIN;
176+ if ($user->username === $owner) return self::userCan($user, 'repos') ? self::ADMIN : self::READ;
177+ $c = $meta['collaborators'][$user->username] ?? null;
178+ if ($c === 'write') return self::WRITE;
179+ if ($c === 'read') return self::READ;
180+ return $public;
181+ }
182+
183+ //////// リポジトリ
184+
185+ /**
186+ * リポジトリを作る
187+ *
188+ * @param array $opt description、private、readme(最初のコミットにREADME.mdを作る)、objectFormat、branch
189+ */
190+ public static function create(\stdClass $user, string $name, array $opt = []): Result {
191+ if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません。');
192+ if (!self::validName($name)) return Result::Error('リポジトリの名前は英数字・「.」「_」「-」の100文字までです(「.git」で終わらない)。');
193+ $owner = (string)$user->username;
194+ if (self::exists($owner, $name) || is_dir(self::path($owner, $name))) return Result::Error("リポジトリ「{$owner}/{$name}」は既に有ります。");
195+ $max = (int)self::config()['maxRepos'];
196+ if ($max > 0 && count(self::list($owner)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。");
197+ $format = (string)($opt['objectFormat'] ?? self::config()['objectFormat']);
198+ $branch = trim((string)($opt['branch'] ?? '')) ?: 'main';
199+ try {
200+ $repo = Repository::init(self::path($owner, $name), in_array($format, ['sha1', 'sha256'], true) ? $format : 'sha1', $branch, 'group', (string)($opt['description'] ?? ''));
201+ } catch (GitException $e) {
202+ return Result::Error('リポジトリを作れません:'.$e->getMessage());
203+ }
204+ self::saveMeta($owner, $name, ['description' => trim((string)($opt['description'] ?? '')), 'private' => !empty($opt['private']), 'created' => time(), 'collaborators' => []]);
205+ if (!empty($opt['readme'])) {
206+ $who = Person::now(($user->displayname ?? '') !== '' ? (string)$user->displayname : $owner, (string)($user->email ?? "{$owner}@localhost"));
207+ $text = "# {$name}\n".(trim((string)($opt['description'] ?? '')) !== '' ? "\n".trim((string)$opt['description'])."\n" : '');
208+ $tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, $text]]);
209+ $c = $repo->writeCommit(Commit::create($tree, [], $who, $who, "最初のコミット\n"));
210+ $repo->refs->update('refs/heads/'.$branch, $c, $repo->zeroId());
211+ }
212+ return Result::Success("リポジトリ「{$owner}/{$name}」を作りました。", ['owner' => $owner, 'name' => $name]);
213+ }
214+
215+ /** 名前を変える */
216+ public static function rename(string $owner, string $name, string $new): Result {
217+ if (!self::validName($new)) return Result::Error('不正なリポジトリの名前です。');
218+ if ($new === $name) return Result::Success();
219+ if (self::exists($owner, $new) || is_dir(self::path($owner, $new))) return Result::Error("リポジトリ「{$owner}/{$new}」は既に有ります。");
220+ if (!@rename(self::path($owner, $name), self::path($owner, $new))) return Result::Error('名前を変えられません。');
221+ return Result::Success("名前を「{$new}」に変えました。");
222+ }
223+
224+ /** リポジトリを消す(元に戻せない) */
225+ public static function delete(string $owner, string $name): bool {
226+ $dir = self::path($owner, $name);
227+ if (!Repository::isRepository($dir)) return false;
228+ $rm = function(string $d) use (&$rm): void {
229+ foreach (@scandir($d) ?: [] as $f) {
230+ if ($f === '.' || $f === '..') continue;
231+ $p = "{$d}/{$f}";
232+ if (is_dir($p) && !is_link($p)) $rm($p);
233+ else {
234+ @chmod($p, 0666);
235+ @unlink($p);
236+ }
237+ }
238+ @rmdir($d);
239+ };
240+ $rm($dir);
241+ @rmdir(dirname($dir));
242+ return !is_dir($dir);
243+ }
244+
245+ /**
246+ * リポジトリの一覧(更新の新しい順)
247+ *
248+ * @param string|null $owner 所有者(nullなら全部)
249+ * @return array owner、name、meta、updated(最後の更新の時刻)
250+ */
251+ public static function list(?string $owner = null): array {
252+ $out = [];
253+ $owners = $owner !== null ? [$owner] : array_map('basename', glob(self::root().'/*', GLOB_ONLYDIR) ?: []);
254+ foreach ($owners as $o) {
255+ if (!self::validOwner($o)) continue;
256+ foreach (glob(self::root()."/{$o}/*.git", GLOB_ONLYDIR) ?: [] as $dir) {
257+ $name = substr(basename($dir), 0, -4);
258+ if (!self::validName($name) || !Repository::isRepository($dir)) continue;
259+ $updated = max((int)@filemtime($dir.'/refs/heads'), (int)@filemtime($dir.'/packed-refs'), (int)@filemtime($dir.'/gitler.json'));
260+ foreach (glob($dir.'/refs/heads/*') ?: [] as $f) $updated = max($updated, (int)@filemtime($f));
261+ $out[] = ['owner' => $o, 'name' => $name, 'meta' => self::meta($o, $name), 'updated' => $updated];
262+ }
263+ }
264+ usort($out, fn($a, $b) => $b['updated'] <=> $a['updated'] ?: strcmp($a['owner'].'/'.$a['name'], $b['owner'].'/'.$b['name']));
265+ return $out;
266+ }
267+
268+ /** 全部のリポジトリを整理する(cronのgitlergc) */
269+ public static function gcAll(): Result {
270+ $n = 0;
271+ $errors = [];
272+ foreach (self::list() as $r) {
273+ try {
274+ Maintenance::gc(self::open($r['owner'], $r['name']));
275+ $n++;
276+ } catch (\Throwable $e) {
277+ $errors[] = "{$r['owner']}/{$r['name']}:".$e->getMessage();
278+ }
279+ }
280+ return empty($errors) ? Result::Success("{$n}個のリポジトリを整理しました。") : Result::Error(implode("\n", $errors));
281+ }
282+}
@@ -0,0 +1,237 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib\Gitler;
39+
40+use Result;
41+
42+/**
43+ * GitlerのSSHの鍵とアクセストークン(data/gitler/users/{ユーザー名}.json)
44+ *
45+ * SSH:sshdのAuthorizedKeysCommand(gitler.php keys)が鍵からユーザーを探し、command=でgitler.php serveを実行させる。
46+ * HTTPS:ユーザー名とアクセストークン(又は二段階認証が無ければパスワード)。
47+ */
48+final class Keys {
49+ /** 保存先(テスト用) */
50+ public static ?string $dir = null;
51+
52+ public const TYPES = ['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nistp256@openssh.com'];
53+
54+ private static function dir(): string {
55+ return rtrim(self::$dir ?? ROOT.'/data/gitler/users', '/\\');
56+ }
57+
58+ private static function file(string $user): string {
59+ if (!preg_match('/^[A-Za-z0-9]+$/', $user)) throw new \InvalidArgumentException("不正なユーザー名:{$user}");
60+ return self::dir()."/{$user}.json";
61+ }
62+
63+ /** @return array keys、tokens */
64+ public static function load(string $user): array {
65+ $j = json_decode((string)@file_get_contents(self::file($user)), true);
66+ $j = is_array($j) ? $j : [];
67+ return ['keys' => (array)($j['keys'] ?? []), 'tokens' => (array)($j['tokens'] ?? [])];
68+ }
69+
70+ private static function save(string $user, array $data): bool {
71+ $dir = self::dir();
72+ if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) return false;
73+ $file = self::file($user);
74+ $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp';
75+ if (@file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) === false) return false;
76+ // SSH(gitユーザー)からも読める様に
77+ @chmod($tmp, 0644);
78+ if (!@rename($tmp, $file)) {
79+ @unlink($file);
80+ if (!@rename($tmp, $file)) {
81+ @unlink($tmp);
82+ return false;
83+ }
84+ }
85+ return true;
86+ }
87+
88+ //////// SSHの鍵
89+
90+ /**
91+ * 公開鍵を解析する(「ssh-ed25519 AAAA… コメント」)
92+ *
93+ * @return array|null type、key(base64)、comment、fingerprint(SHA256:…)、bits(RSA)
94+ */
95+ public static function parse(string $line): ?array {
96+ $line = trim(preg_replace('/\s+/', ' ', $line));
97+ if (!preg_match('/^(\S+) ([A-Za-z0-9+\/]+={0,2})(?: (.*))?$/', $line, $m) || !in_array($m[1], self::TYPES, true)) return null;
98+ $blob = base64_decode($m[2], true);
99+ if ($blob === false || strlen($blob) < 8) return null;
100+ // 最初の文字列は種類と同じ
101+ $pos = 0;
102+ $read = function() use ($blob, &$pos): ?string {
103+ if ($pos + 4 > strlen($blob)) return null;
104+ $len = unpack('N', $blob, $pos)[1];
105+ if ($pos + 4 + $len > strlen($blob)) return null;
106+ $s = substr($blob, $pos + 4, $len);
107+ $pos += 4 + $len;
108+ return $s;
109+ };
110+ if ($read() !== $m[1]) return null;
111+ $bits = null;
112+ if ($m[1] === 'ssh-rsa') {
113+ $e = $read();
114+ $n = $read();
115+ if ($e === null || $n === null) return null;
116+ $n = ltrim($n, "\0");
117+ $bits = strlen($n) * 8 - (8 - strlen(decbin(ord($n[0] ?? "\0"))));
118+ if ($bits < 2048) return null;
119+ } else if ($m[1] === 'ssh-ed25519' || $m[1] === 'sk-ssh-ed25519@openssh.com') {
120+ if (strlen((string)$read()) !== 32) return null;
121+ } else {
122+ if ($read() === null || $read() === null) return null;
123+ }
124+ return ['type' => $m[1], 'key' => $m[2], 'comment' => trim($m[3] ?? ''), 'fingerprint' => 'SHA256:'.rtrim(base64_encode(hash('sha256', $blob, true)), '='), 'bits' => $bits];
125+ }
126+
127+ /**
128+ * 鍵を追加する(同じ鍵は他のユーザーでも使えない)
129+ */
130+ public static function addKey(string $user, string $line, string $title = ''): Result {
131+ $k = self::parse($line);
132+ if ($k === null) return Result::Error('不正な公開鍵です(ssh-ed25519、ecdsa、2048ビット以上のssh-rsa等の「種類 鍵 コメント」の1行)。');
133+ if (self::find($k['type'], $k['key']) !== null) return Result::Error('この鍵は既に登録されています。');
134+ $data = self::load($user);
135+ $title = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $title)) ?: ($k['comment'] !== '' ? $k['comment'] : $k['type']);
136+ $data['keys'][] = ['title' => mb_substr($title, 0, 100), 'type' => $k['type'], 'key' => $k['key'], 'fingerprint' => $k['fingerprint'], 'added' => time(), 'lastUsed' => null];
137+ return self::save($user, $data) ? Result::Success("鍵「{$title}」を追加しました。", ['fingerprint' => $k['fingerprint']]) : Result::Error('鍵を保存出来ません。');
138+ }
139+
140+ public static function removeKey(string $user, string $fingerprint): bool {
141+ $data = self::load($user);
142+ $before = count($data['keys']);
143+ $data['keys'] = array_values(array_filter($data['keys'], fn($k) => ($k['fingerprint'] ?? '') !== $fingerprint));
144+ return count($data['keys']) < $before && self::save($user, $data);
145+ }
146+
147+ /**
148+ * 鍵の持ち主
149+ *
150+ * @return array|null [ユーザー名, 鍵]
151+ */
152+ public static function find(string $type, string $key): ?array {
153+ foreach (self::all() as $user => $keys) {
154+ foreach ($keys as $k) if (($k['type'] ?? '') === $type && hash_equals((string)($k['key'] ?? ''), $key)) return [$user, $k];
155+ }
156+ return null;
157+ }
158+
159+ /** 全部の鍵(ユーザー名 => 鍵の配列) */
160+ public static function all(): array {
161+ $out = [];
162+ foreach (glob(self::dir().'/*.json') ?: [] as $f) {
163+ $user = basename($f, '.json');
164+ if (!preg_match('/^[A-Za-z0-9]+$/', $user)) continue;
165+ $keys = self::load($user)['keys'];
166+ if (!empty($keys)) $out[$user] = $keys;
167+ }
168+ return $out;
169+ }
170+
171+ /** 最後に使った時刻 */
172+ public static function touchKey(string $user, string $fingerprint): void {
173+ $data = self::load($user);
174+ foreach ($data['keys'] as $i => $k) {
175+ if (($k['fingerprint'] ?? '') !== $fingerprint) continue;
176+ // 1時間に1回だけ書く
177+ if (($k['lastUsed'] ?? 0) > time() - 3600) return;
178+ $data['keys'][$i]['lastUsed'] = time();
179+ self::save($user, $data);
180+ return;
181+ }
182+ }
183+
184+ /**
185+ * authorized_keysの1行(command=でgitler.php serveを実行させる)
186+ */
187+ public static function authorizedKey(string $user, array $key): string {
188+ $php = (string)Gitler::config()['php'];
189+ $cmd = $php.' '.ROOT.'/gitler.php serve '.$user.' '.$key['fingerprint'];
190+ return 'restrict,command="'.str_replace(['\\', '"'], ['\\\\', '\\"'], $cmd).'" '.$key['type'].' '.$key['key'];
191+ }
192+
193+ //////// アクセストークン
194+
195+ /**
196+ * トークンを作る(表示は1回だけ、保存するのはハッシュ)
197+ *
198+ * @param bool $write プッシュも出来る(falseなら読むだけ)
199+ * @return Result data:token(平文)、id
200+ */
201+ public static function addToken(string $user, string $name, bool $write): Result {
202+ $name = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $name));
203+ if ($name === '' || mb_strlen($name) > 100) return Result::Error('トークンの名前を100文字以内で入力して下さい。');
204+ $token = 'lbg_'.bin2hex(random_bytes(20));
205+ $id = bin2hex(random_bytes(4));
206+ $data = self::load($user);
207+ $data['tokens'][] = ['id' => $id, 'name' => $name, 'hash' => hash('sha256', $token), 'write' => $write, 'created' => time(), 'lastUsed' => null];
208+ return self::save($user, $data) ? Result::Success("トークン「{$name}」を作りました。", ['token' => $token, 'id' => $id]) : Result::Error('トークンを保存出来ません。');
209+ }
210+
211+ public static function removeToken(string $user, string $id): bool {
212+ $data = self::load($user);
213+ $before = count($data['tokens']);
214+ $data['tokens'] = array_values(array_filter($data['tokens'], fn($t) => ($t['id'] ?? '') !== $id));
215+ return count($data['tokens']) < $before && self::save($user, $data);
216+ }
217+
218+ /**
219+ * トークンを確かめる
220+ *
221+ * @return array|null トークン(write等)
222+ */
223+ public static function checkToken(string $user, string $token): ?array {
224+ if (!preg_match('/^[A-Za-z0-9]+$/', $user) || !str_starts_with($token, 'lbg_')) return null;
225+ $hash = hash('sha256', $token);
226+ $data = self::load($user);
227+ foreach ($data['tokens'] as $i => $t) {
228+ if (!hash_equals((string)($t['hash'] ?? ''), $hash)) continue;
229+ if (($t['lastUsed'] ?? 0) < time() - 3600) {
230+ $data['tokens'][$i]['lastUsed'] = time();
231+ self::save($user, $data);
232+ }
233+ return $t;
234+ }
235+ return null;
236+ }
237+}
@@ -50,6 +50,8 @@
5050 private array $mathErrors = [];
5151 private ?Latex $latex = null;
5252 private static int $tabGroupCount = 0;
53+ /** fromString()の文字列(メタデータ無し) */
54+ private ?string $text = null;
5355
5456 // コードタブのラベル / labels for code tabs
5557 private const LANG_LABELS = [
@@ -72,6 +74,20 @@
7274 }
7375
7476 /**
77+ * 文字列から(READMEファイル等、メタデータ無し)
78+ *
79+ * @param string $text Markdown
80+ * @return self
81+ */
82+ public static function fromString(string $text): self {
83+ $m = (new \ReflectionClass(self::class))->newInstanceWithoutConstructor();
84+ $m->html = [];
85+ $m->path = '';
86+ $m->text = $text;
87+ return $m;
88+ }
89+
90+ /**
7591 * メタデータを取得する
7692 *
7793 * @return \stdClass メタデータオブジェクト
@@ -112,9 +128,13 @@
112128 * @return string HTMLとしてレンダリングされたコンテンツ
113129 */
114130 public function parse(): string {
115- $content = file_get_contents($this->path);
116- $parts = explode(self::METADATA_LINE, $content, 2);
117- $this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
131+ if ($this->text !== null) {
132+ $this->content = trim(str_replace(["\r\n", "\r"], "\n", $this->text));
133+ } else {
134+ $content = file_get_contents($this->path);
135+ $parts = explode(self::METADATA_LINE, $content, 2);
136+ $this->content = count($parts) > 1 ? trim($parts[1]): trim($content);
137+ }
118138 $this->html = [];
119139
120140 $lines = self::removeComments(explode("\n", $this->content));
@@ -0,0 +1,359 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Test;
39+
40+require_once __DIR__.'/../../../autoload.php';
41+
42+use Std\Lib\Tester;
43+use Std\Lib\Auth;
44+use Std\Lib\Git\Commit;
45+use Std\Lib\Git\Person;
46+use Std\Lib\Git\Repository;
47+use Std\Lib\Git\Tree;
48+use Std\Lib\Gitler\Archive;
49+use Std\Lib\Gitler\Gitler;
50+use Std\Lib\Gitler\Keys;
51+use Roles;
52+
53+$test = new Tester([
54+ 'colorOutput' => true,
55+ 'verboseOutput' => true
56+]);
57+
58+// 本物のデータを触らない様に、一時ディレクトリを使う
59+$base = sys_get_temp_dir().'/lbgitler_'.bin2hex(random_bytes(6));
60+foreach (['repos', 'user', 'keys'] as $d) mkdir("{$base}/{$d}", 0700, true);
61+$rmTree = function(string $dir) use (&$rmTree): void {
62+ foreach (@scandir($dir) ?: [] as $f) {
63+ if ($f === '.' || $f === '..') continue;
64+ $p = "{$dir}/{$f}";
65+ if (is_dir($p) && !is_link($p)) $rmTree($p);
66+ else {
67+ @chmod($p, 0666);
68+ @unlink($p);
69+ }
70+ }
71+ @rmdir($dir);
72+};
73+register_shutdown_function(fn() => $rmTree($base));
74+Gitler::$root = "{$base}/repos";
75+Gitler::$userDir = "{$base}/user";
76+Keys::$dir = "{$base}/keys";
77+
78+// ユーザー(role、fediDefaultはロールを管理者が変えた印)
79+$mkUser = function(int $id, string $name, int $role, bool $rolesSet = true) use ($base): \stdClass {
80+ $u = ['id' => $id, 'username' => $name, 'email' => "{$name}@example.com", 'displayname' => '', 'role' => $role, 'password' => 'x', 'tokens' => []];
81+ if ($rolesSet) $u['fediDefault'] = true;
82+ file_put_contents("{$base}/user/{$id}.{$name}.json", json_encode($u));
83+ return Gitler::user($name);
84+};
85+$admin = $mkUser(1, 'admin001', Roles::MEMBER | Roles::ADMIN);
86+$owner = $mkUser(2, 'owner001', Roles::MEMBER | Roles::GIT_REPOS);
87+$other = $mkUser(3, 'other001', Roles::MEMBER, false);
88+$plain = $mkUser(4, 'plain001', Roles::MEMBER);
89+$banned = $mkUser(5, 'banned01', Roles::BANNED);
90+$pulls = $mkUser(6, 'pulls001', Roles::MEMBER | Roles::GIT_PULLS | Roles::GIT_ISSUES);
91+
92+// 公開鍵(ed25519、ecdsa、RSA)
93+$str = fn(string $s) => pack('N', strlen($s)).$s;
94+$ed = fn() => 'ssh-ed25519 '.base64_encode($str('ssh-ed25519').$str(random_bytes(32)));
95+$rsa = function(int $bits) use ($str): string {
96+ $n = chr(0xc0).random_bytes(intdiv($bits, 8) - 1);
97+ return 'ssh-rsa '.base64_encode($str('ssh-rsa').$str("\x01\x00\x01").$str("\0".$n));
98+};
99+
100+
101+$test->describe('Gitler(権限)', function($test) use ($admin, $owner, $other, $plain, $banned, $pulls): void {
102+ $test->it('ロールで使える機能が決まるはず', function($test) use ($admin, $owner, $other, $plain, $banned, $pulls): void {
103+ $can = fn(?\stdClass $u) => [Gitler::userCan($u, 'repos'), Gitler::userCan($u, 'issues'), Gitler::userCan($u, 'pulls')];
104+ $test->assertSame([true, true, true], $can($admin));
105+ $test->assertSame([true, false, false], $can($owner));
106+ $test->assertSame([false, true, false], $can($other), 'ロールを変えていないユーザーはイシューだけ(既定)');
107+ $test->assertSame([false, false, false], $can($plain));
108+ $test->assertSame([false, false, false], $can($banned));
109+ $test->assertSame([false, true, true], $can($pulls));
110+ $test->assertSame([false, false, false], $can(null));
111+ });
112+
113+ $test->it('リポジトリの名前とディレクトリを確かめるはず', function($test): void {
114+ foreach (['repo', 'my-repo_1.0', 'A', '_x'] as $ok) $test->assertTrue(Gitler::validName($ok), $ok);
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+ $test->assertFalse(Gitler::validOwner('a-b'));
117+ Gitler::$root = null;
118+ $test->assertSame(dirname(ROOT).'/git', Gitler::root(), '既定は../git(ROOTから)');
119+ });
120+});
121+
122+$test->describe('Gitler(リポジトリ)', function($test) use ($base, $admin, $owner, $other, $plain, $banned): void {
123+ Gitler::$root = "{$base}/repos";
124+
125+ $test->it('ロールが有ればリポジトリを作れるはず', function($test) use ($owner, $plain): void {
126+ $test->assertFalse(Gitler::create($plain, 'nope')->isSuccess);
127+ $res = Gitler::create($owner, 'public1', ['description' => '公開', 'readme' => true]);
128+ $test->assertTrue($res->isSuccess, (string)$res->message);
129+ $test->assertTrue(Gitler::exists('owner001', 'public1'));
130+ $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);
133+ $test->assertSame('group', $repo->config->get('core.sharedrepository'));
134+ $test->assertSame("公開\n", file_get_contents($repo->path.'/description'));
135+ $test->assertFalse(Gitler::create($owner, 'public1')->isSuccess, '同じ名前');
136+ $test->assertFalse(Gitler::create($owner, 'bad.git')->isSuccess);
137+ $res = Gitler::create($owner, 'secret', ['private' => true, 'objectFormat' => 'sha256', 'branch' => 'trunk']);
138+ $test->assertTrue($res->isSuccess, (string)$res->message);
139+ $repo = Gitler::open('owner001', 'secret');
140+ $test->assertSame('sha256', $repo->hashAlgo);
141+ $test->assertSame('refs/heads/trunk', $repo->headRef());
142+ $test->assertTrue($repo->isEmpty());
143+ $names = array_map(fn($r) => $r['name'], Gitler::list('owner001'));
144+ sort($names);
145+ $test->assertSame(['public1', 'secret'], $names);
146+ });
147+
148+ $test->it('公開・非公開と共同作業者で権限が決まるはず', function($test) use ($admin, $owner, $other, $plain, $banned): void {
149+ $a = fn(?\stdClass $u, string $name) => Gitler::access($u, 'owner001', $name);
150+ $test->assertSame(Gitler::READ, $a(null, 'public1'));
151+ $test->assertSame(Gitler::NONE, $a(null, 'secret'));
152+ $test->assertSame(Gitler::ADMIN, $a($owner, 'secret'));
153+ $test->assertSame(Gitler::ADMIN, $a($admin, 'secret'));
154+ $test->assertSame(Gitler::NONE, $a($other, 'secret'));
155+ $test->assertSame(Gitler::READ, $a($other, 'public1'));
156+ $test->assertSame(Gitler::NONE, $a($banned, 'secret'));
157+ $test->assertSame(Gitler::NONE, $a($owner, 'nothing'));
158+ $meta = Gitler::meta('owner001', 'secret');
159+ $meta['collaborators'] = ['other001' => 'read', 'plain001' => 'write', 'banned01' => 'write'];
160+ Gitler::saveMeta('owner001', 'secret', $meta);
161+ $test->assertSame(Gitler::READ, $a($other, 'secret'));
162+ $test->assertSame(Gitler::WRITE, $a($plain, 'secret'), '書ける共同作業者はロールが無くても良い');
163+ $test->assertSame(Gitler::NONE, $a($banned, 'secret'), 'BANされたユーザーは共同作業者でも駄目');
164+ // 所有者がロールを失うと読むだけ
165+ $lost = clone $owner;
166+ $lost->role = Roles::MEMBER;
167+ $test->assertSame(Gitler::READ, $a($lost, 'secret'));
168+ });
169+
170+ $test->it('名前を変えて、消せるはず', function($test) use ($owner): void {
171+ $test->assertTrue(Gitler::create($owner, 'tmp1')->isSuccess);
172+ $test->assertFalse(Gitler::rename('owner001', 'tmp1', 'public1')->isSuccess, '有る名前');
173+ $test->assertFalse(Gitler::rename('owner001', 'tmp1', '../x')->isSuccess);
174+ $test->assertTrue(Gitler::rename('owner001', 'tmp1', 'tmp2')->isSuccess);
175+ $test->assertFalse(Gitler::exists('owner001', 'tmp1'));
176+ $test->assertTrue(Gitler::delete('owner001', 'tmp2'));
177+ $test->assertFalse(Gitler::exists('owner001', 'tmp2'));
178+ $test->assertFalse(is_dir(Gitler::root().'/owner001/tmp2.git'));
179+ });
180+
181+ $test->it('全部のリポジトリを整理出来るはず', function($test): void {
182+ $res = Gitler::gcAll();
183+ $test->assertTrue($res->isSuccess, (string)$res->message);
184+ $repo = Gitler::open('owner001', 'public1');
185+ $test->assertCount(1, $repo->objects->packs(true));
186+ $test->assertStringContains('公開', $repo->blob($repo->resolve('main:README.md')));
187+ });
188+});
189+
190+$test->describe('Gitler(SSHの鍵とトークン)', function($test) use ($ed, $rsa, $str): void {
191+ $test->it('公開鍵を解析して指紋を出すはず', function($test) use ($ed, $rsa, $str): void {
192+ $k = Keys::parse($ed().' me@pc');
193+ $test->assertSame('ssh-ed25519', $k['type']);
194+ $test->assertSame('me@pc', $k['comment']);
195+ $test->assertSame('SHA256:'.rtrim(base64_encode(hash('sha256', base64_decode($k['key']), true)), '='), $k['fingerprint']);
196+ $test->assertSame(4096, Keys::parse($rsa(4096))['bits']);
197+ $test->assertNull(Keys::parse($rsa(1024)), '短いRSAの鍵');
198+ $test->assertNotNull(Keys::parse('ecdsa-sha2-nistp256 '.base64_encode($str('ecdsa-sha2-nistp256').$str('nistp256').$str("\x04".random_bytes(64)))));
199+ // 種類が中身と違う、壊れた、未対応
200+ $test->assertNull(Keys::parse('ssh-rsa '.substr($ed(), 12)));
201+ $test->assertNull(Keys::parse('ssh-ed25519 '.base64_encode($str('ssh-ed25519').$str('short'))));
202+ $test->assertNull(Keys::parse('ssh-dss AAAAB3NzaC1kc3MAAACBAP'));
203+ $test->assertNull(Keys::parse('from="x" ssh-ed25519 AAAA'));
204+ $test->assertNull(Keys::parse(''));
205+ });
206+
207+ $test->it('鍵を追加・検索・削除出来て、同じ鍵は2回登録出来ないはず', function($test) use ($ed): void {
208+ $key = $ed();
209+ $res = Keys::addKey('owner001', $key.' laptop', '');
210+ $test->assertTrue($res->isSuccess, (string)$res->message);
211+ $fp = $res->data['fingerprint'];
212+ $test->assertSame('laptop', Keys::load('owner001')['keys'][0]['title']);
213+ $test->assertFalse(Keys::addKey('other001', $key, 'x')->isSuccess, '他のユーザーでも同じ鍵は駄目');
214+ [$type, $b64] = explode(' ', $key);
215+ [$user, $found] = Keys::find($type, $b64);
216+ $test->assertSame('owner001', $user);
217+ $test->assertSame($fp, $found['fingerprint']);
218+ $line = Keys::authorizedKey($user, $found);
219+ $test->assertStringContains('restrict,command="', $line);
220+ $test->assertStringContains('/gitler.php serve owner001 '.$fp.'"', $line);
221+ $test->assertTrue(str_ends_with($line, " {$type} {$b64}"));
222+ $test->assertSame(['owner001'], array_keys(Keys::all()));
223+ $test->assertTrue(Keys::removeKey('owner001', $fp));
224+ $test->assertNull(Keys::find($type, $b64));
225+ $test->assertFalse(Keys::removeKey('owner001', $fp));
226+ });
227+
228+ $test->it('アクセストークンを作って確かめられるはず', function($test): void {
229+ $rw = Keys::addToken('owner001', 'ノートPC', true);
230+ $ro = Keys::addToken('owner001', '読むだけ', false);
231+ $test->assertTrue($rw->isSuccess && $ro->isSuccess);
232+ $test->assertTrue(str_starts_with($rw->data['token'], 'lbg_'));
233+ $test->assertTrue(Keys::checkToken('owner001', $rw->data['token'])['write']);
234+ $test->assertFalse(Keys::checkToken('owner001', $ro->data['token'])['write']);
235+ $test->assertNull(Keys::checkToken('other001', $rw->data['token']), '他のユーザーのトークン');
236+ $test->assertNull(Keys::checkToken('owner001', 'lbg_'.str_repeat('0', 40)));
237+ $test->assertFalse(str_contains((string)json_encode(Keys::load('owner001')), $rw->data['token']), '平文は保存しない');
238+ $test->assertTrue(Keys::removeToken('owner001', $rw->data['id']));
239+ $test->assertNull(Keys::checkToken('owner001', $rw->data['token']));
240+ $test->assertFalse(Keys::addToken('owner001', '', true)->isSuccess);
241+ });
242+});
243+
244+$test->describe('Gitler(アーカイブ)', function($test) use ($base): void {
245+ $repo = Repository::init("{$base}/archive.git");
246+ $me = new Person('諏訪子', 's@x', 1700000000, '+0900');
247+ $long = str_repeat('長いディレクトリ/', 12).'ファイル.txt';
248+ $files = ['README.md' => "# テスト\n", 'bin/run.sh' => "#!/bin/sh\necho hi\n", 'big.bin' => random_bytes(70000), $long => "長い\n", 'empty.txt' => ''];
249+ $changes = [];
250+ foreach ($files as $p => $data) $changes[$p] = [$p === 'bin/run.sh' ? Tree::EXEC : Tree::FILE, $data];
251+ $changes['link'] = [Tree::LINK, 'README.md'];
252+ $commit = $repo->writeCommit(Commit::create($repo->updateTree(null, $changes), [], $me, $me, "x\n"));
253+ $repo->refs->update('refs/heads/main', $commit);
254+
255+ $test->it('tarに全部のファイルを入れるはず(長いパス、実行、リンク、gzip)', function($test) use ($repo, $commit, $files, $long): void {
256+ foreach (['tar', 'tar.gz'] as $fmt) {
257+ $s = '';
258+ Archive::write($repo, $commit, $fmt, 'test-1/', function($b) use (&$s): void { $s .= $b; });
259+ if ($fmt === 'tar.gz') $s = (string)gzdecode($s);
260+ // tarを読む
261+ $got = [];
262+ $modes = [];
263+ $links = [];
264+ $pax = null;
265+ for ($pos = 0; $pos + 512 <= strlen($s);) {
266+ $h = substr($s, $pos, 512);
267+ if (trim($h, "\0") === '') break;
268+ $sum = array_sum(array_map('ord', str_split(substr($h, 0, 148).' '.substr($h, 156))));
269+ $test->assertSame($sum, octdec(trim(substr($h, 148, 8), "\0 ")), 'チェックサム');
270+ $name = rtrim(substr($h, 0, 100), "\0");
271+ $prefix = rtrim(substr($h, 345, 155), "\0");
272+ $size = octdec(trim(substr($h, 124, 12), "\0 "));
273+ $type = $h[156];
274+ $data = substr($s, $pos + 512, $size);
275+ $pos += 512 + (int)ceil($size / 512) * 512;
276+ if ($type === 'x') {
277+ preg_match('/ path=([^\n]*)\n/', $data, $m);
278+ $pax = $m[1];
279+ continue;
280+ }
281+ $path = $pax ?? ($prefix !== '' ? $prefix.'/'.$name : $name);
282+ $pax = null;
283+ $modes[$path] = octdec(trim(substr($h, 100, 8), "\0 "));
284+ if ($type === '2') $links[$path] = rtrim(substr($h, 157, 100), "\0");
285+ else if ($type === '0') $got[$path] = $data;
286+ }
287+ foreach ($files as $p => $data) $test->assertSame(bin2hex($data), bin2hex($got['test-1/'.$p] ?? 'なし'), "{$fmt}: {$p}");
288+ $test->assertSame(0775, $modes['test-1/bin/run.sh']);
289+ $test->assertSame(0664, $modes['test-1/README.md']);
290+ $test->assertSame(['test-1/link' => 'README.md'], $links);
291+ $test->assertTrue(isset($modes['test-1/bin/']), 'ディレクトリ');
292+ $test->assertTrue(strlen('test-1/'.$long) > 155);
293+ }
294+ });
295+
296+ $test->it('zipに全部のファイルを入れるはず', function($test) use ($repo, $commit, $files): void {
297+ $s = '';
298+ Archive::write($repo, 'HEAD', 'zip', 'test-1/', function($b) use (&$s): void { $s .= $b; });
299+ // 中央ディレクトリから読む
300+ $end = strrpos($s, "PK\x05\x06");
301+ $e = unpack('Vsig/vdisk/vcdisk/vcount/vtotal/Vsize/Voffset', $s, $end);
302+ $got = [];
303+ $p = $e['offset'];
304+ for ($i = 0; $i < $e['total']; $i++) {
305+ $c = unpack('Vsig/vmade/vneed/vflag/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/velen/vclen/vdisk/vint/Vext/Vlocal', $s, $p);
306+ $name = substr($s, $p + 46, $c['nlen']);
307+ $p += 46 + $c['nlen'] + $c['elen'] + $c['clen'];
308+ $l = unpack('Vsig/vneed/vflag/vmethod/vtime/vdate/Vcrc/Vcsize/Vusize/vnlen/velen', $s, $c['local']);
309+ $raw = substr($s, $c['local'] + 30 + $l['nlen'] + $l['elen'], $c['csize']);
310+ $data = $c['method'] === 8 ? (string)gzinflate($raw) : $raw;
311+ $test->assertSame($c['crc'], crc32($data), $name);
312+ $test->assertSame(0x0800, $c['flag'] & 0x0800, 'UTF-8');
313+ $got[$name] = [$data, $c['ext'] >> 16];
314+ }
315+ foreach ($files as $path => $data) $test->assertSame(bin2hex($data), bin2hex($got['test-1/'.$path][0] ?? 'なし'), $path);
316+ $test->assertSame(0100755, $got['test-1/bin/run.sh'][1]);
317+ $test->assertSame(0120777, $got['test-1/link'][1]);
318+ $test->assertSame('README.md', $got['test-1/link'][0]);
319+ if (class_exists('ZipArchive')) {
320+ file_put_contents(sys_get_temp_dir().'/lbgitler-test.zip', $s);
321+ $z = new \ZipArchive();
322+ $test->assertTrue($z->open(sys_get_temp_dir().'/lbgitler-test.zip', \ZipArchive::CHECKCONS) === true);
323+ $z->close();
324+ @unlink(sys_get_temp_dir().'/lbgitler-test.zip');
325+ }
326+ });
327+});
328+
329+$test->describe('Gitler(HTTPのパスワード)', function($test) use ($base): void {
330+ if (!AUTH_ENABLED) {
331+ $test->skipAll('AUTH_ENABLEDが無効');
332+ return;
333+ }
334+ $_SERVER['REMOTE_ADDR'] ??= '127.0.0.1';
335+ $test->it('パスワードだけを確かめ、二段階認証のユーザーは使えないはず', function($test) use ($base): void {
336+ $pass = 'testTEST1234!#$%@[]:;';
337+ $auth = new class($base) extends Auth {
338+ public function __construct(string $base) {
339+ $this->dataDir = "{$base}/user/";
340+ $this->assDir = "{$base}/ass/";
341+ parent::__construct();
342+ }
343+ public function twoFactorAvailable(): bool { return true; }
344+ };
345+ $u = ['id' => 10, 'username' => 'pass0001', 'email' => 'p@x', 'role' => Roles::MEMBER, 'password' => password_hash($pass, PASSWORD_DEFAULT), 'tokens' => []];
346+ file_put_contents("{$base}/user/10.pass0001.json", json_encode($u));
347+ $ok = $auth->checkPassword('pass0001', $pass);
348+ $test->assertSame('pass0001', $ok?->username);
349+ $test->assertFalse(isset($ok->password));
350+ $test->assertNull($auth->checkPassword('pass0001', 'machigatta'));
351+ $test->assertNull($auth->checkPassword('nobody01', $pass));
352+ $u['twoFactor'] = ['secret' => 'ABC'];
353+ file_put_contents("{$base}/user/10.pass0001.json", json_encode($u));
354+ $test->assertNull($auth->checkPassword('pass0001', $pass), '二段階認証');
355+ $auth->clearLoginFailures();
356+ });
357+});
358+
359+$test->printSummary();
@@ -428,6 +428,14 @@
428428 $test->assertStringContains('<code class="language-sh">echo &quot;&lt;b&gt;&quot;</code>', $body);
429429 });
430430
431+ $test->it('文字列から変換出来て、メタデータの区切りは無いはず', function($test): void {
432+ $body = Markdown::fromString("# 題 <b>\n\n前\n\n----\n\n後\n")->parse();
433+ $test->assertStringContains('題 &lt;b&gt;</h1>', $body);
434+ $test->assertStringContains('前', $body);
435+ $test->assertStringContains('<hr', $body);
436+ $test->assertStringContains('後', $body);
437+ });
438+
431439 rmfile();
432440 });
433441
変更 util.php
@@ -82,6 +82,9 @@
8282 public const int STAFF = 1 << 8; // 256
8383 public const int ADMIN = 1 << 9; // 512
8484 public const int FEDIVERSE = 1 << 10; // 1024(マルチユーザーのFediverseを使える)
85+ public const int GIT_REPOS = 1 << 11; // 2048(Gitlerで自分のリポジトリを作る・管理する、既定は無し)
86+ public const int GIT_ISSUES = 1 << 12; // 4096(Gitlerのイシュー、既定は全員)
87+ public const int GIT_PULLS = 1 << 13; // 8192(Gitlerのプルリクエスト、既定は無し)
8588
8689 // 支援者(以前の「role >= SUBSCRIBER」と同じ範囲。ビットを足しても壊れない様に&で確認する)
8790 public const int PAID = self::SUBSCRIBER | self::NSUBSCRIBER | self::PSUBSCRIBER | self::FSUBSCRIBER
@@ -100,6 +103,9 @@
100103 self::STAFF => 'スタッフ',
101104 self::ADMIN => '管理者',
102105 self::FEDIVERSE => 'Fediverse',
106+ self::GIT_REPOS => 'Gitのリポジトリ',
107+ self::GIT_ISSUES => 'Gitのイシュー',
108+ self::GIT_PULLS => 'Gitのプルリクエスト',
103109 ];
104110 }
105111
@@ -296,6 +302,11 @@
296302 if (AUTH_ENABLED && AUTH_VERIFY_EMAIL) {
297303 // メールアドレスを7日以内に確認しなかったアカウントを削除する
298304 $cron->register('authpurge', fn() => (new \Std\Lib\Auth())->purgeUnverified());
305+ }
306+
307+ if (\Std\Lib\Gitler\Gitler::enabled()) {
308+ // Gitlerのリポジトリを整理する(プッシュで増えたファイルを1つのパックに、1日1回)
309+ $cron->register('gitlergc', fn() => \Std\Lib\Gitler\Gitler::gcAll());
299310 }
300311 }
301312
@@ -0,0 +1,20 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/refbar) @}
4+ <div class="gl-filebar"><span>Blame</span><span><a href="{{ $blobUrl }}">ファイル</a></span></div>
5+ <div class="gl-code">
6+ <table class="gl-lines gl-blame">
7+ <tbody>
8+{@ foreach ($groups as $g) @}
9+{@ foreach ($g['lines'] as $j => $l) @}
10+ <tr{{{ $j === 0 ? ' class="gl-blame-start"' : '' }}}>
11+ <td class="gl-blame-info">{@ if ($j === 0) @}<a href="{{ $g['commit']['url'] }}" title="{{ $g['commit']['subject'] }}">{{ mb_strimwidth($g['commit']['subject'], 0, 40, '…') }}</a><br /><small>{{ $g['commit']['author'] }}、{{ $g['commit']['date'] }}</small>{@ endif @}</td>
12+ <td class="gl-ln">{{ $l['n'] }}</td>
13+ <td class="gl-src">{{ $l['text'] }}</td>
14+ </tr>
15+{@ endforeach @}
16+{@ endforeach @}
17+ </tbody>
18+ </table>
19+ </div>
20+{@ include(common/footer) @}
@@ -0,0 +1,39 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/refbar) @}
4+ <div class="gl-filebar">
5+ <span>{{ $size }}{{ $lineCount > 0 ? '、'.$lineCount.'行' : '' }}</span>
6+ <span>
7+{@ if (isset($plainUrl)) @}
8+ <a href="{{ $plainUrl }}">ソース</a>
9+{@ endif @}
10+{@ if (isset($renderUrl)) @}
11+ <a href="{{ $renderUrl }}">表示</a>
12+{@ endif @}
13+ <a href="{{ $rawUrl }}">生</a>
14+ <a href="{{ $blameUrl }}">Blame</a>
15+ <a href="{{ $historyUrl }}">履歴</a>
16+ </span>
17+ </div>
18+{@ if ($kind === 'text') @}
19+ <div class="gl-code">
20+ <table class="gl-lines">
21+ <tbody>
22+{@ foreach ($lines as $i => $l) @}
23+ <tr id="L{{ $i + 1 }}"><td class="gl-ln"><a href="#L{{ $i + 1 }}">{{ $i + 1 }}</a></td><td class="gl-src">{{{ $l === '' ? ' ' : $l }}}</td></tr>
24+{@ endforeach @}
25+ </tbody>
26+ </table>
27+ </div>
28+{@ elif ($kind === 'markdown') @}
29+ <div class="gl-readme">{{{ $html }}}</div>
30+{@ elif ($kind === 'image') @}
31+ <p class="gl-image"><img src="{{ $rawUrl }}" alt="{{ $path }}" /></p>
32+{@ elif ($kind === 'link') @}
33+ <p class="paragraph">シンボリックリンク:<code>{{ $target }}</code></p>
34+{@ elif ($kind === 'large') @}
35+ <p class="paragraph">大き過ぎるので表示しません(<a href="{{ $rawUrl }}">生のファイル</a>)。</p>
36+{@ else @}
37+ <p class="paragraph">バイナリファイルです(<a href="{{ $rawUrl }}">ダウンロード</a>)。</p>
38+{@ endif @}
39+{@ include(common/footer) @}
@@ -0,0 +1,12 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+ <ul class="gl-commits">
4+{@ foreach ($list as $b) @}
5+ <li>
6+ <a href="{{ $b['url'] }}"><b>{{ $b['name'] }}</b></a>{{{ $b['default'] ? ' <span class="gl-badge">既定</span>' : '' }}}<br />
7+ <small><a href="{{ $b['c']['url'] }}">{{ $b['c']['subject'] }}</a>、{{ $b['c']['author'] }}、{{ $b['c']['date'] }}</small>
8+ <span class="gl-commit-links"><a href="{{ $b['zip'] }}">ZIP</a></span>
9+ </li>
10+{@ endforeach @}
11+ </ul>
12+{@ include(common/footer) @}
@@ -0,0 +1,12 @@
1+ <details class="gl-clone">
2+ <summary>クローン・ダウンロード</summary>
3+ <div>
4+ <label>HTTPS<input type="text" readonly="readonly" value="{{ $clone['https'] }}" /></label>
5+{@ if (isset($clone['ssh'])) @}
6+ <label>SSH<input type="text" readonly="readonly" value="{{ $clone['ssh'] }}" /></label>
7+{@ endif @}
8+{@ if ($zipUrl !== '') @}
9+ <a href="{{ $zipUrl }}">ZIP</a> <a href="{{ $tgzUrl }}">TAR.GZ</a>
10+{@ endif @}
11+ </div>
12+ </details>
@@ -0,0 +1,43 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+ <div class="gl-commit">
4+ <h2>{{ $c['subject'] }}</h2>
5+{@ if ($body !== '') @}
6+ <pre class="gl-body">{{ $body }}</pre>
7+{@ endif @}
8+ <p>
9+ {{ $c['author'] }} &lt;{{ $c['email'] }}&gt;、{{ $c['date'] }}{{ $signed ? '(署名あり)' : '' }}<br />
10+ コミット <code>{{ $c['id'] }}</code>
11+{@ foreach ($parents as $p) @}
12+ ・親 <a href="{{ $p['url'] }}"><code>{{ $p['short'] }}</code></a>
13+{@ endforeach @}
14+ ・<a href="{{ $treeUrl }}">ファイルを見る</a>
15+ </p>
16+ <p>{{ $stats['files'] }}個のファイル、<span class="gl-add">+{{ $stats['add'] }}</span> <span class="gl-del">-{{ $stats['del'] }}</span>{{ $truncated ? '(多過ぎるので途中まで)' : '' }}</p>
17+ </div>
18+{@ foreach ($files as $f) @}
19+ <div class="gl-diff">
20+ <div class="gl-diff-head">
21+ <span class="gl-st gl-st-{{ $f['status'] }}">{{ $f['statusText'] }}</span>
22+ {{ $f['oldPath'] !== '' ? $f['oldPath'].' → ' : '' }}{@ if ($f['url'] !== '') @}<a href="{{ $f['url'] }}">{{ $f['path'] }}</a>{@ else @}{{ $f['path'] }}{@ endif @}
23+ </div>
24+{@ if ($f['note'] !== '') @}
25+ <p class="gl-note">{{ $f['note'] }}</p>
26+{@ endif @}
27+{@ if (!empty($f['hunks'])) @}
28+ <div class="gl-code">
29+ <table class="gl-lines">
30+ <tbody>
31+{@ foreach ($f['hunks'] as $h) @}
32+ <tr class="gl-hunk"><td class="gl-ln"></td><td class="gl-ln"></td><td class="gl-src">{{ $h['head'] }}</td></tr>
33+{@ foreach ($h['rows'] as $r) @}
34+ <tr class="{{ $r['op'] === '+' ? 'gl-line-add' : ($r['op'] === '-' ? 'gl-line-del' : '') }}"><td class="gl-ln">{{ $r['old'] }}</td><td class="gl-ln">{{ $r['new'] }}</td><td class="gl-src">{{ $r['op'] }}{{ $r['text'] }}</td></tr>
35+{@ endforeach @}
36+{@ endforeach @}
37+ </tbody>
38+ </table>
39+ </div>
40+{@ endif @}
41+ </div>
42+{@ endforeach @}
43+{@ include(common/footer) @}
@@ -0,0 +1,21 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/refbar) @}
4+ <ul class="gl-commits">
5+{@ foreach ($list as $c) @}
6+ <li>
7+ <a href="{{ $c['url'] }}">{{ $c['subject'] }}</a><br />
8+ <small>{{ $c['author'] }}、{{ $c['date'] }}</small>
9+ <span class="gl-commit-links"><a href="{{ $c['url'] }}"><code>{{ $c['short'] }}</code></a> <a href="{{ $c['treeUrl'] }}">ファイル</a></span>
10+ </li>
11+{@ endforeach @}
12+ </ul>
13+ <p class="gl-pager">
14+{@ if ($prev !== '') @}
15+ <a href="{{ $prev }}">&laquo; 新しい</a>
16+{@ endif @}
17+{@ if ($next !== '') @}
18+ <a href="{{ $next }}">古い &raquo;</a>
19+{@ endif @}
20+ </p>
21+{@ include(common/footer) @}
@@ -0,0 +1,9 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/clone) @}
4+ <p class="paragraph">このリポジトリは空です。</p>
5+{@ if ($canWrite) @}
6+ <pre class="gl-pre">git remote add origin {{ $clone['ssh'] ?? $clone['https'] }}{{ "\n" }}
7+git push -u origin main</pre>
8+{@ endif @}
9+{@ include(common/footer) @}
@@ -0,0 +1,18 @@
1+{@ include(common/header) @}
2+{@ include(gitler/nav) @}
3+ <h1 class="paragraph">{@ if (isset($avatar)) @}<img src="{{ $avatar }}" alt="" class="gl-avatar" /> {@ endif @}{{ $heading }}</h1>
4+{@ if (empty($list)) @}
5+ <p class="paragraph">{{ $q !== '' ? '一致するリポジトリは有りません。' : 'リポジトリは有りません。' }}</p>
6+{@ endif @}
7+ <ul class="gl-repos">
8+{@ foreach ($list as $r) @}
9+ <li>
10+ <a href="{{ $r['url'] }}"><b>{{ $r['owner'] }}/{{ $r['name'] }}</b></a>{{{ $r['private'] ? ' <span class="gl-badge">非公開</span>' : '' }}}
11+{@ if ($r['description'] !== '') @}
12+ <p>{{ $r['description'] }}</p>
13+{@ endif @}
14+ <small>更新:{{ $r['updated'] }}</small>
15+ </li>
16+{@ endforeach @}
17+ </ul>
18+{@ include(common/footer) @}
@@ -0,0 +1,57 @@
1+{@ include(common/header) @}
2+{@ include(gitler/nav) @}
3+ <h1 class="paragraph">SSHの鍵</h1>
4+{@ if ($ssh === '') @}
5+ <p class="paragraph">このサイトではSSHは使えません(HTTPSとアクセストークンを使って下さい)。</p>
6+{@ else @}
7+ <p class="paragraph">例:<code>git clone {{ $ssh }}:{{ $me }}/リポジトリ.git</code></p>
8+{@ endif @}
9+{@ if (empty($keys)) @}
10+ <p class="paragraph">鍵は有りません。</p>
11+{@ endif @}
12+ <ul class="gl-keys">
13+{@ foreach ($keys as $k) @}
14+ <li>
15+ <b>{{ $k['title'] }}</b>({{ $k['type'] }})<br />
16+ <code>{{ $k['fingerprint'] }}</code><br />
17+ <small>追加:{{ $k['addedText'] }}、最後に使用:{{ $k['usedText'] }}</small>
18+ <form method="post" action="/gitler/-/keys">
19+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
20+ <input type="hidden" name="fingerprint" value="{{ $k['fingerprint'] }}" />
21+ <button name="do" value="removeKey" class="danger">削除</button>
22+ </form>
23+ </li>
24+{@ endforeach @}
25+ </ul>
26+ <form method="post" action="/gitler/-/keys" class="gl-form">
27+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
28+ <label>名前:<input type="text" name="title" maxlength="100" placeholder="例:自宅のPC" /></label>
29+ <label>公開鍵(~/.ssh/id_ed25519.pub等の中身):<textarea name="key" rows="3" required="required" placeholder="ssh-ed25519 AAAA… user@host"></textarea></label>
30+ <button name="do" value="addKey">鍵を追加</button>
31+ </form>
32+
33+ <h1 class="paragraph">アクセストークン</h1>
34+ <p class="paragraph">HTTPSでクローン・プッシュする時のパスワードの代わりです(ユーザー名は{{ $me }})。{{ $twoFactor ? '二段階認証が有効なので、パスワードは使えません。' : '' }}</p>
35+{@ if ($newToken !== null) @}
36+ <p class="gl-message">新しいトークン(この画面でしか表示しません):<br /><code class="gl-token">{{ $newToken }}</code></p>
37+{@ endif @}
38+ <ul class="gl-keys">
39+{@ foreach ($tokens as $t) @}
40+ <li>
41+ <b>{{ $t['name'] }}</b>({{ $t['write'] ? '読み書き' : '読むだけ' }})<br />
42+ <small>作成:{{ $t['createdText'] }}、最後に使用:{{ $t['usedText'] }}</small>
43+ <form method="post" action="/gitler/-/keys">
44+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
45+ <input type="hidden" name="id" value="{{ $t['id'] }}" />
46+ <button name="do" value="removeToken" class="danger">削除</button>
47+ </form>
48+ </li>
49+{@ endforeach @}
50+ </ul>
51+ <form method="post" action="/gitler/-/keys" class="gl-form">
52+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
53+ <label>名前:<input type="text" name="name" maxlength="100" required="required" placeholder="例:ノートPC" /></label>
54+ <label><input type="checkbox" name="write" value="1" checked="checked" /> プッシュも出来る</label>
55+ <button name="do" value="addToken">トークンを作る</button>
56+ </form>
57+{@ include(common/footer) @}
@@ -0,0 +1,5 @@
1+{@ include(common/header) @}
2+{@ include(gitler/nav) @}
3+ <p class="paragraph">{{ $msg }}</p>
4+ <p class="paragraph"><a href="{{ $back }}">戻る</a></p>
5+{@ include(common/footer) @}
@@ -0,0 +1,21 @@
1+ <div class="gl-top">
2+ <a href="/gitler" class="gl-logo">Gitler</a>
3+ <form method="get" action="/gitler" class="gl-search">
4+ <input type="text" name="q" value="{{ $_GET['q'] ?? '' }}" placeholder="リポジトリを検索" />
5+ </form>
6+ <span class="gl-links">
7+{@ if ($canCreate) @}
8+ <a href="/gitler/-/new">+新しいリポジトリ</a>
9+{@ endif @}
10+{@ if (isset($user) && $user) @}
11+ <a href="/gitler/{{ $user->username }}">自分のリポジトリ</a>
12+ <a href="/gitler/-/keys">SSHの鍵・トークン</a>
13+{@ endif @}
14+ </span>
15+ </div>
16+{@ if ($error) @}
17+ <div class="errormes">{{ $error }}</div>
18+{@ endif @}
19+{@ if ($message) @}
20+ <p class="gl-message">{{ $message }}</p>
21+{@ endif @}
@@ -0,0 +1,19 @@
1+{@ include(common/header) @}
2+{@ include(gitler/nav) @}
3+ <h1 class="paragraph">新しいリポジトリ</h1>
4+ <form method="post" action="/gitler/-/new" class="gl-form">
5+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
6+ <label>名前(英数字・「.」「_」「-」):<input type="text" name="name" value="{{ $in['name'] }}" maxlength="100" required="required" /></label>
7+ <label>説明:<input type="text" name="description" value="{{ $in['description'] }}" maxlength="500" /></label>
8+ <label><input type="checkbox" name="private" value="1"{{{ $in['private'] ? ' checked="checked"' : '' }}} /> 非公開(自分と共同作業者だけが見られる)</label>
9+ <label><input type="checkbox" name="readme" value="1"{{{ $in['readme'] ? ' checked="checked"' : '' }}} /> README.mdを作る</label>
10+ <label>最初のブランチ:<input type="text" name="branch" value="{{ $in['branch'] }}" /></label>
11+ <label>オブジェクトの形式:
12+ <select name="objectFormat">
13+ <option value="sha1"{{{ $in['objectFormat'] === 'sha1' ? ' selected="selected"' : '' }}}>SHA-1(普通)</option>
14+ <option value="sha256"{{{ $in['objectFormat'] === 'sha256' ? ' selected="selected"' : '' }}}>SHA-256</option>
15+ </select>
16+ </label>
17+ <button>作る</button>
18+ </form>
19+{@ include(common/footer) @}
@@ -0,0 +1,23 @@
1+ <div class="gl-refbar">
2+ <details class="gl-refs">
3+ <summary>{{ $shortRef }}</summary>
4+ <div>
5+ <b>ブランチ</b>
6+{@ foreach ($branches as $b) @}
7+ <a href="{{ $refUrl($b) }}"{{{ $b === $ref ? ' class="active"' : '' }}}>{{ $b }}</a>
8+{@ endforeach @}
9+{@ if (!empty($tags)) @}
10+ <b>タグ</b>
11+{@ foreach ($tags as $t) @}
12+ <a href="{{ $refUrl($t) }}"{{{ $t === $ref ? ' class="active"' : '' }}}>{{ $t }}</a>
13+{@ endforeach @}
14+{@ endif @}
15+ </div>
16+ </details>
17+ <span class="gl-crumbs">
18+ <a href="{{ $treeRoot }}">{{ $name }}</a>
19+{@ foreach ($crumbs as $c) @}
20+ / <a href="{{ $c['url'] }}">{{ $c['name'] }}</a>
21+{@ endforeach @}
22+ </span>
23+ </div>
@@ -0,0 +1,18 @@
1+{@ include(gitler/nav) @}
2+ <div class="gl-repohead">
3+ <h1><a href="/gitler/{{ $owner }}">{{ $owner }}</a> / <a href="{{ $repoUrl }}">{{ $name }}</a>{{{ $meta['private'] ? ' <span class="gl-badge">非公開</span>' : '' }}}</h1>
4+{@ if ($meta['description'] !== '') @}
5+ <p class="gl-desc">{{ $meta['description'] }}</p>
6+{@ endif @}
7+ <nav class="gl-tabs">
8+ <a href="{{ $treeRoot }}" class="{{ in_array($gitView, ['gitler/tree', 'gitler/blob', 'gitler/blame', 'gitler/empty'], true) ? 'active' : '' }}">ファイル</a>
9+{@ if ($commitsUrl !== '') @}
10+ <a href="{{ $commitsUrl }}" class="{{ in_array($gitView, ['gitler/commits', 'gitler/commit'], true) ? 'active' : '' }}">コミット</a>
11+ <a href="{{ $repoUrl }}/branches" class="{{ $gitView === 'gitler/branches' ? 'active' : '' }}">ブランチ({{ count($branches) }})</a>
12+ <a href="{{ $repoUrl }}/tags" class="{{ $gitView === 'gitler/tags' ? 'active' : '' }}">タグ({{ count($tags) }})</a>
13+{@ endif @}
14+{@ if ($canAdmin) @}
15+ <a href="{{ $repoUrl }}/settings" class="{{ $gitView === 'gitler/settings' ? 'active' : '' }}">設定</a>
16+{@ endif @}
17+ </nav>
18+ </div>
@@ -0,0 +1,66 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+ <h2 class="paragraph">全般</h2>
4+ <form method="post" action="{{ $repoUrl }}/settings" class="gl-form">
5+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
6+ <label>説明:<input type="text" name="description" value="{{ $meta['description'] }}" maxlength="500" /></label>
7+ <label><input type="checkbox" name="private" value="1"{{{ $meta['private'] ? ' checked="checked"' : '' }}} /> 非公開</label>
8+{@ if (!empty($branches)) @}
9+ <label>既定のブランチ:
10+ <select name="branch">
11+{@ foreach ($branches as $b) @}
12+ <option value="{{ $b }}"{{{ $b === $currentBranch ? ' selected="selected"' : '' }}}>{{ $b }}</option>
13+{@ endforeach @}
14+ </select>
15+ </label>
16+{@ endif @}
17+ <button name="do" value="general">保存</button>
18+ </form>
19+
20+ <h2 class="paragraph">共同作業者</h2>
21+{@ if (empty($collaborators)) @}
22+ <p class="paragraph">共同作業者は居ません。</p>
23+{@ endif @}
24+ <ul class="gl-keys">
25+{@ foreach ($collaborators as $who => $perm) @}
26+ <li>
27+ <a href="/gitler/{{ $who }}">{{ $who }}</a>({{ $perm === 'write' ? '読み書き' : '読むだけ' }})
28+ <form method="post" action="{{ $repoUrl }}/settings">
29+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
30+ <input type="hidden" name="username" value="{{ $who }}" />
31+ <button name="do" value="removeCollaborator" class="danger">削除</button>
32+ </form>
33+ </li>
34+{@ endforeach @}
35+ </ul>
36+ <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
37+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
38+ <input type="text" name="username" placeholder="ユーザー名" required="required" />
39+ <select name="permission">
40+ <option value="read">読むだけ</option>
41+ <option value="write">読み書き</option>
42+ </select>
43+ <button name="do" value="addCollaborator">追加</button>
44+ </form>
45+
46+ <h2 class="paragraph">整理</h2>
47+ <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
48+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
49+ <button name="do" value="gc">今すぐ整理する(git gc)</button>
50+ </form>
51+
52+ <h2 class="paragraph">名前の変更</h2>
53+ <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
54+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
55+ <input type="text" name="name" value="{{ $name }}" required="required" />
56+ <button name="do" value="rename">名前を変える</button>
57+ </form>
58+
59+ <h2 class="paragraph">削除</h2>
60+ <p class="paragraph">元に戻せません。確認の為、「{{ $owner }}/{{ $name }}」と入力して下さい。</p>
61+ <form method="post" action="{{ $repoUrl }}/settings" class="gl-row">
62+ <input type="hidden" name="csrf_token" value="{{ $csrf }}" />
63+ <input type="text" name="confirm" required="required" />
64+ <button name="do" value="delete" class="danger">リポジトリを削除</button>
65+ </form>
66+{@ include(common/footer) @}
@@ -0,0 +1,18 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ if (empty($list)) @}
4+ <p class="paragraph">タグは有りません。</p>
5+{@ endif @}
6+ <ul class="gl-commits">
7+{@ foreach ($list as $t) @}
8+ <li>
9+ <a href="{{ $t['url'] }}"><b>{{ $t['name'] }}</b></a>
10+{@ if ($t['message'] !== '') @}
11+ <pre class="gl-body">{{ $t['message'] }}</pre>
12+{@ endif @}
13+ <small><a href="{{ $t['c']['url'] }}"><code>{{ $t['c']['short'] }}</code></a>、{{ $t['c']['date'] }}</small>
14+ <span class="gl-commit-links"><a href="{{ $t['zip'] }}">ZIP</a> <a href="{{ $t['tgz'] }}">TAR.GZ</a></span>
15+ </li>
16+{@ endforeach @}
17+ </ul>
18+{@ include(common/footer) @}
@@ -0,0 +1,28 @@
1+{@ include(common/header) @}
2+{@ include(gitler/repohead) @}
3+{@ include(gitler/refbar) @}
4+{@ include(gitler/clone) @}
5+ <div class="gl-last">
6+ <a href="{{ $last['url'] }}">{{ $last['subject'] }}</a>
7+ <small>{{ $last['author'] }}、{{ $last['date'] }}、<a href="{{ $last['url'] }}"><code>{{ $last['short'] }}</code></a></small>
8+ </div>
9+ <table class="gl-files">
10+ <tbody>
11+{@ if ($parentUrl !== '') @}
12+ <tr><td colspan="2"><a href="{{ $parentUrl }}">..</a></td></tr>
13+{@ endif @}
14+{@ foreach ($entries as $e) @}
15+ <tr>
16+ <td>{{ $e['icon'] }} {@ if ($e['url'] !== '') @}<a href="{{ $e['url'] }}">{{ $e['name'] }}</a>{@ else @}{{ $e['name'] }}{@ endif @}</td>
17+ <td class="gl-size">{{ $e['size'] }}</td>
18+ </tr>
19+{@ endforeach @}
20+ </tbody>
21+ </table>
22+{@ if ($readme !== '') @}
23+ <div class="gl-readme">
24+ <div class="gl-readme-name">{{ $readmeName }}</div>
25+ {{{ $readme }}}
26+ </div>
27+{@ endif @}
28+{@ include(common/footer) @}