'../git', 'ssh' => '', 'sshPort' => 22, 'php' => '/usr/local/bin/php-8.5', 'objectFormat' => 'sha1', 'branch' => 'master', 'maxRepos' => 100]; } /** リポジトリのディレクトリ(相対パスはROOTから) */ public static function root(): string { if (self::$root !== null) return rtrim(self::$root, '/\\'); return self::resolvePath((string)self::config()['root']); } /** * パスを絶対パスにする(相対パスはROOTから) * * 「..」は文字列として解決する(chrootの中と外で同じ設定を使える様に、realpathは使わない)。 */ public static function resolvePath(string $path): string { $r = str_replace('\\', '/', $path); if (!preg_match('#^(/|[A-Za-z]:/)#', $r)) $r = str_replace('\\', '/', ROOT).'/'.$r; $parts = []; foreach (explode('/', $r) as $i => $p) { if ($p === '..') { if (count($parts) > 1) array_pop($parts); } else if ($p !== '.' && ($p !== '' || $i === 0)) { $parts[] = $p; } } return implode('/', $parts) ?: '/'; } /** リポジトリの名前(英数字・「.」「_」「-」、「.git」で終わらない) */ public static function validName(string $name): bool { return (bool)preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]{0,99}$/', $name) && !str_ends_with(strtolower($name), '.git') && !str_contains($name, '..'); } public static function validOwner(string $owner): bool { return (bool)preg_match('/^[A-Za-z0-9]+$/', $owner); } public static function path(string $owner, string $name): string { if (!self::validOwner($owner) || !self::validName($name)) throw new GitException("不正なリポジトリ:{$owner}/{$name}"); return self::root()."/{$owner}/{$name}.git"; } public static function exists(string $owner, string $name): bool { return self::validOwner($owner) && self::validName($name) && Repository::isRepository(self::path($owner, $name)); } public static function open(string $owner, string $name): Repository { return Repository::open(self::path($owner, $name)); } /** * リポジトリの情報 * * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、 * issues(イシューを使う)、pulls(プルリクエストを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ)、 * forkOf(フォーク元の「所有者/名前」又は空) */ public static function meta(string $owner, string $name): array { $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true); $j = is_array($j) ? $j : []; $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'pulls' => true, 'federate' => true, 'archived' => false, 'forkOf' => '']; foreach (['private', 'issues', 'pulls', 'federate', 'archived'] as $k) $j[$k] = (bool)$j[$k]; $j['forkOf'] = (string)$j['forkOf']; $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : []; return $j; } public static function saveMeta(string $owner, string $name, array $meta): void { $repo = self::open($owner, $name); $repo->writeFile($repo->path.'/gitler.json', (string)json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $repo->writeFile($repo->path.'/description', ($meta['description'] ?? '') !== '' ? $meta['description']."\n" : ''); } //////// ユーザーと権限 /** ユーザー(パスワード等を除く)。無ければnull */ public static function user(string $name): ?\stdClass { if (!self::validOwner($name)) return null; $dir = rtrim(self::$userDir ?? Fedi::$userDir ?? ROOT.'/data/user', '/').'/'; foreach (glob($dir.'*.'.$name.'.json') ?: [] as $f) { if (!preg_match('/^\d+\.'.$name.'\.json$/', basename($f))) continue; $u = json_decode((string)file_get_contents($f)); if (!$u instanceof \stdClass || ($u->username ?? '') !== $name) continue; unset($u->password, $u->tokens, $u->verifyToken, $u->twoFactor, $u->twoFactorPending, $u->twoFactorTicket); return $u; } return null; } /** * ユーザーがGitlerの機能を使えるか * * @param string $what repos(自分のリポジトリ、既定は無し)、issues(イシュー、既定は全員)、pulls(プルリクエスト、既定は無し) */ public static function userCan(?\stdClass $user, string $what): bool { if ($user === null || !isset($user->username)) return false; $role = (int)($user->role ?? 0); if ($role === Roles::BANNED || ($user->verified ?? true) === false) return false; if ($role & Roles::ADMIN) return true; return match ($what) { 'repos' => (bool)($role & Roles::GIT_REPOS), 'pulls' => (bool)($role & Roles::GIT_PULLS), // 管理者がロールを変えていなければ全員(Fediverseと同じ) 'issues' => (bool)($role & Roles::GIT_ISSUES) || !isset($user->fediDefault), default => false, }; } /** リポジトリの権限(NONE・READ・WRITE・ADMIN) */ public static function access(?\stdClass $user, string $owner, string $name): int { if (!self::exists($owner, $name)) return self::NONE; $meta = self::meta($owner, $name); $public = $meta['private'] ? self::NONE : self::READ; if ($user === null || !isset($user->username)) return $public; $role = (int)($user->role ?? 0); if ($role === Roles::BANNED || ($user->verified ?? true) === false) return $public; if ($role & Roles::ADMIN) return self::ADMIN; if ($user->username === $owner) return self::userCan($user, 'repos') ? self::ADMIN : self::READ; $c = $meta['collaborators'][$user->username] ?? null; if ($c === 'write') return self::WRITE; if ($c === 'read') return self::READ; return $public; } //////// リポジトリ /** * リポジトリを作る * * @param array $opt description、private、readme(最初のコミットにREADME.mdを作る)、objectFormat、branch */ public static function create(\stdClass $user, string $name, array $opt = []): Result { if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません。'); if (!self::validName($name)) return Result::Error('リポジトリの名前は英数字・「.」「_」「-」の100文字までです(「.git」で終わらない)。'); $owner = (string)$user->username; if (self::exists($owner, $name) || is_dir(self::path($owner, $name))) return Result::Error("リポジトリ「{$owner}/{$name}」は既に有ります。"); $max = (int)self::config()['maxRepos']; if ($max > 0 && count(self::list($owner)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。"); $format = (string)($opt['objectFormat'] ?? self::config()['objectFormat']); $branch = trim((string)($opt['branch'] ?? '')) ?: (string)self::config()['branch']; try { $repo = Repository::init(self::path($owner, $name), in_array($format, ['sha1', 'sha256'], true) ? $format : 'sha1', $branch, 'group', (string)($opt['description'] ?? '')); } catch (GitException $e) { return Result::Error('リポジトリを作れません:'.$e->getMessage()); } self::saveMeta($owner, $name, ['description' => trim((string)($opt['description'] ?? '')), 'private' => !empty($opt['private']), 'created' => time(), 'collaborators' => []]); if (!empty($opt['readme'])) { $who = Person::now(($user->displayname ?? '') !== '' ? (string)$user->displayname : $owner, (string)($user->email ?? "{$owner}@localhost")); $text = "# {$name}\n".(trim((string)($opt['description'] ?? '')) !== '' ? "\n".trim((string)$opt['description'])."\n" : ''); $tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, $text]]); $c = $repo->writeCommit(Commit::create($tree, [], $who, $who, "最初のコミット\n")); $repo->refs->update('refs/heads/'.$branch, $c, $repo->zeroId()); } return Result::Success("リポジトリ「{$owner}/{$name}」を作りました。", ['owner' => $owner, 'name' => $name]); } /** * フォークする(自分のアカウントに同じ名前で。有れば「名前-2」等) * パックはハードリンク(出来なければコピー)、ブランチとタグだけ持って来る。非公開のリポジトリのフォークは非公開 * * @return Result data:owner、name */ public static function fork(\stdClass $user, string $owner, string $name): Result { if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。'); if (self::access($user, $owner, $name) < self::READ) return Result::Error('リポジトリが有りません。'); $me = (string)$user->username; if ($me === $owner) return Result::Error('自分のリポジトリはフォーク出来ません。'); foreach (self::list($me) as $r) { if ($r['meta']['forkOf'] === "{$owner}/{$name}") return Result::Success('既にフォークしています。', ['owner' => $me, 'name' => $r['name']]); } $max = (int)self::config()['maxRepos']; if ($max > 0 && count(self::list($me)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。"); $new = $name; for ($i = 2; self::exists($me, $new) || is_dir(self::path($me, $new)); $i++) $new = "{$name}-{$i}"; if (!self::validName($new)) return Result::Error('フォークの名前を決められません。'); $src = self::open($owner, $name); $meta = self::meta($owner, $name); $head = $src->headRef(); $branch = $head !== null && str_starts_with($head, 'refs/heads/') ? substr($head, 11) : (string)self::config()['branch']; try { $dst = Repository::init(self::path($me, $new), $src->hashAlgo, $branch, 'group', $meta['description']); $from = $src->objects->dir(); $to = $dst->objects->dir(); foreach (glob($from.'/pack/pack-*') ?: [] as $f) { $t = $to.'/pack/'.basename($f); $dst->mkdir(dirname($t)); if (!@link($f, $t) && !@copy($f, $t)) throw new GitException("コピー出来ません:{$f}"); } foreach (glob($from.'/[0-9a-f][0-9a-f]/*') ?: [] as $f) { $t = $to.'/'.basename(dirname($f)).'/'.basename($f); $dst->mkdir(dirname($t)); if (!@copy($f, $t)) throw new GitException("コピー出来ません:{$f}"); $dst->chmodFile($t, true); } $refs = []; foreach (['refs/heads/', 'refs/tags/'] as $prefix) foreach ($src->refs->all($prefix) as $ref => $id) $refs[] = [$ref, $id, null]; if (!empty($refs)) $dst->refs->transaction($refs); } catch (GitException $e) { if (Repository::isRepository(self::path($me, $new))) self::delete($me, $new); return Result::Error('フォーク出来ません:'.$e->getMessage()); } self::saveMeta($me, $new, ['description' => $meta['description'], 'private' => $meta['private'], 'created' => time(), 'collaborators' => [], 'forkOf' => "{$owner}/{$name}"]); return Result::Success("{$owner}/{$name}を{$me}/{$new}にフォークしました。", ['owner' => $me, 'name' => $new]); } /** フォーク([所有者, 名前]の配列) */ public static function forks(string $owner, string $name): array { $out = []; foreach (self::list() as $r) if ($r['meta']['forkOf'] === "{$owner}/{$name}") $out[] = [$r['owner'], $r['name']]; return $out; } /** 名前を変える */ public static function rename(string $owner, string $name, string $new): Result { if (!self::validName($new)) return Result::Error('不正なリポジトリの名前です。'); if ($new === $name) return Result::Success(); if (self::exists($owner, $new) || is_dir(self::path($owner, $new))) return Result::Error("リポジトリ「{$owner}/{$new}」は既に有ります。"); if (!@rename(self::path($owner, $name), self::path($owner, $new))) return Result::Error('名前を変えられません。'); return Result::Success("名前を「{$new}」に変えました。"); } /** リポジトリを消す(元に戻せない) */ public static function delete(string $owner, string $name): bool { $dir = self::path($owner, $name); if (!Repository::isRepository($dir)) return false; $rm = function(string $d) use (&$rm): void { foreach (@scandir($d) ?: [] as $f) { if ($f === '.' || $f === '..') continue; $p = "{$d}/{$f}"; if (is_dir($p) && !is_link($p)) $rm($p); // 読むだけのファイル(Windows)だけ権限を変える(フォークのパックはハードリンクで元と共有) else if (!@unlink($p)) { @chmod($p, 0666); @unlink($p); } } @rmdir($d); }; $rm($dir); @rmdir(dirname($dir)); return !is_dir($dir); } /** * リポジトリの一覧(更新の新しい順) * * @param string|null $owner 所有者(nullなら全部) * @return array owner、name、meta、updated(最後の更新の時刻) */ public static function list(?string $owner = null): array { $out = []; $owners = $owner !== null ? [$owner] : array_map('basename', glob(self::root().'/*', GLOB_ONLYDIR) ?: []); foreach ($owners as $o) { if (!self::validOwner($o)) continue; foreach (glob(self::root()."/{$o}/*.git", GLOB_ONLYDIR) ?: [] as $dir) { $name = substr(basename($dir), 0, -4); if (!self::validName($name) || !Repository::isRepository($dir)) continue; $updated = max((int)@filemtime($dir.'/refs/heads'), (int)@filemtime($dir.'/packed-refs'), (int)@filemtime($dir.'/gitler.json')); foreach (glob($dir.'/refs/heads/*') ?: [] as $f) $updated = max($updated, (int)@filemtime($f)); $out[] = ['owner' => $o, 'name' => $name, 'meta' => self::meta($o, $name), 'updated' => $updated]; } } usort($out, fn($a, $b) => $b['updated'] <=> $a['updated'] ?: strcmp($a['owner'].'/'.$a['name'], $b['owner'].'/'.$b['name'])); return $out; } //////// 後で処理する物 /** SSHのプッシュの通知・配信をリポジトリに溜める(kind:notify、federate) */ public static function defer(string $owner, string $name, array $job): void { $repo = self::open($owner, $name); $dir = $repo->path.'/gitler/spool'; $repo->mkdir($dir); $repo->writeFile($dir.'/'.Fedi::newId().'.json', (string)json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } /** * 溜めた通知と配信を処理する(cronのnotify) * * @return int 処理した数 */ public static function flushDeferred(): int { $n = 0; foreach (self::list() as $r) { foreach (glob(self::path($r['owner'], $r['name']).'/gitler/spool/*.json') ?: [] as $f) { // 同時に動いた別のcronと重ならない様に、名前を変えて取る if (!@rename($f, $f.'.work')) continue; $job = json_decode((string)@file_get_contents($f.'.work'), true); @unlink($f.'.work'); if (!is_array($job)) continue; if (($job['kind'] ?? 'federate') === 'notify') { Notify::deliver((string)($job['user'] ?? ''), (array)($job['record'] ?? []), $job['mail'] ?? null); } else if (is_array($job['activity'] ?? null) && Federation::enabled($r['meta'])) { Federation::deliver($r['owner'], $r['name'], $job['activity'], (array)($job['extra'] ?? [])); } $n++; } } return $n; } /** 全部のリポジトリを整理する(cronのgitlergc) */ public static function gcAll(): Result { $n = 0; $errors = []; foreach (self::list() as $r) { try { Maintenance::gc(self::open($r['owner'], $r['name'])); $n++; } catch (\Throwable $e) { $errors[] = "{$r['owner']}/{$r['name']}:".$e->getMessage(); } } return empty($errors) ? Result::Success("{$n}個のリポジトリを整理しました。") : Result::Error(implode("\n", $errors)); } }