master
ブランチ master
LittleBeast / src / Site / Controller / Gitler.php
42.4 KB、865行 生 Blame 履歴
1<?php
2/*************************************************************
3# 076 License
4
5Copyright (c) テクニカル諏訪子
6
7Permission is hereby granted to any person obtaining a copy of the software
8Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9sublicense, 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
31THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37 */
38namespace Site\Controller;
39
40use Site\Controller\Mods;
41use Std\Lib\Auth;
42use Std\Lib\Git\Blame;
43use Std\Lib\Git\Diff;
44use Std\Lib\Git\GitException;
45use Std\Lib\Git\Http;
46use Std\Lib\Git\Refs;
47use Std\Lib\Git\Repository;
48use Std\Lib\Git\Tree;
49use Std\Lib\Gitler\Archive;
50use Std\Lib\Gitler\Federation;
51use Std\Lib\Gitler\Gitler as Hosting;
52use Std\Lib\Gitler\Issues;
53use Std\Lib\Gitler\Keys;
54use Std\Lib\Gitler\Notify;
55use Std\Lib\Gitler\Pulls;
56use Std\Lib\Gitler\Languages;
57use Std\Lib\Markdown;
58use Std\Lib\Syntax;
59use Std\Lib\Template;
60use Roles;
61
62/**
63 * Gitler(/gitler):Gitのリポジトリのホスティング。JavaScriptなし
64 *
65 * リポジトリのページはクエリでリビジョンとパスを渡す(例:/gitler/user/repo/blob?ref=master&path=src/a.php)。
66 * git clone https://…/gitler/user/repo.git と SSH(gitler.php)に対応。イシュー等はGitlerIssues、プルリクエストとフォークはGitlerPulls。
67 */
68class Gitler {
69 use Mods;
70 use GitlerIssues;
71 use GitlerPulls;
72
73 /** 1ページのコミットの数 */
74 private const PER_PAGE = 30;
75 /** これより大きいファイルは表示しない */
76 private const MAX_VIEW = 1048576;
77 /** 差分を表示する最大のファイル数と行数 */
78 private const MAX_DIFF_FILES = 300;
79 private const MAX_DIFF_LINES = 3000;
80 private const MAX_DIFF_TOTAL = 20000;
81
82 private ?\stdClass $user;
83 private ?Repository $repo = null;
84 private string $owner = '';
85 private string $name = '';
86 private array $meta = [];
87 private int $access = Hosting::NONE;
88
89 public function __construct() {
90 if (!Hosting::enabled()) {
91 header('Location: /404');
92 exit;
93 }
94 $this->user = (new Auth())->getLoggedInUser();
95 }
96
97 //////// 一覧
98
99 /** /gitler:見られるリポジトリ */
100 public function index(array $params): void {
101 $q = trim((string)($_GET['q'] ?? ''));
102 $list = [];
103 foreach (Hosting::list() as $r) {
104 if (Hosting::access($this->user, $r['owner'], $r['name']) === Hosting::NONE) continue;
105 if ($q !== '' && mb_stripos($r['owner'].'/'.$r['name'].' '.$r['meta']['description'], $q) === false) continue;
106 $list[] = $this->presentRepo($r);
107 }
108 $this->render('gitler/index', 'Gitler', ['list' => $list, 'q' => $q, 'heading' => 'リポジトリ']);
109 }
110
111 /** /gitler/{owner}:ユーザーのリポジトリ */
112 public function owner(array $params): void {
113 $owner = (string)($params['owner'] ?? '');
114 $u = Hosting::validOwner($owner) ? Hosting::user($owner) : null;
115 if ($u === null) $this->notFound();
116 $list = [];
117 foreach (Hosting::list($owner) as $r) {
118 if (Hosting::access($this->user, $r['owner'], $r['name']) !== Hosting::NONE) $list[] = $this->presentRepo($r);
119 }
120 $this->render('gitler/index', $owner, ['list' => $list, 'q' => '', 'heading' => ($u->displayname ?? '') !== '' ? "{$u->displayname}({$owner})" : $owner,
121 'ownerPage' => $owner, 'avatar' => avatar_url($u)]);
122 }
123
124 /** /gitler/-/new:リポジトリを作る */
125 public function create(array $params): void {
126 if (!$this->user) $this->redirect('/login');
127 if (!Hosting::userCan($this->user, 'repos')) $this->message('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。');
128 $error = '';
129 $branch = (string)Hosting::config()['branch'];
130 $in = ['name' => '', 'description' => '', 'private' => true, 'readme' => false, 'objectFormat' => (string)Hosting::config()['objectFormat'], 'branch' => $branch];
131 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
132 $in = ['name' => trim((string)($_POST['name'] ?? '')), 'description' => trim((string)($_POST['description'] ?? '')), 'private' => isset($_POST['private']),
133 'readme' => isset($_POST['readme']), 'objectFormat' => (string)($_POST['objectFormat'] ?? 'sha1'), 'branch' => trim((string)($_POST['branch'] ?? $branch))];
134 if (!$this->csrfOk()) {
135 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
136 } else if (mb_strlen($in['description']) > 500) {
137 $error = '説明は500文字までです。';
138 } else {
139 $res = Hosting::create($this->user, $in['name'], $in);
140 if ($res->isSuccess) $this->redirect('/gitler/'.$res->data['owner'].'/'.rawurlencode($res->data['name']));
141 $error = (string)$res->message;
142 }
143 }
144 $this->render('gitler/new', '新しいリポジトリ', ['in' => $in, 'error' => $error]);
145 }
146
147 /** /gitler/-/keys:SSHの鍵とアクセストークン */
148 public function keys(array $params): void {
149 if (!$this->user) $this->redirect('/login');
150 $me = (string)$this->user->username;
151 $error = '';
152 $message = '';
153 $newToken = null;
154 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
155 if (!$this->csrfOk()) {
156 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
157 } else {
158 $do = (string)($_POST['do'] ?? '');
159 if ($do === 'addKey') {
160 $res = Keys::addKey($me, (string)($_POST['key'] ?? ''), (string)($_POST['title'] ?? ''));
161 $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message;
162 } else if ($do === 'removeKey') {
163 Keys::removeKey($me, (string)($_POST['fingerprint'] ?? '')) ? $message = '鍵を削除しました。' : $error = '鍵が有りません。';
164 } else if ($do === 'addToken') {
165 $res = Keys::addToken($me, (string)($_POST['name'] ?? ''), isset($_POST['write']));
166 if ($res->isSuccess) {
167 $message = (string)$res->message;
168 $newToken = $res->data['token'];
169 } else {
170 $error = (string)$res->message;
171 }
172 } else if ($do === 'removeToken') {
173 Keys::removeToken($me, (string)($_POST['id'] ?? '')) ? $message = 'トークンを削除しました。' : $error = 'トークンが有りません。';
174 } else if ($do === 'email') {
175 Keys::setEmail($me, isset($_POST['email'])) ? $message = '保存しました。' : $error = '保存出来ません。';
176 }
177 }
178 }
179 $data = Keys::load($me);
180 $fmt = fn($t) => $t ? date('Y-m-d H:i', (int)$t) : '-';
181 $this->render('gitler/keys', 'Gitlerの設定', [
182 'keys' => array_map(fn($k) => $k + ['addedText' => $fmt($k['added'] ?? null), 'usedText' => $fmt($k['lastUsed'] ?? null)], $data['keys']),
183 'tokens' => array_map(fn($t) => $t + ['createdText' => $fmt($t['created'] ?? null), 'usedText' => $fmt($t['lastUsed'] ?? null)], $data['tokens']),
184 'newToken' => $newToken, 'error' => $error, 'message' => $message, 'ssh' => (string)Hosting::config()['ssh'], 'me' => $me,
185 'twoFactor' => !empty($this->user->twoFactorOn), 'emailOn' => $data['email'], 'mailer' => MAILER_ENABLED, 'email' => (string)($this->user->email ?? ''),
186 ]);
187 }
188
189 //////// リポジトリ
190
191 /** /gitler/{owner}/{repo}:ファイルとREADME */
192 public function repo(array $params): void {
193 // ブラウザで「.git」を付けて開いた場合
194 if (str_ends_with((string)($params['repo'] ?? ''), '.git')) $this->redirect('/gitler/'.$params['owner'].'/'.rawurlencode(substr($params['repo'], 0, -4)));
195 // Fediverseからはリポジトリのアクター
196 if ($this->wantsActivity()) $this->apActor($params);
197 header('Vary: Accept');
198 $this->load($params);
199 if ($this->repo->isEmpty()) {
200 // SSHの鍵を登録していればSSH、無ければHTTPS
201 $clone = $this->cloneUrls();
202 $ssh = isset($clone['ssh']) && $this->user && !empty(Keys::load((string)$this->user->username)['keys']);
203 $this->renderRepo('gitler/empty', '', ['pushUrl' => $ssh ? $clone['ssh'] : $clone['https'], 'pushBranch' => $this->defaultBranch()]);
204 return;
205 }
206 $this->showTree($this->defaultRef(), '');
207 }
208
209 /** …/tree?ref=&path= */
210 public function tree(array $params): void {
211 $this->load($params);
212 $this->showTree($this->ref(), $this->path());
213 }
214
215 private function showTree(string $ref, string $path): void {
216 $commit = $this->commitOf($ref);
217 $entry = $this->repo->entry($commit, $path);
218 if ($entry === null) $this->notFound();
219 if ($entry[0] !== Tree::DIR) $this->redirect($this->url('blob', $ref, $path));
220 $dirs = [];
221 $files = [];
222 $readme = null;
223 foreach ($this->repo->tree($entry[2])->entries as [$mode, $name, $id]) {
224 $p = ltrim($path.'/'.$name, '/');
225 if ($mode === Tree::DIR) {
226 $dirs[] = ['name' => $name, 'url' => $this->url('tree', $ref, $p), 'icon' => '📁', 'size' => ''];
227 } else if ($mode === Tree::SUBMODULE) {
228 $dirs[] = ['name' => $name.' @ '.substr($id, 0, 7), 'url' => '', 'icon' => '📦', 'size' => ''];
229 } else {
230 $size = $this->repo->objects->info($id)[1] ?? 0;
231 $files[] = ['name' => $name, 'url' => $this->url('blob', $ref, $p), 'icon' => $mode === Tree::LINK ? '🔗' : '📄', 'size' => $this->size($size)];
232 if ($readme === null && preg_match('/^readme(\.(md|markdown|txt))?$/i', $name)) $readme = [$name, $id];
233 }
234 }
235 $readmeHtml = '';
236 if ($readme !== null) {
237 $text = $this->repo->blob($readme[1]);
238 $readmeHtml = preg_match('/\.(md|markdown)$/i', $readme[0]) ? $this->markdown($text, $ref, $path) : '<pre>'.htmlspecialchars($text, ENT_QUOTES, 'UTF-8').'</pre>';
239 }
240 // ルートには言語の割合
241 $langs = [];
242 if ($path === '') {
243 try {
244 $langs = Languages::present(Languages::get($this->repo, $commit, $ref === $this->defaultRef()) ?? []);
245 } catch (GitException) {
246 $langs = [];
247 }
248 }
249 $c = $this->repo->commit($commit);
250 $this->renderRepo('gitler/tree', $path === '' ? '' : $path, [
251 'langs' => $langs,
252 'entries' => array_merge($dirs, $files),
253 'readmeName' => $readme[0] ?? '',
254 'readme' => $readmeHtml,
255 'last' => $this->presentCommit($c),
256 'parentUrl' => $path === '' ? '' : $this->url('tree', $ref, dirname($path) === '.' ? '' : dirname($path)),
257 ], $ref, $path);
258 }
259
260 /** …/blob?ref=&path=:ファイル */
261 public function blob(array $params): void {
262 $this->load($params);
263 $ref = $this->ref();
264 $path = $this->path();
265 $entry = $this->repo->entry($this->commitOf($ref), $path);
266 if ($entry === null) $this->notFound();
267 if ($entry[0] === Tree::DIR) $this->redirect($this->url('tree', $ref, $path));
268 $size = $this->repo->objects->info($entry[2])[1] ?? 0;
269 $vars = ['size' => $this->size($size), 'lines' => [], 'html' => '', 'kind' => 'text', 'rawUrl' => $this->url('raw', $ref, $path),
270 'blameUrl' => $this->url('blame', $ref, $path), 'historyUrl' => $this->url('commits', $ref, $path), 'lineCount' => 0, 'rendered' => false];
271 if ($entry[0] === Tree::LINK) {
272 $vars['kind'] = 'link';
273 $vars['target'] = $this->repo->blob($entry[2]);
274 } else if ($size > self::MAX_VIEW) {
275 $vars['kind'] = 'large';
276 } else {
277 $data = $this->repo->blob($entry[2]);
278 if (Diff::isBinary($data) || !mb_check_encoding($data, 'UTF-8')) {
279 $vars['kind'] = preg_match('/\.(png|jpe?g|gif|webp|avif|bmp|ico|svg)$/i', $path) ? 'image' : 'binary';
280 } else if (preg_match('/\.(md|markdown)$/i', $path) && !isset($_GET['plain'])) {
281 $vars['kind'] = 'markdown';
282 $vars['html'] = $this->markdown($data, $ref, dirname($path) === '.' ? '' : dirname($path));
283 $vars['plainUrl'] = $this->url('blob', $ref, $path).'&plain=1';
284 } else {
285 $vars['lines'] = $this->highlight($data, $path);
286 $vars['lineCount'] = count($vars['lines']);
287 if (preg_match('/\.(md|markdown)$/i', $path)) $vars['renderUrl'] = $this->url('blob', $ref, $path);
288 }
289 }
290 $this->renderRepo('gitler/blob', $path, $vars, $ref, $path);
291 }
292
293 /** …/raw?ref=&path=:ファイルの中身(スクリプトは動かない様に) */
294 public function raw(array $params): void {
295 $this->load($params);
296 $path = $this->path();
297 $entry = $this->repo->entry($this->commitOf($this->ref()), $path);
298 if ($entry === null || $entry[0] === Tree::DIR || $entry[0] === Tree::SUBMODULE) $this->notFound();
299 $data = $this->repo->blob($entry[2]);
300 $types = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'avif' => 'image/avif',
301 'bmp' => 'image/bmp', 'ico' => 'image/x-icon', 'svg' => 'image/svg+xml', 'pdf' => 'application/pdf'];
302 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
303 $type = $types[$ext] ?? (Diff::isBinary($data) || !mb_check_encoding($data, 'UTF-8') ? 'application/octet-stream' : 'text/plain; charset=UTF-8');
304 while (ob_get_level() > 0) ob_end_clean();
305 header('Content-Type: '.$type);
306 header('Content-Length: '.strlen($data));
307 header("Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox");
308 header('X-Content-Type-Options: nosniff');
309 if ($type === 'application/octet-stream') header("Content-Disposition: attachment; filename*=UTF-8''".rawurlencode(basename($path)));
310 header('Cache-Control: '.($this->meta['private'] ? 'private' : 'public').', max-age=300');
311 echo $data;
312 exit;
313 }
314
315 /** …/blame?ref=&path= */
316 public function blame(array $params): void {
317 $this->load($params);
318 $ref = $this->ref();
319 $path = $this->path();
320 $commit = $this->commitOf($ref);
321 $entry = $this->repo->entry($commit, $path);
322 if ($entry === null || $entry[0] === Tree::DIR) $this->notFound();
323 if (($this->repo->objects->info($entry[2])[1] ?? 0) > self::MAX_VIEW || Diff::isBinary($this->repo->blob($entry[2]))) {
324 $this->message('このファイルのblameは表示出来ません。', $this->url('blob', $ref, $path));
325 }
326 $lines = Blame::file($this->repo, $commit, $path, 2000);
327 $groups = [];
328 $commits = [];
329 foreach ($lines as $i => $l) {
330 $commits[$l['commit']] ??= $this->presentCommit($this->repo->commit($l['commit']));
331 if (empty($groups) || end($groups)['commit']['id'] !== $l['commit']) $groups[] = ['commit' => $commits[$l['commit']], 'lines' => []];
332 $groups[count($groups) - 1]['lines'][] = ['n' => $i + 1, 'text' => $l['text']];
333 }
334 $this->renderRepo('gitler/blame', $path, ['groups' => $groups, 'blobUrl' => $this->url('blob', $ref, $path)], $ref, $path);
335 }
336
337 /** …/commits?ref=&path=&page= */
338 public function commits(array $params): void {
339 $this->load($params);
340 $ref = $this->ref();
341 $path = $this->path();
342 $page = max(1, (int)($_GET['page'] ?? 1));
343 $list = [];
344 $more = false;
345 foreach ($this->repo->log($this->commitOf($ref), ['path' => $path, 'skip' => ($page - 1) * self::PER_PAGE, 'limit' => self::PER_PAGE + 1]) as $c) {
346 if (count($list) === self::PER_PAGE) {
347 $more = true;
348 break;
349 }
350 $list[] = $this->presentCommit($c);
351 }
352 $base = $this->url('commits', $ref, $path);
353 $this->renderRepo('gitler/commits', 'コミット', [
354 'list' => $list,
355 'prev' => $page > 1 ? $base.'&page='.($page - 1) : '',
356 'next' => $more ? $base.'&page='.($page + 1) : '',
357 ], $ref, $path);
358 }
359
360 /** …/commit/{id}:コミットと差分 */
361 public function commit(array $params): void {
362 $this->load($params);
363 $id = $this->repo->resolve((string)($params['id'] ?? ''));
364 $id = $id === null ? null : $this->repo->peel($id, 'commit');
365 if ($id === null) $this->notFound();
366 $c = $this->repo->commit($id);
367 $parent = $c->parents[0] ?? null;
368 $changes = Diff::trees($this->repo, $parent !== null ? $this->repo->commit($parent)->tree : null, $c->tree);
369 $files = [];
370 $add = 0;
371 $del = 0;
372 $budget = self::MAX_DIFF_TOTAL;
373 foreach (array_slice($changes, 0, self::MAX_DIFF_FILES) as $ch) $files[] = $this->presentChange($ch, $id, $add, $del, $budget);
374 $body = trim(substr($c->message, strlen(strtok($c->message, "\n") ?: '')));
375 $this->renderRepo('gitler/commit', $c->subject(), [
376 'c' => $this->presentCommit($c),
377 'body' => $body,
378 'parents' => array_map(fn($p) => ['id' => $p, 'short' => substr($p, 0, 7), 'url' => $this->repoUrl().'/commit/'.$p], $c->parents),
379 'files' => $files,
380 'truncated' => count($changes) > self::MAX_DIFF_FILES,
381 'stats' => ['files' => count($changes), 'add' => $add, 'del' => $del],
382 'treeUrl' => $this->url('tree', $id, ''),
383 'signed' => $c->header('gpgsig') !== null,
384 ], $id);
385 }
386
387 /** …/branches */
388 public function branches(array $params): void {
389 $this->load($params);
390 $default = $this->defaultBranch();
391 $list = [];
392 foreach ($this->repo->refs->all('refs/heads/') as $name => $id) {
393 $short = substr($name, 11);
394 $c = $this->repo->commit($id);
395 $list[] = ['name' => $short, 'url' => $this->url('tree', $short, ''), 'default' => $short === $default, 'c' => $this->presentCommit($c), 'time' => $c->committer?->time ?? 0,
396 'zip' => $this->url('archive', $short, '').'&format=zip'];
397 }
398 usort($list, fn($a, $b) => [$b['default'], $b['time']] <=> [$a['default'], $a['time']]);
399 $this->renderRepo('gitler/branches', 'ブランチ', ['list' => $list]);
400 }
401
402 /** …/tags */
403 public function tags(array $params): void {
404 $this->load($params);
405 $list = [];
406 foreach ($this->repo->refs->all('refs/tags/') as $name => $id) {
407 $short = substr($name, 10);
408 $target = $this->repo->peel($id, 'commit');
409 if ($target === null) continue;
410 $c = $this->repo->commit($target);
411 $message = ($this->repo->objects->info($id)[0] ?? '') === 'tag' ? trim(preg_replace('/-----BEGIN [A-Z ]+SIGNATURE-----.*$/s', '', $this->repo->tag($id)->message)) : '';
412 $list[] = ['name' => $short, 'url' => $this->url('tree', $short, ''), 'c' => $this->presentCommit($c), 'time' => $c->committer?->time ?? 0, 'message' => $message,
413 'zip' => $this->url('archive', $short, '').'&format=zip', 'tgz' => $this->url('archive', $short, '').'&format=tar.gz'];
414 }
415 usort($list, fn($a, $b) => $b['time'] <=> $a['time']);
416 $this->renderRepo('gitler/tags', 'タグ', ['list' => $list]);
417 }
418
419 /** …/archive?ref=&format=zip|tar.gz|tar */
420 public function archive(array $params): void {
421 $this->load($params);
422 $ref = $this->ref();
423 $format = (string)($_GET['format'] ?? 'zip');
424 if (!isset(Archive::FORMATS[$format])) $this->notFound();
425 $commit = $this->commitOf($ref);
426 $label = preg_replace('/[^A-Za-z0-9._-]/', '-', $this->repo->isId($ref) ? substr($ref, 0, 7) : $ref);
427 @set_time_limit(0);
428 while (ob_get_level() > 0) ob_end_clean();
429 header('Content-Type: '.Archive::FORMATS[$format]);
430 header('Content-Disposition: attachment; filename="'.$this->name.'-'.$label.'.'.$format.'"');
431 header('X-Content-Type-Options: nosniff');
432 Archive::write($this->repo, $commit, $format, $this->name.'-'.$label.'/', function(string $b): void {
433 echo $b;
434 flush();
435 });
436 exit;
437 }
438
439 /** …/settings */
440 public function settings(array $params): void {
441 $this->load($params);
442 if ($this->access < Hosting::ADMIN) $this->notFound();
443 $error = '';
444 $message = '';
445 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
446 if (!$this->csrfOk()) {
447 $error = 'セッションの有効期限が切れました。もう一度お試し下さい。';
448 } else {
449 $do = (string)($_POST['do'] ?? '');
450 $meta = $this->meta;
451 if ($do === 'general') {
452 $desc = trim((string)($_POST['description'] ?? ''));
453 $branch = (string)($_POST['branch'] ?? '');
454 if (mb_strlen($desc) > 500) {
455 $error = '説明は500文字までです。';
456 } else {
457 $meta['description'] = preg_replace('/[\x00-\x09\x0b-\x1f\x7f]/', '', $desc);
458 $meta['private'] = isset($_POST['private']);
459 Hosting::saveMeta($this->owner, $this->name, $meta);
460 if ($branch !== '' && $this->repo->refs->resolve('refs/heads/'.$branch) !== null) $this->repo->refs->setSymbolic('HEAD', 'refs/heads/'.$branch);
461 $message = '保存しました。';
462 }
463 } else if ($do === 'addCollaborator') {
464 $who = trim((string)($_POST['username'] ?? ''));
465 $perm = ($_POST['permission'] ?? 'read') === 'write' ? 'write' : 'read';
466 if ($who === $this->owner || Hosting::user($who) === null) {
467 $error = "ユーザー「{$who}」は居ません。";
468 } else {
469 $meta['collaborators'][$who] = $perm;
470 Hosting::saveMeta($this->owner, $this->name, $meta);
471 $message = "{$who}を追加しました。";
472 }
473 } else if ($do === 'removeCollaborator') {
474 unset($meta['collaborators'][(string)($_POST['username'] ?? '')]);
475 Hosting::saveMeta($this->owner, $this->name, $meta);
476 $message = '削除しました。';
477 } else if ($do === 'rename') {
478 $new = trim((string)($_POST['name'] ?? ''));
479 $res = Hosting::rename($this->owner, $this->name, $new);
480 if ($res->isSuccess) $this->redirect('/gitler/'.$this->owner.'/'.rawurlencode($new).'/settings');
481 $error = (string)$res->message;
482 } else if ($do === 'delete') {
483 if ((string)($_POST['confirm'] ?? '') !== $this->owner.'/'.$this->name) {
484 $error = '確認の為、リポジトリの名前(所有者/名前)を正しく入力して下さい。';
485 } else {
486 Hosting::delete($this->owner, $this->name);
487 $this->redirect('/gitler/'.$this->owner);
488 }
489 } else if ($do === 'issues') {
490 $meta['issues'] = isset($_POST['issues']);
491 $meta['pulls'] = isset($_POST['pulls']);
492 $meta['federate'] = isset($_POST['federate']);
493 Hosting::saveMeta($this->owner, $this->name, $meta);
494 $message = '保存しました。';
495 } else if ($do === 'archive' && !isset($_POST['confirm'])) {
496 $error = '確認にチェックを入れて下さい。';
497 } else if ($do === 'archive' || $do === 'unarchive') {
498 $meta['archived'] = $do === 'archive';
499 $meta['archivedAt'] = $meta['archived'] ? time() : null;
500 Hosting::saveMeta($this->owner, $this->name, $meta);
501 $message = $meta['archived'] ? 'アーカイブしました。読むだけになります。' : 'アーカイブを解除しました。';
502 } else if ($do === 'block' || $do === 'unblock') {
503 $res = Federation::block($this->owner, $this->name, (string)($_POST['target'] ?? ''), $do === 'block');
504 $res->isSuccess ? $message = (string)$res->message : $error = (string)$res->message;
505 } else if ($do === 'gc') {
506 try {
507 $r = \Std\Lib\Git\Maintenance::gc($this->repo);
508 $message = "整理しました(オブジェクト{$r['objects']}個)。";
509 } catch (GitException $e) {
510 $error = $e->getMessage();
511 }
512 }
513 $this->meta = Hosting::meta($this->owner, $this->name);
514 }
515 }
516 $this->renderRepo('gitler/settings', '設定', [
517 'error' => $error, 'message' => $message,
518 'branches' => array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/'))),
519 'currentBranch' => $this->defaultBranch(),
520 'collaborators' => $this->meta['collaborators'],
521 'fediAvailable' => \Std\Lib\Fedi\Fedi::enabled(), 'fediOn' => Federation::enabled($this->meta),
522 'fediHandle' => '@'.Federation::handle($this->owner, $this->name).'@'.\Std\Lib\Fedi\Fedi::host(),
523 'followerCount' => count(Federation::followers($this->owner, $this->name)), 'blocks' => Federation::blocks($this->owner, $this->name),
524 ]);
525 }
526
527 //////// Git(HTTP)
528
529 /** …/info/refs、…/git-upload-pack、…/git-receive-pack */
530 public function git(array $params): void {
531 $owner = (string)($params['owner'] ?? '');
532 $name = (string)($params['repo'] ?? '');
533 if (str_ends_with($name, '.git')) $name = substr($name, 0, -4);
534 $action = (string)($params['action'] ?? '');
535 $write = $action === 'git-receive-pack' || ($action === 'info/refs' && ($_GET['service'] ?? '') === 'git-receive-pack');
536 [$user, $tokenWrite, $tried] = $this->basicAuth();
537 $user ??= $this->user;
538 $exists = Hosting::validOwner($owner) && Hosting::validName($name) && Hosting::exists($owner, $name);
539 $access = $exists ? Hosting::access($user, $owner, $name) : Hosting::NONE;
540 // 読めてプルリクエストのロールが有ればrefs/for/(AGit)にだけプッシュ出来る
541 $meta = $exists ? Hosting::meta($owner, $name) : [];
542 $agit = $write && $access === Hosting::READ && $user !== null && ($meta['pulls'] ?? false) && !($meta['archived'] ?? true) && Hosting::userCan($user, 'pulls');
543 $need = $write && !$agit ? Hosting::WRITE : Hosting::READ;
544 if ($write && $access >= Hosting::WRITE && Hosting::meta($owner, $name)['archived']) {
545 http_response_code(403);
546 echo "アーカイブされたリポジトリにはプッシュ出来ません。\n";
547 exit;
548 }
549 if ($access < $need || ($write && !$tokenWrite)) {
550 // 認証していなければ、先に認証を求める(非公開のリポジトリが有るか分からない様に)
551 if ($user === null || ($write && $access >= Hosting::WRITE && !$tokenWrite && !$tried)) {
552 header('WWW-Authenticate: Basic realm="Gitler", charset="UTF-8"');
553 http_response_code(401);
554 echo "認証が必要です(ユーザー名とアクセストークン)。\n";
555 exit;
556 }
557 http_response_code($exists && $access >= Hosting::READ ? 403 : 404);
558 echo $exists && $access >= Hosting::READ ? "プッシュする権限が有りません。\n" : "リポジトリが有りません。\n";
559 exit;
560 }
561 $repo = Hosting::open($owner, $name);
562 Http::handle($repo, $action, ['push' => $write, 'agent' => 'Gitler', 'receive' => self::receiveOptions($owner, $name, $user, $access)]);
563 exit;
564 }
565
566 /**
567 * プッシュの制限(書けるならブランチとタグ、AGitのrefs/for/)と、プッシュの後のプルリクエストとイシューの処理
568 * SSHではリポジトリが後で決まるので、所有者、名前、権限は参照で受け取る
569 */
570 public static function receiveOptions(string &$owner, string &$name, ?\stdClass $user, int &$access): array {
571 return [
572 'update' => function(string $ref, string $old, string $new) use (&$owner, &$name, $user, &$access): ?string {
573 if (str_starts_with($ref, 'refs/for/')) return Pulls::checkAgit($owner, $name, $user, $ref, $new, str_repeat('0', strlen($new)));
574 if ($access < Hosting::WRITE) return 'only refs/for/<branch>/<topic> (pull request)';
575 return str_starts_with($ref, 'refs/heads/') || str_starts_with($ref, 'refs/tags/') ? null : 'only branches and tags';
576 },
577 'intercept' => fn(string $ref): bool => str_starts_with($ref, 'refs/for/'),
578 'process' => function(array $done) use (&$owner, &$name, $user): array {
579 return $owner !== '' && $name !== '' ? Pulls::afterPush($owner, $name, $user, $done) : [];
580 },
581 'after' => function(array $done) use (&$owner, &$name): void {
582 if ($owner !== '' && $name !== '') self::afterPush($owner, $name, $done);
583 },
584 ];
585 }
586
587 /**
588 * Basic認証(ユーザー名とアクセストークン、又は二段階認証が無ければパスワード)
589 *
590 * @return array [ユーザー, プッシュ出来るか, 認証を送ったか]
591 */
592 private function basicAuth(): array {
593 $name = $_SERVER['PHP_AUTH_USER'] ?? null;
594 $pass = $_SERVER['PHP_AUTH_PW'] ?? null;
595 $header = (string)($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '');
596 if ($name === null && preg_match('/^Basic\s+(\S+)$/i', $header, $m)) {
597 $dec = (string)base64_decode($m[1], true);
598 if (str_contains($dec, ':')) [$name, $pass] = explode(':', $dec, 2);
599 }
600 if ($name === null || $pass === null) return [null, $this->user !== null, false];
601 $name = (string)$name;
602 if (!Hosting::validOwner($name)) return [null, false, true];
603 $token = Keys::checkToken($name, (string)$pass);
604 if ($token !== null) {
605 $u = Hosting::user($name);
606 return [$u, $u !== null && !empty($token['write']), true];
607 }
608 $u = (new Auth())->checkPassword($name, (string)$pass);
609 return [$u, $u !== null, true];
610 }
611
612 //////// 共通
613
614 /** リポジトリを開いて権限を確かめる */
615 private function load(array $params): void {
616 $this->owner = (string)($params['owner'] ?? '');
617 $this->name = (string)($params['repo'] ?? '');
618 if (!Hosting::validOwner($this->owner) || !Hosting::validName($this->name) || !Hosting::exists($this->owner, $this->name)) $this->notFound();
619 $this->access = Hosting::access($this->user, $this->owner, $this->name);
620 if ($this->access === Hosting::NONE) {
621 if (!$this->user) $this->redirect('/login');
622 $this->notFound();
623 }
624 $this->repo = Hosting::open($this->owner, $this->name);
625 $this->meta = Hosting::meta($this->owner, $this->name);
626 }
627
628 private function defaultBranch(): string {
629 $h = $this->repo->headRef();
630 return $h !== null && str_starts_with($h, 'refs/heads/') ? substr($h, 11) : (string)Hosting::config()['branch'];
631 }
632
633 private function defaultRef(): string {
634 $b = $this->defaultBranch();
635 if ($this->repo->refs->resolve('refs/heads/'.$b) !== null) return $b;
636 $first = array_key_first($this->repo->refs->all('refs/heads/'));
637 return $first !== null ? substr($first, 11) : $b;
638 }
639
640 private function ref(): string {
641 $r = trim((string)($_GET['ref'] ?? ''));
642 return $r === '' ? $this->defaultRef() : $r;
643 }
644
645 private function path(): string {
646 $p = trim(str_replace('\\', '/', (string)($_GET['path'] ?? '')), '/');
647 if (preg_match('#(^|/)\.\.?(/|$)#', $p) || str_contains($p, "\0")) $this->notFound();
648 return $p;
649 }
650
651 /** リビジョンのコミット(無ければ404) */
652 private function commitOf(string $ref): string {
653 if ($ref === '' || str_contains($ref, ':')) $this->notFound();
654 $id = $this->repo->resolve($ref);
655 $id = $id === null ? null : $this->repo->peel($id, 'commit');
656 if ($id === null) $this->notFound();
657 return $id;
658 }
659
660 private function repoUrl(): string {
661 return '/gitler/'.$this->owner.'/'.rawurlencode($this->name);
662 }
663
664 private function url(string $page, string $ref, string $path): string {
665 return $this->repoUrl().'/'.$page.'?ref='.rawurlencode($ref).($path !== '' ? '&path='.str_replace('%2F', '/', rawurlencode($path)) : '');
666 }
667
668 private function presentRepo(array $r): array {
669 return ['owner' => $r['owner'], 'name' => $r['name'], 'description' => $r['meta']['description'], 'private' => $r['meta']['private'], 'archived' => $r['meta']['archived'],
670 'url' => '/gitler/'.$r['owner'].'/'.rawurlencode($r['name']), 'updated' => $r['updated'] ? date('Y-m-d H:i', $r['updated']) : '-',
671 'lang' => Languages::primary(Hosting::path($r['owner'], $r['name']))];
672 }
673
674 private function presentCommit(\Std\Lib\Git\Commit $c): array {
675 $id = (string)$c->id;
676 return ['id' => $id, 'short' => substr($id, 0, 7), 'subject' => $c->subject(), 'url' => $this->repoUrl().'/commit/'.$id,
677 'author' => $c->author?->name ?? '', 'email' => $c->author?->email ?? '', 'date' => $c->author ? $c->author->date()->format('Y-m-d H:i') : '',
678 'treeUrl' => $this->url('tree', $id, '')];
679 }
680
681 /** 差分の1ファイル */
682 private function presentChange(array $ch, string $commit, int &$add, int &$del, int &$budget): array {
683 [$om, $op, $oid] = $ch['old'] ?? [null, null, null];
684 [$nm, $np, $nid] = $ch['new'] ?? [null, null, null];
685 $status = ['A' => '追加', 'D' => '削除', 'M' => '変更', 'R' => '名前の変更', 'T' => '種類の変更'][$ch['status']];
686 $out = ['status' => $ch['status'], 'statusText' => $status, 'path' => $np ?? $op, 'oldPath' => $ch['status'] === 'R' ? $op : '', 'hunks' => [], 'note' => '',
687 'url' => $np !== null ? $this->url('blob', $commit, $np) : ''];
688 if ($oid === $nid) return $out;
689 $old = $oid !== null && $om !== Tree::SUBMODULE ? $this->repo->blob($oid) : '';
690 $new = $nid !== null && $nm !== Tree::SUBMODULE ? $this->repo->blob($nid) : '';
691 if ($om === Tree::SUBMODULE || $nm === Tree::SUBMODULE) {
692 $out['note'] = 'サブモジュール:'.substr((string)$oid, 0, 7).' → '.substr((string)$nid, 0, 7);
693 return $out;
694 }
695 if (Diff::isBinary($old) || Diff::isBinary($new)) {
696 $out['note'] = 'バイナリファイル';
697 return $out;
698 }
699 if (strlen($old) + strlen($new) > Diff::MAX_BYTES) {
700 $out['note'] = '大き過ぎるので表示しません';
701 return $out;
702 }
703 if ($budget <= 0) {
704 $out['note'] = '差分が多過ぎるので表示しません';
705 return $out;
706 }
707 $lines = 0;
708 foreach (Diff::hunks(Diff::split($old), Diff::split($new)) as $h) {
709 $rows = [];
710 $o = $h['oldStart'];
711 $n = $h['newStart'];
712 foreach ($h['lines'] as [$op2, $text]) {
713 if ($op2 === '+') $add++;
714 if ($op2 === '-') $del++;
715 $rows[] = ['op' => $op2, 'old' => $op2 === '+' ? '' : $o++, 'new' => $op2 === '-' ? '' : $n++, 'text' => $text];
716 }
717 $lines += count($rows);
718 $budget -= count($rows);
719 $out['hunks'][] = ['head' => "@@ -{$h['oldStart']},{$h['oldLines']} +{$h['newStart']},{$h['newLines']} @@", 'rows' => $rows];
720 if ($lines > self::MAX_DIFF_LINES || $budget <= 0) {
721 $out['note'] = '長いので途中までです';
722 break;
723 }
724 }
725 return $out;
726 }
727
728 /** 行毎のHTML(ハイライト出来る言語なら色付き) */
729 private function highlight(string $text, string $path): array {
730 $text = str_replace(["\r\n", "\r"], "\n", $text);
731 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
732 $lang = $ext !== '' ? Syntax::resolve($ext) : null;
733 $html = $lang !== null ? (new Syntax($lang))->colorize($text) : htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
734 $lines = explode("\n", $html);
735 if (end($lines) === '') array_pop($lines);
736 // 複数行のspan(コメント等)を行毎に閉じて開き直す
737 $open = [];
738 foreach ($lines as $i => $l) {
739 $prefix = implode('', $open);
740 preg_match_all('#<span class="[^"]*">|</span>#', $l, $m);
741 foreach ($m[0] as $tag) {
742 if ($tag === '</span>') array_pop($open);
743 else $open[] = $tag;
744 }
745 $lines[$i] = $prefix.$l.str_repeat('</span>', count($open));
746 }
747 return $lines;
748 }
749
750 /** READMEのMarkdown(リンクを安全にし、相対リンクをリポジトリのファイルにする) */
751 private function markdown(string $text, string $ref, string $dir): string {
752 $html = Markdown::fromString($text)->parse();
753 return (string)preg_replace_callback('/\s(href|src)="([^"]*)"/i', function(array $m) use ($ref, $dir): string {
754 $url = html_entity_decode($m[2], ENT_QUOTES, 'UTF-8');
755 if (preg_match('#^(https?:|mailto:|\#)#i', $url)) return " {$m[1]}=\"{$m[2]}\"";
756 if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $url) || str_starts_with($url, '//')) return " {$m[1]}=\"#\"";
757 if (str_starts_with($url, '/')) return " {$m[1]}=\"".htmlspecialchars($url, ENT_QUOTES, 'UTF-8').'"';
758 // 相対パス
759 [$p, $frag] = array_pad(explode('#', $url, 2), 2, null);
760 $parts = [];
761 foreach (explode('/', ($dir !== '' ? $dir.'/' : '').$p) as $seg) {
762 if ($seg === '..') array_pop($parts);
763 else if ($seg !== '.' && $seg !== '') $parts[] = $seg;
764 }
765 $target = $this->url(strtolower($m[1]) === 'src' ? 'raw' : 'blob', $ref, implode('/', $parts)).($frag !== null ? '#'.$frag : '');
766 return " {$m[1]}=\"".htmlspecialchars($target, ENT_QUOTES, 'UTF-8').'"';
767 }, $html);
768 }
769
770 private function cloneUrls(): array {
771 $base = rtrim((string)(SITEINFO['url'] ?? ''), '/');
772 if ($base === '') $base = ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http').'://'.($_SERVER['HTTP_HOST'] ?? 'localhost');
773 $out = ['https' => $base.'/gitler/'.$this->owner.'/'.$this->name.'.git'];
774 $ssh = (string)Hosting::config()['ssh'];
775 if ($ssh !== '') {
776 $port = (int)Hosting::config()['sshPort'];
777 $out['ssh'] = $port === 22 ? "{$ssh}:{$this->owner}/{$this->name}.git" : "ssh://{$ssh}:{$port}/{$this->owner}/{$this->name}.git";
778 }
779 return $out;
780 }
781
782 private function size(int $n): string {
783 if ($n < 1024) return "{$n} B";
784 if ($n < 1048576) return round($n / 1024, 1).' KB';
785 return round($n / 1048576, 1).' MB';
786 }
787
788 private function csrfOk(): bool {
789 return is_string($_POST['csrf_token'] ?? null) && verify_csrf_token($_POST['csrf_token']);
790 }
791
792 private function redirect(string $to): never {
793 header('Location: '.$to);
794 exit;
795 }
796
797 private function notFound(): never {
798 header('Location: /404');
799 exit;
800 }
801
802 private function message(string $msg, string $back = '/gitler'): never {
803 $this->render('gitler/message', 'Gitler', ['msg' => $msg, 'back' => $back]);
804 exit;
805 }
806
807 /** リポジトリのページ(タブとパンくず) */
808 private function renderRepo(string $view, string $title, array $vars, ?string $ref = null, string $path = ''): void {
809 $ref ??= $this->repo->isEmpty() ? '' : $this->defaultRef();
810 $crumbs = [];
811 $acc = '';
812 foreach ($path === '' ? [] : explode('/', $path) as $seg) {
813 $acc = ltrim($acc.'/'.$seg, '/');
814 $crumbs[] = ['name' => $seg, 'url' => $this->url('tree', $ref, $acc)];
815 }
816 $branches = array_map(fn($n) => substr($n, 11), array_keys($this->repo->refs->all('refs/heads/')));
817 $tags = array_map(fn($n) => substr($n, 10), array_keys($this->repo->refs->all('refs/tags/')));
818 $this->render($view, "{$this->owner}/{$this->name}".($title !== '' ? ":{$title}" : ''), $vars + [
819 'owner' => $this->owner, 'name' => $this->name, 'repoUrl' => $this->repoUrl(), 'meta' => $this->meta,
820 'canAdmin' => $this->access >= Hosting::ADMIN, 'canWrite' => $this->access >= Hosting::WRITE,
821 'ref' => $ref, 'shortRef' => $this->repo->isId($ref) ? substr($ref, 0, 7) : $ref, 'crumbs' => $crumbs, 'path' => $path,
822 'branches' => $branches, 'tags' => $tags, 'clone' => $this->cloneUrls(), 'gitView' => $view,
823 'refUrl' => fn(string $r) => $this->url(str_starts_with($view, 'gitler/commits') ? 'commits' : (str_starts_with($view, 'gitler/blob') ? 'blob' : 'tree'), $r, $path),
824 'treeRoot' => $ref !== '' ? $this->url('tree', $ref, '') : $this->repoUrl(),
825 'commitsUrl' => $ref !== '' ? $this->url('commits', $ref, '') : '',
826 'zipUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=zip' : '',
827 'tgzUrl' => $ref !== '' ? $this->url('archive', $ref, '').'&format=tar.gz' : '',
828 'issuesOn' => $this->meta['issues'], 'openIssues' => $this->meta['issues'] ? (new Issues($this->repo))->counts()['open'] : 0,
829 'archived' => $this->meta['archived'], 'here' => (string)($_SERVER['REQUEST_URI'] ?? ''),
830 'pullsOn' => $this->meta['pulls'], 'openPulls' => $this->meta['pulls'] ? (new Issues($this->repo))->counts(true)['open'] : 0,
831 'forkOf' => $this->meta['forkOf'], 'forkCount' => count(Hosting::forks($this->owner, $this->name)),
832 'canFork' => $this->user !== null && $this->user->username !== $this->owner && Hosting::userCan($this->user, 'repos'),
833 'parentCompare' => $this->forkCompareUrl($ref),
834 'watch' => $this->user ? Notify::watchState($this->owner, $this->name, (string)$this->user->username, $this->meta) : '',
835 ]);
836 }
837
838 /** フォーク元にプルリクエストを作るURL(フォークでなければ空) */
839 private function forkCompareUrl(string $ref): string {
840 if ($this->meta['forkOf'] === '' || !$this->user || !Hosting::userCan($this->user, 'pulls')) return '';
841 [$o, $n] = explode('/', $this->meta['forkOf'], 2);
842 if (!Hosting::exists($o, $n) || !Hosting::meta($o, $n)['pulls'] || Hosting::access($this->user, $o, $n) === Hosting::NONE) return '';
843 $branch = $ref !== '' && $this->repo->refs->resolve('refs/heads/'.$ref) !== null ? $ref : $this->defaultBranch();
844 return '/gitler/'.$o.'/'.rawurlencode($n).'/compare?head='.rawurlencode("{$this->owner}/{$this->name}:{$branch}");
845 }
846
847 private function render(string $view, string $title, array $vars): void {
848 $tmpl = new Template('/');
849 $tmpl->assign('pagetit', $title);
850 $tmpl->assign('curPage', 'gitler');
851 $tmpl->assign('menu', $this->getMenu());
852 $tmpl->assign('support', $this->getSupport());
853 $tmpl->assign('sns', $this->getSns());
854 $tmpl->assign('description', SITEINFO['description']);
855 $tmpl->assign('user', $this->user);
856 $tmpl->assign('canCreate', Hosting::userCan($this->user, 'repos'));
857 // クッキーはテンプレートの出力の前に設定する
858 $tmpl->assign('csrf', make_csrf_token());
859 foreach (['error' => '', 'message' => ''] as $k => $v) $tmpl->assign($k, $vars[$k] ?? $v);
860 foreach ($vars as $k => $v) $tmpl->assign($k, $v);
861 $tmpl->addCss('code');
862 $tmpl->addCss('gitler');
863 $tmpl->render($view);
864 }
865}