add repo owner

This commit is contained in:
hanyixuanten
2026-08-10 00:19:21 +08:00
parent 5cd26c9f02
commit 2e56f74db9
14 changed files with 451 additions and 118 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_URI} !index\.php$
RewriteRule .* index.php [L,QSA]
RewriteRule ^index\.php$ - [L]
RewriteRule ^ index.php [L,QSA]
</IfModule>
+22 -14
View File
@@ -10,8 +10,10 @@ This project serves configured Git repositories through PHP. It supports:
- Remote branch and tag creation, update and deletion through push.
- MySQL-backed account registration and login.
- Hashed, revocable access tokens for authenticated Git pushes.
- Authenticated bare-repository creation from the home page.
- Per-repository controls for reads, pushes, authentication, branch refs,
- Repository ownership with owner-only pushes.
- Public/private repositories and authenticated private reads.
- Authenticated public/private bare-repository creation from the home page.
- Per-repository controls for reads, pushes, visibility, branch refs,
tag refs, other ref namespaces and push request size.
`index.php` is the application entry point. Protocol responsibilities are split
@@ -36,8 +38,7 @@ If Git or `proc_open` is unavailable, the native PHP backend supports ordinary
SHA-1 clone, fetch, pull and push, including deltas, branches and tags. It does
not currently support SHA-256 repositories, shallow or filtered fetches, signed
push certificates, Git hooks or protocol v2-only features.
Push is disabled by default and should be enabled only behind HTTPS and trusted
web-server authentication.
Push is disabled by default and should be enabled only behind HTTPS.
Configuration
-------------
@@ -51,17 +52,22 @@ $repos = array(
array('/project.git', '/srv/git/project.git', array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE,
'owner' => 'alice',
'private' => TRUE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE)));
```
With `require_auth` enabled, Git uses HTTP Basic authentication: enter the
registered username as the username and an access token as the password. The
application stores password hashes and token SHA-256 digests in MySQL; token
plaintext is shown only once. Setting `require_auth` to `FALSE` permits
anonymous push and is suitable only for isolated development environments.
Git uses HTTP Basic authentication: enter the registered username as the
username and an access token as the password. Private repositories require a
valid token for every Git read, and only the configured owner can push to any
writable repository. The application stores password hashes and token SHA-256
digests in MySQL; token plaintext is shown only once.
Existing installations should run `migration.repository-ownership.mysql.sql`
and assign an owner/visibility row for each existing managed repository before
enabling pushes.
To create bare repositories from the home page, enable managed repositories.
They are always stored in the application's `repos` directory and discovered
@@ -69,14 +75,16 @@ automatically; this path cannot be changed in `config.php`:
```php
$managed_repositories = array(
'require_auth' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE));
'push' => TRUE));
```
The home page uses the logged-in application session for repository creation.
The home page uses the logged-in application session for repository creation,
records that account as owner, and lets the owner choose public or private.
Anonymous visitors see only the public repository section. Logged-in users also
see a separate private section, but Git access to private repositories still
requires an access token rather than the browser session.
The `repos` directory is created automatically when missing. Its parent must be
writable for that first creation, and the resulting directory must be readable,
writable and searchable by PHP and reserved for this application. Set
+5 -4
View File
@@ -34,11 +34,9 @@ $auth = array(
* Set this to array() to disable repository creation.
*/
$managed_repositories = array(
'require_auth' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
@@ -52,7 +50,8 @@ $managed_repositories = array(
*
* read Allow clone, fetch and pull. Default: TRUE.
* push Allow Smart HTTP push. Default: FALSE.
* require_auth Require an application access token for push. Default: TRUE.
* owner Account username allowed to push. Default: NULL.
* private Require an access token for reads. Default: FALSE.
* branches Allow push updates under refs/heads/. Default: TRUE.
* tags Allow push updates under refs/tags/. Default: TRUE.
* other_refs Allow push updates to other ref namespaces. Default: FALSE.
@@ -64,5 +63,7 @@ $managed_repositories = array(
$repos = array(
array('/php-git-server.git', '.git', array(
'read' => TRUE,
'push' => FALSE)),
'push' => FALSE,
'owner' => NULL,
'private' => FALSE)),
);
+116 -32
View File
@@ -37,12 +37,8 @@ function home_managed_repositories_configured($configuration) {
return is_array($configuration) && !empty($configuration);
}
function home_creation_requires_auth($configuration) {
return !isset($configuration['require_auth']) || $configuration['require_auth'];
}
function home_creation_is_authorized($configuration) {
return !home_creation_requires_auth($configuration) || get_authenticated_user() !== NULL;
return auth_is_enabled() && get_authenticated_user() !== NULL;
}
function home_session_cookie_is_secure($configuration) {
@@ -221,13 +217,27 @@ function home_handle_auth_action($url_base, $configuration, $action) {
home_redirect($url_base);
}
function home_repository_url_exists($url_base, $definitions, $name) {
function home_repository_url_conflicts($url_base, $definitions, $configuration, $name) {
$expected_url = rtrim((string) $url_base, '/').'/'.$name;
$root = managed_repository_root($configuration);
$managed_path = $root === FALSE ? NULL : $root.DIRECTORY_SEPARATOR.$name;
foreach ($definitions as $definition) {
$repository = normalize_repository($definition, $url_base);
if ($repository !== FALSE && $repository['url'] === $expected_url) {
return TRUE;
if ($repository === FALSE || $repository['url'] !== $expected_url) {
continue;
}
if ($managed_path !== NULL && $repository['path'] === $managed_path) {
continue;
}
if ($managed_path !== NULL
&& realpath($repository['path']) !== FALSE
&& realpath($repository['path']) === realpath($managed_path)) {
continue;
}
return TRUE;
}
return FALSE;
@@ -246,6 +256,8 @@ function home_creation_result_notice($result) {
return array(503, 'error', '仓库存放目录不可用或不可写。');
case 'git_unavailable':
return array(503, 'error', 'Git 初始化服务当前不可用。');
case 'metadata_unavailable':
return array(503, 'error', '仓库所有权信息无法保存,请稍后重试。');
default:
return array(500, 'error', '仓库创建失败,请检查服务器日志。');
}
@@ -264,13 +276,29 @@ function home_create_repository(
}
home_require_csrf($url_base, $configuration);
$owner = auth_session_user();
if ($owner === NULL) {
send_error(403, 'Forbidden', 'Login is required to create repositories.');
}
$visibility = home_post_value('visibility');
if ($visibility !== 'public' && $visibility !== 'private') {
send_error(422, 'Unprocessable Content', 'Repository visibility is invalid.');
}
$value = isset($_POST['repository_name']) && is_string($_POST['repository_name'])
? $_POST['repository_name'] : '';
$name = normalize_managed_repository_name($value);
if ($name !== FALSE && home_repository_url_exists($url_base, $definitions, $name)) {
if ($name !== FALSE
&& home_repository_url_conflicts($url_base, $definitions, $configuration, $name)) {
$result = array('status' => 'already_exists', 'name' => $name);
} else {
$result = git_service_create_managed_repository($application, $configuration, $value);
$result = git_service_create_managed_repository(
$application,
$configuration,
$value,
$owner['id'],
$visibility === 'private');
}
if ($result['status'] === 'created') {
@@ -287,7 +315,8 @@ function home_create_repository(
$definitions,
$configuration,
array('type' => $notice[1], 'message' => $notice[2]),
$value);
$value,
$visibility);
die();
}
@@ -295,8 +324,8 @@ function home_repository_url_cmp($left, $right) {
return strcmp($left['url'], $right['url']);
}
function home_visible_repositories($url_base, $definitions) {
$repositories = array();
function home_visible_repositories($url_base, $definitions, $include_private) {
$repositories = array('public' => array(), 'private' => array());
foreach ($definitions as $definition) {
$repository = normalize_repository($definition, $url_base);
@@ -304,16 +333,22 @@ function home_visible_repositories($url_base, $definitions) {
continue;
}
$visibility = repository_is_private($repository) ? 'private' : 'public';
if ($visibility === 'private' && !$include_private) {
continue;
}
$git_path = realpath($repository['path']);
if ($git_path === FALSE || !is_dir($git_path)) {
continue;
}
$repository['path'] = $git_path;
$repositories[] = $repository;
$repositories[$visibility][] = $repository;
}
usort($repositories, 'home_repository_url_cmp');
usort($repositories['public'], 'home_repository_url_cmp');
usort($repositories['private'], 'home_repository_url_cmp');
return $repositories;
}
@@ -398,10 +433,21 @@ p { margin: 0 0 1rem; }
.account .credentials { display: grid; grid-template-columns: 1fr 1fr; gap: .6rem; flex: 1; }
.field { flex: 1 1 22rem; }
label { display: block; margin-bottom: .3rem; font-weight: 600; }
input { box-sizing: border-box; width: 100%; min-height: 2.6rem; padding: .5rem .7rem;
input:not([type="radio"]) { box-sizing: border-box; width: 100%; min-height: 2.6rem; padding: .5rem .7rem;
border: 1px solid #9ba4b0; border-radius: .35rem; background: #fff; color: #1f2530;
font: inherit; }
input:focus { border-color: #1769aa; outline: 2px solid #1769aa; outline-offset: 1px; }
input:not([type="radio"]):focus { border-color: #1769aa; outline: 2px solid #1769aa; outline-offset: 1px; }
.visibility { flex: 0 0 13rem; margin: 0; padding: 0; border: 0; }
.visibility legend { margin-bottom: .3rem; font-weight: 600; }
.visibility-options { display: grid; grid-template-columns: 1fr 1fr; min-height: 2.6rem;
overflow: hidden; border: 1px solid #9ba4b0; border-radius: .35rem; }
.visibility-option { position: relative; margin: 0; font-weight: 500; }
.visibility-option + .visibility-option { border-left: 1px solid #9ba4b0; }
.visibility-option input { position: absolute; opacity: 0; }
.visibility-option span { display: flex; height: 100%; align-items: center; justify-content: center;
padding: 0 .75rem; cursor: pointer; }
.visibility-option input:checked + span { color: #fff; background: #176b43; }
.visibility-option input:focus-visible + span { outline: 2px solid #1769aa; outline-offset: -3px; }
button { min-height: 2.6rem; padding: .5rem 1rem; border: 1px solid #175b3a;
border-radius: .35rem; color: #fff; background: #176b43; font: inherit;
font-weight: 600; cursor: pointer; }
@@ -420,6 +466,8 @@ button:hover { background: #125635; }
.notice-success { border-color: #1f7a4b; background: #edf8f1; color: #155735; }
.notice-error { border-color: #b33a3a; background: #fff0f0; color: #842828; }
table { width: 100%; border-collapse: collapse; }
.repositories { margin-top: 2.5rem; }
.repositories h2 { margin-top: 0; }
caption { padding-bottom: .5rem; color: #5b6472; text-align: left; }
th, td { padding: .6rem .5rem; border-bottom: 1px solid #d5dae1; text-align: left;
vertical-align: top; }
@@ -441,6 +489,7 @@ footer { margin-top: 2.5rem; color: #5b6472; font-size: .9rem; }
body { padding-top: 1.5rem; }
.account-grid, .account .credentials { display: block; }
.account form, .create form, .token-form { display: block; }
.visibility { margin-top: .65rem; }
button { width: 100%; margin-top: .65rem; }
.account-bar button, .token-list button { width: auto; margin-top: 0; }
table { display: block; overflow-x: auto; }
@@ -449,7 +498,8 @@ footer { margin-top: 2.5rem; color: #5b6472; font-size: .9rem; }
body { color: #e6e9ef; background: #12161c; }
.lead, .hint, caption, footer, .badge-quiet, .empty { color: #9aa4b2; }
.account, .create, th, td, .token-list li { border-color: #2b323d; }
input { border-color: #596474; background: #1a1f27; color: #e6e9ef; }
input:not([type="radio"]) { border-color: #596474; background: #1a1f27; color: #e6e9ef; }
.visibility-options, .visibility-option + .visibility-option { border-color: #596474; }
pre { border-color: #2b323d; background: #1a1f27; }
.empty { border-color: #3a424f; }
.notice-success { background: #152b20; color: #95dab1; }
@@ -541,7 +591,7 @@ function home_send_authentication($url_base, $configuration, $notice) {
echo '</section>' ."\n";
}
function home_send_creation($url_base, $configuration, $notice, $value) {
function home_send_creation($url_base, $configuration, $notice, $value, $visibility) {
if (!home_managed_repositories_configured($configuration)) {
return;
}
@@ -568,21 +618,28 @@ function home_send_creation($url_base, $configuration, $notice, $value) {
echo '<form method="post" action="'.home_escape(home_page_url($url_base)).'">' ."\n";
echo '<input type="hidden" name="csrf_token" value="'.home_escape($token).'">' ."\n";
echo '<input type="hidden" name="action" value="create_repository">' ."\n";
echo '<div class="field"><label for="repository-name">仓库名称</label>' ."\n";
echo '<input id="repository-name" name="repository_name" type="text" maxlength="68" '
.'pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9_-])?(?:\.git)?" '
.'placeholder="project" value="'
.home_escape($value).'" autocomplete="off" required>' ."\n";
echo '<p class="hint">可使用字母、数字、点、短横线和下划线;<code>.git</code> 后缀可省略。</p></div>' ."\n";
echo '<fieldset class="visibility"><legend>可见性</legend><div class="visibility-options">' ."\n";
echo '<label class="visibility-option"><input name="visibility" type="radio" value="public"'
.($visibility !== 'private' ? ' checked' : '').'><span>公开</span></label>' ."\n";
echo '<label class="visibility-option"><input name="visibility" type="radio" value="private"'
.($visibility === 'private' ? ' checked' : '').'><span>私有</span></label>' ."\n";
echo '</div></fieldset>' ."\n";
echo '<button type="submit">创建仓库</button>' ."\n";
echo '</form>' ."\n";
echo '</section>' ."\n";
}
function home_send_repository_table($repositories, $prefix) {
function home_send_repository_table($repositories, $prefix, $caption) {
echo '<table>'."\n";
echo '<caption>已配置且允许读取的仓库</caption>'."\n";
echo '<thead><tr><th scope="col">仓库</th><th scope="col">克隆地址</th>'
echo '<caption>'.home_escape($caption).'</caption>'."\n";
echo '<thead><tr><th scope="col">仓库</th><th scope="col">所有者</th><th scope="col">克隆地址</th>'
.'<th scope="col">默认分支</th><th scope="col" class="count">分支</th>'
.'<th scope="col" class="count">标签</th><th scope="col">权限</th></tr></thead>'."\n";
echo '<tbody>'."\n";
@@ -596,12 +653,16 @@ function home_send_repository_table($repositories, $prefix) {
} else {
$head = '<span class="badge badge-quiet">未指向分支</span>';
}
$access = $repository['options']['push']
? '<span class="badge badge-push">读取 / 推送</span>'
$owner = $repository['options']['owner'] === NULL
? '<span class="badge badge-quiet">未设置</span>'
: '<code>'.home_escape($repository['options']['owner']).'</code>';
$access = $repository['options']['push'] && $repository['options']['owner'] !== NULL
? '<span class="badge badge-push">所有者可推送</span>'
: '<span class="badge badge-quiet">只读</span>';
echo '<tr>';
echo '<td>'.home_escape(basename($repository['url'])).'</td>';
echo '<td>'.$owner.'</td>';
echo '<td><code>'.home_escape($prefix.$repository['url']).'</code></td>';
echo '<td>'.$head.'</td>';
echo '<td class="count">'.home_escape($summary['branches']).'</td>';
@@ -613,6 +674,17 @@ function home_send_repository_table($repositories, $prefix) {
echo '</tbody>'."\n".'</table>'."\n";
}
function home_send_repository_section($id, $title, $repositories, $prefix, $empty_message) {
echo '<section class="repositories" aria-labelledby="'.home_escape($id).'">' ."\n";
echo '<h2 id="'.home_escape($id).'">'.home_escape($title).'</h2>' ."\n";
if (empty($repositories)) {
echo '<p class="empty">'.home_escape($empty_message).'</p>' ."\n";
} else {
home_send_repository_table($repositories, $prefix, $title);
}
echo '</section>' ."\n";
}
function home_send_empty_notice() {
echo <<<'HTML'
<div class="empty">
@@ -641,8 +713,10 @@ function home_render(
$definitions,
$configuration,
$notice=NULL,
$value='') {
$repositories = home_visible_repositories($url_base, $definitions);
$value='',
$visibility='public') {
$show_private = get_authenticated_user() !== NULL;
$repositories = home_visible_repositories($url_base, $definitions, $show_private);
$prefix = home_clone_url_prefix();
header_nocache();
@@ -656,15 +730,24 @@ function home_render(
.home_escape($notice['message']).'</p>' ."\n";
}
home_send_authentication($url_base, $configuration, $notice);
home_send_creation($url_base, $configuration, NULL, $value);
home_send_creation($url_base, $configuration, NULL, $value, $visibility);
if (empty($repositories)) {
home_send_empty_notice();
} else {
home_send_repository_table($repositories, $prefix);
home_send_repository_section(
'public-repositories',
'公开仓库',
$repositories['public'],
$prefix,
'当前没有公开仓库。');
if ($show_private) {
home_send_repository_section(
'private-repositories',
'私有仓库',
$repositories['private'],
$prefix,
'当前没有私有仓库。');
}
home_send_usage($repositories, $prefix);
home_send_usage(array_merge($repositories['public'], $repositories['private']), $prefix);
echo '<footer>详细安装、配置和安全说明见 <code>usage.md</code>。</footer>'."\n";
echo '</body>'."\n".'</html>'."\n";
@@ -749,4 +832,5 @@ if ($repository === FALSE) {
}
$request = create_http_request($url_path, $repository);
repository_require_private_access($repository, $request);
dispatch_service($services, $repository, $request, $application);
+104
View File
@@ -404,6 +404,110 @@ function auth_revoke_access_token($user_id, $token_id) {
}
}
function auth_repository_metadata() {
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->query(
'SELECT pgit_repositories.repository_name, pgit_repositories.is_private, '
.'pgit_repositories.is_ready, '
.'pgit_users.username AS owner '
.'FROM pgit_repositories JOIN pgit_users '
.'ON pgit_users.id = pgit_repositories.owner_user_id');
$metadata = array();
foreach ($statement->fetchAll() as $repository) {
$metadata[$repository['repository_name']] = array(
'owner' => $repository['owner'],
'private' => (bool) $repository['is_private'],
'ready' => (bool) $repository['is_ready']);
}
return $metadata;
} catch (PDOException $exception) {
error_log('Repository metadata lookup failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_reserve_repository_metadata($name, $owner_user_id, $private) {
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'INSERT INTO pgit_repositories '
.'(repository_name, owner_user_id, is_private, is_ready) VALUES (?, ?, ?, 0)');
$statement->execute(array($name, $owner_user_id, $private ? 1 : 0));
return array('status' => 'reserved', 'id' => (int) $database->lastInsertId());
} catch (PDOException $exception) {
$driver_code = isset($exception->errorInfo[1]) ? (int) $exception->errorInfo[1] : 0;
if ($driver_code !== 1062) {
error_log('Repository metadata reservation failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
try {
$statement = $database->prepare(
'SELECT id, owner_user_id, is_ready FROM pgit_repositories '
.'WHERE repository_name = ? LIMIT 1');
$statement->execute(array($name));
$metadata = $statement->fetch();
if ($metadata === FALSE || (int) $metadata['is_ready'] !== 0
|| (int) $metadata['owner_user_id'] !== (int) $owner_user_id) {
return array('status' => 'already_exists');
}
$update = $database->prepare(
'UPDATE pgit_repositories SET is_private = ? WHERE id = ? AND is_ready = 0');
$update->execute(array($private ? 1 : 0, $metadata['id']));
return array('status' => 'reserved', 'id' => (int) $metadata['id']);
} catch (PDOException $exception) {
error_log('Repository metadata reservation recovery failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_complete_repository_metadata($metadata_id, $owner_user_id) {
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->prepare(
'UPDATE pgit_repositories SET is_ready = 1 '
.'WHERE id = ? AND owner_user_id = ? AND is_ready = 0');
$statement->execute(array($metadata_id, $owner_user_id));
return $statement->rowCount() === 1;
} catch (PDOException $exception) {
error_log('Repository metadata completion failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_recover_repository_metadata($name, $owner_user_id, $private) {
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'UPDATE pgit_repositories SET is_private = ?, is_ready = 1 '
.'WHERE repository_name = ? AND owner_user_id = ? AND is_ready = 0');
$statement->execute(array($private ? 1 : 0, $name, $owner_user_id));
return array('status' => $statement->rowCount() === 1 ? 'recovered' : 'not_found');
} catch (PDOException $exception) {
error_log('Repository metadata recovery failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_get_authenticated_user() {
global $auth_cached_user_resolved, $auth_cached_user;
if ($auth_cached_user_resolved) {
+41 -8
View File
@@ -125,7 +125,12 @@ function git_service_init_bare_repository($application, $path) {
return @file_put_contents($path.'/HEAD', $head, LOCK_EX) === strlen($head);
}
function git_service_create_managed_repository($application, $configuration, $value) {
function git_service_create_managed_repository(
$application,
$configuration,
$value,
$owner_user_id,
$private) {
$name = normalize_managed_repository_name($value);
if ($name === FALSE) {
return array('status' => 'invalid_name');
@@ -137,10 +142,6 @@ function git_service_create_managed_repository($application, $configuration, $va
}
$path = $root.DIRECTORY_SEPARATOR.$name;
if (file_exists($path) || is_link($path)) {
return array('status' => 'already_exists', 'name' => $name);
}
$lock_path = $root.DIRECTORY_SEPARATOR.'.create.lock';
$lock = @fopen($lock_path, 'c+b');
if ($lock === FALSE) {
@@ -156,6 +157,20 @@ function git_service_create_managed_repository($application, $configuration, $va
$temporary_path = NULL;
try {
if (file_exists($path) || is_link($path)) {
if (managed_repository_is_bare($path)) {
$recovery = auth_recover_repository_metadata(
$name, (int) $owner_user_id, $private);
if ($recovery['status'] === 'recovered') {
return array(
'status' => 'created',
'name' => $name,
'path' => $path,
'private' => (bool) $private);
}
if ($recovery['status'] === 'database_unavailable') {
return array('status' => 'metadata_unavailable', 'name' => $name);
}
}
return array('status' => 'already_exists', 'name' => $name);
}
@@ -175,12 +190,30 @@ function git_service_create_managed_repository($application, $configuration, $va
return array('status' => 'already_exists', 'name' => $name);
}
$reservation = auth_reserve_repository_metadata(
$name, (int) $owner_user_id, $private);
if ($reservation['status'] === 'already_exists') {
return array('status' => 'already_exists', 'name' => $name);
}
if ($reservation['status'] !== 'reserved') {
return array('status' => 'metadata_unavailable', 'name' => $name);
}
if (!@rename($temporary_path, $path)) {
return array('status' => 'create_failed', 'name' => $name);
}
$temporary_path = NULL;
return array('status' => 'created', 'name' => $name, 'path' => $path);
if (!auth_complete_repository_metadata(
$reservation['id'], (int) $owner_user_id)) {
return array('status' => 'metadata_unavailable', 'name' => $name);
}
return array(
'status' => 'created',
'name' => $name,
'path' => $path,
'private' => (bool) $private);
} finally {
if ($temporary_path !== NULL) {
remove_managed_repository_directory($temporary_path);
@@ -399,7 +432,7 @@ function git_service_advertise($application, $repository, $request, $service) {
send_error(503, 'Service Unavailable', 'The native Git service requires PHP zlib and hash support.');
}
header_nocache();
repository_header_nocache($repository);
header('Content-Type: application/x-'.$service.'-advertisement');
header('X-Content-Type-Options: nosniff');
@@ -434,7 +467,7 @@ function git_service_rpc($application, $repository, $request, $service, $input)
send_error(503, 'Service Unavailable', 'The native Git service requires PHP zlib and hash support.');
}
header_nocache();
repository_header_nocache($repository);
header('Content-Type: application/x-'.$service.'-result');
header('X-Content-Type-Options: nosniff');
+1
View File
@@ -64,6 +64,7 @@ function get_access_token_user() {
function require_authentication($message) {
header('WWW-Authenticate: Basic realm="PHP Git Server", charset="UTF-8"');
header('Cache-Control: private, no-store, max-age=0');
send_error(401, 'Unauthorized', $message);
}
+60 -3
View File
@@ -17,7 +17,8 @@ function repository_default_options() {
return array(
'read' => TRUE,
'push' => FALSE,
'require_auth' => TRUE,
'owner' => NULL,
'private' => FALSE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
@@ -27,6 +28,42 @@ function repository_default_options() {
'max_request_bytes' => 0);
}
function repository_is_private($repository) {
return !empty($repository['options']['private']);
}
function repository_user_is_owner($repository, $user) {
$owner = isset($repository['options']['owner']) ? $repository['options']['owner'] : NULL;
return is_string($owner) && $owner !== ''
&& is_string($user) && hash_equals($owner, $user);
}
function repository_require_private_access($repository, $request) {
if (repository_is_private($repository) && $request['user'] === NULL) {
require_authentication(
'A valid username and access token are required to access this private repository.');
}
if (repository_is_private($repository)) {
header('Cache-Control: private, no-store, max-age=0');
}
}
function repository_require_read_access($repository, $request) {
repository_require_private_access($repository, $request);
if (!$repository['options']['read']) {
send_error(403, 'Forbidden', 'Repository reads are disabled.');
}
}
function repository_header_nocache($repository) {
header_nocache();
if (repository_is_private($repository)) {
header('Cache-Control: private, no-store, max-age=0');
}
}
function managed_repository_root($configuration) {
if (!is_array($configuration) || empty($configuration)) {
return FALSE;
@@ -110,6 +147,10 @@ function managed_repository_definitions($configuration) {
$options = isset($configuration['options']) && is_array($configuration['options'])
? $configuration['options'] : array();
$metadata = auth_repository_metadata();
if ($metadata === FALSE) {
$metadata = array();
}
$definitions = array();
$entries = @scandir($root);
if ($entries === FALSE) {
@@ -126,7 +167,17 @@ function managed_repository_definitions($configuration) {
continue;
}
$definitions[] = array('/'.$entry, $path, $options);
if (isset($metadata[$entry]) && !$metadata[$entry]['ready']) {
continue;
}
$repository_metadata = isset($metadata[$entry])
? $metadata[$entry] : array('owner' => NULL, 'private' => TRUE);
unset($repository_metadata['ready']);
$definitions[] = array(
'/'.$entry,
$path,
array_merge($options, $repository_metadata));
}
return $definitions;
@@ -191,10 +242,16 @@ function normalize_repository($definition, $url_base) {
return FALSE;
}
$normalized_options = array_merge(repository_default_options(), $options);
if (!is_string($normalized_options['owner']) || $normalized_options['owner'] === '') {
$normalized_options['owner'] = NULL;
}
$normalized_options['private'] = (bool) $normalized_options['private'];
return array(
'url' => $url,
'path' => $git_path,
'options' => array_merge(repository_default_options(), $options));
'options' => $normalized_options);
}
function find_configured_repository($url_base, $definitions, $url_path) {
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE pgit_repositories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
repository_name VARCHAR(68) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
owner_user_id BIGINT UNSIGNED NOT NULL,
is_private TINYINT(1) NOT NULL DEFAULT 0,
is_ready TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY pgit_repositories_name_unique (repository_name),
KEY pgit_repositories_owner (owner_user_id),
CONSTRAINT pgit_repositories_owner_foreign
FOREIGN KEY (owner_user_id) REFERENCES pgit_users (id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+25 -17
View File
@@ -23,15 +23,7 @@ function register_clone_services(&$services) {
'clone_get_idx_file');
}
function clone_require_read_access($repository) {
if (!$repository['options']['read']) {
send_error(403, 'Forbidden', 'Repository reads are disabled.');
}
}
function clone_send_local_file($type, $repository, $name) {
clone_require_read_access($repository);
$path = get_safe_file_path($repository['path'], $name);
if ($path === FALSE) {
send_error(404, 'Not Found', 'Git object not found.');
@@ -52,34 +44,50 @@ function clone_send_local_file($type, $repository, $name) {
fclose($file);
}
function clone_header_object_cache($repository) {
if (repository_is_private($repository)) {
header('Expires: Fri, 01 Jan 1980 00:00:00 GMT');
header('Pragma: no-cache');
header('Cache-Control: private, no-store, max-age=0');
return;
}
header_cache_forever();
}
function clone_get_text_file($repository, $request, $application) {
header_nocache();
repository_require_read_access($repository, $request);
repository_header_nocache($repository);
clone_send_local_file('text/plain; charset=utf-8', $repository, $request['path']);
}
function clone_get_loose_object($repository, $request, $application) {
header_cache_forever();
repository_require_read_access($repository, $request);
clone_header_object_cache($repository);
clone_send_local_file('application/x-git-loose-object', $repository, $request['path']);
}
function clone_get_pack_file($repository, $request, $application) {
header_cache_forever();
repository_require_read_access($repository, $request);
clone_header_object_cache($repository);
clone_send_local_file('application/x-git-packed-objects', $repository, $request['path']);
}
function clone_get_idx_file($repository, $request, $application) {
header_cache_forever();
clone_send_local_file('application/x-git-packed-objects-toc', $repository, $request['path']);
repository_require_read_access($repository, $request);
clone_header_object_cache($repository);
clone_send_local_file(
'application/x-git-packed-objects-toc', $repository, $request['path']);
}
function clone_get_info_refs($repository, $request, $application) {
clone_require_read_access($repository);
repository_require_read_access($repository, $request);
if (!empty($request['query'])) {
send_error(403, 'Forbidden', 'Unsupported Git service.');
}
header_nocache();
repository_header_nocache($repository);
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
@@ -89,8 +97,8 @@ function clone_get_info_refs($repository, $request, $application) {
}
function clone_get_info_packs($repository, $request, $application) {
clone_require_read_access($repository);
header_nocache();
repository_require_read_access($repository, $request);
repository_header_nocache($repository);
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
+2 -8
View File
@@ -14,19 +14,13 @@ function pull_is_upload_pack_request($request) {
return request_has_service($request, 'git-upload-pack');
}
function pull_require_read_access($repository) {
if (!$repository['options']['read']) {
send_error(403, 'Forbidden', 'Repository reads are disabled.');
}
}
function pull_advertise_upload_pack($repository, $request, $application) {
pull_require_read_access($repository);
repository_require_read_access($repository, $request);
git_service_advertise($application, $repository, $request, 'git-upload-pack');
}
function pull_run_upload_pack($repository, $request, $application) {
pull_require_read_access($repository);
repository_require_read_access($repository, $request);
if (!request_content_type_is($request['content_type'], 'application/x-git-upload-pack-request')) {
send_error(415, 'Unsupported Media Type', 'Invalid upload-pack content type.');
+5 -1
View File
@@ -19,9 +19,13 @@ function push_require_access($repository, $request) {
send_error(403, 'Forbidden', 'Repository pushes are disabled.');
}
if ($repository['options']['require_auth'] && $request['user'] === NULL) {
if ($request['user'] === NULL) {
require_authentication('A valid username and access token are required for push.');
}
if (!repository_user_is_owner($repository, $request['user'])) {
send_error(403, 'Forbidden', 'Only the repository owner can push.');
}
}
function push_advertise_receive_pack($repository, $request, $application) {
+15
View File
@@ -23,4 +23,19 @@ CREATE TABLE pgit_access_tokens (
KEY pgit_access_tokens_user_active (user_id, revoked_at),
CONSTRAINT pgit_access_tokens_user_foreign
FOREIGN KEY (user_id) REFERENCES pgit_users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE pgit_repositories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
repository_name VARCHAR(68) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
owner_user_id BIGINT UNSIGNED NOT NULL,
is_private TINYINT(1) NOT NULL DEFAULT 0,
is_ready TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY pgit_repositories_name_unique (repository_name),
KEY pgit_repositories_owner (owner_user_id),
CONSTRAINT pgit_repositories_owner_foreign
FOREIGN KEY (owner_user_id) REFERENCES pgit_users (id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+39 -29
View File
@@ -121,11 +121,9 @@ $auth = array(
'password' => 'replace-with-a-long-random-password'));
$managed_repositories = array(
'require_auth' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
@@ -135,7 +133,8 @@ $repos = array(
array('/project.git', '/srv/git/project.git', array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE,
'owner' => 'alice',
'private' => TRUE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
@@ -179,14 +178,12 @@ array('/self.git', '.git')
```php
$managed_repositories = array(
'require_auth' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE));
'push' => TRUE));
```
- 顶层 `require_auth` 控制谁能从主界面创建仓库,默认是 `TRUE`
- 创建仓库必须登录;当前账号自动成为仓库所有者,并可在表单中选择“公开”或“私有”
- 启用账号认证时,`$auth['session_cookie_secure']` 控制登录与表单共用 Session Cookie 的 Secure 属性。应用直连 HTTPS 时会自动识别;TLS 在可信反向代理终止时应显式设为 `TRUE`,并确保外部流量只能通过 HTTPS 访问。
- `options` 是所有主界面新建仓库共同继承的仓库选项,其含义与 `$repos` 条目相同。
- 设置 `$managed_repositories = array();` 可完全关闭主界面创建功能。
@@ -194,7 +191,7 @@ $managed_repositories = array(
- 新仓库是 bare 仓库,默认分支为 `main`。应用内的创建请求使用锁、暂存目录和原子改名,不会互相覆盖;托管目录不应由其他进程同时写入。
- Git 或 `proc_open` 不可用时,应用以纯 PHP 创建标准 SHA-1 格式的空 bare 仓库;之后可以通过 Dumb HTTP clone,但首次写入仍需在其他具备 Git 的环境中生成仓库内容并同步到服务器。
静态 `$repos` 条目与托管目录中的仓库 URL 冲突时,以静态条目为准。生产环境应启用应用账号认证并保持顶层 `require_auth => TRUE`仓库创建要求已登录 Session,所有修改表单还使用会话 CSRF 令牌。
静态 `$repos` 条目与托管目录中的仓库 URL 冲突时,以静态条目为准。仓库创建要求已登录 Session,所有修改表单还使用会话 CSRF 令牌。托管仓库的所有者和可见性保存在 `pgit_repositories`;缺少元数据的旧托管仓库按私有、无所有者处理,在完成迁移前不可 push
## 5. 仓库选项
@@ -204,7 +201,8 @@ $managed_repositories = array(
| --- | --- | --- |
| `read` | `TRUE` | 允许 clone、fetch、pull 和 Dumb HTTP 对象读取 |
| `push` | `FALSE` | 启用 Smart HTTP receive-pack |
| `require_auth` | `TRUE` | push 前必须提供有效的应用用户名和 Access Token |
| `owner` | `NULL` | 允许 push 的账号用户名;未设置时任何人都不能 push |
| `private` | `FALSE` | 是否要求有效 Access Token 才能进行任何 Git 读取 |
| `branches` | `TRUE` | 允许更新 `refs/heads/*` |
| `tags` | `TRUE` | 允许更新 `refs/tags/*` |
| `other_refs` | `FALSE` | 允许 notes、replace 等其他 ref 命名空间 |
@@ -213,7 +211,7 @@ $managed_repositories = array(
| `max_pack_objects` | `100000` | 原生 PHP 后端一次 push pack 允许的最大对象数 |
| `max_request_bytes` | `0` | push 请求最大字节数;`0` 表示不限制 |
`branches``tags``other_refs` 只控制 push 更新,不会隐藏已经存在的 refs。若要限制读取内容,应发布不同的仓库,而不是依赖 ref 更新选项
所有 push 都必须提供 access token,且 token 所属用户名必须与 `owner` 完全一致。`branches``tags``other_refs` 只控制 push 更新,不会隐藏已经存在的 refs。私有仓库的 Smart HTTP 与 Dumb HTTP 路径都会先验证 token,私有对象响应禁止共享缓存
push 请求会先写入系统临时目录,以便在交给 `git-receive-pack` 前检查 ref 命名空间。启用较大的 push 时,应保证 PHP 系统临时目录具有足够空间;也可以使用 `max_request_bytes` 设置上限。
@@ -236,17 +234,26 @@ git clone https://git.example.com/php-git-server/project.git
git push origin main
```
Git 收到受保护 push 的 `401` 响应后会提示输入用户名和密码。也可以使用操作系统的 Git Credential Manager 或其他安全凭据助手保存 token;不要把 token 写入远程 URL、shell 历史、仓库配置或脚本。
Git 收到私有读取或 push 的 `401` 响应后会提示输入用户名和密码。也可以使用操作系统的 Git Credential Manager 或其他安全凭据助手保存 token;不要把 token 写入远程 URL、shell 历史、仓库配置或脚本。
默认允许匿名 clone/fetch/pull`require_auth` 当前保护 push 与主界面仓库创建。Token 验证成功后,用户名会作为 `REMOTE_USER` 传给 Git 子进程和 hooks,现有 hooks 可以继续读取该变量。
公开仓库允许匿名 clone/fetch/pull私有仓库只接受 access token。浏览器登录 Session 只用于首页、创建仓库和显示私有仓库列表,不能代替 Git access token。Token 验证成功后,用户名会作为 `REMOTE_USER` 传给 Git 子进程和 hooks,现有 hooks 可以继续读取该变量。
如果设置:
### 现有数据库与仓库迁移
```php
'require_auth' => FALSE
从旧版本升级时,先执行独立迁移文件:
```sh
mysql -u root -p php_git_server < migration.repository-ownership.mysql.sql
```
则任何能访问 URL 的用户都可 push。此设置只适合隔离的本地开发环境或已经由其他网络边界严格保护的服务。
随后为每个已有托管仓库写入所有者和可见性,仓库名必须包含 `.git` 后缀:
```sql
INSERT INTO pgit_repositories (repository_name, owner_user_id, is_private)
SELECT 'project.git', id, 1 FROM pgit_users WHERE username = 'alice';
```
静态 `$repos` 不写入 `pgit_repositories`,必须直接在配置中设置 `owner``private`。升级前遗留的 `require_auth` 配置不再控制访问,也不能关闭 owner/token 校验。
## 7. 创建和授权仓库
@@ -409,13 +416,15 @@ git ls-remote https://git.example.com/php-git-server/project.git
建议在临时仓库上依次验证:
1. `git clone`
2. 创建提交并 `git push origin main`
3. 创建并 push 新分支
4. 创建并 push annotated tag
5. 在另一个工作目录执行 `git fetch --all --tags``git pull`
6. 删除测试分支和标签
7. 确认未启用 `other_refs` 时,推送到 `refs/notes/*` 返回 `403`
1. 匿名 clone 公开仓库
2. 使用 access token clone 私有仓库,并确认匿名访问返回 `401`
3. 用 owner token 创建提交并 `git push origin main`
4. 确认非 owner token 的 push 返回 `403`
5. 创建并 push 新分支
6. 创建并 push annotated tag
7. 在另一个工作目录执行 `git fetch --all --tags` `git pull`
8. 删除测试分支和标签。
9. 确认未启用 `other_refs` 时,推送到 `refs/notes/*` 返回 `403`
### HTTP 状态码
@@ -431,7 +440,7 @@ curl -i https://git.example.com/php-git-server/project.git/not-found
curl -i -X POST https://git.example.com/php-git-server/project.git/HEAD
```
受保护 push 未提供有效 token 时应返回 `401``WWW-Authenticate`;push 未启用或 ref 命名空间被禁止时应返回 `403`
私有仓库读取或 push 未提供有效 token 时应返回 `401``WWW-Authenticate`非所有者 push、push 未启用或 ref 命名空间被禁止时应返回 `403`
## 11. 常见问题
@@ -451,11 +460,12 @@ curl -i -X POST https://git.example.com/php-git-server/project.git/HEAD
依次检查:
1. 仓库是否设置 `'push' => TRUE`
2. 分支更新是否启用了 `branches`
3. 标签更新是否启用了 `tags`
4. 目标是否属于其他 ref 命名空间,而 `other_refs` 仍为 `FALSE`
2. access token 所属用户名是否与仓库 `owner` 完全一致
3. 分支更新是否启用了 `branches`
4. 标签更新是否启用了 `tags`
5. 目标是否属于其他 ref 命名空间,而 `other_refs` 仍为 `FALSE`
### push 返回 401 或反复询问密码
### clone / pull / push 返回 401 或反复询问密码
依次检查:
@@ -500,7 +510,7 @@ GIT_TRACE=1 GIT_CURL_VERBOSE=1 git clone \
- push 默认保持关闭,只为确实需要写入的仓库启用。
- 主界面创建默认要求已登录应用账号,托管目录不要放置其他文件。
- 公开注册应配合速率限制;不需要公开注册时设置 `registration_enabled => FALSE`
- 数据库账号只授予 `pgit_users``pgit_access_tokens` 所需的最小读写权限,并单独备份。
- 数据库账号只授予 `pgit_users``pgit_access_tokens``pgit_repositories` 所需的最小读写权限,并单独备份。
- 定期撤销不再使用的 token;不要记录 `Authorization` 头或 token 明文。
- 仓库路径必须来自静态配置或受控托管目录,不根据 URL 拼接任意文件系统路径。
- 只给 Web 服务器最小必要的文件权限。