master
ブランチ master
LittleBeast / src / Std / Lib / Gitler / Gitler.php
19.4 KB、394行 生 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 Std\Lib\Gitler;
39
40use Std\Lib\Fedi\Fedi;
41use Std\Lib\Git\Commit;
42use Std\Lib\Git\GitException;
43use Std\Lib\Git\Maintenance;
44use Std\Lib\Git\Person;
45use Std\Lib\Git\Repository;
46use Std\Lib\Git\Tree;
47use Result;
48use Roles;
49
50/**
51 * Gitler(Gitのホスティング):リポジトリと権限
52 *
53 * リポジトリは{root}/{所有者}/{名前}.git(GITINFOのroot)。説明・非公開・共同作業者はリポジトリの中のgitler.json。
54 * 権限:NONE(見えない)、READ(読める)、WRITE(プッシュ出来る)、ADMIN(設定出来る)。
55 * アーカイブしたリポジトリは読むだけ(プッシュ、イシュー等の変更は出来ない)。
56 */
57final class Gitler {
58 public const NONE = 0;
59 public const READ = 1;
60 public const WRITE = 2;
61 public const ADMIN = 3;
62
63 /** リポジトリのディレクトリ(テスト用) */
64 public static ?string $root = null;
65 /** ユーザーのデータ(テスト用、既定はdata/user/) */
66 public static ?string $userDir = null;
67 /**
68 * SSHのプッシュ(gitユーザー)ではdata/に書けないので、通知とFediverseへの配信をリポジトリのgitler/spool/に溜める。
69 * cronのnotifyが送る
70 */
71 public static bool $deferred = false;
72
73 public static function enabled(): bool {
74 return defined('GITLER_ENABLED') && GITLER_ENABLED && AUTH_ENABLED;
75 }
76
77 /** GITINFOと既定値 */
78 public static function config(): array {
79 return (defined('GITINFO') ? GITINFO : []) + ['root' => '../git', 'ssh' => '', 'sshPort' => 22, 'php' => '/usr/local/bin/php-8.5', 'objectFormat' => 'sha1', 'branch' => 'master', 'maxRepos' => 100];
80 }
81
82 /** リポジトリのディレクトリ(相対パスはROOTから) */
83 public static function root(): string {
84 if (self::$root !== null) return rtrim(self::$root, '/\\');
85 return self::resolvePath((string)self::config()['root']);
86 }
87
88 /**
89 * パスを絶対パスにする(相対パスはROOTから)
90 *
91 * 「..」は文字列として解決する(chrootの中と外で同じ設定を使える様に、realpathは使わない)。
92 */
93 public static function resolvePath(string $path): string {
94 $r = str_replace('\\', '/', $path);
95 if (!preg_match('#^(/|[A-Za-z]:/)#', $r)) $r = str_replace('\\', '/', ROOT).'/'.$r;
96 $parts = [];
97 foreach (explode('/', $r) as $i => $p) {
98 if ($p === '..') {
99 if (count($parts) > 1) array_pop($parts);
100 } else if ($p !== '.' && ($p !== '' || $i === 0)) {
101 $parts[] = $p;
102 }
103 }
104 return implode('/', $parts) ?: '/';
105 }
106
107 /** リポジトリの名前(英数字・「.」「_」「-」、「.git」で終わらない) */
108 public static function validName(string $name): bool {
109 return (bool)preg_match('/^[A-Za-z0-9_][A-Za-z0-9._-]{0,99}$/', $name) && !str_ends_with(strtolower($name), '.git') && !str_contains($name, '..');
110 }
111
112 public static function validOwner(string $owner): bool {
113 return (bool)preg_match('/^[A-Za-z0-9]+$/', $owner);
114 }
115
116 public static function path(string $owner, string $name): string {
117 if (!self::validOwner($owner) || !self::validName($name)) throw new GitException("不正なリポジトリ:{$owner}/{$name}");
118 return self::root()."/{$owner}/{$name}.git";
119 }
120
121 public static function exists(string $owner, string $name): bool {
122 return self::validOwner($owner) && self::validName($name) && Repository::isRepository(self::path($owner, $name));
123 }
124
125 public static function open(string $owner, string $name): Repository {
126 return Repository::open(self::path($owner, $name));
127 }
128
129 /**
130 * リポジトリの情報
131 *
132 * @return array description、private、created、collaborators(ユーザー名 => read又はwrite)、defaultBranch、
133 * issues(イシューを使う)、pulls(プルリクエストを使う)、federate(公開ならFediverseに出す)、archived(アーカイブ)、
134 * forkOf(フォーク元の「所有者/名前」又は空)
135 */
136 public static function meta(string $owner, string $name): array {
137 $j = json_decode((string)@file_get_contents(self::path($owner, $name).'/gitler.json'), true);
138 $j = is_array($j) ? $j : [];
139 $j += ['description' => '', 'private' => false, 'created' => 0, 'collaborators' => [], 'issues' => true, 'pulls' => true, 'federate' => true, 'archived' => false, 'forkOf' => ''];
140 foreach (['private', 'issues', 'pulls', 'federate', 'archived'] as $k) $j[$k] = (bool)$j[$k];
141 $j['forkOf'] = (string)$j['forkOf'];
142 $j['collaborators'] = is_array($j['collaborators']) ? $j['collaborators'] : [];
143 return $j;
144 }
145
146 public static function saveMeta(string $owner, string $name, array $meta): void {
147 $repo = self::open($owner, $name);
148 $repo->writeFile($repo->path.'/gitler.json', (string)json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
149 $repo->writeFile($repo->path.'/description', ($meta['description'] ?? '') !== '' ? $meta['description']."\n" : '');
150 }
151
152 //////// ユーザーと権限
153
154 /** ユーザー(パスワード等を除く)。無ければnull */
155 public static function user(string $name): ?\stdClass {
156 if (!self::validOwner($name)) return null;
157 $dir = rtrim(self::$userDir ?? Fedi::$userDir ?? ROOT.'/data/user', '/').'/';
158 foreach (glob($dir.'*.'.$name.'.json') ?: [] as $f) {
159 if (!preg_match('/^\d+\.'.$name.'\.json$/', basename($f))) continue;
160 $u = json_decode((string)file_get_contents($f));
161 if (!$u instanceof \stdClass || ($u->username ?? '') !== $name) continue;
162 unset($u->password, $u->tokens, $u->verifyToken, $u->twoFactor, $u->twoFactorPending, $u->twoFactorTicket);
163 return $u;
164 }
165 return null;
166 }
167
168 /**
169 * ユーザーがGitlerの機能を使えるか
170 *
171 * @param string $what repos(自分のリポジトリ、既定は無し)、issues(イシュー、既定は全員)、pulls(プルリクエスト、既定は無し)
172 */
173 public static function userCan(?\stdClass $user, string $what): bool {
174 if ($user === null || !isset($user->username)) return false;
175 $role = (int)($user->role ?? 0);
176 if ($role === Roles::BANNED || ($user->verified ?? true) === false) return false;
177 if ($role & Roles::ADMIN) return true;
178 return match ($what) {
179 'repos' => (bool)($role & Roles::GIT_REPOS),
180 'pulls' => (bool)($role & Roles::GIT_PULLS),
181 // 管理者がロールを変えていなければ全員(Fediverseと同じ)
182 'issues' => (bool)($role & Roles::GIT_ISSUES) || !isset($user->fediDefault),
183 default => false,
184 };
185 }
186
187 /** リポジトリの権限(NONE・READ・WRITE・ADMIN) */
188 public static function access(?\stdClass $user, string $owner, string $name): int {
189 if (!self::exists($owner, $name)) return self::NONE;
190 $meta = self::meta($owner, $name);
191 $public = $meta['private'] ? self::NONE : self::READ;
192 if ($user === null || !isset($user->username)) return $public;
193 $role = (int)($user->role ?? 0);
194 if ($role === Roles::BANNED || ($user->verified ?? true) === false) return $public;
195 if ($role & Roles::ADMIN) return self::ADMIN;
196 if ($user->username === $owner) return self::userCan($user, 'repos') ? self::ADMIN : self::READ;
197 $c = $meta['collaborators'][$user->username] ?? null;
198 if ($c === 'write') return self::WRITE;
199 if ($c === 'read') return self::READ;
200 return $public;
201 }
202
203 //////// リポジトリ
204
205 /**
206 * リポジトリを作る
207 *
208 * @param array $opt description、private、readme(最初のコミットにREADME.mdを作る)、objectFormat、branch
209 */
210 public static function create(\stdClass $user, string $name, array $opt = []): Result {
211 if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません。');
212 if (!self::validName($name)) return Result::Error('リポジトリの名前は英数字・「.」「_」「-」の100文字までです(「.git」で終わらない)。');
213 $owner = (string)$user->username;
214 if (self::exists($owner, $name) || is_dir(self::path($owner, $name))) return Result::Error("リポジトリ「{$owner}/{$name}」は既に有ります。");
215 $max = (int)self::config()['maxRepos'];
216 if ($max > 0 && count(self::list($owner)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。");
217 $format = (string)($opt['objectFormat'] ?? self::config()['objectFormat']);
218 $branch = trim((string)($opt['branch'] ?? '')) ?: (string)self::config()['branch'];
219 try {
220 $repo = Repository::init(self::path($owner, $name), in_array($format, ['sha1', 'sha256'], true) ? $format : 'sha1', $branch, 'group', (string)($opt['description'] ?? ''));
221 } catch (GitException $e) {
222 return Result::Error('リポジトリを作れません:'.$e->getMessage());
223 }
224 self::saveMeta($owner, $name, ['description' => trim((string)($opt['description'] ?? '')), 'private' => !empty($opt['private']), 'created' => time(), 'collaborators' => []]);
225 if (!empty($opt['readme'])) {
226 $who = Person::now(($user->displayname ?? '') !== '' ? (string)$user->displayname : $owner, (string)($user->email ?? "{$owner}@localhost"));
227 $text = "# {$name}\n".(trim((string)($opt['description'] ?? '')) !== '' ? "\n".trim((string)$opt['description'])."\n" : '');
228 $tree = $repo->updateTree(null, ['README.md' => [Tree::FILE, $text]]);
229 $c = $repo->writeCommit(Commit::create($tree, [], $who, $who, "最初のコミット\n"));
230 $repo->refs->update('refs/heads/'.$branch, $c, $repo->zeroId());
231 }
232 return Result::Success("リポジトリ「{$owner}/{$name}」を作りました。", ['owner' => $owner, 'name' => $name]);
233 }
234
235 /**
236 * フォークする(自分のアカウントに同じ名前で。有れば「名前-2」等)
237 * パックはハードリンク(出来なければコピー)、ブランチとタグだけ持って来る。非公開のリポジトリのフォークは非公開
238 *
239 * @return Result data:owner、name
240 */
241 public static function fork(\stdClass $user, string $owner, string $name): Result {
242 if (!self::userCan($user, 'repos')) return Result::Error('リポジトリを作る権限が有りません(管理者に「Gitのリポジトリ」のロールを付けて貰って下さい)。');
243 if (self::access($user, $owner, $name) < self::READ) return Result::Error('リポジトリが有りません。');
244 $me = (string)$user->username;
245 if ($me === $owner) return Result::Error('自分のリポジトリはフォーク出来ません。');
246 foreach (self::list($me) as $r) {
247 if ($r['meta']['forkOf'] === "{$owner}/{$name}") return Result::Success('既にフォークしています。', ['owner' => $me, 'name' => $r['name']]);
248 }
249 $max = (int)self::config()['maxRepos'];
250 if ($max > 0 && count(self::list($me)) >= $max && !((int)($user->role ?? 0) & Roles::ADMIN)) return Result::Error("リポジトリは{$max}個までです。");
251 $new = $name;
252 for ($i = 2; self::exists($me, $new) || is_dir(self::path($me, $new)); $i++) $new = "{$name}-{$i}";
253 if (!self::validName($new)) return Result::Error('フォークの名前を決められません。');
254 $src = self::open($owner, $name);
255 $meta = self::meta($owner, $name);
256 $head = $src->headRef();
257 $branch = $head !== null && str_starts_with($head, 'refs/heads/') ? substr($head, 11) : (string)self::config()['branch'];
258 try {
259 $dst = Repository::init(self::path($me, $new), $src->hashAlgo, $branch, 'group', $meta['description']);
260 $from = $src->objects->dir();
261 $to = $dst->objects->dir();
262 foreach (glob($from.'/pack/pack-*') ?: [] as $f) {
263 $t = $to.'/pack/'.basename($f);
264 $dst->mkdir(dirname($t));
265 if (!@link($f, $t) && !@copy($f, $t)) throw new GitException("コピー出来ません:{$f}");
266 }
267 foreach (glob($from.'/[0-9a-f][0-9a-f]/*') ?: [] as $f) {
268 $t = $to.'/'.basename(dirname($f)).'/'.basename($f);
269 $dst->mkdir(dirname($t));
270 if (!@copy($f, $t)) throw new GitException("コピー出来ません:{$f}");
271 $dst->chmodFile($t, true);
272 }
273 $refs = [];
274 foreach (['refs/heads/', 'refs/tags/'] as $prefix) foreach ($src->refs->all($prefix) as $ref => $id) $refs[] = [$ref, $id, null];
275 if (!empty($refs)) $dst->refs->transaction($refs);
276 } catch (GitException $e) {
277 if (Repository::isRepository(self::path($me, $new))) self::delete($me, $new);
278 return Result::Error('フォーク出来ません:'.$e->getMessage());
279 }
280 self::saveMeta($me, $new, ['description' => $meta['description'], 'private' => $meta['private'], 'created' => time(), 'collaborators' => [], 'forkOf' => "{$owner}/{$name}"]);
281 return Result::Success("{$owner}/{$name}を{$me}/{$new}にフォークしました。", ['owner' => $me, 'name' => $new]);
282 }
283
284 /** フォーク([所有者, 名前]の配列) */
285 public static function forks(string $owner, string $name): array {
286 $out = [];
287 foreach (self::list() as $r) if ($r['meta']['forkOf'] === "{$owner}/{$name}") $out[] = [$r['owner'], $r['name']];
288 return $out;
289 }
290
291 /** 名前を変える */
292 public static function rename(string $owner, string $name, string $new): Result {
293 if (!self::validName($new)) return Result::Error('不正なリポジトリの名前です。');
294 if ($new === $name) return Result::Success();
295 if (self::exists($owner, $new) || is_dir(self::path($owner, $new))) return Result::Error("リポジトリ「{$owner}/{$new}」は既に有ります。");
296 if (!@rename(self::path($owner, $name), self::path($owner, $new))) return Result::Error('名前を変えられません。');
297 return Result::Success("名前を「{$new}」に変えました。");
298 }
299
300 /** リポジトリを消す(元に戻せない) */
301 public static function delete(string $owner, string $name): bool {
302 $dir = self::path($owner, $name);
303 if (!Repository::isRepository($dir)) return false;
304 $rm = function(string $d) use (&$rm): void {
305 foreach (@scandir($d) ?: [] as $f) {
306 if ($f === '.' || $f === '..') continue;
307 $p = "{$d}/{$f}";
308 if (is_dir($p) && !is_link($p)) $rm($p);
309 // 読むだけのファイル(Windows)だけ権限を変える(フォークのパックはハードリンクで元と共有)
310 else if (!@unlink($p)) {
311 @chmod($p, 0666);
312 @unlink($p);
313 }
314 }
315 @rmdir($d);
316 };
317 $rm($dir);
318 @rmdir(dirname($dir));
319 return !is_dir($dir);
320 }
321
322 /**
323 * リポジトリの一覧(更新の新しい順)
324 *
325 * @param string|null $owner 所有者(nullなら全部)
326 * @return array owner、name、meta、updated(最後の更新の時刻)
327 */
328 public static function list(?string $owner = null): array {
329 $out = [];
330 $owners = $owner !== null ? [$owner] : array_map('basename', glob(self::root().'/*', GLOB_ONLYDIR) ?: []);
331 foreach ($owners as $o) {
332 if (!self::validOwner($o)) continue;
333 foreach (glob(self::root()."/{$o}/*.git", GLOB_ONLYDIR) ?: [] as $dir) {
334 $name = substr(basename($dir), 0, -4);
335 if (!self::validName($name) || !Repository::isRepository($dir)) continue;
336 $updated = max((int)@filemtime($dir.'/refs/heads'), (int)@filemtime($dir.'/packed-refs'), (int)@filemtime($dir.'/gitler.json'));
337 foreach (glob($dir.'/refs/heads/*') ?: [] as $f) $updated = max($updated, (int)@filemtime($f));
338 $out[] = ['owner' => $o, 'name' => $name, 'meta' => self::meta($o, $name), 'updated' => $updated];
339 }
340 }
341 usort($out, fn($a, $b) => $b['updated'] <=> $a['updated'] ?: strcmp($a['owner'].'/'.$a['name'], $b['owner'].'/'.$b['name']));
342 return $out;
343 }
344
345 //////// 後で処理する物
346
347 /** SSHのプッシュの通知・配信をリポジトリに溜める(kind:notify、federate) */
348 public static function defer(string $owner, string $name, array $job): void {
349 $repo = self::open($owner, $name);
350 $dir = $repo->path.'/gitler/spool';
351 $repo->mkdir($dir);
352 $repo->writeFile($dir.'/'.Fedi::newId().'.json', (string)json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
353 }
354
355 /**
356 * 溜めた通知と配信を処理する(cronのnotify)
357 *
358 * @return int 処理した数
359 */
360 public static function flushDeferred(): int {
361 $n = 0;
362 foreach (self::list() as $r) {
363 foreach (glob(self::path($r['owner'], $r['name']).'/gitler/spool/*.json') ?: [] as $f) {
364 // 同時に動いた別のcronと重ならない様に、名前を変えて取る
365 if (!@rename($f, $f.'.work')) continue;
366 $job = json_decode((string)@file_get_contents($f.'.work'), true);
367 @unlink($f.'.work');
368 if (!is_array($job)) continue;
369 if (($job['kind'] ?? 'federate') === 'notify') {
370 Notify::deliver((string)($job['user'] ?? ''), (array)($job['record'] ?? []), $job['mail'] ?? null);
371 } else if (is_array($job['activity'] ?? null) && Federation::enabled($r['meta'])) {
372 Federation::deliver($r['owner'], $r['name'], $job['activity'], (array)($job['extra'] ?? []));
373 }
374 $n++;
375 }
376 }
377 return $n;
378 }
379
380 /** 全部のリポジトリを整理する(cronのgitlergc) */
381 public static function gcAll(): Result {
382 $n = 0;
383 $errors = [];
384 foreach (self::list() as $r) {
385 try {
386 Maintenance::gc(self::open($r['owner'], $r['name']));
387 $n++;
388 } catch (\Throwable $e) {
389 $errors[] = "{$r['owner']}/{$r['name']}:".$e->getMessage();
390 }
391 }
392 return empty($errors) ? Result::Success("{$n}個のリポジトリを整理しました。") : Result::Error(implode("\n", $errors));
393 }
394}