| 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\Federation; |
| 51 | use Std\Lib\Gitler\Gitler as Hosting; |
| 52 | use Std\Lib\Gitler\Issues; |
| 53 | use Std\Lib\Gitler\Keys; |
| 54 | use Std\Lib\Gitler\Notify; |
| 55 | use Std\Lib\Gitler\Pulls; |
| 56 | use Std\Lib\Gitler\Languages; |
| 57 | use Std\Lib\Markdown; |
| 58 | use Std\Lib\Syntax; |
| 59 | use Std\Lib\Template; |
| 60 | use 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 | */ |
| 68 | class 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 | } |