16 Commits
Author SHA1 Message Date
hanyixuanten cb76f5f344 add nginx conf 2026-08-19 13:14:43 +08:00
hanyixuanten b4deffc9d9 fix fetch error 2026-08-16 20:45:30 +08:00
hanyixuanten a41884f7b8 feat: improve pure PHP Git protocol validation and negotiation 2026-08-14 19:31:41 +08:00
hanyixuanten 03b708916f edit gitignore 2026-08-14 19:06:55 +08:00
hanyixuanten 7ad2783be1 remove git excuteable need 2026-08-14 17:55:21 +08:00
hanyixuanten 5dac5e1dfe remove migration sql 2026-08-14 17:47:53 +08:00
hanyixuanten 4c69987859 fix: master and main 2026-08-14 17:44:34 +08:00
hanyixuanten 14c1373f01 finish translate support 2026-08-12 12:08:29 +08:00
hanyixuanten 537899cef7 edit ui 2026-08-12 11:57:05 +08:00
hanyixuanten 84c973ec87 add i18n support 2026-08-12 11:36:24 +08:00
hanyixuanten e055bc1d82 fix permission errors 2026-08-10 13:38:16 +08:00
hanyixuanten 778e1b7e26 add installation page 2026-08-10 13:17:42 +08:00
hanyixuanten ea431ae124 add management page 2026-08-10 12:58:53 +08:00
hanyixuanten 2e56f74db9 add repo owner 2026-08-10 00:19:21 +08:00
hanyixuanten 5cd26c9f02 add login and register 2026-08-09 20:39:23 +08:00
hanyixuanten 447cc628cc lock repo dir to repos 2026-08-09 19:35:32 +08:00
30 changed files with 5111 additions and 677 deletions
+1
View File
@@ -1 +1,2 @@
/config.php
.hermes/
+3 -2
View File
@@ -1,5 +1,6 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REQUEST_URI} !index\.php$
RewriteRule .* index.php [L,QSA]
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteRule ^(?:index|manage|install)\.php$ - [L]
RewriteRule ^ index.php [L,QSA]
</IfModule>
+50
View File
@@ -0,0 +1,50 @@
# Repository Guidelines
## Project Structure & Module Organization
This is a PHP Smart HTTP Git server with no dependency manager or generated build output.
- `index.php` is the public entry point and request router.
- `install.php` creates the initial configuration and database schema.
- `manage.php` provides administrator and managed-repository operations.
- `lib/` contains shared HTTP, authentication, repository, routing, Git protocol, and i18n code.
- `operations/` handles clone, pull, push, branch, and tag requests.
- `schema.mysql.sql` and `migration.repository-ownership.mysql.sql` define database changes.
- `config.php.sample` documents settings; local `config.php` must never be committed.
- `repos/` contains runtime bare repositories. `README.md` and `usage.md` document deployment.
- `tests/` contains the dependency-free protocol regression suite and disposable repository fixtures.
## Build, Test, and Development Commands
PHP is interpreted directly, so there is no build step. Run the complete regression suite from the repository root:
```sh
php tests/run.php
```
Run syntax checks across production and test code:
```sh
php -l index.php
php -l install.php
php -l manage.php
for file in lib/*.php operations/*.php tests/*.php; do php -l "$file" || exit 1; done
```
Check required runtime support with `php -m | grep pdo_mysql`. For a local smoke test, use a disposable `config.php` and PHP-capable web server, then exercise the UI and Git endpoints with `git ls-remote` or `git clone`.
## Coding Style & Naming Conventions
Use four-space indentation, same-line opening braces, uppercase PHP constants (`TRUE`, `FALSE`, `NULL`), and explicit `<?php` files. Use `snake_case` functions and variables, page-specific helper prefixes such as `home_`, `install_`, and `manage_`, and lowercase filenames. Escape HTML values with the local `*_escape()` helper. Keep security headers, CSRF checks, path validation, and protocol responses intact. Put user-facing web text in `lib/i18n.php` and access it through `t()`.
## Testing Guidelines
No PHPUnit or coverage requirement is configured. Every change should pass all `php -l` checks and include a focused manual smoke test for affected HTTP or Git behavior. For authentication, repository, or push changes, test both success and rejection paths.
## Commit & Pull Request Guidelines
Existing commits use short, imperative, lowercase summaries such as `add management page` and `fix permission errors`. Keep commits focused and concise. Pull requests should explain behavior and security impact, list validation commands, identify configuration or schema changes, and include screenshots for UI changes. Never include credentials, production `config.php`, or real repository data.
## Security & Configuration Tips
Use a disposable database locally. Review `usage.md` before changing routing, permissions, authentication, or ownership. Keep `config.php` restricted to the application, prevent direct web exposure of `repos/`, and update SQL migration files when persistent metadata changes.
+62 -31
View File
@@ -8,74 +8,105 @@ This project serves configured Git repositories through PHP. It supports:
- Smart HTTP `upload-pack` for clone, fetch and pull.
- Smart HTTP `receive-pack` for push.
- Remote branch and tag creation, update and deletion through push.
- Authenticated bare-repository creation from the home page.
- Per-repository controls for reads, pushes, authentication, branch refs,
- MySQL-backed account registration and login.
- Hashed, revocable access tokens for authenticated Git pushes.
- Repository ownership with owner-only pushes.
- Public/private repositories and authenticated private reads.
- Authenticated public/private bare-repository creation from the home page.
- Owner deletion of managed repositories from the home page.
- Configured administrators with user and repository management at `manage.php`.
- 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
into `operations/clone.php`, `operations/pull.php`, `operations/push.php`,
`operations/branch.php` and `operations/tag.php`; shared routing, repository,
HTTP and Git process code is under `lib/`.
HTTP and pure-PHP Git protocol code is under `lib/`.
Requirements
------------
- PHP 7.4 or newer.
- Apache with `mod_rewrite` and `.htaccess` enabled for normal deployment.
- PHP zlib and hash extensions for the native Git protocol implementation.
- Git and PHP `proc_open` are optional; when available, Git remains the Smart
HTTP backend for full protocol and hook compatibility.
- MySQL 5.7+/MariaDB 10.2+ and PHP PDO MySQL (`pdo_mysql`) when account
authentication is enabled.
- PHP zlib and hash extensions are required for the Smart HTTP implementation.
- Read access to published repositories; write access is also required for push
and for the managed repository directory when home-page creation is enabled.
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.
The pure-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.
Configuration
-------------
Copy `config.php.sample` to `config.php`, then configure `$url_base`,
`$git_executable`, `$repos` and optionally `$managed_repositories`. A writable
repository can be configured as:
Copy `config.php.sample` to `config.php`, import `schema.mysql.sql`, then
configure `$url_base`, `$auth`, `$repos` and optionally
`$managed_repositories`. A writable repository can be configured as:
```php
$auth['administrators'] = array('alice');
```
Administrator names are exact, case-sensitive usernames of existing accounts.
After signing in, an administrator can open `manage.php` to create, activate,
deactivate and delete users, reset passwords, revoke tokens, transfer managed
repositories, change visibility, and delete managed repositories. Static
`$repos` entries are shown read-only because they remain owned by `config.php`.
A dedicated database account needs `SELECT`, `INSERT`, `UPDATE` and `DELETE`.
A writable static repository can be configured as:
```php
$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)));
```
`require_auth` trusts only `REMOTE_USER`, which must be set by authenticated
Apache or reverse-proxy configuration. Setting it 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.
To create bare repositories from the home page, configure a pre-existing
writable directory. Newly created repositories are discovered automatically;
the application never rewrites `config.php`:
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
automatically; this path cannot be changed in `config.php`:
```php
$managed_repositories = array(
'path' => '/srv/git',
'require_auth' => TRUE,
'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE));
'push' => TRUE));
```
The managed directory must be readable, writable and searchable by PHP and
reserved for this application. Set `session_cookie_secure` to `TRUE` when HTTPS
terminates at a trusted reverse proxy; direct HTTPS deployments are detected
from the web-server connection automatically.
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.
The owner can also permanently delete a managed repository after explicit
confirmation. This removes its database metadata and bare repository directory;
it never applies to static `$repos` entries.
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
`$auth['session_cookie_secure']` to `TRUE` when HTTPS terminates at a trusted
reverse proxy; direct HTTPS deployments are detected from the web-server
connection automatically.
See `usage.md` for complete Chinese installation, configuration, operation and
security instructions.
+31 -22
View File
@@ -6,26 +6,39 @@
$url_base = '';
/* Git executable used by Smart HTTP when available. If it is unavailable,
* supported SHA-1 Smart HTTP operations run through the native PHP backend. */
$git_executable = 'git';
/* Interface language: 'en', 'zh', or empty to follow the browser. */
$language = '';
/*
* Optional directory for repositories created from the home page.
* The directory must already exist and be readable, writable and searchable by
* the PHP process, and should be reserved for this application.
* Application accounts and access tokens. Import schema.mysql.sql before
* enabling this section. Use a dedicated MySQL user with SELECT, INSERT,
* UPDATE and DELETE privileges on these tables; schema privileges are not
* required by the application.
*/
$auth = array(
'enabled' => TRUE,
'registration_enabled' => TRUE,
/* Exact, case-sensitive usernames. Add only existing trusted accounts. */
'administrators' => array(
// 'replace-with-existing-username',
),
/* Set to TRUE when HTTPS terminates at a trusted reverse proxy. */
// 'session_cookie_secure' => TRUE,
'database' => array(
'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=php_git_server;charset=utf8mb4',
'username' => 'php_git_server',
'password' => 'replace-with-a-long-random-password'));
/*
* Optional settings for repositories created from the home page.
* Repositories are always stored in this application's repos directory. It is
* created when missing and must be writable and searchable by the PHP process.
* Set this to array() to disable repository creation.
*/
$managed_repositories = array(
'path' => './repos',
'require_auth' => TRUE,
/* Uncomment when TLS ends at a trusted reverse proxy. */
// 'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
@@ -39,7 +52,8 @@ $managed_repositories = array(
*
* read Allow clone, fetch and pull. Default: TRUE.
* push Allow Smart HTTP push. Default: FALSE.
* require_auth Require a trusted REMOTE_USER 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.
@@ -51,12 +65,7 @@ $managed_repositories = array(
$repos = array(
array('/php-git-server.git', '.git', array(
'read' => TRUE,
'push' => FALSE)),
array('/wiki.git', './repos/wiki.git', array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
'max_request_bytes' => 0)));
'push' => FALSE,
'owner' => NULL,
'private' => FALSE)),
);
+573 -151
View File
@@ -1,7 +1,14 @@
<?php
if (!file_exists(__DIR__.'/config.php')) {
require(__DIR__.'/lib/install_redirect.php');
redirect_to_installer();
}
require(__DIR__.'/config.php');
require(__DIR__.'/lib/http.php');
require(__DIR__.'/lib/i18n.php');
require(__DIR__.'/lib/auth.php');
require(__DIR__.'/lib/repository.php');
require(__DIR__.'/lib/router.php');
require(__DIR__.'/lib/git_protocol.php');
@@ -33,18 +40,11 @@ function home_escape($value) {
}
function home_managed_repositories_configured($configuration) {
return is_array($configuration)
&& isset($configuration['path'])
&& is_string($configuration['path'])
&& $configuration['path'] !== '';
}
function home_creation_requires_auth($configuration) {
return !isset($configuration['require_auth']) || $configuration['require_auth'];
return is_array($configuration) && !empty($configuration);
}
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) {
@@ -58,6 +58,9 @@ function home_session_cookie_is_secure($configuration) {
}
function home_start_session($url_base, $configuration) {
if (auth_is_enabled()) {
return auth_start_session();
}
if (session_status() === PHP_SESSION_ACTIVE) {
return TRUE;
}
@@ -103,13 +106,130 @@ function home_take_notice($url_base, $configuration) {
return is_array($notice) ? $notice : NULL;
}
function home_repository_url_exists($url_base, $definitions, $name) {
function home_set_notice($url_base, $configuration, $type, $message, $token=NULL) {
if (!home_start_session($url_base, $configuration)) {
return FALSE;
}
$_SESSION['home_notice'] = array('type' => $type, 'message' => $message);
if ($token !== NULL) {
$_SESSION['home_notice']['token'] = $token;
}
return TRUE;
}
function home_redirect($url_base) {
send_status(303, 'See Other');
header('Location: '.home_page_url($url_base));
die();
}
function home_post_value($name) {
return isset($_POST[$name]) && is_string($_POST[$name]) ? $_POST[$name] : '';
}
function home_require_csrf($url_base, $configuration) {
if (!request_content_type_is(get_request_header('Content-Type'), 'application/x-www-form-urlencoded')) {
send_error(415, 'Unsupported Media Type', 'Expected a form-encoded request.');
}
$expected_token = home_csrf_token($url_base, $configuration);
if ($expected_token === FALSE
|| !hash_equals($expected_token, home_post_value('csrf_token'))) {
send_error(403, 'Forbidden', 'The form security token is invalid.');
}
}
function home_auth_result_notice($result) {
switch ($result['status']) {
case 'registered': return array('success', t('notice.registered'));
case 'logged_in': return array('success', t('notice.logged_in'));
case 'registration_disabled': return array('error', t('notice.registration_disabled'));
case 'invalid_username': return array('error', t('notice.invalid_username'));
case 'invalid_password': return array('error', t('notice.invalid_password'));
case 'password_mismatch': return array('error', t('notice.password_mismatch'));
case 'username_exists': return array('error', t('notice.username_exists'));
case 'invalid_credentials': return array('error', t('notice.invalid_credentials'));
case 'invalid_token_name': return array('error', t('notice.invalid_token_name'));
case 'token_created': return array('success', t('notice.token_created'));
case 'token_revoked': return array('success', t('notice.token_revoked'));
case 'invalid_token': return array('error', t('notice.invalid_token'));
case 'session_unavailable': return array('error', t('notice.session_unavailable'));
default: return array('error', t('notice.auth_database_unavailable'));
}
}
function home_handle_auth_action($url_base, $configuration, $action) {
if (!auth_is_enabled()) {
send_error(404, 'Not Found', 'Account authentication is disabled.');
}
home_require_csrf($url_base, $configuration);
$session_user = auth_session_user();
if ($action === 'register') {
if ($session_user !== NULL) {
send_error(409, 'Conflict', 'Already authenticated.');
}
$result = auth_register(
home_post_value('username'),
home_post_value('password'),
home_post_value('password_confirmation'));
} else if ($action === 'login') {
if ($session_user !== NULL) {
send_error(409, 'Conflict', 'Already authenticated.');
}
$result = auth_login(home_post_value('username'), home_post_value('password'));
} else if ($action === 'logout') {
if ($session_user === NULL) {
send_error(403, 'Forbidden', 'Login is required.');
}
if (!auth_logout()) {
send_error(500, 'Internal Server Error', 'Unable to close the login session.');
}
home_set_notice($url_base, $configuration, 'success', t('notice.logged_out'));
home_redirect($url_base);
} else if ($action === 'create_token') {
if ($session_user === NULL) {
send_error(403, 'Forbidden', 'Login is required.');
}
$result = auth_create_access_token($session_user['id'], home_post_value('token_name'));
} else if ($action === 'revoke_token') {
if ($session_user === NULL) {
send_error(403, 'Forbidden', 'Login is required.');
}
$result = auth_revoke_access_token($session_user['id'], home_post_value('token_id'));
} else {
send_error(400, 'Bad Request', 'Unknown account action.');
}
$notice = home_auth_result_notice($result);
$plaintext_token = isset($result['token']) ? $result['token'] : NULL;
home_set_notice($url_base, $configuration, $notice[0], $notice[1], $plaintext_token);
home_redirect($url_base);
}
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;
@@ -118,18 +238,30 @@ function home_repository_url_exists($url_base, $definitions, $name) {
function home_creation_result_notice($result) {
$name = isset($result['name']) ? $result['name'] : '';
switch ($result['status']) {
case 'invalid_name':
return array(422, 'error', '仓库名称格式无效。');
case 'already_exists':
return array(409, 'error', '仓库 '.$name.' 已存在。');
case 'create_busy':
return array(409, 'error', '另一个仓库正在创建,请稍后重试。');
case 'root_unavailable':
return array(503, 'error', '仓库存放目录不可用或不可写。');
case 'git_unavailable':
return array(503, 'error', 'Git 初始化服务当前不可用。');
default:
return array(500, 'error', '仓库创建失败,请检查服务器日志。');
case 'invalid_name': return array(422, 'error', t('notice.repository_invalid_name'));
case 'already_exists': return array(409, 'error', t('notice.repository_exists', array('name' => $name)));
case 'create_busy': return array(409, 'error', t('notice.repository_create_busy'));
case 'root_unavailable': return array(503, 'error', t('notice.repository_root_unavailable'));
case 'metadata_unavailable': return array(503, 'error', t('notice.repository_metadata_unsaved'));
default: return array(500, 'error', t('notice.repository_create_failed'));
}
}
function home_deletion_result_notice($result) {
$name = isset($result['name']) ? $result['name'] : '';
switch ($result['status']) {
case 'deleted': return array(303, 'success', t('notice.repository_deleted', array('name' => $name)));
case 'record_deleted': return array(303, 'success', t('notice.repository_record_deleted', array('name' => $name)));
case 'invalid_repository': return array(422, 'error', t('notice.repository_invalid_name'));
case 'forbidden': return array(403, 'error', t('notice.repository_delete_forbidden'));
case 'configured_repository': return array(403, 'error', t('notice.repository_configured_home'));
case 'not_found': return array(404, 'error', t('notice.repository_not_found'));
case 'repository_busy': return array(409, 'error', t('notice.repository_busy'));
case 'root_unavailable': return array(503, 'error', t('notice.repository_root_unavailable'));
case 'metadata_unavailable': return array(503, 'error', t('notice.repository_metadata_unavailable'));
case 'cleanup_failed': return array(500, 'error', t('notice.repository_cleanup_failed'));
case 'restore_failed': return array(500, 'error', t('notice.repository_restore_failed'));
default: return array(500, 'error', t('notice.repository_delete_failed'));
}
}
@@ -144,33 +276,36 @@ function home_create_repository(
if (!home_creation_is_authorized($configuration)) {
send_error(403, 'Forbidden', 'Authenticated access is required to create repositories.');
}
if (!request_content_type_is(get_request_header('Content-Type'), 'application/x-www-form-urlencoded')) {
send_error(415, 'Unsupported Media Type', 'Expected a form-encoded request.');
home_require_csrf($url_base, $configuration);
$owner = auth_session_user();
if ($owner === NULL) {
send_error(403, 'Forbidden', 'Login is required to create repositories.');
}
$token = isset($_POST['csrf_token']) && is_string($_POST['csrf_token'])
? $_POST['csrf_token'] : '';
$expected_token = home_csrf_token($url_base, $configuration);
if ($expected_token === FALSE || !hash_equals($expected_token, $token)) {
send_error(403, 'Forbidden', 'The form security token is invalid.');
$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(
$configuration,
$value,
$owner['id'],
$visibility === 'private');
}
if ($result['status'] === 'created') {
$_SESSION['home_notice'] = array(
'type' => 'success',
'message' => '仓库 '.$result['name'].' 已创建。');
send_status(303, 'See Other');
header('Location: '.home_page_url($url_base));
die();
home_set_notice(
$url_base, $configuration, 'success', t('notice.repository_created', array('name' => $result['name'])));
home_redirect($url_base);
}
$notice = home_creation_result_notice($result);
@@ -181,16 +316,50 @@ function home_create_repository(
$definitions,
$configuration,
array('type' => $notice[1], 'message' => $notice[2]),
$value);
$value,
$visibility);
die();
}
function home_delete_repository($url_base, $definitions, $configuration) {
if (!home_managed_repositories_configured($configuration)) {
send_error(403, 'Forbidden', 'Managed repositories are disabled.');
}
home_require_csrf($url_base, $configuration);
$user = auth_session_user();
if ($user === NULL) {
send_error(403, 'Forbidden', 'Login is required to delete repositories.');
}
if (home_post_value('confirmation') !== 'delete') {
send_error(422, 'Unprocessable Content', 'Repository deletion must be confirmed.');
}
$result = delete_managed_repository(
$configuration,
$definitions,
$url_base,
home_post_value('repository_name'),
$user['id']);
$notice = home_deletion_result_notice($result);
if ($result['status'] === 'deleted') {
home_set_notice($url_base, $configuration, $notice[1], $notice[2]);
home_redirect($url_base);
}
send_error($notice[0], $notice[0] === 403 ? 'Forbidden'
: ($notice[0] === 404 ? 'Not Found'
: ($notice[0] === 409 ? 'Conflict'
: ($notice[0] === 422 ? 'Unprocessable Content'
: ($notice[0] === 503 ? 'Service Unavailable' : 'Internal Server Error')))), $notice[2]);
}
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);
@@ -198,16 +367,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;
}
@@ -267,127 +442,307 @@ function home_clone_url_prefix() {
return $scheme.'://'.$host;
}
function home_send_head() {
function home_send_head($url_base='') {
$lang = home_escape(i18n_html_lang());
$title = home_escape(t('home.title'));
echo '<!DOCTYPE html>'."\n"
.'<html lang="'.$lang.'">'."\n"
.'<head>'."\n"
.'<meta charset="utf-8">'."\n"
.'<meta name="viewport" content="width=device-width, initial-scale=1">'."\n"
.'<title>'.$title.'</title>'."\n";
echo <<<'HTML'
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PHP Git 服务器</title>
<style>
:root { color-scheme: light dark; }
body { max-width: 62rem; margin: 0 auto; padding: 2.5rem 1.25rem; line-height: 1.6;
font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans CJK SC", sans-serif;
color: #1f2530; background: #ffffff; }
h1 { margin: 0 0 .25rem; font-size: 1.5rem; }
h2 { margin: 2.5rem 0 .5rem; font-size: 1.1rem; }
:root {
color-scheme: light dark;
--canvas: #f3f7f5; --surface: #ffffff; --surface-soft: #f8fbf9;
--ink: #17231d; --muted: #62726a; --line: #d9e4dd;
--accent: #167447; --accent-strong: #0d5a34; --accent-soft: #e8f5ed;
--link: #0d658f; --danger: #b23b3b; --danger-soft: #fff1f1;
--shadow: 0 16px 42px rgba(25, 59, 42, .08);
}
* { box-sizing: border-box; }
html { background: var(--canvas); }
body {
width: min(74rem, calc(100% - 2rem)); margin: 0 auto; padding: 3.5rem 0 3rem;
color: var(--ink); background: transparent; line-height: 1.6;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Noto Sans CJK SC", sans-serif;
}
body::before {
position: fixed; z-index: -1; inset: 0; content: "";
background: radial-gradient(circle at 8% 0%, #dff3e7 0, transparent 27rem),
radial-gradient(circle at 94% 8%, #e1f0f7 0, transparent 26rem), var(--canvas);
}
h1 { margin: 0; font-size: clamp(1.8rem, 4vw, 2.45rem); line-height: 1.15; letter-spacing: -.045em; }
h1::before { display: inline-grid; width: 2.35rem; height: 2.35rem; place-items: center; margin-right: .7rem;
content: "⌘"; border-radius: .68rem; color: #fff; background: var(--accent); font-size: 1.45rem;
vertical-align: -.18em; box-shadow: 0 7px 18px rgba(22, 116, 71, .24); }
h2 { margin: 0 0 .45rem; font-size: 1.12rem; line-height: 1.3; letter-spacing: -.015em; }
p { margin: 0 0 1rem; }
.lead { color: #5b6472; }
.create { margin: 1.75rem 0 2rem; padding: 1rem 0; border-top: 1px solid #d5dae1;
border-bottom: 1px solid #d5dae1; }
.create h2 { margin: 0 0 .25rem; }
.create form { display: flex; gap: .6rem; align-items: end; }
.lead { max-width: 48rem; margin: .6rem 0 0; color: var(--muted); font-size: 1.02rem; }
.language-switcher { display: inline-flex; margin-top: .85rem; padding: .25rem .5rem; border: 1px solid var(--line);
border-radius: 999px; background: rgba(255,255,255,.62); font-size: .82rem; }
.language-switcher a, a { color: var(--link); }
.account, .create, .repositories, .empty {
border: 1px solid var(--line); border-radius: .9rem; background: var(--surface); box-shadow: var(--shadow);
}
.account, .create { margin: 1.75rem 0; padding: 1.35rem; }
.account::before, .create::before, .repositories h2::before {
display: inline-block; width: .48rem; height: .48rem; margin: 0 .48rem .09rem 0; content: "";
border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft);
}
.account-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1.25rem; }
.account form, .create form, .token-form { display: flex; gap: .65rem; align-items: end; }
.account .credentials { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; 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;
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; }
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; }
button:hover { background: #125635; }
.hint { margin: .4rem 0 0; color: #5b6472; font-size: .9rem; }
.notice { margin: 1rem 0; padding: .65rem .8rem; border-left: .25rem solid; }
.notice-success { border-color: #1f7a4b; background: #edf8f1; color: #155735; }
.notice-error { border-color: #b33a3a; background: #fff0f0; color: #842828; }
table { width: 100%; border-collapse: collapse; }
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; }
th { font-weight: 600; white-space: nowrap; }
label { display: block; margin-bottom: .35rem; color: #304239; font-size: .86rem; font-weight: 700; }
input:not([type="radio"]):not([type="checkbox"]) {
width: 100%; min-height: 2.7rem; padding: .56rem .75rem; border: 1px solid #bbcbbf;
border-radius: .52rem; color: var(--ink); background: #fff; font: inherit; transition: border-color .15s, box-shadow .15s;
}
input:not([type="radio"]):not([type="checkbox"]):focus { border-color: var(--accent); outline: 0;
box-shadow: 0 0 0 3px rgba(22,116,71,.16); }
.visibility { flex: 0 0 13rem; margin: 0; padding: 0; border: 0; }
.visibility legend { margin-bottom: .35rem; color: #304239; font-size: .86rem; font-weight: 700; }
.visibility-options { display: grid; grid-template-columns: 1fr 1fr; min-height: 2.7rem; overflow: hidden;
border: 1px solid #bbcbbf; border-radius: .52rem; background: #fff; }
.visibility-option { position: relative; margin: 0; font-weight: 600; }
.visibility-option + .visibility-option { border-left: 1px solid #bbcbbf; }
.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: var(--accent); }
.visibility-option input:focus-visible + span { outline: 3px solid #79c99d; outline-offset: -3px; }
button { min-height: 2.7rem; padding: .55rem 1rem; border: 1px solid var(--accent-strong); border-radius: .52rem;
color: #fff; background: var(--accent); font: inherit; font-weight: 700; cursor: pointer;
box-shadow: 0 2px 4px rgba(13,90,52,.15); transition: transform .15s, background .15s, box-shadow .15s; }
button:hover { background: var(--accent-strong); box-shadow: 0 5px 12px rgba(13,90,52,.2); transform: translateY(-1px); }
button:focus-visible { outline: 3px solid #79c99d; outline-offset: 2px; }
.button-danger { border-color: #922f2f; background: var(--danger); }
.button-danger:hover { background: #902d2d; }
.account-bar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.account-actions { display: flex; align-items: center; gap: .75rem; }
.account-bar form, .token-list form { display: block; }
.account-bar button, .token-list button { width: auto; margin: 0; }
.account-actions a { font-weight: 700; }
.confirm-delete { display: flex; align-items: center; gap: .35rem; margin: 0; color: var(--muted); font-size: .78rem; font-weight: 500; white-space: nowrap; }
.confirm-delete input { width: auto; min-height: auto; accent-color: var(--danger); }
.token-result { padding: .8rem 1rem; user-select: all; }
.token-list { margin: 1rem 0 0; padding: 0; list-style: none; }
.token-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .75rem 0; border-bottom: 1px solid var(--line); }
.hint { margin: .42rem 0 0; color: var(--muted); font-size: .87rem; }
.notice { margin: 1.1rem 0; padding: .78rem 1rem; border: 1px solid; border-left: .32rem solid; border-radius: .55rem; font-weight: 600; }
.notice-success { border-color: #a7d9b9; border-left-color: var(--accent); background: var(--accent-soft); color: #155735; }
.notice-error { border-color: #f1c5c5; border-left-color: var(--danger); background: var(--danger-soft); color: #852b2b; }
.repositories { margin-top: 1.4rem; overflow: hidden; }
.repositories h2 { margin: 0; padding: 1.1rem 1.3rem; border-bottom: 1px solid var(--line); background: var(--surface-soft); }
.repositories table { width: 100%; border-collapse: collapse; }
caption { padding: .9rem 1.3rem .2rem; color: var(--muted); text-align: left; }
th, td { padding: .78rem .8rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
th { color: #526259; background: #f5f9f6; font-size: .75rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; }
tbody tr { transition: background .12s; }
tbody tr:hover { background: #f8fcf9; }
tbody tr:last-child td { border-bottom: 0; }
th.count, td.count { text-align: right; }
code { font-size: .95em; word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
pre { padding: .75rem 1rem; overflow-x: auto; border: 1px solid #d5dae1;
border-radius: .4rem; background: #f6f7f9; }
.badge { display: inline-block; padding: .05rem .55rem; border: 1px solid currentColor;
border-radius: 999px; font-size: .8rem; white-space: nowrap; }
.badge-push { color: #1f6f43; }
.badge-quiet { color: #5b6472; }
.empty { padding: 1.25rem; border: 1px dashed #b9c0ca; border-radius: .5rem;
color: #5b6472; }
code { padding: .08rem .28rem; border-radius: .25rem; background: #edf3ef; font-size: .9em; word-break: break-all; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
pre { padding: .85rem 1rem; overflow-x: auto; border: 1px solid var(--line); border-radius: .62rem; background: #f5f8f6; }
pre code { padding: 0; background: transparent; }
.badge { display: inline-block; padding: .12rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: .76rem; font-weight: 700; white-space: nowrap; }
.badge-push { color: var(--accent); background: var(--accent-soft); }
.badge-quiet { color: var(--muted); background: #f3f5f4; }
.empty { margin-top: 1.4rem; padding: 1.25rem; color: var(--muted); border-style: dashed; box-shadow: none; }
.repositories .empty { margin: 1.1rem; }
.empty p:last-child { margin-bottom: 0; }
footer { margin-top: 2.5rem; color: #5b6472; font-size: .9rem; }
footer { margin-top: 2rem; color: var(--muted); font-size: .86rem; text-align: center; }
@media (max-width: 42rem) {
body { padding-top: 1.5rem; }
.create form { display: block; }
button { width: 100%; margin-top: .65rem; }
table { display: block; overflow-x: auto; }
body { width: min(100% - 1.25rem, 74rem); padding-top: 2rem; }
h1::before { display: none; }
.account, .create { padding: 1.1rem; }
.account-grid, .account .credentials { display: block; }
.account form, .create form, .token-form { display: block; }
.visibility { margin-top: .75rem; }
button { width: 100%; margin-top: .75rem; }
.account-bar button, .token-list button { width: auto; margin-top: 0; }
.repositories { overflow-x: auto; }
.repositories table { min-width: 50rem; }
}
@media (prefers-color-scheme: dark) {
body { color: #e6e9ef; background: #12161c; }
.lead, .hint, caption, footer, .badge-quiet, .empty { color: #9aa4b2; }
.create, th, td { border-color: #2b323d; }
input { border-color: #596474; background: #1a1f27; color: #e6e9ef; }
pre { border-color: #2b323d; background: #1a1f27; }
.empty { border-color: #3a424f; }
.notice-success { background: #152b20; color: #95dab1; }
.notice-error { background: #331c1c; color: #f0abab; }
:root { --canvas: #101916; --surface: #17231e; --surface-soft: #1d2b25; --ink: #e5efe8;
--muted: #a0b2a8; --line: #31443a; --accent: #42a76d; --accent-strong: #2d8953;
--accent-soft: #173b27; --link: #75c4eb; --danger: #d05a5a; --danger-soft: #3a2020;
--shadow: 0 16px 42px rgba(0,0,0,.2); }
body::before { background: radial-gradient(circle at 8% 0%, #173e2a 0, transparent 27rem), radial-gradient(circle at 94% 8%, #163744 0, transparent 26rem), var(--canvas); }
h1::before { color: #102319; }
label, .visibility legend { color: #d5e2d9; }
input:not([type="radio"]):not([type="checkbox"]), .visibility-options { border-color: #587064; background: #132019; color: var(--ink); }
.visibility-option + .visibility-option { border-color: #587064; }
code { background: #22332b; }
pre, th { background: #1b2a23; }
tbody tr:hover { background: #1b2a23; }
.language-switcher { background: rgba(23,35,30,.62); }
.notice-success { border-color: #356d4b; color: #a9e5bd; }
.notice-error { border-color: #713838; color: #ffb6b6; }
.badge-quiet { background: #26362e; }
}
</style>
</head>
<body>
<h1>PHP Git 服务器</h1>
<p class="lead">通过 HTTP 发布下列 Git 仓库,支持 clone、fetch、pull,并可按仓库启用 push。</p>
HTML;
echo '<h1>'.home_escape(t('home.title')).'</h1>'."\n";
echo '<p class="lead">'.home_escape(t('home.lead')).'</p>'."\n";
echo i18n_language_switcher(home_page_url($url_base))."\n";
}
function home_send_creation($url_base, $configuration, $notice, $value) {
function home_send_authentication($url_base, $configuration, $notice) {
if (!auth_is_enabled()) {
return;
}
echo '<section class="account" aria-labelledby="account-title">' ."\n";
echo '<h2 id="account-title">'.home_escape(t('home.account_title')).'</h2>' ."\n";
if ($notice !== NULL && isset($notice['token']) && is_string($notice['token'])) {
echo '<pre class="token-result"><code>'.home_escape($notice['token']).'</code></pre>' ."\n";
}
$csrf_token = home_csrf_token($url_base, $configuration);
if ($csrf_token === FALSE) {
echo '<p class="notice notice-error" role="status">'.home_escape(t('home.form_unavailable')).'</p>' ."\n";
echo '</section>' ."\n";
return;
}
$user = auth_session_user();
$action = home_escape(home_page_url($url_base));
$csrf_field = '<input type="hidden" name="csrf_token" value="'
.home_escape($csrf_token).'">' ."\n";
if ($user === NULL) {
echo '<div class="account-grid">' ."\n";
echo '<form method="post" action="'.$action.'">' ."\n".$csrf_field;
echo '<input type="hidden" name="action" value="login">' ."\n";
echo '<div class="credentials"><div><label for="login-username">'.home_escape(t('home.username')).'</label>' ."\n";
echo '<input id="login-username" name="username" maxlength="64" autocomplete="username" required></div>' ."\n";
echo '<div><label for="login-password">'.home_escape(t('home.password')).'</label>' ."\n";
echo '<input id="login-password" name="password" type="password" minlength="8" maxlength="72" autocomplete="current-password" required></div></div>' ."\n";
echo '<button type="submit">'.home_escape(t('home.login')).'</button></form>' ."\n";
if (auth_registration_is_enabled()) {
echo '<form method="post" action="'.$action.'">' ."\n".$csrf_field;
echo '<input type="hidden" name="action" value="register">' ."\n";
echo '<div class="credentials"><div><label for="register-username">'.home_escape(t('home.register_username')).'</label>' ."\n";
echo '<input id="register-username" name="username" minlength="3" maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9_-]" autocomplete="username" required></div>' ."\n";
echo '<div><label for="register-password">'.home_escape(t('home.password')).'</label>' ."\n";
echo '<input id="register-password" name="password" type="password" minlength="8" maxlength="72" autocomplete="new-password" required></div>' ."\n";
echo '<div><label for="register-password-confirmation">'.home_escape(t('home.password_confirmation')).'</label>' ."\n";
echo '<input id="register-password-confirmation" name="password_confirmation" type="password" minlength="8" maxlength="72" autocomplete="new-password" required></div></div>' ."\n";
echo '<button type="submit">'.home_escape(t('home.register')).'</button></form>' ."\n";
}
echo '</div>' ."\n";
echo '<p class="hint">'.home_escape(t('home.auth_hint')).'</p>' ."\n";
echo '</section>' ."\n";
return;
}
echo '<div class="account-bar"><p>'.home_escape(t('home.current_account')).'<strong>'
.home_escape($user['username']).'</strong></p>' ."\n";
echo '<div class="account-actions">';
if (auth_user_is_administrator($user)) {
echo '<a href="'.home_escape(rtrim((string) $url_base, '/').'/manage.php').'">'
.home_escape(t('home.manage')).'</a>';
}
echo '<form method="post" action="'.$action.'">'.$csrf_field;
echo '<input type="hidden" name="action" value="logout">' ."\n";
echo '<button class="button-danger" type="submit">'.home_escape(t('home.logout')).'</button></form></div></div>' ."\n";
echo '<form class="token-form" method="post" action="'.$action.'">'.$csrf_field;
echo '<input type="hidden" name="action" value="create_token">' ."\n";
echo '<div class="field"><label for="token-name">'.home_escape(t('home.token_name')).'</label>' ."\n";
echo '<input id="token-name" name="token_name" maxlength="80" placeholder="'
.home_escape(t('home.token_name_placeholder')).'" required></div>' ."\n";
echo '<button type="submit">'.home_escape(t('home.create_token')).'</button></form>' ."\n";
$tokens = auth_list_access_tokens($user['id']);
if ($tokens === FALSE) {
echo '<p class="notice notice-error">'.home_escape(t('home.token_list_unavailable')).'</p>' ."\n";
} else if (!empty($tokens)) {
echo '<ul class="token-list">' ."\n";
foreach ($tokens as $token) {
$last_used = $token['last_used_at'] === NULL
? t('home.token_never_used')
: t('home.token_last_used', array('time' => $token['last_used_at']));
$created = t('home.token_created_at', array('time' => $token['created_at']));
echo '<li><span><strong>'.home_escape($token['name']).'</strong><br>';
echo '<span class="hint">'.home_escape($created).' · '.home_escape($last_used).'</span></span>' ."\n";
echo '<form method="post" action="'.$action.'">'.$csrf_field;
echo '<input type="hidden" name="action" value="revoke_token">' ."\n";
echo '<input type="hidden" name="token_id" value="'.home_escape($token['id']).'">' ."\n";
echo '<button class="button-danger" type="submit">'.home_escape(t('home.revoke')).'</button></form></li>' ."\n";
}
echo '</ul>' ."\n";
}
echo '</section>' ."\n";
}
function home_send_creation($url_base, $configuration, $notice, $value, $visibility) {
if (!home_managed_repositories_configured($configuration)) {
return;
}
echo '<section class="create" aria-labelledby="create-title">' ."\n";
echo '<h2 id="create-title">创建仓库</h2>' ."\n";
echo '<h2 id="create-title">'.home_escape(t('home.create_repository')).'</h2>' ."\n";
if ($notice !== NULL) {
echo '<p class="notice notice-'.home_escape($notice['type']).'" role="status">'
.home_escape($notice['message']).'</p>' ."\n";
}
if (!home_creation_is_authorized($configuration)) {
echo '<p class="lead">需要先通过 Web 服务器身份验证,才能创建仓库。</p>' ."\n";
echo '<p class="lead">'.home_escape(t('home.create_login_required')).'</p>' ."\n";
echo '</section>' ."\n";
return;
}
$token = home_csrf_token($url_base, $configuration);
if ($token === FALSE) {
echo '<p class="notice notice-error" role="status">当前无法初始化安全表单。</p>' ."\n";
echo '<p class="notice notice-error" role="status">'.home_escape(t('home.form_unavailable')).'</p>' ."\n";
echo '</section>' ."\n";
return;
}
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 '<div class="field"><label for="repository-name">仓库名称</label>' ."\n";
echo '<input type="hidden" name="action" value="create_repository">' ."\n";
echo '<div class="field"><label for="repository-name">'.home_escape(t('home.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 '<button type="submit">创建仓库</button>' ."\n";
echo '<p class="hint">'.t('home.repository_name_hint').'</p></div>' ."\n";
echo '<fieldset class="visibility"><legend>'.home_escape(t('home.visibility')).'</legend><div class="visibility-options">' ."\n";
echo '<label class="visibility-option"><input name="visibility" type="radio" value="public"'
.($visibility !== 'private' ? ' checked' : '').'><span>'.home_escape(t('home.public')).'</span></label>' ."\n";
echo '<label class="visibility-option"><input name="visibility" type="radio" value="private"'
.($visibility === 'private' ? ' checked' : '').'><span>'.home_escape(t('home.private')).'</span></label>' ."\n";
echo '</div></fieldset>' ."\n";
echo '<button type="submit">'.home_escape(t('home.create_repository')).'</button>' ."\n";
echo '</form>' ."\n";
echo '</section>' ."\n";
}
function home_send_repository_table($repositories, $prefix) {
function home_send_repository_table(
$repositories,
$prefix,
$caption,
$url_base,
$configuration) {
$user = auth_session_user();
$csrf_token = $user === NULL ? FALSE : home_csrf_token($url_base, $configuration);
echo '<table>'."\n";
echo '<caption>已配置且允许读取的仓库</caption>'."\n";
echo '<thead><tr><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 '<caption>'.home_escape($caption).'</caption>'."\n";
echo '<thead><tr><th scope="col">'.home_escape(t('home.th_repository')).'</th>'
.'<th scope="col">'.home_escape(t('home.th_owner')).'</th>'
.'<th scope="col">'.home_escape(t('home.th_clone_url')).'</th>'
.'<th scope="col">'.home_escape(t('home.th_default_branch')).'</th>'
.'<th scope="col" class="count">'.home_escape(t('home.th_branches')).'</th>'
.'<th scope="col" class="count">'.home_escape(t('home.th_tags')).'</th>'
.'<th scope="col">'.home_escape(t('home.th_access')).'</th>'
.'<th scope="col">'.home_escape(t('home.th_actions')).'</th></tr></thead>' ."\n";
echo '<tbody>'."\n";
foreach ($repositories as $repository) {
@@ -395,35 +750,75 @@ function home_send_repository_table($repositories, $prefix) {
if ($summary['head'] !== NULL) {
$head = '<code>'.home_escape($summary['head']).'</code>';
} else if ($summary['branches'] === 0) {
$head = '<span class="badge badge-quiet">空仓库</span>';
$head = '<span class="badge badge-quiet">'.home_escape(t('home.badge_empty')).'</span>';
} else {
$head = '<span class="badge badge-quiet">未指向分支</span>';
$head = '<span class="badge badge-quiet">'.home_escape(t('home.badge_no_branch')).'</span>';
}
$access = $repository['options']['push']
? '<span class="badge badge-push">读取 / 推送</span>'
: '<span class="badge badge-quiet">只读</span>';
$owner = $repository['options']['owner'] === NULL
? '<span class="badge badge-quiet">'.home_escape(t('home.badge_unset')).'</span>'
: '<code>'.home_escape($repository['options']['owner']).'</code>';
$access = $repository['options']['push'] && $repository['options']['owner'] !== NULL
? '<span class="badge badge-push">'.home_escape(t('home.badge_owner_push')).'</span>'
: '<span class="badge badge-quiet">'.home_escape(t('home.badge_read_only')).'</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>';
echo '<td class="count">'.home_escape($summary['tags']).'</td>';
echo '<td>'.$access.'</td>';
echo '<td>';
if ($user !== NULL && $csrf_token !== FALSE
&& repository_user_is_owner($repository, $user['username'])
&& home_repository_is_managed($repository, $configuration)) {
echo '<form method="post" action="'.home_escape(home_page_url($url_base)).'">' ."\n";
echo '<input type="hidden" name="csrf_token" value="'.home_escape($csrf_token).'">' ."\n";
echo '<input type="hidden" name="action" value="delete_repository">' ."\n";
echo '<input type="hidden" name="repository_name" value="'
.home_escape(basename($repository['url'])).'">' ."\n";
echo '<label class="confirm-delete"><input name="confirmation" type="checkbox" '
.'value="delete" required> '.home_escape(t('home.confirm_delete')).'</label>' ."\n";
echo '<button class="button-danger" type="submit">'.home_escape(t('home.delete')).'</button></form>';
} else {
echo '<span class="badge badge-quiet">'.home_escape(t('home.badge_none')).'</span>';
}
echo '</td>';
echo '</tr>'."\n";
}
echo '</tbody>'."\n".'</table>'."\n";
}
function home_repository_is_managed($repository, $configuration) {
return !empty($repository['options']['_managed']);
}
function home_send_repository_section(
$id,
$title,
$repositories,
$prefix,
$empty_message,
$url_base,
$configuration) {
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, $url_base, $configuration);
}
echo '</section>' ."\n";
}
function home_send_empty_notice() {
echo <<<'HTML'
<div class="empty">
<p>当前没有可读取的仓库。</p>
<p>请复制 <code>config.php.sample</code> 为 <code>config.php</code>,在 <code>$repos</code>
中配置仓库路径,并确认仓库目录存在且 <code>read</code> 选项为 <code>TRUE</code>。</p>
</div>
HTML;
echo '<div class="empty">'."\n";
echo '<p>'.home_escape(t('home.empty_title')).'</p>'."\n";
echo '<p>'.t('home.empty_body').'</p>'."\n";
echo '</div>'."\n";
}
function home_send_usage($repositories, $prefix) {
@@ -431,12 +826,11 @@ function home_send_usage($repositories, $prefix) {
? $prefix.'/project.git'
: $prefix.$repositories[0]['url'];
echo '<h2>使用方法</h2>'."\n";
echo '<h2>'.home_escape(t('home.usage_title')).'</h2>'."\n";
echo '<pre><code>git clone '.home_escape($example)."\n"
.'git pull'."\n"
.'git push origin main</code></pre>'."\n";
echo '<p class="lead">push 需要仓库启用 <code>push</code> 选项;启用认证时还需要'
.'由 Web 服务器完成身份验证。</p>'."\n";
echo '<p class="lead">'.t('home.usage_hint').'</p>'."\n";
}
function home_render(
@@ -444,8 +838,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();
@@ -453,18 +849,36 @@ function home_render(
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: default-src \'none\'; style-src \'unsafe-inline\'');
home_send_head();
home_send_creation($url_base, $configuration, $notice, $value);
home_send_head($url_base);
if ($notice !== NULL) {
echo '<p class="notice notice-'.home_escape($notice['type']).'" role="status">'
.home_escape($notice['message']).'</p>' ."\n";
}
home_send_authentication($url_base, $configuration, $notice);
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',
t('home.public_repositories'),
$repositories['public'],
$prefix,
t('home.no_public_repositories'),
$url_base,
$configuration);
if ($show_private) {
home_send_repository_section(
'private-repositories',
t('home.private_repositories'),
$repositories['private'],
$prefix,
t('home.no_private_repositories'),
$url_base,
$configuration);
}
home_send_usage($repositories, $prefix);
home_send_usage(array_merge($repositories['public'], $repositories['private']), $prefix);
echo '<footer>详细安装、配置和安全说明见 <code>usage.md</code>。</footer>'."\n";
echo '<footer>'.t('home.footer').'</footer>'."\n";
echo '</body>'."\n".'</html>'."\n";
}
@@ -472,6 +886,12 @@ function home_dispatch($url_base, $definitions, $configuration, $application) {
$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
if ($method === 'POST') {
$action = home_post_value('action');
if ($action === 'delete_repository') {
home_delete_repository($url_base, $definitions, $configuration);
} else if ($action !== '' && $action !== 'create_repository') {
home_handle_auth_action($url_base, $configuration, $action);
}
home_create_repository($url_base, $definitions, $configuration, $application);
}
@@ -483,10 +903,7 @@ function home_dispatch($url_base, $definitions, $configuration, $application) {
die();
}
$notice = $method === 'GET'
&& home_managed_repositories_configured($configuration)
&& home_creation_is_authorized($configuration)
? home_take_notice($url_base, $configuration) : NULL;
$notice = $method === 'GET' ? home_take_notice($url_base, $configuration) : NULL;
home_render($url_base, $definitions, $configuration, $notice);
die();
}
@@ -499,9 +916,15 @@ if (!isset($repos) || !is_array($repos)) {
send_error(500, 'Internal Server Error', 'The repository configuration is invalid.');
}
if (!isset($git_executable)) {
$git_executable = 'git';
if (!isset($auth)) {
$auth = array();
}
if (!is_array($auth)) {
send_error(500, 'Internal Server Error', 'The authentication configuration is invalid.');
}
i18n_configure(isset($language) ? $language : NULL, $url_base);
auth_configure($auth, $url_base);
if (!isset($managed_repositories)) {
$managed_repositories = array();
@@ -510,9 +933,7 @@ if (!is_array($managed_repositories)) {
send_error(500, 'Internal Server Error', 'The managed repository configuration is invalid.');
}
$application = array(
'git_executable' => $git_executable,
'push_ref_rules' => array());
$application = array('push_ref_rules' => array());
$services = array();
register_branch_operation($application);
@@ -538,4 +959,5 @@ if ($repository === FALSE) {
}
$request = create_http_request($url_path, $repository);
repository_require_private_access($repository, $request);
dispatch_service($services, $repository, $request, $application);
+550
View File
@@ -0,0 +1,550 @@
<?php
/* First-run installer. It imports schema.mysql.sql into an existing database and
writes config.php. The missing config.php is the only thing gating this
endpoint, so it refuses to run once that file exists. */
require(__DIR__.'/lib/http.php');
require(__DIR__.'/lib/i18n.php');
// Installer has no config yet, so language follows the request/cookie/browser.
i18n_configure(NULL, rtrim(str_replace('\\', '/', dirname(install_page_url())), '/'));
function install_escape($value) {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function install_config_path() {
return __DIR__.DIRECTORY_SEPARATOR.'config.php';
}
function install_is_complete() {
return file_exists(install_config_path());
}
function install_page_url() {
$script = isset($_SERVER['SCRIPT_NAME']) ? (string) $_SERVER['SCRIPT_NAME'] : '';
return $script === '' ? '/install.php' : $script;
}
function install_home_url() {
$base = rtrim(str_replace('\\', '/', dirname(install_page_url())), '/');
return $base === '' ? '/' : $base.'/';
}
function install_request_is_https() {
$https = isset($_SERVER['HTTPS']) ? strtolower((string) $_SERVER['HTTPS']) : '';
return $https === 'on' || $https === '1'
|| (isset($_SERVER['SERVER_PORT']) && (string) $_SERVER['SERVER_PORT'] === '443');
}
function install_start_session() {
if (session_status() === PHP_SESSION_ACTIVE) {
return TRUE;
}
if (session_status() === PHP_SESSION_DISABLED || headers_sent()) {
return FALSE;
}
session_name('PHPGITSERVERINSTALL');
session_set_cookie_params(array(
'lifetime' => 0,
'path' => install_home_url(),
'secure' => install_request_is_https(),
'httponly' => TRUE,
'samesite' => 'Strict'));
return @session_start();
}
function install_csrf_token() {
if (!install_start_session()) {
return FALSE;
}
if (!isset($_SESSION['install_csrf_token'])
|| !is_string($_SESSION['install_csrf_token'])
|| strlen($_SESSION['install_csrf_token']) !== 64) {
try {
$_SESSION['install_csrf_token'] = bin2hex(random_bytes(32));
} catch (Exception $exception) {
return FALSE;
}
}
return $_SESSION['install_csrf_token'];
}
function install_post_value($name) {
return isset($_POST[$name]) && is_string($_POST[$name]) ? $_POST[$name] : '';
}
function install_post_checked($name) {
return isset($_POST[$name]) && $_POST[$name] === '1';
}
/* MySQL identifiers cannot be bound as parameters, so they are restricted to a
conservative character set before any interpolation. Hyphens are allowed
because shared hosting panels routinely generate names that contain them. */
function install_identifier_is_valid($value) {
return is_string($value) && preg_match('~^[A-Za-z0-9_$-]{1,64}$~D', $value) === 1;
}
function install_host_is_valid($value) {
return is_string($value) && preg_match('~^[A-Za-z0-9._%-]{1,255}$~D', $value) === 1;
}
function install_quote_identifier($value) {
return '`'.str_replace('`', '``', $value).'`';
}
function install_schema_statements() {
$buffer = @file_get_contents(__DIR__.DIRECTORY_SEPARATOR.'schema.mysql.sql');
if ($buffer === FALSE) {
return FALSE;
}
$statements = array();
foreach (preg_split('~;\s*(?:\r?\n|$)~', $buffer) as $statement) {
$statement = trim($statement);
if ($statement !== '') {
$statements[] = $statement;
}
}
return $statements;
}
function install_validate($input) {
$errors = array();
if ($input['url_base'] !== ''
&& !preg_match('~^/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$~D', $input['url_base'])) {
$errors[] = t('install.error_url_base');
}
if (!install_host_is_valid($input['db_host'])) {
$errors[] = t('install.error_db_host');
}
if (!preg_match('~^[1-9][0-9]{0,4}$~D', $input['db_port']) || (int) $input['db_port'] > 65535) {
$errors[] = t('install.error_db_port');
}
if (!install_identifier_is_valid($input['db_name'])) {
$errors[] = t('install.error_db_name');
}
if (!install_identifier_is_valid($input['db_user'])) {
$errors[] = t('install.error_db_user');
}
if ($input['db_password'] === '' || strlen($input['db_password']) > 255) {
$errors[] = t('install.error_db_password');
}
if (auth_normalize_username($input['admin_username']) === FALSE) {
$errors[] = t('install.error_admin_username');
}
if (!auth_password_is_valid($input['admin_password'])) {
$errors[] = t('install.error_admin_password');
} else if (!hash_equals($input['admin_password'], $input['admin_password_confirmation'])) {
$errors[] = t('install.error_admin_password_mismatch');
}
return $errors;
}
function install_connect($dsn, $username, $password) {
return new PDO($dsn, $username, $password, array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => FALSE));
}
function install_database_dsn($input) {
return 'mysql:host='.$input['db_host'].';port='.$input['db_port']
.';dbname='.$input['db_name'].';charset=utf8mb4';
}
function install_import_schema($connection) {
$statements = install_schema_statements();
if ($statements === FALSE) {
return array('status' => 'schema_unreadable');
}
$existing = $connection->query('SHOW TABLES LIKE \'pgit_%\'')->fetchAll(PDO::FETCH_COLUMN);
if (!empty($existing)) {
return array('status' => 'schema_present', 'tables' => count($existing));
}
try {
foreach ($statements as $statement) {
$connection->exec($statement);
}
} catch (PDOException $exception) {
$driver_code = isset($exception->errorInfo[1]) ? (int) $exception->errorInfo[1] : 0;
if ($driver_code === 1142 || $driver_code === 1044) {
return array('status' => 'schema_denied');
}
throw $exception;
}
return array('status' => 'schema_imported', 'tables' => count($statements));
}
/* SHOW GRANTS output is awkward to parse reliably, so DELETE is confirmed with a
statement that matches no rows but still requires the privilege. */
function install_verify_delete_privilege($connection) {
try {
$connection->exec('DELETE FROM pgit_users WHERE 1 = 0');
return TRUE;
} catch (PDOException $exception) {
return FALSE;
}
}
function install_create_administrator($connection, $username, $password) {
$statement = $connection->prepare(
'SELECT id FROM pgit_users WHERE username = ? LIMIT 1');
$statement->execute(array($username));
if ($statement->fetch() !== FALSE) {
return 'administrator_exists';
}
$statement = $connection->prepare(
'INSERT INTO pgit_users (username, password_hash) VALUES (?, ?)');
$statement->execute(array($username, password_hash($password, PASSWORD_DEFAULT)));
return 'administrator_created';
}
function install_config_contents($input) {
$administrators = " ".var_export($input['admin_username'], TRUE).",\n";
$secure_cookie = $input['session_cookie_secure']
? " 'session_cookie_secure' => TRUE,\n" : '';
return "<?php\n\n"
."/* Generated by install.php. See config.php.sample for every option. */\n\n"
."\$url_base = ".var_export($input['url_base'], TRUE).";\n"
."\$language = ".var_export($input['language'], TRUE).";\n\n"
."\$auth = array(\n"
." 'enabled' => TRUE,\n"
." 'registration_enabled' => ".($input['registration_enabled'] ? 'TRUE' : 'FALSE').",\n"
.$secure_cookie
." 'administrators' => array(\n"
.$administrators
." ),\n"
." 'database' => array(\n"
." 'dsn' => ".var_export(install_database_dsn($input), TRUE).",\n"
." 'username' => ".var_export($input['db_user'], TRUE).",\n"
." 'password' => ".var_export($input['db_password'], TRUE)."));\n\n"
."\$managed_repositories = array(\n"
." 'options' => array(\n"
." 'read' => TRUE,\n"
." 'push' => TRUE,\n"
." 'branches' => TRUE,\n"
." 'tags' => TRUE,\n"
." 'other_refs' => FALSE,\n"
." 'allow_non_fast_forward' => FALSE,\n"
." 'max_object_bytes' => 268435456,\n"
." 'max_pack_objects' => 100000,\n"
." 'max_request_bytes' => 0));\n\n"
."\$repos = array(\n"
." array('/php-git-server.git', '.git', array(\n"
." 'read' => TRUE,\n"
." 'push' => FALSE,\n"
." 'owner' => NULL,\n"
." 'private' => FALSE)),\n"
." );\n";
}
/* Exclusive creation keeps a second concurrent installer from overwriting a
configuration that was just written. */
function install_write_config($input) {
$path = install_config_path();
$handle = @fopen($path, 'xb');
if ($handle === FALSE) {
return FALSE;
}
$contents = install_config_contents($input);
$written = fwrite($handle, $contents);
fclose($handle);
if ($written !== strlen($contents)) {
@unlink($path);
return FALSE;
}
@chmod($path, 0600);
return TRUE;
}
function install_run($input) {
$steps = array();
try {
$connection = install_connect(
install_database_dsn($input), $input['db_user'], $input['db_password']);
$steps[] = array('success', t('install.step_connected'));
$import = install_import_schema($connection);
if ($import['status'] === 'schema_unreadable') {
return array('errors' => array(t('install.error_schema_unreadable')), 'steps' => $steps);
}
if ($import['status'] === 'schema_denied') {
return array(
'errors' => array(t('install.error_schema_denied')),
'steps' => $steps);
}
$steps[] = $import['status'] === 'schema_imported'
? array('success', t('install.step_schema_imported', array('count' => $import['tables'])))
: array('success', t('install.step_schema_present'));
if (!install_verify_delete_privilege($connection)) {
return array(
'errors' => array(t('install.error_delete_privilege')),
'steps' => $steps);
}
$steps[] = array('success', t('install.step_privileges_ok'));
$administrator = install_create_administrator(
$connection, $input['admin_username'], $input['admin_password']);
$steps[] = $administrator === 'administrator_created'
? array('success', t('install.step_admin_created', array('name' => $input['admin_username'])))
: array('success', t('install.step_admin_promoted', array('name' => $input['admin_username'])));
} catch (PDOException $exception) {
return array(
'errors' => array(t('install.error_database', array('message' => $exception->getMessage()))),
'steps' => $steps);
}
if (!install_write_config($input)) {
return array(
'errors' => array(t('install.error_config_write')),
'steps' => $steps);
}
$steps[] = array('success', t('install.step_config_written'));
return array('errors' => array(), 'steps' => $steps, 'complete' => TRUE);
}
function install_send_head() {
echo <<<'HTML'
<!DOCTYPE html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
:root { color-scheme: light dark; --canvas:#f3f7f5;--surface:#fff;--surface-soft:#f7faf8;--ink:#17231d;--muted:#63736a;--line:#d9e4dd;--accent:#167447;--accent-strong:#0d5a34;--accent-soft:#e8f5ed;--danger:#b23b3b;--warning:#9a6b12;--link:#0d658f;--shadow:0 18px 46px rgba(25,59,42,.09); }
* { box-sizing:border-box; } html { background:var(--canvas); } body { width:min(54rem,calc(100% - 2rem));margin:0 auto;padding:3.5rem 0;color:var(--ink);background:transparent;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI","Noto Sans CJK SC",sans-serif;line-height:1.6; } body::before { position:fixed;z-index:-1;inset:0;content:"";background:radial-gradient(circle at 7% 0%,#dcf1e5 0,transparent 28rem),radial-gradient(circle at 96% 7%,#e0f1f8 0,transparent 27rem),var(--canvas); }
h1 { margin:0;font-size:clamp(1.75rem,4vw,2.35rem);line-height:1.15;letter-spacing:-.045em; } h1::before { display:inline-grid;width:2.3rem;height:2.3rem;place-items:center;margin-right:.7rem;content:"⌘";border-radius:.68rem;color:#fff;background:var(--accent);font-size:1.38rem;vertical-align:-.18em;box-shadow:0 7px 18px rgba(22,116,71,.24); } h2 { margin:2rem 0 .6rem;font-size:1.15rem;letter-spacing:-.02em; } p { margin:0 0 1rem; }.language-switcher { display:inline-flex;margin:.8rem 0 1.5rem;padding:.25rem .5rem;border:1px solid var(--line);border-radius:999px;background:rgba(255,255,255,.6);font-size:.82rem; }.language-switcher a,a { color:var(--link); }.lead { margin:0 0 1.4rem;padding:1.1rem 1.2rem;border:1px solid #cde3d5;border-radius:.75rem;color:#315340;background:var(--accent-soft); }
form { padding:1.35rem;border:1px solid var(--line);border-radius:.9rem;background:var(--surface);box-shadow:var(--shadow); } fieldset { margin:0 0 1.1rem;padding:1.15rem;border:1px solid var(--line);border-radius:.7rem;background:var(--surface-soft); } legend { padding:0 .45rem;color:var(--ink);font-weight:800; }.grid { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.95rem; } label { display:block;margin-bottom:.35rem;color:#304239;font-size:.85rem;font-weight:800; } input[type="text"],input[type="password"],input[type="number"] { width:100%;min-height:2.65rem;padding:.52rem .7rem;border:1px solid #bbcbbf;border-radius:.52rem;background:#fff;color:var(--ink);font:inherit;transition:border-color .15s,box-shadow .15s; } input:focus { border-color:var(--accent);outline:0;box-shadow:0 0 0 3px rgba(22,116,71,.16); }.check { display:flex;align-items:flex-start;gap:.55rem;margin-top:.9rem; }.check input { margin-top:.32rem;accent-color:var(--accent); }.check label { margin:0;font-weight:600; }.hint { margin:.35rem 0 0;color:var(--muted);font-size:.84rem; }button { min-height:2.7rem;padding:.55rem 1.35rem;border:1px solid var(--accent-strong);border-radius:.52rem;color:#fff;background:var(--accent);font:inherit;font-weight:800;cursor:pointer;box-shadow:0 2px 4px rgba(13,90,52,.15);transition:transform .15s,background .15s,box-shadow .15s; }button:hover { background:var(--accent-strong);box-shadow:0 5px 12px rgba(13,90,52,.2);transform:translateY(-1px); }button:focus-visible { outline:3px solid #79c99d;outline-offset:2px; }.notice { margin:0 0 1rem;padding:.78rem 1rem;border:1px solid;border-left:.32rem solid;border-radius:.55rem;font-weight:600; }.notice-success { border-color:#a7d9b9;border-left-color:var(--accent);background:var(--accent-soft);color:#155735; }.notice-error { border-color:#f1c5c5;border-left-color:var(--danger);background:#fff1f1;color:#852b2b; }.notice-warning { border-color:#ebd39c;border-left-color:var(--warning);background:#fff7e6;color:#6d4c11; }ul { margin:0 0 1rem;padding-left:1.2rem; }code { padding:.07rem .25rem;border-radius:.24rem;background:#edf3ef;font-size:.9em;word-break:break-all;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }pre { padding:.85rem 1rem;overflow-x:auto;border:1px solid var(--line);border-radius:.62rem;background:#f5f8f6; }pre code { padding:0;background:transparent; }
@media (max-width:40rem) { body { width:min(100% - 1.25rem,54rem);padding-top:2rem; }h1::before { display:none; }.grid { grid-template-columns:1fr; }form { padding:1.05rem; }fieldset { padding:1rem; }button { width:100%; } }
@media (prefers-color-scheme:dark) { :root { --canvas:#101916;--surface:#17231e;--surface-soft:#1d2b25;--ink:#e5efe8;--muted:#a0b2a8;--line:#31443a;--accent:#42a76d;--accent-strong:#2d8953;--accent-soft:#173b27;--danger:#d05a5a;--warning:#e0ad50;--link:#75c4eb;--shadow:0 18px 46px rgba(0,0,0,.22); }body::before { background:radial-gradient(circle at 7% 0%,#173e2a 0,transparent 28rem),radial-gradient(circle at 96% 7%,#163744 0,transparent 27rem),var(--canvas); }h1::before { color:#102319; }.language-switcher { background:rgba(23,35,30,.62); }label { color:#d5e2d9; }fieldset { background:var(--surface-soft); }input[type="text"],input[type="password"],input[type="number"] { border-color:#587064;background:#132019;color:var(--ink); }code { background:#22332b; }pre { background:#1b2a23; }.lead { border-color:#356d4b;color:#b7dfc4; }.notice-success { border-color:#356d4b;color:#a9e5bd; }.notice-error { border-color:#713838;background:#3a2020;color:#ffb6b6; }.notice-warning { border-color:#745b27;background:#3e3218;color:#f1d48e; } }
</style>
</head>
<body>
<h1>__INSTALL_HEADING__</h1>
HTML;
echo i18n_language_switcher(install_page_url())."\n";
}
function install_send_form($input, $errors) {
$token = install_csrf_token();
if ($token === FALSE) {
echo '<p class="notice notice-error">'.install_escape(t('install.session_unavailable')).'</p>' ."\n";
return;
}
echo '<p class="lead">'.t('install.lead').'</p>' ."\n";
if (!install_request_is_https()) {
echo '<p class="notice notice-warning">'.t('install.warning_http').'</p>' ."\n";
}
if (!extension_loaded('pdo_mysql')) {
echo '<p class="notice notice-error">'.t('install.error_no_pdo_mysql').'</p>' ."\n";
}
if (!is_writable(__DIR__)) {
echo '<p class="notice notice-error">'.t('install.error_not_writable').'</p>' ."\n";
}
if (!empty($errors)) {
echo '<div class="notice notice-error"><ul>' ."\n";
foreach ($errors as $error) {
echo '<li>'.install_escape($error).'</li>' ."\n";
}
echo '</ul></div>' ."\n";
}
echo '<form method="post" action="'.install_escape(install_page_url()).'">' ."\n";
echo '<input type="hidden" name="csrf_token" value="'.install_escape($token).'">' ."\n";
echo '<fieldset><legend>'.install_escape(t('install.legend_application')).'</legend><div class="grid">' ."\n";
echo '<div><label for="url_base">'.install_escape(t('install.label_url_base')).'</label>'
.'<input id="url_base" name="url_base" type="text" value="'
.install_escape($input['url_base']).'" placeholder="/php-git-server">'
.'<p class="hint">'.t('install.hint_url_base').'</p></div>' ."\n";
echo '<div><label for="language">'.install_escape(t('install.label_language')).'</label>'
.'<select id="language" name="language">'
.'<option value=""'.($input['language'] === '' ? ' selected' : '').'>'
.install_escape(t('install.language_auto')).'</option>'
.'<option value="en"'.($input['language'] === 'en' ? ' selected' : '').'>'.install_escape(t('install.language_en')).'</option>'
.'<option value="zh"'.($input['language'] === 'zh' ? ' selected' : '').'>'.install_escape(t('install.language_zh')).'</option>'
.'</select></div>' ."\n";
echo '</div><div class="check"><input id="registration_enabled" name="registration_enabled" '
.'type="checkbox" value="1"'.($input['registration_enabled'] ? ' checked' : '').'>'
.'<label for="registration_enabled">'.install_escape(t('install.label_registration_enabled')).'</label></div>' ."\n";
echo '<div class="check"><input id="session_cookie_secure" name="session_cookie_secure" '
.'type="checkbox" value="1"'.($input['session_cookie_secure'] ? ' checked' : '').'>'
.'<label for="session_cookie_secure">'.install_escape(t('install.label_session_cookie_secure')).'</label>'
.'</div></fieldset>' ."\n";
echo '<fieldset><legend>'.install_escape(t('install.legend_database')).'</legend>' ."\n";
echo '<p class="hint">'.t('install.hint_database').'</p>' ."\n";
echo '<div class="grid">' ."\n";
echo '<div><label for="db_host">'.install_escape(t('install.label_db_host')).'</label><input id="db_host" name="db_host" type="text" '
.'value="'.install_escape($input['db_host']).'" required>'
.'<p class="hint">'.t('install.hint_db_host').'</p></div>' ."\n";
echo '<div><label for="db_port">'.install_escape(t('install.label_db_port')).'</label><input id="db_port" name="db_port" '
.'type="number" min="1" max="65535" value="'.install_escape($input['db_port'])
.'" required></div>' ."\n";
echo '<div><label for="db_name">'.install_escape(t('install.label_db_name')).'</label><input id="db_name" name="db_name" '
.'type="text" value="'.install_escape($input['db_name']).'" required>'
.'<p class="hint">'.t('install.hint_db_name').'</p></div>' ."\n";
echo '<div><label for="db_user">'.install_escape(t('install.label_db_user')).'</label><input id="db_user" name="db_user" '
.'type="text" value="'.install_escape($input['db_user']).'" required></div>' ."\n";
echo '<div><label for="db_password">'.install_escape(t('install.label_db_password')).'</label><input id="db_password" '
.'name="db_password" type="password" autocomplete="new-password" required></div>' ."\n";
echo '</div>' ."\n";
echo '<p class="hint">'.t('install.hint_schema_import').'</p>' ."\n";
echo '</fieldset>' ."\n";
echo '<fieldset><legend>'.install_escape(t('install.legend_administrator')).'</legend>' ."\n";
echo '<p class="hint">'.t('install.hint_administrator').'</p>' ."\n";
echo '<div class="grid">' ."\n";
echo '<div><label for="admin_username">'.install_escape(t('install.label_admin_username')).'</label><input id="admin_username" '
.'name="admin_username" type="text" minlength="3" maxlength="64" value="'
.install_escape($input['admin_username']).'" autocomplete="off" required></div>' ."\n";
echo '<div><label for="admin_password">'.install_escape(t('install.label_admin_password')).'</label><input id="admin_password" '
.'name="admin_password" type="password" minlength="8" maxlength="72" '
.'autocomplete="new-password" required></div>' ."\n";
echo '<div><label for="admin_password_confirmation">'.install_escape(t('install.label_admin_password_confirmation')).'</label>'
.'<input id="admin_password_confirmation" name="admin_password_confirmation" '
.'type="password" minlength="8" maxlength="72" autocomplete="new-password" required>'
.'</div>' ."\n";
echo '</div></fieldset>' ."\n";
echo '<button type="submit">'.install_escape(t('install.button_submit')).'</button>' ."\n";
echo '</form>' ."\n";
}
function install_send_result($steps) {
echo '<p class="notice notice-success">'.install_escape(t('install.complete')).'</p>' ."\n";
foreach ($steps as $step) {
echo '<p class="notice notice-'.install_escape($step[0]).'">'
.install_escape($step[1]).'</p>' ."\n";
}
echo '<h2>'.install_escape(t('install.next_steps_title')).'</h2>' ."\n";
echo '<p>'.install_escape(t('install.next_steps_remove')).'</p>' ."\n";
echo '<pre><code>rm install.php</code></pre>' ."\n";
echo '<p>'.t('install.next_steps_shared_hosting').'</p>' ."\n";
echo '<p>'.t('install.next_steps_permissions').'</p>' ."\n";
echo '<p><a href="'.install_escape(install_home_url()).'">'.install_escape(t('install.link_home')).'</a></p>' ."\n";
}
function install_send_foot() {
echo '</body>' ."\n".'</html>' ."\n";
}
if (install_is_complete()) {
send_error(
403,
'Forbidden',
'Installation is already complete. Remove install.php, or delete config.php to reinstall.');
}
require(__DIR__.'/lib/auth.php');
/* Session must be started before any output so the cookie can be set. */
install_start_session();
ob_start('i18n_translate_markup');
header_nocache();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: default-src \'none\'; style-src \'unsafe-inline\'; '
.'form-action \'self\'; base-uri \'none\'; frame-ancestors \'none\'');
$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
if ($method !== 'GET' && $method !== 'HEAD' && $method !== 'POST') {
send_status(405, 'Method Not Allowed');
header('Allow: GET, HEAD, POST');
header('Content-Type: text/plain; charset=utf-8');
echo 'Method Not Allowed';
die();
}
$input = array(
'url_base' => rtrim(str_replace('\\', '/', dirname(install_page_url())), '/'),
'registration_enabled' => TRUE,
'session_cookie_secure' => FALSE,
'language' => '',
'db_host' => '127.0.0.1',
'db_port' => '3306',
'db_name' => 'php_git_server',
'db_user' => 'php_git_server',
'db_password' => '',
'admin_username' => '',
'admin_password' => '',
'admin_password_confirmation' => '');
if ($method !== 'POST') {
install_send_head();
install_send_form($input, array());
install_send_foot();
die();
}
if (!request_content_type_is(
get_request_header('Content-Type'), 'application/x-www-form-urlencoded')) {
send_error(415, 'Unsupported Media Type', 'Expected a form-encoded request.');
}
$expected_token = install_csrf_token();
if ($expected_token === FALSE
|| !hash_equals($expected_token, install_post_value('csrf_token'))) {
send_error(403, 'Forbidden', 'The form security token is invalid.');
}
$input = array(
'url_base' => rtrim(trim(install_post_value('url_base')), '/'),
'registration_enabled' => install_post_checked('registration_enabled'),
'session_cookie_secure' => install_post_checked('session_cookie_secure'),
'language' => install_post_value('language'),
'db_host' => trim(install_post_value('db_host')),
'db_port' => trim(install_post_value('db_port')),
'db_name' => trim(install_post_value('db_name')),
'db_user' => trim(install_post_value('db_user')),
'db_password' => install_post_value('db_password'),
'admin_username' => trim(install_post_value('admin_username')),
'admin_password' => install_post_value('admin_password'),
'admin_password_confirmation' => install_post_value('admin_password_confirmation'));
$errors = install_validate($input);
if (!extension_loaded('pdo_mysql')) {
$errors[] = t('install.error_pdo_mysql_missing');
}
install_send_head();
if (!empty($errors)) {
install_send_form($input, $errors);
install_send_foot();
die();
}
$result = install_run($input);
if (empty($result['errors'])) {
install_send_result($result['steps']);
} else {
foreach ($result['steps'] as $step) {
echo '<p class="notice notice-'.install_escape($step[0]).'">'
.install_escape($step[1]).'</p>' ."\n";
}
install_send_form($input, $result['errors']);
}
install_send_foot();
+919
View File
@@ -0,0 +1,919 @@
<?php
$auth_configuration = array();
$auth_url_base = '';
$auth_cached_user_resolved = FALSE;
$auth_cached_user = NULL;
function auth_configure($configuration, $url_base) {
global $auth_configuration, $auth_url_base;
global $auth_cached_user_resolved, $auth_cached_user;
$auth_configuration = is_array($configuration) ? $configuration : array();
$auth_url_base = (string) $url_base;
$auth_cached_user_resolved = FALSE;
$auth_cached_user = NULL;
}
function auth_is_enabled() {
global $auth_configuration;
return !empty($auth_configuration)
&& (!isset($auth_configuration['enabled']) || $auth_configuration['enabled'] === TRUE);
}
function auth_registration_is_enabled() {
global $auth_configuration;
return auth_is_enabled()
&& (!isset($auth_configuration['registration_enabled'])
|| $auth_configuration['registration_enabled'] === TRUE);
}
function auth_user_is_administrator($user) {
global $auth_configuration;
$username = is_array($user) && isset($user['username'])
? $user['username'] : $user;
if (!is_string($username)
|| !isset($auth_configuration['administrators'])
|| !is_array($auth_configuration['administrators'])) {
return FALSE;
}
foreach ($auth_configuration['administrators'] as $administrator) {
$normalized = auth_normalize_username($administrator);
if ($normalized !== FALSE && hash_equals($normalized, $username)) {
return TRUE;
}
}
return FALSE;
}
function auth_session_cookie_is_secure() {
global $auth_configuration;
if (isset($auth_configuration['session_cookie_secure'])) {
return $auth_configuration['session_cookie_secure'] === TRUE;
}
$https = isset($_SERVER['HTTPS']) ? strtolower((string) $_SERVER['HTTPS']) : '';
return $https === 'on' || $https === '1'
|| (isset($_SERVER['SERVER_PORT']) && (string) $_SERVER['SERVER_PORT'] === '443');
}
function auth_session_path() {
global $auth_url_base;
$base = rtrim($auth_url_base, '/');
return $base === '' ? '/' : $base.'/';
}
function auth_start_session() {
if (session_status() === PHP_SESSION_ACTIVE) {
return TRUE;
}
if (session_status() === PHP_SESSION_DISABLED || headers_sent()) {
return FALSE;
}
session_name('PHPGITSERVER');
session_set_cookie_params(array(
'lifetime' => 0,
'path' => auth_session_path(),
'secure' => auth_session_cookie_is_secure(),
'httponly' => TRUE,
'samesite' => 'Strict'));
return @session_start();
}
function auth_database() {
global $auth_configuration;
static $connection = NULL;
static $connection_key = NULL;
if (!auth_is_enabled()) {
return FALSE;
}
$database = isset($auth_configuration['database'])
&& is_array($auth_configuration['database'])
? $auth_configuration['database'] : array();
$dsn = isset($database['dsn']) ? $database['dsn'] : '';
$username = isset($database['username']) ? $database['username'] : '';
$password = isset($database['password']) ? $database['password'] : '';
if (!is_string($dsn) || $dsn === ''
|| !is_string($username) || !is_string($password)) {
error_log('Authentication database configuration is invalid.');
return FALSE;
}
$key = hash('sha256', $dsn."\0".$username);
if ($connection instanceof PDO && $connection_key === $key) {
return $connection;
}
try {
$connection = new PDO($dsn, $username, $password, array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => FALSE));
$connection_key = $key;
return $connection;
} catch (PDOException $exception) {
error_log('Authentication database connection failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_normalize_username($value) {
if (!is_string($value)) {
return FALSE;
}
$username = trim($value);
if (!preg_match('~^(?=.{3,64}$)[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9_-]$~D', $username)) {
return FALSE;
}
return $username;
}
function auth_password_is_valid($password) {
return is_string($password)
&& strlen($password) <= 72
&& preg_match('~^.{8,72}$~usD', $password) === 1;
}
function auth_find_active_user_by_id($database, $user_id) {
$statement = $database->prepare(
'SELECT id, username FROM pgit_users WHERE id = ? AND is_active = 1 LIMIT 1');
$statement->execute(array($user_id));
$user = $statement->fetch();
return $user === FALSE ? NULL : $user;
}
function auth_find_user_by_id($user_id_value) {
$user_id = auth_normalize_record_id($user_id_value);
if ($user_id === FALSE) {
return NULL;
}
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->prepare(
'SELECT id, username, is_active, created_at, updated_at '
.'FROM pgit_users WHERE id = ? LIMIT 1');
$statement->execute(array($user_id));
$user = $statement->fetch();
return $user === FALSE ? NULL : $user;
} catch (PDOException $exception) {
error_log('User lookup failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_session_user() {
if (!auth_is_enabled()) {
return NULL;
}
if (session_status() !== PHP_SESSION_ACTIVE
&& (!isset($_COOKIE['PHPGITSERVER']) || !is_string($_COOKIE['PHPGITSERVER']))) {
return NULL;
}
if (!auth_start_session()
|| !isset($_SESSION['auth_user_id']) || !is_int($_SESSION['auth_user_id'])) {
return NULL;
}
$database = auth_database();
if ($database === FALSE) {
return NULL;
}
try {
$user = auth_find_active_user_by_id($database, $_SESSION['auth_user_id']);
if ($user === NULL) {
unset($_SESSION['auth_user_id']);
}
return $user;
} catch (PDOException $exception) {
error_log('Authentication session lookup failed: '.$exception->getMessage());
return NULL;
}
}
function auth_basic_credentials() {
if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
return array((string) $_SERVER['PHP_AUTH_USER'], (string) $_SERVER['PHP_AUTH_PW']);
}
$authorization = get_request_header('Authorization');
if (!is_string($authorization)
|| !preg_match('~^Basic[ \t]+([A-Za-z0-9+/]+={0,2})$~iD', trim($authorization), $matches)) {
return NULL;
}
$decoded = base64_decode($matches[1], TRUE);
if ($decoded === FALSE || strpos($decoded, ':') === FALSE) {
return NULL;
}
return explode(':', $decoded, 2);
}
function auth_token_user() {
if (!auth_is_enabled()) {
return NULL;
}
$credentials = auth_basic_credentials();
if ($credentials === NULL) {
return NULL;
}
$username = auth_normalize_username($credentials[0]);
$token = $credentials[1];
if ($username === FALSE || !preg_match('~^pgs_[a-f0-9]{64}$~D', $token)) {
return NULL;
}
$database = auth_database();
if ($database === FALSE) {
return NULL;
}
try {
$statement = $database->prepare(
'SELECT pgit_users.id, pgit_users.username, pgit_access_tokens.id AS token_id '
.'FROM pgit_access_tokens JOIN pgit_users '
.'ON pgit_users.id = pgit_access_tokens.user_id '
.'WHERE pgit_users.username = ? AND pgit_users.is_active = 1 '
.'AND pgit_access_tokens.token_hash = ? AND pgit_access_tokens.revoked_at IS NULL '
.'AND (pgit_access_tokens.expires_at IS NULL '
.'OR pgit_access_tokens.expires_at > CURRENT_TIMESTAMP) '
.'LIMIT 1');
$statement->execute(array($username, hash('sha256', $token)));
$user = $statement->fetch();
if ($user === FALSE) {
return NULL;
}
$update = $database->prepare(
'UPDATE pgit_access_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?');
$update->execute(array($user['token_id']));
unset($user['token_id']);
return $user;
} catch (PDOException $exception) {
error_log('Access token lookup failed: '.$exception->getMessage());
return NULL;
}
}
function auth_register($username_value, $password, $password_confirmation) {
if (!auth_registration_is_enabled()) {
return array('status' => 'registration_disabled');
}
$username = auth_normalize_username($username_value);
if ($username === FALSE) {
return array('status' => 'invalid_username');
}
if (auth_user_is_administrator($username)) {
return array('status' => 'username_exists');
}
if (!auth_password_is_valid($password)) {
return array('status' => 'invalid_password');
}
if (!is_string($password_confirmation) || !hash_equals($password, $password_confirmation)) {
return array('status' => 'password_mismatch');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'INSERT INTO pgit_users (username, password_hash) VALUES (?, ?)');
$statement->execute(array($username, password_hash($password, PASSWORD_DEFAULT)));
$user_id = (int) $database->lastInsertId();
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return array('status' => 'username_exists');
}
error_log('User registration failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
if (!auth_start_session() || !session_regenerate_id(TRUE)) {
return array('status' => 'session_unavailable');
}
$_SESSION['auth_user_id'] = $user_id;
auth_reset_cached_user();
return array('status' => 'registered', 'username' => $username);
}
function auth_create_user($username_value, $password, $password_confirmation) {
$username = auth_normalize_username($username_value);
if ($username === FALSE) {
return array('status' => 'invalid_username');
}
if (!auth_password_is_valid($password)) {
return array('status' => 'invalid_password');
}
if (!is_string($password_confirmation) || !hash_equals($password, $password_confirmation)) {
return array('status' => 'password_mismatch');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'INSERT INTO pgit_users (username, password_hash) VALUES (?, ?)');
$statement->execute(array($username, password_hash($password, PASSWORD_DEFAULT)));
return array(
'status' => 'user_created',
'id' => (int) $database->lastInsertId(),
'username' => $username);
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return array('status' => 'username_exists');
}
error_log('Administrative user creation failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_set_user_password($user_id_value, $password, $password_confirmation) {
$user_id = auth_normalize_record_id($user_id_value);
if ($user_id === FALSE) {
return array('status' => 'invalid_user');
}
if (!auth_password_is_valid($password)) {
return array('status' => 'invalid_password');
}
if (!is_string($password_confirmation) || !hash_equals($password, $password_confirmation)) {
return array('status' => 'password_mismatch');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'UPDATE pgit_users SET password_hash = ? WHERE id = ?');
$statement->execute(array(password_hash($password, PASSWORD_DEFAULT), $user_id));
return array('status' => $statement->rowCount() === 1
? 'password_updated' : 'invalid_user');
} catch (PDOException $exception) {
error_log('Administrative password reset failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_login($username_value, $password) {
$dummy_hash = '$2y$12$1EmDXXQYPUpbo5wFP6frV.F5Qu6bsg2hw.q9wFG8DxlLRiEQaqcL.';
$username = auth_normalize_username($username_value);
if ($username === FALSE || !auth_password_is_valid($password)) {
password_verify('', $dummy_hash);
return array('status' => 'invalid_credentials');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'SELECT id, username, password_hash FROM pgit_users '
.'WHERE username = ? AND is_active = 1 LIMIT 1');
$statement->execute(array($username));
$user = $statement->fetch();
} catch (PDOException $exception) {
error_log('User login lookup failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
$hash = $user === FALSE ? $dummy_hash : $user['password_hash'];
if (!password_verify($password, $hash) || $user === FALSE) {
return array('status' => 'invalid_credentials');
}
if (password_needs_rehash($user['password_hash'], PASSWORD_DEFAULT)) {
try {
$statement = $database->prepare(
'UPDATE pgit_users SET password_hash = ? WHERE id = ?');
$statement->execute(array(password_hash($password, PASSWORD_DEFAULT), $user['id']));
} catch (PDOException $exception) {
error_log('Password rehash failed: '.$exception->getMessage());
}
}
if (!auth_start_session() || !session_regenerate_id(TRUE)) {
return array('status' => 'session_unavailable');
}
$_SESSION['auth_user_id'] = (int) $user['id'];
auth_reset_cached_user();
return array('status' => 'logged_in', 'username' => $user['username']);
}
function auth_logout() {
if (!auth_start_session()) {
return FALSE;
}
unset(
$_SESSION['auth_user_id'],
$_SESSION['home_csrf_token'],
$_SESSION['manage_csrf_token'],
$_SESSION['manage_notice']);
if (!session_regenerate_id(TRUE)) {
return FALSE;
}
auth_reset_cached_user();
return TRUE;
}
function auth_normalize_token_name($value) {
if (!is_string($value)) {
return FALSE;
}
$name = trim($value);
if (!preg_match('~^.{1,80}$~usD', $name) || preg_match('~[\x00-\x1F\x7F]~', $name)) {
return FALSE;
}
return $name;
}
function auth_create_access_token($user_id, $name_value) {
$name = auth_normalize_token_name($name_value);
if ($name === FALSE) {
return array('status' => 'invalid_token_name');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$token = 'pgs_'.bin2hex(random_bytes(32));
$statement = $database->prepare(
'INSERT INTO pgit_access_tokens (user_id, name, token_hash) VALUES (?, ?, ?)');
$statement->execute(array($user_id, $name, hash('sha256', $token)));
return array('status' => 'token_created', 'name' => $name, 'token' => $token);
} catch (Exception $exception) {
error_log('Access token creation failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_list_access_tokens($user_id) {
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->prepare(
'SELECT id, name, created_at, last_used_at, expires_at '
.'FROM pgit_access_tokens WHERE user_id = ? AND revoked_at IS NULL '
.'ORDER BY created_at DESC, id DESC');
$statement->execute(array($user_id));
return $statement->fetchAll();
} catch (PDOException $exception) {
error_log('Access token listing failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_revoke_access_token($user_id, $token_id) {
if (!is_string($token_id) || !preg_match('~^[1-9][0-9]*$~D', $token_id)) {
return array('status' => 'invalid_token');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'UPDATE pgit_access_tokens SET revoked_at = CURRENT_TIMESTAMP '
.'WHERE id = ? AND user_id = ? AND revoked_at IS NULL');
$statement->execute(array($token_id, $user_id));
return array('status' => $statement->rowCount() === 1 ? 'token_revoked' : 'invalid_token');
} catch (PDOException $exception) {
error_log('Access token revocation failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_normalize_record_id($value) {
if (is_int($value)) {
return $value > 0 ? $value : FALSE;
}
if (!is_string($value) || !preg_match('~^[1-9][0-9]*$~D', $value)) {
return FALSE;
}
$id = (int) $value;
return $id > 0 ? $id : FALSE;
}
function auth_list_users() {
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->query(
'SELECT pgit_users.id, pgit_users.username, pgit_users.is_active, '
.'pgit_users.created_at, pgit_users.updated_at, '
.'(SELECT COUNT(*) FROM pgit_access_tokens '
.'WHERE pgit_access_tokens.user_id = pgit_users.id '
.'AND pgit_access_tokens.revoked_at IS NULL) AS active_token_count, '
.'(SELECT COUNT(*) FROM pgit_repositories '
.'WHERE pgit_repositories.owner_user_id = pgit_users.id) AS repository_count '
.'FROM pgit_users ORDER BY pgit_users.username');
return $statement->fetchAll();
} catch (PDOException $exception) {
error_log('User administration listing failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_set_user_active($user_id_value, $active) {
$user_id = auth_normalize_record_id($user_id_value);
if ($user_id === FALSE || !is_bool($active)) {
return array('status' => 'invalid_user');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
if (!$active) {
$statement = $database->prepare(
'SELECT username FROM pgit_users WHERE id = ? LIMIT 1');
$statement->execute(array($user_id));
$user = $statement->fetch();
if ($user === FALSE) {
return array('status' => 'invalid_user');
}
if (auth_user_is_administrator($user)) {
return array('status' => 'administrator_protected');
}
}
$statement = $database->prepare(
'UPDATE pgit_users SET is_active = ? WHERE id = ?');
$statement->execute(array($active ? 1 : 0, $user_id));
if ($statement->rowCount() === 1) {
return array('status' => 'user_updated');
}
$statement = $database->prepare('SELECT id FROM pgit_users WHERE id = ? LIMIT 1');
$statement->execute(array($user_id));
return array('status' => $statement->fetch() === FALSE
? 'invalid_user' : 'user_updated');
} catch (PDOException $exception) {
error_log('User status update failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_revoke_user_access_tokens($user_id_value) {
$user_id = auth_normalize_record_id($user_id_value);
if ($user_id === FALSE) {
return array('status' => 'invalid_user');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare('SELECT id FROM pgit_users WHERE id = ? LIMIT 1');
$statement->execute(array($user_id));
if ($statement->fetch() === FALSE) {
return array('status' => 'invalid_user');
}
$statement = $database->prepare(
'UPDATE pgit_access_tokens SET revoked_at = CURRENT_TIMESTAMP '
.'WHERE user_id = ? AND revoked_at IS NULL');
$statement->execute(array($user_id));
return array('status' => 'tokens_revoked', 'count' => $statement->rowCount());
} catch (PDOException $exception) {
error_log('User access token revocation failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_delete_user($user_id_value) {
$user_id = auth_normalize_record_id($user_id_value);
if ($user_id === FALSE) {
return array('status' => 'invalid_user');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'SELECT username FROM pgit_users WHERE id = ? LIMIT 1');
$statement->execute(array($user_id));
$user = $statement->fetch();
if ($user === FALSE) {
return array('status' => 'invalid_user');
}
if (auth_user_is_administrator($user)) {
return array('status' => 'administrator_protected');
}
$statement = $database->prepare('DELETE FROM pgit_users WHERE id = ?');
$statement->execute(array($user_id));
return array('status' => $statement->rowCount() === 1
? 'user_deleted' : 'invalid_user');
} catch (PDOException $exception) {
if ((string) $exception->getCode() === '23000') {
return array('status' => 'user_owns_repositories');
}
error_log('User deletion failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_list_repository_metadata() {
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->query(
'SELECT pgit_repositories.id, pgit_repositories.repository_name, '
.'pgit_repositories.owner_user_id, pgit_repositories.is_private, '
.'pgit_repositories.is_ready, pgit_repositories.created_at, '
.'pgit_repositories.updated_at, pgit_users.username AS owner '
.'FROM pgit_repositories JOIN pgit_users '
.'ON pgit_users.id = pgit_repositories.owner_user_id '
.'ORDER BY pgit_repositories.repository_name');
return $statement->fetchAll();
} catch (PDOException $exception) {
error_log('Repository administration listing failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_find_repository_metadata($name) {
$database = auth_database();
if ($database === FALSE) {
return FALSE;
}
try {
$statement = $database->prepare(
'SELECT pgit_repositories.id, pgit_repositories.repository_name, '
.'pgit_repositories.owner_user_id, 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 '
.'WHERE pgit_repositories.repository_name = ? LIMIT 1');
$statement->execute(array($name));
$repository = $statement->fetch();
return $repository === FALSE ? NULL : $repository;
} catch (PDOException $exception) {
error_log('Repository metadata lookup failed: '.$exception->getMessage());
return FALSE;
}
}
function auth_update_repository_metadata(
$repository_id_value,
$owner_user_id_value,
$private) {
$repository_id = auth_normalize_record_id($repository_id_value);
$owner_user_id = auth_normalize_record_id($owner_user_id_value);
if ($repository_id === FALSE || $owner_user_id === FALSE || !is_bool($private)) {
return array('status' => 'invalid_repository');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$statement = $database->prepare(
'SELECT owner_user_id FROM pgit_repositories WHERE id = ? LIMIT 1');
$statement->execute(array($repository_id));
$repository = $statement->fetch();
if ($repository === FALSE) {
return array('status' => 'invalid_repository');
}
if ((int) $repository['owner_user_id'] !== $owner_user_id) {
$statement = $database->prepare(
'SELECT id FROM pgit_users WHERE id = ? AND is_active = 1 LIMIT 1');
$statement->execute(array($owner_user_id));
}
if ((int) $repository['owner_user_id'] !== $owner_user_id
&& $statement->fetch() === FALSE) {
return array('status' => 'invalid_owner');
}
$statement = $database->prepare(
'UPDATE pgit_repositories SET owner_user_id = ?, is_private = ? WHERE id = ?');
$statement->execute(array($owner_user_id, $private ? 1 : 0, $repository_id));
if ($statement->rowCount() === 1) {
return array('status' => 'repository_updated');
}
$statement = $database->prepare(
'SELECT id FROM pgit_repositories WHERE id = ? LIMIT 1');
$statement->execute(array($repository_id));
return array('status' => $statement->fetch() === FALSE
? 'invalid_repository' : 'repository_updated');
} catch (PDOException $exception) {
error_log('Repository metadata update failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
function auth_delete_repository_metadata($repository_id_value, $name, $owner_user_id_value=NULL) {
$repository_id = auth_normalize_record_id($repository_id_value);
$owner_user_id = $owner_user_id_value === NULL
? NULL : auth_normalize_record_id($owner_user_id_value);
if ($repository_id === FALSE || !is_string($name)
|| ($owner_user_id_value !== NULL && $owner_user_id === FALSE)) {
return array('status' => 'invalid_repository');
}
$database = auth_database();
if ($database === FALSE) {
return array('status' => 'database_unavailable');
}
try {
$query = 'DELETE FROM pgit_repositories WHERE id = ? AND repository_name = ?';
$values = array($repository_id, $name);
if ($owner_user_id !== NULL) {
$query .= ' AND owner_user_id = ?';
$values[] = $owner_user_id;
}
$statement = $database->prepare($query);
$statement->execute($values);
return array('status' => $statement->rowCount() === 1
? 'metadata_deleted' : 'metadata_changed');
} catch (PDOException $exception) {
error_log('Repository metadata deletion failed: '.$exception->getMessage());
return array('status' => 'database_unavailable');
}
}
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) {
return $auth_cached_user;
}
$auth_cached_user_resolved = TRUE;
$user = auth_session_user();
$auth_cached_user = $user === NULL ? NULL : $user['username'];
return $auth_cached_user;
}
function auth_get_access_token_user() {
$user = auth_token_user();
return $user === NULL ? NULL : $user['username'];
}
function auth_reset_cached_user() {
global $auth_cached_user_resolved, $auth_cached_user;
$auth_cached_user_resolved = FALSE;
$auth_cached_user = NULL;
}
+58 -9
View File
@@ -359,25 +359,45 @@ function git_object_store_apply_delta($base, $delta, $max_result_bytes=0) {
function git_object_store_links($object) {
$links = array();
if ($object['type'] === 'commit') {
foreach (preg_split('~\n~', $object['body']) as $line) {
if ($line === '') {
break;
}
if (preg_match('~^(?:tree|parent) ([0-9a-f]{40})$~D', $line, $matches)) {
$headers = strstr($object['body'], "\n\n", TRUE);
if ($headers === FALSE) {
return FALSE;
}
$lines = explode("\n", $headers);
if (empty($lines) || !preg_match('~^tree ([0-9a-f]{40})$~D', $lines[0], $matches)) {
return FALSE;
}
$links[] = $matches[1];
foreach (array_slice($lines, 1) as $line) {
if (strpos($line, 'parent ') === 0) {
if (!preg_match('~^parent ([0-9a-f]{40})$~D', $line, $matches)) {
return FALSE;
}
$links[] = $matches[1];
}
}
} else if ($object['type'] === 'tag') {
if (preg_match('~^object ([0-9a-f]{40})$~m', $object['body'], $matches)) {
$links[] = $matches[1];
$headers = strstr($object['body'], "\n\n", TRUE);
if ($headers === FALSE) {
return FALSE;
}
$lines = explode("\n", $headers);
if (count($lines) < 3
|| !preg_match('~^object ([0-9a-f]{40})$~D', $lines[0], $object_match)
|| !preg_match('~^type (commit|tree|blob|tag)$~D', $lines[1])
|| !preg_match('~^tag .+$~D', $lines[2])) {
return FALSE;
}
$links[] = $object_match[1];
} else if ($object['type'] === 'tree') {
$position = 0;
$length = strlen($object['body']);
while ($position < $length) {
$separator = strpos($object['body'], "\0", $position);
if ($separator === FALSE || $separator + 21 > $length
|| !preg_match('~^[0-7]+ [^/]+$~D', substr($object['body'], $position, $separator - $position))) {
|| !preg_match(
'~^(?:40000|100644|100755|120000|160000) [^/\x00]+$~D',
substr($object['body'], $position, $separator - $position))) {
return FALSE;
}
$links[] = bin2hex(substr($object['body'], $separator + 1, 20));
@@ -388,6 +408,35 @@ function git_object_store_links($object) {
return $links;
}
function git_object_store_validate_links(&$store, $object, $links) {
if ($object['type'] === 'commit') {
foreach ($links as $index => $oid) {
$linked = git_object_store_read($store, $oid);
if ($linked === FALSE
|| ($index === 0 && $linked['type'] !== 'tree')
|| ($index > 0 && $linked['type'] !== 'commit')) {
return FALSE;
}
}
} else if ($object['type'] === 'tag') {
if (!preg_match('~^type (commit|tree|blob|tag)$~m', $object['body'], $matches)) {
return FALSE;
}
$linked = git_object_store_read($store, $links[0]);
if ($linked === FALSE || $linked['type'] !== $matches[1]) {
return FALSE;
}
} else if ($object['type'] === 'tree') {
foreach ($links as $oid) {
if (git_object_store_read($store, $oid) === FALSE) {
return FALSE;
}
}
}
return TRUE;
}
function git_object_store_collect(&$store, $roots) {
$objects = array();
$pending = array_values($roots);
@@ -401,7 +450,7 @@ function git_object_store_collect(&$store, $roots) {
return FALSE;
}
$links = git_object_store_links($object);
if ($links === FALSE) {
if ($links === FALSE || !git_object_store_validate_links($store, $object, $links)) {
return FALSE;
}
$objects[$oid] = $object;
+34 -11
View File
@@ -438,6 +438,9 @@ function git_receive_pack_commit_updates($git_path, $locks) {
foreach ($locks as $lock) {
$updates[] = $lock['update'];
}
$packed_path = get_safe_file_path($git_path, '/packed-refs');
$packed_contents = $packed_path === FALSE ? NULL : @file_get_contents($packed_path);
$committed = array();
if (!git_receive_pack_remove_packed_refs($git_path, $updates)) {
return FALSE;
}
@@ -447,17 +450,37 @@ function git_receive_pack_commit_updates($git_path, $locks) {
$lock['file'] = NULL;
if ($lock['update']['new'] === str_repeat('0', 40)) {
if (is_file($lock['path']) && !@unlink($lock['path'])) {
git_receive_pack_release_locks($locks);
return FALSE;
break;
}
@unlink($lock['lock_path']);
} else if (!@rename($lock['lock_path'], $lock['path'])) {
git_receive_pack_release_locks($locks);
return FALSE;
break;
}
$committed[] = $lock['update'];
}
unset($lock);
return TRUE;
if (count($committed) === count($locks)) {
return TRUE;
}
git_receive_pack_release_locks($locks);
foreach ($committed as $update) {
$path = $git_path.'/'.$update['ref'];
if ($update['old'] === str_repeat('0', 40)) {
@unlink($path);
continue;
}
if (!is_dir(dirname($path))) {
@mkdir(dirname($path), 0777, TRUE);
}
@file_put_contents($path, $update['old']."\n", LOCK_EX);
}
if ($packed_contents !== NULL) {
@file_put_contents($git_path.'/packed-refs', $packed_contents, LOCK_EX);
} else {
@unlink($git_path.'/packed-refs');
}
return FALSE;
}
function git_receive_pack_status($capabilities, $updates, $unpack_ok, $message) {
@@ -526,7 +549,7 @@ function git_receive_pack_rpc_native($repository, $input) {
$objects = array();
if ($store === FALSE) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'unsupported repository format');
return TRUE;
return FALSE;
}
if ($needs_pack) {
$pack = stream_get_contents($input);
@@ -537,7 +560,7 @@ function git_receive_pack_rpc_native($repository, $input) {
(int) $repository['options']['max_pack_objects']);
if ($objects === FALSE) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'invalid pack');
return TRUE;
return FALSE;
}
}
@@ -550,27 +573,27 @@ function git_receive_pack_rpc_native($repository, $input) {
|| (strpos($update['ref'], 'refs/heads/') === 0
&& $store['objects'][$update['new']]['type'] !== 'commit')) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'invalid object graph');
return TRUE;
return FALSE;
}
if (strpos($update['ref'], 'refs/heads/') === 0
&& $update['old'] !== $zero
&& !$repository['options']['allow_non_fast_forward']
&& !git_receive_pack_is_ancestor($store, $update['old'], $update['new'])) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'non-fast-forward');
return TRUE;
return FALSE;
}
}
$locks = git_receive_pack_lock_updates($repository['path'], $commands['updates']);
if ($locks === FALSE) {
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'stale or locked ref');
return TRUE;
return FALSE;
}
if (!git_receive_pack_write_objects($repository['path'], $objects)
|| !git_receive_pack_commit_updates($repository['path'], $locks)) {
git_receive_pack_release_locks($locks);
git_receive_pack_status($commands['capabilities'], $commands['updates'], FALSE, 'repository update failed');
return TRUE;
return FALSE;
}
git_receive_pack_status($commands['capabilities'], $commands['updates'], TRUE, '');
+85 -314
View File
@@ -1,32 +1,5 @@
<?php
function git_service_executable_available($application) {
if (!isset($application['git_executable'])
|| !is_string($application['git_executable'])
|| $application['git_executable'] === '') {
return FALSE;
}
$executable = $application['git_executable'];
if (strpos($executable, DIRECTORY_SEPARATOR) !== FALSE) {
return is_file($executable) && is_executable($executable);
}
$path = getenv('PATH');
if ($path === FALSE) {
return FALSE;
}
foreach (explode(PATH_SEPARATOR, $path) as $directory) {
$candidate = rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$executable;
if (is_file($candidate) && is_executable($candidate)) {
return TRUE;
}
}
return FALSE;
}
function git_service_native_available() {
return function_exists('inflate_init')
&& function_exists('inflate_add')
@@ -40,7 +13,39 @@ function git_service_write_repository_file($path, $contents) {
return @file_put_contents($path, $contents, LOCK_EX) === strlen($contents);
}
function git_service_init_bare_repository_with_php($path) {
/* A newly-created repository starts with an unborn main branch. If the first
push creates a differently named branch, make that branch the default. */
function git_service_update_unborn_head($git_path, $updated_refs) {
$head = resolve_ref($git_path, 'HEAD');
if ($head[1] !== NULL) {
return TRUE;
}
$head_path = get_safe_file_path($git_path, '/HEAD');
$contents = $head_path === FALSE ? FALSE : @file_get_contents($head_path);
if ($contents === FALSE
|| !preg_match('~^ref:\s*(refs/heads/[^\r\n]+)\s*$~', $contents)) {
return TRUE;
}
$branch = NULL;
foreach ($updated_refs as $ref) {
$resolved = resolve_ref($git_path, $ref);
if (strpos($ref, 'refs/heads/') === 0 && $resolved[1] !== NULL) {
$branch = $ref;
break;
}
}
if ($branch === NULL) {
return TRUE;
}
$new_contents = "ref: ".$branch."\n";
return @file_put_contents($git_path.'/HEAD', $new_contents, LOCK_EX)
=== strlen($new_contents);
}
function git_service_init_bare_repository($path) {
$directories = array(
'',
'/branches',
@@ -73,59 +78,11 @@ function git_service_init_bare_repository_with_php($path) {
return TRUE;
}
function git_service_init_bare_repository($application, $path) {
if (!function_exists('proc_open') || !git_service_executable_available($application)) {
return git_service_init_bare_repository_with_php($path);
}
$error = @tmpfile();
if ($error === FALSE) {
return FALSE;
}
$descriptor_spec = array(
0 => array('file', '/dev/null', 'r'),
1 => array('file', '/dev/null', 'w'),
2 => $error);
$pipes = array();
$command = array(
$application['git_executable'],
'init',
'--bare',
'--quiet',
$path);
$request = array('git_protocol' => NULL, 'user' => get_authenticated_user());
$process = @proc_open(
$command,
$descriptor_spec,
$pipes,
dirname($path),
git_service_environment($request));
if (!is_resource($process)) {
fclose($error);
return FALSE;
}
$exit_code = proc_close($process);
if ($exit_code !== 0) {
rewind($error);
$message = stream_get_contents($error);
error_log(
'Git repository initialization failed for '.$path.' with exit code '
.$exit_code.': '.trim($message));
}
fclose($error);
if ($exit_code !== 0) {
return FALSE;
}
$head = "ref: refs/heads/main\n";
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(
$configuration,
$value,
$owner_user_id,
$private) {
$name = normalize_managed_repository_name($value);
if ($name === FALSE) {
return array('status' => 'invalid_name');
@@ -137,10 +94,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 +109,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);
}
@@ -166,7 +133,7 @@ function git_service_create_managed_repository($application, $configuration, $va
}
$temporary_path = $root.DIRECTORY_SEPARATOR.'.create-'.$suffix.'.tmp';
if (!git_service_init_bare_repository($application, $temporary_path)
if (!git_service_init_bare_repository($temporary_path)
|| !managed_repository_is_bare($temporary_path)) {
return array('status' => 'create_failed', 'name' => $name);
}
@@ -175,12 +142,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);
@@ -190,268 +175,54 @@ function git_service_create_managed_repository($application, $configuration, $va
}
}
function git_service_is_protocol_v2($request) {
if ($request['git_protocol'] === NULL) {
return FALSE;
}
return preg_match('~(?:^|:)version=2(?:$|:)~', $request['git_protocol']) === 1;
}
function git_service_environment($request) {
$environment = getenv();
if (!is_array($environment)) {
$environment = array();
}
unset($environment['GIT_DIR']);
unset($environment['GIT_WORK_TREE']);
unset($environment['GIT_PROTOCOL']);
if ($request['git_protocol'] !== NULL
&& strlen($request['git_protocol']) <= 1024
&& strpos($request['git_protocol'], "\0") === FALSE
&& strpos($request['git_protocol'], "\n") === FALSE
&& strpos($request['git_protocol'], "\r") === FALSE) {
$environment['GIT_PROTOCOL'] = $request['git_protocol'];
}
if ($request['user'] !== NULL) {
$environment['REMOTE_USER'] = $request['user'];
}
if (isset($_SERVER['REMOTE_ADDR'])) {
$environment['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'];
}
if (isset($_SERVER['HTTP_USER_AGENT'])) {
$environment['GIT_HTTP_USER_AGENT'] = $_SERVER['HTTP_USER_AGENT'];
}
return $environment;
}
function git_service_command($application, $service, $repository, $advertise) {
if ($service === 'git-upload-pack') {
$subcommand = 'upload-pack';
} else if ($service === 'git-receive-pack') {
$subcommand = 'receive-pack';
} else {
return FALSE;
}
$command = array(
$application['git_executable'],
$subcommand,
'--stateless-rpc');
if ($subcommand === 'upload-pack') {
$command[] = '--strict';
}
if ($advertise) {
$command[] = '--advertise-refs';
}
$command[] = $repository['path'];
return $command;
}
function git_service_pump_process($pipes, $input) {
stream_set_blocking($pipes[0], FALSE);
stream_set_blocking($pipes[1], FALSE);
$input_done = $input === NULL;
$input_buffer = '';
$stdin_open = TRUE;
$stdout_open = TRUE;
while ($stdin_open || $stdout_open) {
if (!$input_done && strlen($input_buffer) < 65536) {
$chunk = fread($input, 65536);
if ($chunk === FALSE) {
return FALSE;
}
if ($chunk !== '') {
$input_buffer .= $chunk;
}
if (feof($input)) {
$input_done = TRUE;
}
}
if ($stdin_open && $input_done && $input_buffer === '') {
fclose($pipes[0]);
$stdin_open = FALSE;
}
$read = $stdout_open ? array($pipes[1]) : array();
$write = $stdin_open && $input_buffer !== '' ? array($pipes[0]) : array();
$except = NULL;
if (empty($read) && empty($write)) {
continue;
}
$ready = @stream_select($read, $write, $except, 30);
if ($ready === FALSE) {
return FALSE;
}
if (!empty($write)) {
$written = fwrite($pipes[0], $input_buffer);
if ($written === FALSE) {
return FALSE;
}
if ($written > 0) {
$input_buffer = (string) substr($input_buffer, $written);
}
}
if (!empty($read)) {
$output = fread($pipes[1], 65536);
if ($output === FALSE) {
return FALSE;
}
if ($output !== '') {
echo $output;
}
if (feof($pipes[1])) {
fclose($pipes[1]);
$stdout_open = FALSE;
}
}
}
return TRUE;
}
function git_service_run(
$application,
$repository,
$request,
$service,
$advertise,
$input=NULL,
$prefix='') {
if (!function_exists('proc_open') || !git_service_executable_available($application)) {
send_error(503, 'Service Unavailable', 'Git Smart HTTP is not available.');
}
$command = git_service_command($application, $service, $repository, $advertise);
if ($command === FALSE) {
send_error(403, 'Forbidden', 'Unsupported Git service.');
}
$error = @tmpfile();
if ($error === FALSE) {
send_error(500, 'Internal Server Error', 'Unable to create a Git error stream.');
}
$descriptor_spec = array(
0 => array('pipe', 'r'),
1 => array('pipe', 'w'),
2 => $error);
$pipes = array();
$process = @proc_open(
$command,
$descriptor_spec,
$pipes,
dirname($repository['path']),
git_service_environment($request));
if (!is_resource($process)) {
fclose($error);
send_error(503, 'Service Unavailable', 'Unable to start the Git service.');
}
if ($prefix !== '') {
echo $prefix;
}
$stream_succeeded = git_service_pump_process($pipes, $input);
foreach ($pipes as $pipe) {
if (is_resource($pipe)) {
fclose($pipe);
}
}
$exit_code = proc_close($process);
if (!$stream_succeeded || $exit_code !== 0) {
rewind($error);
$message = stream_get_contents($error);
error_log(
'Git service '.$service.' failed for '.$repository['url'].' with exit code '
.$exit_code.': '.trim($message));
}
fclose($error);
return $stream_succeeded && $exit_code === 0 ? 0 : $exit_code;
}
function git_service_advertise($application, $repository, $request, $service) {
if ((!function_exists('proc_open') || !git_service_executable_available($application))
&& !git_service_native_available()) {
if (!git_service_native_available()) {
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');
if ((!function_exists('proc_open') || !git_service_executable_available($application))
&& $service === 'git-upload-pack') {
echo format_packet_line('# service='.$service."\n").'0000';
echo format_packet_line('# service='.$service."\n").'0000';
if ($service === 'git-upload-pack') {
if (!git_upload_pack_advertise_native($repository)) {
error_log('Native Git advertisement failed for '.$repository['url'].'.');
}
return;
}
if ((!function_exists('proc_open') || !git_service_executable_available($application))
&& $service === 'git-receive-pack') {
echo format_packet_line('# service='.$service."\n").'0000';
if ($service === 'git-receive-pack') {
if (!git_receive_pack_advertise_native($repository)) {
error_log('Native Git receive advertisement failed for '.$repository['url'].'.');
}
return;
}
$prefix = '';
if (!git_service_is_protocol_v2($request)) {
$prefix = format_packet_line('# service='.$service."\n").'0000';
}
git_service_run($application, $repository, $request, $service, TRUE, NULL, $prefix);
send_error(403, 'Forbidden', 'Unsupported Git service.');
}
function git_service_rpc($application, $repository, $request, $service, $input) {
if ((!function_exists('proc_open') || !git_service_executable_available($application))
&& !git_service_native_available()) {
if (!git_service_native_available()) {
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');
if ((!function_exists('proc_open') || !git_service_executable_available($application))
&& $service === 'git-upload-pack') {
if ($service === 'git-upload-pack') {
if (!git_upload_pack_rpc_native($repository, $input)) {
error_log('Native Git upload-pack failed for '.$repository['url'].'.');
}
return;
}
if ((!function_exists('proc_open') || !git_service_executable_available($application))
&& $service === 'git-receive-pack') {
if ($service === 'git-receive-pack') {
if (!git_receive_pack_rpc_native($repository, $input)) {
error_log('Native Git receive-pack failed for '.$repository['url'].'.');
return 1;
}
return;
return 0;
}
git_service_run($application, $repository, $request, $service, FALSE, $input);
send_error(403, 'Forbidden', 'Unsupported Git service.');
}
+45 -4
View File
@@ -2,8 +2,12 @@
function git_upload_pack_capabilities($git_path) {
$capabilities = array(
'multi_ack_detailed',
'no-done',
'side-band-64k',
'ofs-delta',
'thin-pack',
'include-tag',
'agent=php-git-server/1');
$head = resolve_ref($git_path, 'HEAD');
@@ -47,11 +51,23 @@ function git_upload_pack_advertise_native($repository) {
}
function git_upload_pack_parse_request($input) {
$request = array('wants' => array(), 'haves' => array(), 'capabilities' => array());
$request = array(
'wants' => array(),
'haves' => array(),
'capabilities' => array(),
'done' => FALSE);
$first_want = TRUE;
$want_phase_done = FALSE;
$complete = FALSE;
while (($packet = git_protocol_read_packet($input)) !== FALSE) {
if ($packet['type'] === 'flush') {
if ($want_phase_done && !empty($request['haves'])
&& isset($request['capabilities']['no-done'])) {
$complete = TRUE;
break;
}
$want_phase_done = TRUE;
continue;
}
if ($packet['type'] !== 'data') {
@@ -70,13 +86,15 @@ function git_upload_pack_parse_request($input) {
} else if (preg_match('~^have ([0-9a-f]{40})$~D', $line, $matches)) {
$request['haves'][] = $matches[1];
} else if ($line === 'done') {
$request['done'] = TRUE;
$complete = TRUE;
break;
} else {
return FALSE;
}
}
if ($packet === FALSE || empty($request['wants'])) {
if (!$complete || empty($request['wants'])) {
return FALSE;
}
return $request;
@@ -121,6 +139,21 @@ function git_upload_pack_rpc_native($repository, $input) {
if ($objects === FALSE) {
return FALSE;
}
if (isset($request['capabilities']['include-tag'])) {
foreach (get_repository_refs($repository['path']) as $ref) {
if (strpos($ref[0], 'refs/tags/') !== 0) {
continue;
}
$tag = git_object_store_read($store, $ref[1]);
if ($tag === FALSE || $tag['type'] !== 'tag') {
continue;
}
$links = git_object_store_links($tag);
if ($links !== FALSE && isset($objects[$links[0]])) {
$objects[$tag['oid']] = $tag;
}
}
}
$last_common = NULL;
foreach ($request['haves'] as $have) {
@@ -139,8 +172,16 @@ function git_upload_pack_rpc_native($repository, $input) {
return FALSE;
}
echo git_protocol_format_packet(
$last_common === NULL ? "NAK\n" : 'ACK '.$last_common."\n");
if ($last_common === NULL) {
echo git_protocol_format_packet("NAK\n");
} else if (isset($request['capabilities']['multi_ack_detailed']) && !$request['done']) {
echo git_protocol_format_packet('ACK '.$last_common." common\n");
echo git_protocol_format_packet('ACK '.$last_common." ready\n");
echo git_protocol_format_packet("NAK\n");
echo git_protocol_format_packet('ACK '.$last_common."\n");
} else {
echo git_protocol_format_packet('ACK '.$last_common."\n");
}
if (isset($request['capabilities']['side-band-64k'])) {
git_upload_pack_send_sideband(1, $pack);
echo '0000';
+14 -4
View File
@@ -30,6 +30,10 @@ function get_request_header($name) {
return $_SERVER[$key];
}
if (!strcasecmp($name, 'Authorization') && isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) {
return $_SERVER['REDIRECT_HTTP_AUTHORIZATION'];
}
if (!strcasecmp($name, 'Content-Type') && isset($_SERVER['CONTENT_TYPE'])) {
return $_SERVER['CONTENT_TYPE'];
}
@@ -51,11 +55,17 @@ function request_content_type_is($content_type, $expected) {
}
function get_authenticated_user() {
if (isset($_SERVER['REMOTE_USER']) && $_SERVER['REMOTE_USER'] !== '') {
return $_SERVER['REMOTE_USER'];
}
return auth_get_authenticated_user();
}
return NULL;
function get_access_token_user() {
return auth_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);
}
function format_packet_line($payload) {
+798
View File
@@ -0,0 +1,798 @@
<?php
/* Bilingual user interface strings. English is the fallback: a key missing from
the active catalog is looked up in English, and an unknown key is returned as
is so a missing translation stays visible instead of rendering an empty page.
Only browser output is translated. Plain-text HTTP and Git protocol responses
stay in English because they are read by tooling rather than by people. */
function i18n_supported_languages() {
return array('en', 'zh');
}
function i18n_fallback_language() {
return 'en';
}
function i18n_cookie_name() {
return 'PHPGITSERVERLANG';
}
function i18n_language_is_supported($value) {
return is_string($value) && in_array($value, i18n_supported_languages(), TRUE);
}
/* Accept-Language is only inspected for a language tag, never a region, so
zh-Hans, zh-TW and zh all select the Chinese catalog. */
function i18n_detect_language() {
$header = isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])
? (string) $_SERVER['HTTP_ACCEPT_LANGUAGE'] : '';
if ($header === '' || strlen($header) > 512) {
return i18n_fallback_language();
}
$best = i18n_fallback_language();
$best_quality = -1.0;
foreach (explode(',', $header) as $entry) {
$parts = explode(';', $entry);
$tag = strtolower(trim($parts[0]));
if ($tag === '') {
continue;
}
$quality = 1.0;
for ($index = 1; $index < count($parts); $index += 1) {
$parameter = trim($parts[$index]);
if (strncasecmp($parameter, 'q=', 2) === 0) {
$quality = (float) substr($parameter, 2);
}
}
$primary = explode('-', $tag);
$language = $primary[0] === '*' ? i18n_fallback_language() : $primary[0];
if (!i18n_language_is_supported($language)) {
continue;
}
if ($quality > $best_quality) {
$best = $language;
$best_quality = $quality;
}
}
return $best_quality < 0 ? i18n_fallback_language() : $best;
}
/* Resolution order: an explicit ?lang= request, a previously remembered choice,
the configured language, then Accept-Language. */
function i18n_configure($configured=NULL, $url_base='') {
$requested = isset($_GET['lang']) && is_string($_GET['lang']) ? $_GET['lang'] : '';
if (i18n_language_is_supported($requested)) {
i18n_remember_language($requested, $url_base);
return i18n_language($requested);
}
$remembered = isset($_COOKIE[i18n_cookie_name()])
&& is_string($_COOKIE[i18n_cookie_name()]) ? $_COOKIE[i18n_cookie_name()] : '';
if (i18n_language_is_supported($remembered)) {
return i18n_language($remembered);
}
if (i18n_language_is_supported($configured)) {
return i18n_language($configured);
}
return i18n_language(i18n_detect_language());
}
function i18n_remember_language($language, $url_base) {
if (headers_sent()) {
return FALSE;
}
$base = rtrim((string) $url_base, '/');
$secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== ''
&& strcasecmp((string) $_SERVER['HTTPS'], 'off') !== 0;
return setcookie(i18n_cookie_name(), $language, array(
'expires' => time() + 31536000,
'path' => $base === '' ? '/' : $base.'/',
'secure' => $secure,
'httponly' => FALSE,
'samesite' => 'Lax'));
}
function i18n_language($language=NULL) {
static $active = NULL;
if ($language !== NULL) {
$active = i18n_language_is_supported($language)
? $language : i18n_fallback_language();
}
return $active === NULL ? i18n_fallback_language() : $active;
}
function i18n_html_lang() {
return i18n_language() === 'zh' ? 'zh-CN' : 'en';
}
function t($key, $arguments=array()) {
$catalogs = i18n_catalogs();
$language = i18n_language();
if (isset($catalogs[$language][$key])) {
$value = $catalogs[$language][$key];
} else if (isset($catalogs[i18n_fallback_language()][$key])) {
$value = $catalogs[i18n_fallback_language()][$key];
} else {
return $key;
}
if (empty($arguments)) {
return $value;
}
$replacements = array();
foreach ($arguments as $name => $argument) {
$replacements['{'.$name.'}'] = (string) $argument;
}
return strtr($value, $replacements);
}
/* Renders the language links. The caller supplies the page URL because each
entry point has its own address, and none of them use a query string. */
function i18n_language_switcher($url) {
$labels = array('en' => 'English', 'zh' => '中文');
$active = i18n_language();
$links = array();
foreach (i18n_supported_languages() as $language) {
$label = htmlspecialchars($labels[$language], ENT_QUOTES, 'UTF-8');
if ($language === $active) {
$links[] = '<strong lang="'.($language === 'zh' ? 'zh-CN' : 'en').'">'
.$label.'</strong>';
continue;
}
$href = htmlspecialchars($url.'?lang='.$language, ENT_QUOTES, 'UTF-8');
$links[] = '<a lang="'.($language === 'zh' ? 'zh-CN' : 'en').'" href="'.$href.'">'
.$label.'</a>';
}
return '<span class="language-switcher">'.implode(' · ', $links).'</span>';
}
function i18n_translate_markup($markup) {
$markup = str_replace('__LANG__', htmlspecialchars(i18n_html_lang(), ENT_QUOTES, 'UTF-8'), $markup);
$markup = str_replace('__TITLE__', htmlspecialchars(t('install.title'), ENT_QUOTES, 'UTF-8'), $markup);
$markup = str_replace('__INSTALL_HEADING__', htmlspecialchars(t('install.heading'), ENT_QUOTES, 'UTF-8'), $markup);
if (i18n_language() === 'en') {
$catalog = i18n_catalog_zh();
$english = i18n_catalog_en();
foreach ($catalog as $key => $source) {
if (isset($english[$key]) && $source !== $english[$key]) {
$markup = str_replace($source, $english[$key], $markup);
}
}
}
return $markup;
}
function i18n_catalogs() {
static $catalogs = NULL;
if ($catalogs === NULL) {
$catalogs = array('en' => i18n_catalog_en(), 'zh' => i18n_catalog_zh());
}
return $catalogs;
}
function i18n_catalog_en() {
return array(
'app.name' => 'PHP Git Server',
'language.label' => 'Language',
/* Shared account results, used by the home page and the installer. */
'notice.registered' => 'The account was registered and signed in.',
'notice.logged_in' => 'Signed in.',
'notice.logged_out' => 'Signed out.',
'notice.registration_disabled' => 'New account registration is disabled.',
'notice.invalid_username' => 'A username must be 3 to 64 letters, digits, dots,'
.' hyphens or underscores.',
'notice.invalid_password' => 'A password must be 8 to 72 characters and no more'
.' than 72 bytes.',
'notice.password_mismatch' => 'The two passwords do not match.',
'notice.username_exists' => 'That username already exists or is unavailable.',
'notice.invalid_credentials' => 'The username or password is incorrect.',
'notice.invalid_token_name' => 'A token name is required and may be at most 80'
.' characters.',
'notice.token_created' => 'The access token was created. Copy it now; it cannot be'
.' shown again after you leave this page.',
'notice.token_revoked' => 'The access token was revoked.',
'notice.invalid_token' => 'That access token does not exist or was already revoked.',
'notice.session_unavailable' => 'A secure session cannot be established right now.',
'notice.auth_database_unavailable' => 'The account database is unavailable right now.',
'notice.repository_invalid_name' => 'The repository name is not valid.',
'notice.repository_exists' => 'Repository {name} already exists.',
'notice.repository_create_busy' => 'Another repository is being created. Try again'
.' shortly.',
'notice.repository_root_unavailable' => 'The repository directory is unavailable or'
.' not writable.',
'notice.repository_metadata_unsaved' => 'Repository ownership could not be saved.'
.' Try again shortly.',
'notice.repository_create_failed' => 'Repository creation failed. Check the server'
.' log.',
'notice.repository_created' => 'Repository {name} was created.',
'notice.repository_deleted' => 'Repository {name} was deleted.',
'notice.repository_record_deleted' => 'The record for {name} was deleted. A missing'
.' or non-bare path was left unchanged.',
'notice.repository_delete_forbidden' => 'Only the repository owner can delete it.',
'notice.repository_configured_home' => 'That path is configured statically in'
.' config.php and cannot be deleted from the web page.',
'notice.repository_not_found' => 'The managed repository does not exist or is not'
.' finished yet.',
'notice.repository_busy' => 'The repository is busy with another operation. Try'
.' again shortly.',
'notice.repository_metadata_unavailable' => 'Repository ownership data is'
.' unavailable right now.',
'notice.repository_cleanup_failed' => 'The repository record was deleted but the'
.' leftover directory could not be cleaned up. Check the server log.',
'notice.repository_restore_failed' => 'Deletion failed and the repository directory'
.' could not be restored. Check the server log immediately.',
'notice.repository_delete_failed' => 'Repository deletion failed. Check the server'
.' log.',
/* Home page. */
'home.title' => 'PHP Git Server',
'home.lead' => 'The repositories below are published over HTTP with support for'
.' clone, fetch and pull, and push can be enabled per repository.',
'home.account_title' => 'Account and access tokens',
'home.form_unavailable' => 'A secure form cannot be initialized right now.',
'home.username' => 'Username',
'home.password' => 'Password',
'home.login' => 'Sign in',
'home.register_username' => 'New username',
'home.password_confirmation' => 'Confirm password',
'home.register' => 'Register',
'home.auth_hint' => 'Web sign-in uses your password. Git clone, pull and push use'
.' your username and an access token.',
'home.current_account' => 'Signed in as',
'home.manage' => 'Manage',
'home.logout' => 'Sign out',
'home.token_name' => 'New token name',
'home.token_name_placeholder' => 'work laptop',
'home.create_token' => 'Create token',
'home.token_list_unavailable' => 'The access token list cannot be read right now.',
'home.token_never_used' => 'never used',
'home.token_last_used' => 'last used {time}',
'home.token_created_at' => 'created {time}',
'home.revoke' => 'Revoke',
'home.create_repository' => 'Create repository',
'home.create_login_required' => 'Sign in to an application account to create a'
.' repository.',
'home.repository_name' => 'Repository name',
'home.repository_name_hint' => 'Letters, digits, dots, hyphens and underscores are'
.' allowed. The <code>.git</code> suffix is optional.',
'home.visibility' => 'Visibility',
'home.public' => 'Public',
'home.private' => 'Private',
'home.th_repository' => 'Repository',
'home.th_owner' => 'Owner',
'home.th_clone_url' => 'Clone URL',
'home.th_default_branch' => 'Default branch',
'home.th_branches' => 'Branches',
'home.th_tags' => 'Tags',
'home.th_access' => 'Access',
'home.th_actions' => 'Actions',
'home.badge_empty' => 'empty',
'home.badge_no_branch' => 'no branch',
'home.badge_unset' => 'unset',
'home.badge_owner_push' => 'owner can push',
'home.badge_read_only' => 'read only',
'home.confirm_delete' => 'Confirm deletion',
'home.delete' => 'Delete',
'home.badge_none' => 'none',
'home.public_repositories' => 'Public repositories',
'home.private_repositories' => 'Private repositories',
'home.no_public_repositories' => 'There are no public repositories yet.',
'home.no_private_repositories' => 'There are no private repositories yet.',
'home.empty_title' => 'No readable repository is configured.',
'home.empty_body' => 'Copy <code>config.php.sample</code> to <code>config.php</code>,'
.' set the repository paths in <code>$repos</code>, and make sure each'
.' repository directory exists with the <code>read</code> option set to'
.' <code>TRUE</code>.',
'home.usage_title' => 'Usage',
'home.usage_hint' => 'Push requires the <code>push</code> option on the repository.'
.' When authentication is enabled it also requires an account username and an'
.' access token.',
'home.footer' => 'See <code>usage.md</code> for installation, configuration and'
.' security details.',
/* Installer. */
'install.title' => 'Install - PHP Git Server',
'install.heading' => 'PHP Git Server installation',
'install.session_unavailable' => 'A secure session cannot be initialized, so the'
.' installation cannot continue.',
'install.lead' => 'No <code>config.php</code> was found in the project root. Prepare'
.' an empty database and an account for it, then fill in the fields below to'
.' import the schema, create the first administrator and generate the'
.' configuration file.',
'install.warning_http' => 'This is not an HTTPS connection. Passwords would be sent'
.' in clear text. Use HTTPS, or open the installer from the local machine only.',
'install.error_no_pdo_mysql' => 'The PHP <code>pdo_mysql</code> extension is not'
.' enabled, so the installation cannot continue. Install it and restart PHP.',
'install.error_not_writable' => 'The project directory is not writable, so'
.' <code>config.php</code> cannot be generated. Adjust the directory'
.' permissions and reload.',
'install.legend_application' => 'Application',
'install.label_url_base' => 'Base path',
'install.hint_url_base' => 'Leave empty when deploying at the domain root.',
'install.label_language' => 'Interface language',
'install.language_auto' => 'Follow the browser (English fallback)',
'install.language_en' => 'English',
'install.language_zh' => 'Chinese',
'install.label_registration_enabled' => 'Allow public registration of new accounts',
'install.label_session_cookie_secure' => 'HTTPS terminates at a trusted reverse'
.' proxy (set a Secure cookie)',
'install.legend_database' => 'Database',
'install.hint_database' => 'Create the database and its account yourself. The'
.' installer does not create them and does not need root privileges. The'
.' account needs SELECT, INSERT, UPDATE and DELETE on that database. If it can'
.' also create tables, the installer imports the schema automatically.',
'install.label_db_host' => 'Host',
'install.hint_db_host' => 'Usually <code>localhost</code> on shared hosting.',
'install.label_db_port' => 'Port',
'install.label_db_name' => 'Database name',
'install.hint_db_name' => 'Panel-generated names are usually prefixed, for example'
.' <code>cpuser_git</code>.',
'install.label_db_user' => 'Database account',
'install.label_db_password' => 'Database account password',
'install.hint_schema_import' => 'If the account cannot create tables, import'
.' <code>schema.mysql.sql</code> with the phpMyAdmin in your hosting panel'
.' first, then submit this page. Existing tables are skipped.',
'install.legend_administrator' => 'Administrator account',
'install.hint_administrator' => 'This account is written to'
.' <code>$auth[\'administrators\']</code> and can reach the management page as'
.' soon as the installation finishes.',
'install.label_admin_username' => 'Username',
'install.label_admin_password' => 'Password',
'install.label_admin_password_confirmation' => 'Confirm password',
'install.button_submit' => 'Start installation',
'install.error_url_base' => 'The base path must start with / and must not end with'
.' /. Leave it empty when deploying at the domain root.',
'install.error_db_host' => 'The database host name is not valid.',
'install.error_db_port' => 'The database port is not valid.',
'install.error_db_name' => 'A database name may contain only letters, digits,'
.' underscores and hyphens, up to 64 characters.',
'install.error_db_user' => 'A database username may contain only letters, digits,'
.' underscores and hyphens, up to 64 characters.',
'install.error_db_password' => 'The database password is required and may be at'
.' most 255 characters.',
'install.error_admin_username' => 'An administrator username must be 3 to 64'
.' letters, digits, dots, hyphens or underscores.',
'install.error_admin_password' => 'An administrator password must be 8 to 72'
.' characters and no more than 72 bytes.',
'install.error_admin_password_mismatch' => 'The two administrator passwords do not'
.' match.',
'install.error_pdo_mysql_missing' => 'The PHP pdo_mysql extension is not enabled.',
'install.error_schema_unreadable' => 'schema.mysql.sql could not be read.',
'install.error_schema_denied' => 'This database account cannot create tables.'
.' Import schema.mysql.sql from the project root using the phpMyAdmin or the'
.' database import feature of your hosting panel, then submit this page again.'
.' The installer detects the existing tables and skips the import.',
'install.error_delete_privilege' => 'This database account is missing the DELETE'
.' privilege, so deleting repositories and users would fail. Grant DELETE to'
.' the account in your hosting panel or database, then try again.',
'install.error_database' => 'The database operation failed: {message}',
'install.error_config_write' => 'config.php could not be written. Check that the'
.' project directory is writable and that the file does not already exist.',
'install.step_connected' => 'Connected with the database account.',
'install.step_schema_imported' => 'Imported {count} tables.',
'install.step_schema_present' => 'Existing pgit_ tables were found, so the import'
.' was skipped.',
'install.step_privileges_ok' => 'Confirmed the account has SELECT, INSERT, UPDATE'
.' and DELETE.',
'install.step_admin_created' => 'Administrator account {username} was created.',
'install.step_admin_promoted' => 'Account {username} already existed and was made an'
.' administrator.',
'install.step_config_written' => 'config.php was written with mode 0600.',
'install.complete' => 'Installation complete.',
'install.next_steps_title' => 'Do this now',
'install.next_steps_remove' => 'Delete the installer so it cannot run again if the'
.' configuration is ever removed. With a shell:',
'install.next_steps_shared_hosting' => 'On shared hosting, delete'
.' <code>install.php</code> with the file manager in your panel or over FTP.',
'install.next_steps_permissions' => 'Confirm that <code>config.php</code> is'
.' readable only by the application process, and check the permissions on the'
.' <code>repos</code> directory. See <code>usage.md</code> for details.',
'install.link_home' => 'Go to the home page and sign in',
/* Management page. */
'manage.title' => 'Server management - PHP Git Server',
'manage.heading' => 'PHP Git Server / management',
'manage.administrator_label' => 'Administrator',
'manage.back_to_home' => 'Back to the repository home page',
'manage.create_user_title' => 'Create user',
'manage.create_user_lead' => 'An account created by an administrator is active'
.' immediately.',
'manage.label_username' => 'Username',
'manage.label_initial_password' => 'Initial password',
'manage.label_password_confirmation' => 'Confirm password',
'manage.button_create_user' => 'Create user',
'manage.users_title' => 'Users',
'manage.users_lead' => 'Deactivating an account immediately blocks web sign-in and'
.' access token authentication. A user\'s repositories must be handled before'
.' the user can be deleted.',
'manage.users_unavailable' => 'The user list cannot be read right now.',
'manage.users_empty' => 'There are no users yet.',
'manage.th_user' => 'User',
'manage.th_status' => 'Status',
'manage.th_repositories' => 'Repositories',
'manage.th_active_tokens' => 'Active tokens',
'manage.th_created_at' => 'Created',
'manage.th_account_actions' => 'Account actions',
'manage.th_password' => 'Password',
'manage.badge_administrator' => 'administrator',
'manage.badge_current_account' => 'current account',
'manage.badge_active' => 'active',
'manage.badge_inactive' => 'inactive',
'manage.button_deactivate' => 'Deactivate',
'manage.button_activate' => 'Activate',
'manage.button_revoke_all_tokens' => 'Revoke all tokens',
'manage.confirm_delete_account' => 'Confirm account deletion',
'manage.button_delete_user' => 'Delete user',
'manage.summary_reset_password' => 'Reset password',
'manage.placeholder_new_password' => 'New password',
'manage.placeholder_confirm_new_password' => 'Confirm new password',
'manage.button_save_password' => 'Save new password',
'manage.repositories_title' => 'Managed repositories',
'manage.repositories_lead' => 'These actions affect the database records and the'
.' managed bare repositories in the <code>repos</code> directory.',
'manage.repositories_unavailable' => 'The managed repository list cannot be read'
.' right now.',
'manage.repositories_empty' => 'There are no managed repositories yet.',
'manage.th_repository' => 'Repository',
'manage.th_owner_visibility' => 'Owner and visibility',
'manage.th_delete' => 'Delete',
'manage.aria_repository_owner' => 'Repository owner',
'manage.aria_repository_visibility' => 'Repository visibility',
'manage.user_deactivated_suffix' => ' (deactivated)',
'manage.button_save' => 'Save',
'manage.badge_not_deletable' => 'not deletable',
'manage.confirm_permanent_delete' => 'Confirm permanent deletion',
'manage.button_delete_repository' => 'Delete repository',
'manage.state_root_unavailable' => 'root unavailable',
'manage.state_configured' => 'statically configured',
'manage.state_record_only' => 'record only',
'manage.state_incomplete' => 'incomplete',
'manage.state_ready' => 'ready',
'manage.configured_title' => 'Configured repositories',
'manage.configured_lead' => 'These repositories come from <code>config.php</code>.'
.' The management page shows them read only and changes neither the'
.' configuration nor the files.',
'manage.configured_empty' => 'There are no configured repositories.',
'manage.th_url' => 'URL',
'manage.th_path' => 'Path',
'manage.th_visibility' => 'Visibility',
'manage.th_read_write' => 'Read / write',
'manage.value_unset' => 'unset',
'manage.value_read' => 'read',
'manage.value_read_disabled' => 'read disabled',
'manage.value_write' => 'write',
'manage.value_read_only' => 'read only',
'manage.public' => 'Public',
'manage.private' => 'Private',
'manage.user_created' => 'User {username} was created.',
'manage.user_updated' => 'The account status was updated.',
'manage.tokens_revoked' => 'Revoked {count} active access tokens.',
'manage.password_updated' => 'The account password was reset.',
'manage.user_deleted' => 'The user was deleted.',
'manage.repository_updated' => 'The repository owner and visibility were updated.',
'manage.repository_deleted' => 'Managed repository {name} was deleted.',
'manage.repository_record_deleted' => 'The record for {name} was deleted. A missing'
.' or non-bare path was left unchanged.',
'manage.username_exists' => 'That username already exists.',
'manage.invalid_user' => 'The user does not exist or the parameters are not valid.',
'manage.invalid_owner' => 'The new repository owner does not exist or is'
.' unavailable.',
'manage.invalid_repository' => 'The repository does not exist or the parameters are'
.' not valid.',
'manage.administrator_protected' => 'An administrator listed in the configuration'
.' cannot be deactivated or deleted.',
'manage.self_protected' => 'The signed-in account cannot be deactivated or deleted.',
'manage.user_owns_repositories' => 'This user still owns repositories. Transfer or'
.' delete them first.',
'manage.configured_owner' => 'This user owns a static repository from config.php.'
.' Change the owner in the configuration first.',
'manage.configured_repository' => 'That path is configured statically in config.php'
.' and cannot be deleted from the management page.',
'manage.confirmation_required' => 'The confirmation value is not valid. Confirm'
.' again.',
'manage.forbidden' => 'The repository permissions changed, so the operation was'
.' refused.',
'manage.not_found' => 'The managed repository directory or record does not exist.',
'manage.repository_busy' => 'The repository is busy with another operation. Try'
.' again shortly.',
'manage.root_unavailable' => 'The repository directory is unavailable or not'
.' writable.',
'manage.cleanup_failed' => 'The repository record was deleted but the leftover'
.' directory could not be cleaned up. Check the server log.',
'manage.restore_failed' => 'Deletion failed and the repository directory could not'
.' be restored. Check the server log immediately.',
'manage.database_unavailable' => 'The management database is unavailable right now.',
'manage.action_failed' => 'The operation failed. Check the server log.');
}
function i18n_catalog_zh() {
return array(
'app.name' => 'PHP Git 服务器',
'language.label' => '语言',
'notice.registered' => '账号已注册并登录。',
'notice.logged_in' => '登录成功。',
'notice.logged_out' => '已退出登录。',
'notice.registration_disabled' => '当前不允许注册新账号。',
'notice.invalid_username' => '用户名须为 3 至 64 个字母、数字、点、短横线或下划线。',
'notice.invalid_password' => '密码长度须为 8 至 72 个字符,且不能超过 72 字节。',
'notice.password_mismatch' => '两次输入的密码不一致。',
'notice.username_exists' => '该用户名已存在或不可用。',
'notice.invalid_credentials' => '用户名或密码错误。',
'notice.invalid_token_name' => 'Token 名称不能为空,且最多 80 个字符。',
'notice.token_created' => 'Access token 已创建。请立即保存,关闭页面后无法再次查看。',
'notice.token_revoked' => 'Access token 已撤销。',
'notice.invalid_token' => 'Access token 不存在或已撤销。',
'notice.session_unavailable' => '当前无法建立安全会话。',
'notice.auth_database_unavailable' => '认证数据库当前不可用。',
'notice.repository_invalid_name' => '仓库名称格式无效。',
'notice.repository_exists' => '仓库 {name} 已存在。',
'notice.repository_create_busy' => '另一个仓库正在创建,请稍后重试。',
'notice.repository_root_unavailable' => '仓库存放目录不可用或不可写。',
'notice.repository_metadata_unsaved' => '仓库所有权信息无法保存,请稍后重试。',
'notice.repository_create_failed' => '仓库创建失败,请检查服务器日志。',
'notice.repository_created' => '仓库 {name} 已创建。',
'notice.repository_deleted' => '仓库 {name} 已删除。',
'notice.repository_record_deleted' => '仓库记录 {name} 已删除,非 bare 路径未作改动。',
'notice.repository_delete_forbidden' => '只有仓库所有者可以删除该仓库。',
'notice.repository_configured_home' => '该路径由 config.php 静态配置,不能从网页删除。',
'notice.repository_not_found' => '托管仓库不存在或尚未创建完成。',
'notice.repository_busy' => '仓库正在执行其他操作,请稍后重试。',
'notice.repository_metadata_unavailable' => '仓库所有权信息当前不可用。',
'notice.repository_cleanup_failed' => '仓库记录已删除,但残留目录清理失败,请检查服务器日志。',
'notice.repository_restore_failed' => '删除失败且仓库目录无法恢复,请立即检查服务器日志。',
'notice.repository_delete_failed' => '仓库删除失败,请检查服务器日志。',
'home.title' => 'PHP Git 服务器',
'home.lead' => '通过 HTTP 发布下列 Git 仓库,支持 clone、fetch、pull,并可按仓库启用 push。',
'home.account_title' => '账号与 Access Token',
'home.form_unavailable' => '当前无法初始化安全表单。',
'home.username' => '用户名',
'home.password' => '密码',
'home.login' => '登录',
'home.register_username' => '注册用户名',
'home.password_confirmation' => '确认密码',
'home.register' => '注册',
'home.auth_hint' => '网页登录使用密码;Git clone、pull 和 push 使用用户名与 access token。',
'home.current_account' => '当前账号',
'home.manage' => '管理',
'home.logout' => '退出',
'home.token_name' => '新 Token 名称',
'home.token_name_placeholder' => '工作电脑',
'home.create_token' => '创建 Token',
'home.token_list_unavailable' => '当前无法读取 access token 列表。',
'home.token_never_used' => '从未使用',
'home.token_last_used' => '最后使用 {time}',
'home.token_created_at' => '创建于 {time}',
'home.revoke' => '撤销',
'home.create_repository' => '创建仓库',
'home.create_login_required' => '需要先登录应用账号,才能创建仓库。',
'home.repository_name' => '仓库名称',
'home.repository_name_hint' => '可使用字母、数字、点、短横线和下划线;<code>.git</code> 后缀可省略。',
'home.visibility' => '可见性',
'home.public' => '公开',
'home.private' => '私有',
'home.th_repository' => '仓库',
'home.th_owner' => '所有者',
'home.th_clone_url' => '克隆地址',
'home.th_default_branch' => '默认分支',
'home.th_branches' => '分支',
'home.th_tags' => '标签',
'home.th_access' => '权限',
'home.th_actions' => '操作',
'home.badge_empty' => '空仓库',
'home.badge_no_branch' => '未指向分支',
'home.badge_unset' => '未设置',
'home.badge_owner_push' => '所有者可推送',
'home.badge_read_only' => '只读',
'home.confirm_delete' => '确认删除',
'home.delete' => '删除',
'home.badge_none' => '无',
'home.public_repositories' => '公开仓库',
'home.private_repositories' => '私有仓库',
'home.no_public_repositories' => '当前没有公开仓库。',
'home.no_private_repositories' => '当前没有私有仓库。',
'home.empty_title' => '当前没有可读取的仓库。',
'home.empty_body' => '请复制 <code>config.php.sample</code> 为 <code>config.php</code>'
.'在 <code>$repos</code> 中配置仓库路径,并确认仓库目录存在且 <code>read</code> '
.'选项为 <code>TRUE</code>。',
'home.usage_title' => '使用方法',
'home.usage_hint' => 'push 需要仓库启用 <code>push</code> 选项;启用认证时还需要'
.'使用账号用户名和 access token。',
'home.footer' => '详细安装、配置和安全说明见 <code>usage.md</code>。',
'install.title' => '安装 - PHP Git 服务器',
'install.heading' => 'PHP Git 服务器安装',
'install.session_unavailable' => '当前无法初始化安全会话,无法继续安装。',
'install.lead' => '检测到项目根目录没有 <code>config.php</code>。请先准备好一个空数据库'
.'和对应账号,填写以下信息即可导入表结构、创建首个管理员账号并生成配置文件。',
'install.warning_http' => '当前不是 HTTPS 连接。密码将以明文传输,'
.'建议改用 HTTPS 或仅从本机访问安装页面。',
'install.error_no_pdo_mysql' => 'PHP 未启用 <code>pdo_mysql</code> 扩展,'
.'安装无法继续。请先安装该扩展并重启 PHP。',
'install.error_not_writable' => '项目目录不可写,无法生成 <code>config.php</code>。'
.'请调整目录权限后刷新。',
'install.legend_application' => '应用',
'install.label_url_base' => '基础路径',
'install.hint_url_base' => '部署在域名根路径时留空。',
'install.label_language' => '界面语言',
'install.language_auto' => '跟随浏览器(默认英语)',
'install.language_en' => 'English',
'install.language_zh' => '中文',
'install.label_registration_enabled' => '允许公开注册新账号',
'install.label_session_cookie_secure' => 'HTTPS 在可信反向代理终止(设置 Secure Cookie',
'install.legend_database' => '数据库',
'install.hint_database' => '请先自行创建好数据库和账号,安装器不会创建它们,也不需要 root 权限。'
.'账号需对该库具有 SELECT、INSERT、UPDATE 和 DELETE 权限;'
.'若还具有建表权限,安装器会自动导入表结构。',
'install.label_db_host' => '主机',
'install.hint_db_host' => '共享主机通常是 <code>localhost</code>。',
'install.label_db_port' => '端口',
'install.label_db_name' => '数据库名',
'install.hint_db_name' => '面板生成的名称通常带前缀,例如 <code>cpuser_git</code>。',
'install.label_db_user' => '数据库账号',
'install.label_db_password' => '数据库账号密码',
'install.hint_schema_import' => '若该账号没有建表权限,请先用面板的 phpMyAdmin 导入 '
.'<code>schema.mysql.sql</code>,再提交本页面;已存在的表会被自动跳过。',
'install.legend_administrator' => '管理员账号',
'install.hint_administrator' => '该账号会写入 <code>$auth[\'administrators\']</code>'
.'安装完成后即可访问管理界面。',
'install.label_admin_username' => '用户名',
'install.label_admin_password' => '密码',
'install.label_admin_password_confirmation' => '确认密码',
'install.button_submit' => '开始安装',
'install.error_url_base' => '基础路径必须以 / 开头,且不能以 / 结尾;部署在域名根路径时请留空。',
'install.error_db_host' => '数据库主机名无效。',
'install.error_db_port' => '数据库端口无效。',
'install.error_db_name' => '数据库名只能包含字母、数字、下划线和短横线,最多 64 个字符。',
'install.error_db_user' => '数据库用户名只能包含字母、数字、下划线和短横线,最多 64 个字符。',
'install.error_db_password' => '数据库密码不能为空,且最多 255 个字符。',
'install.error_admin_username' => '管理员用户名须为 3 至 64 个字母、数字、点、短横线或下划线。',
'install.error_admin_password' => '管理员密码长度须为 8 至 72 个字符,且不能超过 72 字节。',
'install.error_admin_password_mismatch' => '两次输入的管理员密码不一致。',
'install.error_pdo_mysql_missing' => 'PHP 未启用 pdo_mysql 扩展。',
'install.error_schema_unreadable' => '无法读取 schema.mysql.sql。',
'install.error_schema_denied' => '该数据库账号没有建表权限。请用主机面板的 phpMyAdmin 或数据库导入功能'
.'导入项目根目录的 schema.mysql.sql,然后重新提交本页面;'
.'安装器会自动识别已存在的表并跳过导入。',
'install.error_delete_privilege' => '该数据库账号缺少 DELETE 权限,删除仓库和用户会失败。'
.'请在主机面板或数据库中为该账号授予 DELETE 权限后重试。',
'install.error_database' => '数据库操作失败:{message}',
'install.error_config_write' => 'config.php 写入失败。请确认项目目录可写,或该文件是否已存在。',
'install.step_connected' => '已使用数据库账号连接成功。',
'install.step_schema_imported' => '已导入 {count} 张表。',
'install.step_schema_present' => '检测到已存在的 pgit_ 表,跳过导入。',
'install.step_privileges_ok' => '已确认账号具备 SELECT、INSERT、UPDATE 和 DELETE 权限。',
'install.step_admin_created' => '管理员账号 {username} 已创建。',
'install.step_admin_promoted' => '账号 {username} 已存在,将其设为管理员。',
'install.step_config_written' => 'config.php 已写入,权限设为 0600。',
'install.complete' => '安装完成。',
'install.next_steps_title' => '请立即完成以下操作',
'install.next_steps_remove' => '删除安装脚本,避免它在配置被移除后再次可用。有 shell 时执行:',
'install.next_steps_shared_hosting' => '共享主机可用面板的文件管理器或 FTP 直接删除 '
.'<code>install.php</code>。',
'install.next_steps_permissions' => '确认 <code>config.php</code> 仅应用进程可读,'
.'并检查 <code>repos</code> 目录权限。详细说明见 <code>usage.md</code>。',
'install.link_home' => '进入首页并登录',
'manage.title' => '服务器管理 - PHP Git 服务器',
'manage.heading' => 'PHP Git 服务器 / 管理',
'manage.administrator_label' => '管理员',
'manage.back_to_home' => '返回仓库首页',
'manage.create_user_title' => '创建用户',
'manage.create_user_lead' => '管理员创建的账号会立即启用。',
'manage.label_username' => '用户名',
'manage.label_initial_password' => '初始密码',
'manage.label_password_confirmation' => '确认密码',
'manage.button_create_user' => '创建用户',
'manage.users_title' => '用户',
'manage.users_lead' => '停用会立即阻止网页登录和 Access Token 认证。删除用户前必须先处理其仓库。',
'manage.users_unavailable' => '当前无法读取用户列表。',
'manage.users_empty' => '当前没有用户。',
'manage.th_user' => '用户',
'manage.th_status' => '状态',
'manage.th_repositories' => '仓库',
'manage.th_active_tokens' => '有效 Token',
'manage.th_created_at' => '创建时间',
'manage.th_account_actions' => '账号操作',
'manage.th_password' => '密码',
'manage.badge_administrator' => '管理员',
'manage.badge_current_account' => '当前账号',
'manage.badge_active' => '启用',
'manage.badge_inactive' => '停用',
'manage.button_deactivate' => '停用',
'manage.button_activate' => '启用',
'manage.button_revoke_all_tokens' => '撤销全部 Token',
'manage.confirm_delete_account' => '确认删除账号',
'manage.button_delete_user' => '删除用户',
'manage.summary_reset_password' => '重置密码',
'manage.placeholder_new_password' => '新密码',
'manage.placeholder_confirm_new_password' => '确认新密码',
'manage.button_save_password' => '保存新密码',
'manage.repositories_title' => '托管仓库',
'manage.repositories_lead' => '此处操作数据库记录与 <code>repos</code> 目录中的受管 bare 仓库。',
'manage.repositories_unavailable' => '当前无法读取托管仓库列表。',
'manage.repositories_empty' => '当前没有托管仓库。',
'manage.th_repository' => '仓库',
'manage.th_owner_visibility' => '所有者与可见性',
'manage.th_delete' => '删除',
'manage.aria_repository_owner' => '仓库所有者',
'manage.aria_repository_visibility' => '仓库可见性',
'manage.user_deactivated_suffix' => '(已停用)',
'manage.button_save' => '保存',
'manage.badge_not_deletable' => '不可删除',
'manage.confirm_permanent_delete' => '确认永久删除',
'manage.button_delete_repository' => '删除仓库',
'manage.state_root_unavailable' => '根目录不可用',
'manage.state_configured' => '静态配置',
'manage.state_record_only' => '仅记录',
'manage.state_incomplete' => '未完成',
'manage.state_ready' => '可用',
'manage.configured_title' => '配置仓库',
'manage.configured_lead' => '这些仓库来自 <code>config.php</code>,管理界面只读显示,'
.'不修改配置或文件。',
'manage.configured_empty' => '当前没有配置仓库。',
'manage.th_url' => 'URL',
'manage.th_path' => '路径',
'manage.th_visibility' => '可见性',
'manage.th_read_write' => '读 / 写',
'manage.value_unset' => '未设置',
'manage.value_read' => '读',
'manage.value_read_disabled' => '禁用读取',
'manage.value_write' => '写',
'manage.value_read_only' => '只读',
'manage.public' => '公开',
'manage.private' => '私有',
'manage.user_created' => '用户 {username} 已创建。',
'manage.user_updated' => '用户状态已更新。',
'manage.tokens_revoked' => '已撤销 {count} 个有效 Access Token。',
'manage.password_updated' => '用户密码已重置。',
'manage.user_deleted' => '用户已删除。',
'manage.repository_updated' => '仓库所有者与可见性已更新。',
'manage.repository_deleted' => '托管仓库 {name} 已删除。',
'manage.repository_record_deleted' => '仓库记录 {name} 已删除;缺失或非 bare 路径未作改动。',
'manage.username_exists' => '该用户名已存在。',
'manage.invalid_user' => '用户不存在或参数无效。',
'manage.invalid_owner' => '新的仓库所有者不存在或不可用。',
'manage.invalid_repository' => '仓库不存在或参数无效。',
'manage.administrator_protected' => '配置中的管理员账号不能被停用或删除。',
'manage.self_protected' => '不能停用或删除当前登录账号。',
'manage.user_owns_repositories' => '该用户仍拥有仓库;请先转移或删除这些仓库。',
'manage.configured_owner' => '该用户是 config.php 静态仓库的所有者;请先在配置中更换所有者。',
'manage.configured_repository' => '该路径由 config.php 静态配置,不能从管理界面删除。',
'manage.confirmation_required' => '操作确认值无效,请重新确认。',
'manage.forbidden' => '仓库权限已变化,操作被拒绝。',
'manage.not_found' => '托管仓库目录或记录不存在。',
'manage.repository_busy' => '仓库正在执行其他操作,请稍后重试。',
'manage.root_unavailable' => '仓库存放目录不可用或不可写。',
'manage.cleanup_failed' => '仓库记录已删除,但残留目录清理失败,请检查服务器日志。',
'manage.restore_failed' => '删除失败且仓库目录无法恢复,请立即检查服务器日志。',
'manage.database_unavailable' => '管理数据库当前不可用。',
'manage.action_failed' => '操作失败,请检查服务器日志。');
}
+25
View File
@@ -0,0 +1,25 @@
<?php
/* Sends a first-run visitor to install.php. The target is derived from the
current script path, so it is always a fixed same-directory location. */
function redirect_to_installer() {
$script = isset($_SERVER['SCRIPT_NAME']) ? (string) $_SERVER['SCRIPT_NAME'] : '';
$base = rtrim(str_replace('\\', '/', dirname($script)), '/');
$target = $base.'/install.php';
if (!file_exists(__DIR__.'/../install.php')) {
$protocol = isset($_SERVER['SERVER_PROTOCOL'])
? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.1';
header($protocol.' 500 Internal Server Error');
header('Content-Type: text/plain; charset=utf-8');
header('X-Content-Type-Options: nosniff');
echo 'config.php is missing and install.php is not available.';
die();
}
$protocol = isset($_SERVER['SERVER_PROTOCOL']) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.1';
header($protocol.' 303 See Other');
header('Location: '.$target);
header('Cache-Control: no-store');
die();
}
+201 -11
View File
@@ -17,7 +17,9 @@ function repository_default_options() {
return array(
'read' => TRUE,
'push' => FALSE,
'require_auth' => TRUE,
'owner' => NULL,
'private' => FALSE,
'_managed' => FALSE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
@@ -27,21 +29,63 @@ 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)
|| !isset($configuration['path'])
|| !is_string($configuration['path'])
|| $configuration['path'] === '') {
if (!is_array($configuration) || empty($configuration)) {
return FALSE;
}
$path = $configuration['path'];
if ($path[0] !== DIRECTORY_SEPARATOR) {
$path = dirname(__DIR__).DIRECTORY_SEPARATOR.$path;
$application_root = realpath(dirname(__DIR__));
if ($application_root === FALSE) {
return FALSE;
}
$path = $application_root.DIRECTORY_SEPARATOR.'repos';
if (!file_exists($path) && !is_link($path)
&& !@mkdir($path, 0777) && !is_dir($path)) {
return FALSE;
}
if (is_link($path)) {
return FALSE;
}
$root = realpath($path);
return $root !== FALSE && is_dir($root) ? $root : FALSE;
return $root === $path && is_dir($root) ? $root : FALSE;
}
function normalize_managed_repository_name($value) {
@@ -96,6 +140,131 @@ function remove_managed_repository_directory($path) {
return @rmdir($path);
}
/* Static $repos entries may point into the managed root; those paths stay under
config.php control and must never be removed by a web request. */
function managed_repository_path_is_configured($definitions, $url_base, $path) {
$real_path = realpath($path);
foreach ($definitions as $definition) {
$repository = normalize_repository($definition, $url_base);
if ($repository === FALSE || !empty($repository['options']['_managed'])) {
continue;
}
if ($repository['path'] === $path) {
return TRUE;
}
$configured_path = realpath($repository['path']);
if ($real_path !== FALSE && $configured_path !== FALSE
&& $configured_path === $real_path) {
return TRUE;
}
}
return FALSE;
}
function delete_managed_repository(
$configuration,
$definitions,
$url_base,
$value,
$requesting_user_id,
$administrator=FALSE) {
$name = normalize_managed_repository_name($value);
if ($name === FALSE) {
return array('status' => 'invalid_repository');
}
$user_id = auth_normalize_record_id($requesting_user_id);
if ($user_id === FALSE) {
return array('status' => 'forbidden', 'name' => $name);
}
$root = managed_repository_root($configuration);
if ($root === FALSE || !is_writable($root)) {
return array('status' => 'root_unavailable', 'name' => $name);
}
$path = $root.DIRECTORY_SEPARATOR.$name;
if (managed_repository_path_is_configured($definitions, $url_base, $path)) {
return array('status' => 'configured_repository', 'name' => $name);
}
$lock = @fopen($root.DIRECTORY_SEPARATOR.'.create.lock', 'c+b');
if ($lock === FALSE) {
return array('status' => 'delete_failed', 'name' => $name);
}
if (!@flock($lock, LOCK_EX | LOCK_NB)) {
fclose($lock);
return array('status' => 'repository_busy', 'name' => $name);
}
$deletion_path = NULL;
try {
$metadata = auth_find_repository_metadata($name);
if ($metadata === FALSE) {
return array('status' => 'metadata_unavailable', 'name' => $name);
}
if ($metadata === NULL) {
return array('status' => 'not_found', 'name' => $name);
}
if (!$administrator && (int) $metadata['owner_user_id'] !== $user_id) {
return array('status' => 'forbidden', 'name' => $name);
}
if (!$administrator && (int) $metadata['is_ready'] !== 1) {
return array('status' => 'not_found', 'name' => $name);
}
if ($administrator && !managed_repository_is_bare($path)) {
$deletion = auth_delete_repository_metadata($metadata['id'], $name);
return array(
'status' => $deletion['status'] === 'metadata_deleted'
? 'record_deleted' : 'metadata_unavailable',
'name' => $name);
}
if (!managed_repository_is_bare($path)) {
return array('status' => 'not_found', 'name' => $name);
}
try {
$suffix = bin2hex(random_bytes(16));
} catch (Exception $exception) {
return array('status' => 'delete_failed', 'name' => $name);
}
$deletion_path = $root.DIRECTORY_SEPARATOR.'.delete-'.$suffix.'.tmp';
if (!@rename($path, $deletion_path)) {
return array('status' => 'delete_failed', 'name' => $name);
}
$owner_id = $administrator ? NULL : $user_id;
$deletion = auth_delete_repository_metadata(
$metadata['id'], $name, $owner_id);
if ($deletion['status'] !== 'metadata_deleted') {
if (!@rename($deletion_path, $path)) {
error_log('Unable to restore repository after metadata deletion failure: '.$name);
return array('status' => 'restore_failed', 'name' => $name);
}
$deletion_path = NULL;
return array(
'status' => $deletion['status'] === 'metadata_changed'
? 'forbidden' : 'metadata_unavailable',
'name' => $name);
}
if (!remove_managed_repository_directory($deletion_path)) {
error_log('Unable to remove deleted repository directory: '.$deletion_path);
return array('status' => 'cleanup_failed', 'name' => $name);
}
$deletion_path = NULL;
return array('status' => 'deleted', 'name' => $name);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
function managed_repository_definitions($configuration) {
$root = managed_repository_root($configuration);
if ($root === FALSE) {
@@ -104,6 +273,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) {
@@ -120,7 +293,18 @@ 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']);
$repository_metadata['_managed'] = TRUE;
$definitions[] = array(
'/'.$entry,
$path,
array_merge($options, $repository_metadata));
}
return $definitions;
@@ -185,10 +369,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) {
+1 -1
View File
@@ -18,7 +18,7 @@ function create_http_request($url_path, $repository) {
'content_length' => get_request_header('Content-Length'),
'content_encoding' => get_request_header('Content-Encoding'),
'git_protocol' => get_request_header('Git-Protocol'),
'user' => get_authenticated_user());
'user' => get_access_token_user());
}
function request_has_service($request, $service) {
+584
View File
@@ -0,0 +1,584 @@
<?php
if (!file_exists(__DIR__.'/config.php')) {
require(__DIR__.'/lib/install_redirect.php');
redirect_to_installer();
}
require(__DIR__.'/config.php');
require(__DIR__.'/lib/http.php');
require(__DIR__.'/lib/i18n.php');
require(__DIR__.'/lib/auth.php');
require(__DIR__.'/lib/repository.php');
function manage_escape($value) {
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function manage_home_url($url_base) {
$base = rtrim((string) $url_base, '/');
return $base === '' ? '/' : $base.'/';
}
function manage_page_url($url_base) {
return rtrim((string) $url_base, '/').'/manage.php';
}
function manage_post_value($name) {
return isset($_POST[$name]) && is_string($_POST[$name]) ? $_POST[$name] : '';
}
function manage_csrf_token() {
if (!auth_start_session()) {
return FALSE;
}
if (!isset($_SESSION['manage_csrf_token'])
|| !is_string($_SESSION['manage_csrf_token'])
|| strlen($_SESSION['manage_csrf_token']) !== 64) {
try {
$_SESSION['manage_csrf_token'] = bin2hex(random_bytes(32));
} catch (Exception $exception) {
return FALSE;
}
}
return $_SESSION['manage_csrf_token'];
}
function manage_require_csrf() {
if (!request_content_type_is(
get_request_header('Content-Type'),
'application/x-www-form-urlencoded')) {
send_error(415, 'Unsupported Media Type', 'Expected a form-encoded request.');
}
$expected = manage_csrf_token();
if ($expected === FALSE
|| !hash_equals($expected, manage_post_value('csrf_token'))) {
send_error(403, 'Forbidden', 'The form security token is invalid.');
}
}
function manage_set_notice($type, $message) {
if (!auth_start_session()) {
return FALSE;
}
$_SESSION['manage_notice'] = array(
'type' => $type,
'message' => $message);
return TRUE;
}
function manage_take_notice() {
if (!auth_start_session() || !isset($_SESSION['manage_notice'])) {
return NULL;
}
$notice = $_SESSION['manage_notice'];
unset($_SESSION['manage_notice']);
return is_array($notice) ? $notice : NULL;
}
function manage_redirect($url_base) {
send_status(303, 'See Other');
header('Location: '.manage_page_url($url_base));
die();
}
function manage_result_notice($result) {
$name = isset($result['name']) ? $result['name'] : '';
switch ($result['status']) {
case 'user_created': return array('success', t('manage.user_created', array('username' => $result['username'])));
case 'user_updated': return array('success', t('manage.user_updated'));
case 'tokens_revoked': return array('success', t('manage.tokens_revoked', array('count' => $result['count'])));
case 'password_updated': return array('success', t('manage.password_updated'));
case 'user_deleted': return array('success', t('manage.user_deleted'));
case 'repository_updated': return array('success', t('manage.repository_updated'));
case 'deleted': return array('success', t('manage.repository_deleted', array('name' => $name)));
case 'record_deleted': return array('success', t('manage.repository_record_deleted', array('name' => $name)));
case 'invalid_username': return array('error', t('notice.invalid_username'));
case 'invalid_password': return array('error', t('notice.invalid_password'));
case 'password_mismatch': return array('error', t('notice.password_mismatch'));
case 'username_exists': return array('error', t('manage.username_exists'));
case 'invalid_user': return array('error', t('manage.invalid_user'));
case 'invalid_owner': return array('error', t('manage.invalid_owner'));
case 'invalid_repository': return array('error', t('manage.invalid_repository'));
case 'administrator_protected': return array('error', t('manage.administrator_protected'));
case 'self_protected': return array('error', t('manage.self_protected'));
case 'user_owns_repositories': return array('error', t('manage.user_owns_repositories'));
case 'configured_owner': return array('error', t('manage.configured_owner'));
case 'configured_repository': return array('error', t('manage.configured_repository'));
case 'confirmation_required': return array('error', t('manage.confirmation_required'));
case 'forbidden': return array('error', t('manage.forbidden'));
case 'not_found': return array('error', t('manage.not_found'));
case 'repository_busy': return array('error', t('manage.repository_busy'));
case 'root_unavailable': return array('error', t('manage.root_unavailable'));
case 'cleanup_failed': return array('error', t('manage.cleanup_failed'));
case 'restore_failed': return array('error', t('manage.restore_failed'));
case 'metadata_unavailable':
case 'database_unavailable': return array('error', t('manage.database_unavailable'));
default: return array('error', t('manage.action_failed'));
}
}
function manage_target_user($value) {
$user = auth_find_user_by_id($value);
if ($user === FALSE) {
return array('status' => 'database_unavailable');
}
if ($user === NULL) {
return array('status' => 'invalid_user');
}
return array('status' => 'found', 'user' => $user);
}
/* A deleted username can be registered again, so an account still named as a
static $repos owner must keep its push identity reserved. */
function manage_user_owns_configured_repository($definitions, $url_base, $username) {
foreach ($definitions as $definition) {
$repository = normalize_repository($definition, $url_base);
if ($repository === FALSE || !empty($repository['options']['_managed'])) {
continue;
}
if (repository_user_is_owner($repository, $username)) {
return TRUE;
}
}
return FALSE;
}
function manage_handle_action($url_base, $definitions, $configuration, $administrator) {
manage_require_csrf();
$action = manage_post_value('action');
if ($action === 'create_user') {
$result = auth_create_user(
manage_post_value('username'),
manage_post_value('password'),
manage_post_value('password_confirmation'));
} else if ($action === 'set_user_status') {
$target = manage_target_user(manage_post_value('user_id'));
if ($target['status'] !== 'found') {
$result = $target;
} else if ((int) $target['user']['id'] === (int) $administrator['id']
&& manage_post_value('active') !== '1') {
$result = array('status' => 'self_protected');
} else {
$active = manage_post_value('active');
$result = ($active === '0' || $active === '1')
? auth_set_user_active($target['user']['id'], $active === '1')
: array('status' => 'invalid_user');
}
} else if ($action === 'revoke_user_tokens') {
$result = auth_revoke_user_access_tokens(manage_post_value('user_id'));
} else if ($action === 'reset_user_password') {
$result = auth_set_user_password(
manage_post_value('user_id'),
manage_post_value('password'),
manage_post_value('password_confirmation'));
} else if ($action === 'delete_user') {
$target = manage_target_user(manage_post_value('user_id'));
if ($target['status'] !== 'found') {
$result = $target;
} else if ((int) $target['user']['id'] === (int) $administrator['id']) {
$result = array('status' => 'self_protected');
} else if (!hash_equals(
$target['user']['username'],
manage_post_value('confirmation'))) {
$result = array('status' => 'confirmation_required');
} else if (manage_user_owns_configured_repository(
$definitions, $url_base, $target['user']['username'])) {
$result = array('status' => 'configured_owner');
} else {
$result = auth_delete_user($target['user']['id']);
}
} else if ($action === 'update_repository') {
$visibility = manage_post_value('visibility');
$result = ($visibility === 'public' || $visibility === 'private')
? auth_update_repository_metadata(
manage_post_value('repository_id'),
manage_post_value('owner_user_id'),
$visibility === 'private')
: array('status' => 'invalid_repository');
} else if ($action === 'delete_repository') {
$name = normalize_managed_repository_name(
manage_post_value('repository_name'));
if ($name === FALSE) {
$result = array('status' => 'invalid_repository');
} else if (!hash_equals($name, manage_post_value('confirmation'))) {
$result = array('status' => 'confirmation_required');
} else {
$result = delete_managed_repository(
$configuration,
$definitions,
$url_base,
$name,
$administrator['id'],
TRUE);
}
} else {
send_error(400, 'Bad Request', 'Unknown management action.');
}
$notice = manage_result_notice($result);
manage_set_notice($notice[0], $notice[1]);
manage_redirect($url_base);
}
function manage_send_head($url_base, $administrator) {
echo <<<'HTML'
<!DOCTYPE html>
<html lang="__LANG__">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__TITLE__</title>
<style>
:root { color-scheme: light dark; --canvas: #f3f7f5; --surface: #fff; --surface-soft: #f7faf8;
--ink: #17231d; --muted: #63736a; --line: #d9e4dd; --accent: #167447; --accent-strong: #0d5a34;
--accent-soft: #e8f5ed; --danger: #b23b3b; --warning: #9a6b12; --link: #0d658f;
--shadow: 0 16px 42px rgba(25,59,42,.07); }
* { box-sizing: border-box; }
body { margin: 0; color: var(--ink); background: var(--canvas); font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Noto Sans CJK SC", sans-serif; line-height: 1.55; }
body::before { position: fixed; z-index: -1; inset: 0; content: ""; background: radial-gradient(circle at 4% 0%, #dcf1e5 0, transparent 28rem), radial-gradient(circle at 96% 0%, #e0f1f8 0, transparent 29rem); }
header { border-bottom: 1px solid rgba(217,228,221,.85); background: rgba(255,255,255,.8); backdrop-filter: blur(16px); }
.header-inner, main { width: min(92rem, calc(100% - 2rem)); margin: 0 auto; }
.header-inner { display: flex; min-height: 5.15rem; align-items: center; justify-content: space-between; gap: 1rem; }
h1 { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 1.25rem; letter-spacing: -.035em; }
h1::before { display: inline-grid; width: 1.95rem; height: 1.95rem; place-items: center; margin-right: .55rem; content: "⌘"; border-radius: .55rem; color: #fff; background: var(--accent); font-size: 1.1rem; vertical-align: -.15em; }
.identity { margin: .22rem 0 0 2.52rem; color: var(--muted); font-size: .84rem; }
nav { display: flex; align-items: center; gap: .75rem; font-size: .9rem; }
a, summary { color: var(--link); } nav a { font-weight: 700; text-decoration: none; } nav a:hover { text-decoration: underline; }
main { padding: 2rem 0 4rem; }
section { margin-bottom: 1.25rem; padding: 1.4rem; border: 1px solid var(--line); border-radius: .9rem; background: var(--surface); box-shadow: var(--shadow); }
section + section { padding-top: 1.4rem; border-top: 1px solid var(--line); }
h2 { margin: 0 0 .35rem; font-size: 1.15rem; letter-spacing: -.018em; } h2::before { display: inline-block; width: .48rem; height: .48rem; margin: 0 .48rem .09rem 0; content: ""; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px var(--accent-soft); }
.section-lead { margin: 0 0 1.25rem; color: var(--muted); }
.notice { margin: 0 0 1.25rem; padding: .78rem 1rem; border: 1px solid; border-left: .32rem solid; border-radius: .55rem; font-weight: 600; }
.notice-success { border-color: #a7d9b9; border-left-color: var(--accent); background: var(--accent-soft); color: #155735; }.notice-error { border-color: #f1c5c5; border-left-color: var(--danger); background: #fff1f1; color: #852b2b; }
.create-user { display: grid; grid-template-columns: minmax(12rem,1fr) minmax(12rem,1fr) minmax(12rem,1fr) auto; gap: .75rem; align-items: end; max-width: 70rem; }
label, .label { display: block; margin-bottom: .32rem; color: #304239; font-size: .82rem; font-weight: 800; }
input:not([type="checkbox"]), select, button { min-height: 2.55rem; border-radius: .52rem; font: inherit; }
input:not([type="checkbox"]), select { width: 100%; padding: .48rem .66rem; border: 1px solid #bbcbbf; color: var(--ink); background: #fff; }
input:not([type="checkbox"]):focus, select:focus { border-color: var(--accent); outline: 0; box-shadow: 0 0 0 3px rgba(22,116,71,.16); }
button { padding: .48rem .85rem; border: 1px solid var(--accent-strong); color: #fff; background: var(--accent); font-weight: 700; cursor: pointer; white-space: nowrap; box-shadow: 0 2px 4px rgba(13,90,52,.15); transition: transform .15s, background .15s; } button:hover { background: var(--accent-strong); transform: translateY(-1px); }.button-danger { border-color: #922f2f; background: var(--danger); }.button-danger:hover { background: #902d2d; }.button-muted { border-color: #56616d; background: #626e7b; }.button-muted:hover { background: #505a65; }
.table-wrap { overflow-x: auto; margin: 1rem -1.4rem -1.4rem; border-top: 1px solid var(--line); border-radius: 0 0 .9rem .9rem; }
table { width: 100%; border-collapse: collapse; font-size: .91rem; } th, td { padding: .78rem .8rem; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; } th { color: #526259; background: #f4f8f5; font-size: .72rem; font-weight: 800; letter-spacing: .065em; text-transform: uppercase; white-space: nowrap; } tbody tr { transition: background .12s; } tbody tr:hover { background: #f8fcf9; } tbody tr:last-child td { border-bottom: 0; }
code { padding: .07rem .24rem; border-radius: .24rem; background: #edf3ef; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; word-break: break-all; }.badge { display: inline-block; padding: .12rem .55rem; border: 1px solid currentColor; border-radius: 999px; font-size: .74rem; font-weight: 700; white-space: nowrap; }.badge-good { color: var(--accent); background: var(--accent-soft); }.badge-muted { color: var(--muted); background: #f2f5f3; }.badge-warning { color: var(--warning); background: #fff7e6; }
.stack { display: grid; gap: .45rem; min-width: 9rem; }.inline-form { display: flex; align-items: center; gap: .4rem; }.inline-form select { min-width: 8rem; }.confirm { display: flex; align-items: center; gap: .4rem; margin: 0; color: var(--muted); font-weight: 500; font-size: .78rem; }.confirm input { width: auto; min-height: auto; accent-color: var(--danger); }
details { min-width: 15rem; } summary { cursor: pointer; font-weight: 700; } details form { display: grid; gap: .45rem; margin-top: .55rem; }.empty { margin: 0; padding: 1rem; border: 1px dashed #aebfb3; border-radius: .55rem; color: var(--muted); background: var(--surface-soft); }
@media (max-width: 54rem) { .header-inner { align-items: flex-start; flex-direction: column; padding: .95rem 0; }.create-user { grid-template-columns: 1fr; } }
@media (max-width: 40rem) { .header-inner, main { width: min(100% - 1.25rem, 92rem); } nav { flex-wrap: wrap; } section { padding: 1.1rem; }.table-wrap { margin: 1rem -1.1rem -1.1rem; } }
@media (prefers-color-scheme: dark) { :root { --canvas:#101916;--surface:#17231e;--surface-soft:#1d2b25;--ink:#e5efe8;--muted:#a0b2a8;--line:#31443a;--accent:#42a76d;--accent-strong:#2d8953;--accent-soft:#173b27;--danger:#d05a5a;--warning:#e0ad50;--link:#75c4eb;--shadow:0 16px 42px rgba(0,0,0,.2); } body::before { background:radial-gradient(circle at 4% 0%,#173e2a 0,transparent 28rem),radial-gradient(circle at 96% 0%,#163744 0,transparent 29rem); } header { border-color:rgba(49,68,58,.85);background:rgba(23,35,30,.8); } label,.label { color:#d5e2d9; } input:not([type="checkbox"]),select { border-color:#587064;background:#132019;color:var(--ink); } th { color:#b9cabf;background:#1b2a23; } tbody tr:hover { background:#1b2a23; } code { background:#22332b; }.badge-muted { background:#26362e; }.badge-warning { background:#3e3218; }.notice-success { border-color:#356d4b;color:#a9e5bd; }.notice-error { border-color:#713838;background:#3a2020;color:#ffb6b6; } }
</style>
</head>
<body>
HTML;
echo '<header><div class="header-inner"><div><h1>'.manage_escape(t('manage.heading')).'</h1>' ."\n";
echo '<p class="identity">'.manage_escape(t('manage.administrator_label')).'<strong>'.manage_escape($administrator['username'])
.'</strong></p></div>' ."\n";
echo '<nav><a href="'.manage_escape(manage_home_url($url_base)).'">'.manage_escape(t('manage.back_to_home')).'</a> '
.i18n_language_switcher(manage_page_url($url_base));
echo '</nav></div></header>' ."\n";
}
function manage_send_create_user($url_base, $csrf_token) {
echo '<section aria-labelledby="create-user-title"><h2 id="create-user-title">'.manage_escape(t('manage.create_user_title')).'</h2>' ."\n";
echo '<p class="section-lead">'.manage_escape(t('manage.create_user_lead')).'</p>' ."\n";
echo '<form class="create-user" method="post" action="'
.manage_escape(manage_page_url($url_base)).'">' ."\n";
echo '<input type="hidden" name="csrf_token" value="'.manage_escape($csrf_token).'">' ."\n";
echo '<input type="hidden" name="action" value="create_user">' ."\n";
echo '<div><label for="new-username">'.manage_escape(t('manage.label_username')).'</label><input id="new-username" name="username" '
.'minlength="3" maxlength="64" autocomplete="off" required></div>' ."\n";
echo '<div><label for="new-password">'.manage_escape(t('manage.label_initial_password')).'</label><input id="new-password" name="password" '
.'type="password" minlength="8" maxlength="72" autocomplete="new-password" required></div>' ."\n";
echo '<div><label for="new-password-confirmation">'.manage_escape(t('manage.label_password_confirmation')).'</label>'
.'<input id="new-password-confirmation" name="password_confirmation" type="password" '
.'minlength="8" maxlength="72" autocomplete="new-password" required></div>' ."\n";
echo '<button type="submit">'.manage_escape(t('manage.button_create_user')).'</button></form></section>' ."\n";
}
function manage_hidden_fields($csrf_token, $action, $user_id=NULL) {
$fields = '<input type="hidden" name="csrf_token" value="'
.manage_escape($csrf_token).'">' ."\n";
$fields .= '<input type="hidden" name="action" value="'
.manage_escape($action).'">' ."\n";
if ($user_id !== NULL) {
$fields .= '<input type="hidden" name="user_id" value="'
.manage_escape($user_id).'">' ."\n";
}
return $fields;
}
function manage_send_users($url_base, $csrf_token, $users, $administrator) {
echo '<section aria-labelledby="users-title"><h2 id="users-title">'.manage_escape(t('manage.users_title')).'</h2>' ."\n";
echo '<p class="section-lead">'.manage_escape(t('manage.users_lead')).'</p>' ."\n";
if ($users === FALSE) {
echo '<p class="notice notice-error">'.manage_escape(t('manage.users_unavailable')).'</p></section>' ."\n";
return;
}
if (empty($users)) {
echo '<p class="empty">'.manage_escape(t('manage.users_empty')).'</p></section>' ."\n";
return;
}
$action_url = manage_escape(manage_page_url($url_base));
echo '<div class="table-wrap"><table><thead><tr><th>'.manage_escape(t('manage.th_user')).'</th><th>'.manage_escape(t('manage.th_status')).'</th><th>'.manage_escape(t('manage.th_repositories')).'</th>'
.'<th>'.manage_escape(t('manage.th_active_tokens')).'</th><th>'.manage_escape(t('manage.th_created_at')).'</th><th>'.manage_escape(t('manage.th_account_actions')).'</th><th>'.manage_escape(t('manage.th_password')).'</th></tr></thead><tbody>' ."\n";
foreach ($users as $user) {
$active = (int) $user['is_active'] === 1;
$is_administrator = auth_user_is_administrator($user);
$is_self = (int) $user['id'] === (int) $administrator['id'];
echo '<tr><td><code>'.manage_escape($user['username']).'</code><br>';
if ($is_administrator) {
echo '<span class="badge badge-good">'.manage_escape(t('manage.badge_administrator')).'</span>';
} else if ($is_self) {
echo '<span class="badge badge-muted">'.manage_escape(t('manage.badge_current_account')).'</span>';
}
echo '</td><td><span class="badge '.($active ? 'badge-good' : 'badge-warning').'">'
.manage_escape($active ? t('manage.badge_active') : t('manage.badge_inactive')).'</span></td>';
echo '<td>'.manage_escape($user['repository_count']).'</td>';
echo '<td>'.manage_escape($user['active_token_count']).'</td>';
echo '<td>'.manage_escape($user['created_at']).'</td><td><div class="stack">';
if ((!$is_administrator || !$active) && !($is_self && $active)) {
echo '<form method="post" action="'.$action_url.'">'
.manage_hidden_fields($csrf_token, 'set_user_status', $user['id']);
echo '<input type="hidden" name="active" value="'.($active ? '0' : '1').'">';
echo '<button class="button-muted" type="submit">'.manage_escape($active ? t('manage.button_deactivate') : t('manage.button_activate')).'</button></form>';
}
echo '<form method="post" action="'.$action_url.'">'
.manage_hidden_fields($csrf_token, 'revoke_user_tokens', $user['id']);
echo '<button class="button-muted" type="submit">'.manage_escape(t('manage.button_revoke_all_tokens')).'</button></form>';
if (!$is_administrator && !$is_self && (int) $user['repository_count'] === 0) {
echo '<form class="stack" method="post" action="'.$action_url.'">'
.manage_hidden_fields($csrf_token, 'delete_user', $user['id']);
echo '<label class="confirm"><input name="confirmation" type="checkbox" value="'
.manage_escape($user['username']).'" required> '.manage_escape(t('manage.confirm_delete_account')).'</label>';
echo '<button class="button-danger" type="submit">'.manage_escape(t('manage.button_delete_user')).'</button></form>';
}
echo '</div></td><td><details><summary>'.manage_escape(t('manage.summary_reset_password')).'</summary>';
echo '<form method="post" action="'.$action_url.'">'
.manage_hidden_fields($csrf_token, 'reset_user_password', $user['id']);
echo '<input name="password" type="password" minlength="8" maxlength="72" '
.'autocomplete="new-password" aria-label="'.manage_escape(t('manage.placeholder_new_password')).'" placeholder="'.manage_escape(t('manage.placeholder_new_password')).'" required>';
echo '<input name="password_confirmation" type="password" minlength="8" maxlength="72" '
.'autocomplete="new-password" aria-label="'.manage_escape(t('manage.placeholder_confirm_new_password')).'" placeholder="'.manage_escape(t('manage.placeholder_confirm_new_password')).'" required>';
echo '<button type="submit">'.manage_escape(t('manage.button_save_password')).'</button></form></details></td></tr>' ."\n";
}
echo '</tbody></table></div></section>' ."\n";
}
function manage_repository_state($repository, $configuration, $definitions, $url_base) {
$root = managed_repository_root($configuration);
if ($root === FALSE) {
return array('badge-warning', t('manage.state_root_unavailable'), FALSE);
}
$path = $root.DIRECTORY_SEPARATOR.$repository['repository_name'];
if (managed_repository_path_is_configured($definitions, $url_base, $path)) {
return array('badge-warning', t('manage.state_configured'), FALSE);
}
if (!managed_repository_is_bare($path)) {
return array('badge-warning', t('manage.state_record_only'), TRUE);
}
if ((int) $repository['is_ready'] !== 1) {
return array('badge-warning', t('manage.state_incomplete'), TRUE);
}
return array('badge-good', t('manage.state_ready'), TRUE);
}
function manage_send_repositories(
$url_base,
$csrf_token,
$repositories,
$users,
$configuration,
$definitions) {
echo '<section aria-labelledby="repositories-title"><h2 id="repositories-title">'.manage_escape(t('manage.repositories_title')).'</h2>' ."\n";
echo '<p class="section-lead">'.t('manage.repositories_lead').'</p>' ."\n";
if ($repositories === FALSE) {
echo '<p class="notice notice-error">'.manage_escape(t('manage.repositories_unavailable')).'</p></section>' ."\n";
return;
}
if (empty($repositories)) {
echo '<p class="empty">'.manage_escape(t('manage.repositories_empty')).'</p></section>' ."\n";
return;
}
$action_url = manage_escape(manage_page_url($url_base));
echo '<div class="table-wrap"><table><thead><tr><th>'.manage_escape(t('manage.th_repository')).'</th><th>'.manage_escape(t('manage.th_status')).'</th><th>'.manage_escape(t('manage.th_owner_visibility')).'</th>'
.'<th>'.manage_escape(t('manage.th_created_at')).'</th><th>'.manage_escape(t('manage.th_delete')).'</th></tr></thead><tbody>' ."\n";
foreach ($repositories as $repository) {
$state = manage_repository_state(
$repository, $configuration, $definitions, $url_base);
echo '<tr><td><code>'.manage_escape($repository['repository_name']).'</code></td>';
echo '<td><span class="badge '.manage_escape($state[0]).'">'
.manage_escape($state[1]).'</span></td><td>';
echo '<form class="inline-form" method="post" action="'.$action_url.'">';
echo '<input type="hidden" name="csrf_token" value="'.manage_escape($csrf_token).'">';
echo '<input type="hidden" name="action" value="update_repository">';
echo '<input type="hidden" name="repository_id" value="'
.manage_escape($repository['id']).'">';
echo '<select name="owner_user_id" aria-label="'.manage_escape(t('manage.aria_repository_owner')).'">';
foreach ($users === FALSE ? array() : $users as $user) {
if ((int) $user['is_active'] !== 1
&& (int) $user['id'] !== (int) $repository['owner_user_id']) {
continue;
}
echo '<option value="'.manage_escape($user['id']).'"'
.((int) $user['id'] === (int) $repository['owner_user_id'] ? ' selected' : '').'>'
.manage_escape($user['username']).((int) $user['is_active'] === 1 ? '' : manage_escape(t('manage.user_deactivated_suffix')))
.'</option>';
}
echo '</select><select name="visibility" aria-label="'.manage_escape(t('manage.aria_repository_visibility')).'">';
echo '<option value="public"'.((int) $repository['is_private'] === 0 ? ' selected' : '')
.'>'.manage_escape(t('manage.public')).'</option>';
echo '<option value="private"'.((int) $repository['is_private'] === 1 ? ' selected' : '')
.'>'.manage_escape(t('manage.private')).'</option></select><button type="submit">'.manage_escape(t('manage.button_save')).'</button></form></td>';
echo '<td>'.manage_escape($repository['created_at']).'</td><td>';
if (!$state[2]) {
echo '<span class="badge badge-muted">'.manage_escape(t('manage.badge_not_deletable')).'</span>';
} else {
echo '<form class="stack" method="post" action="'.$action_url.'">';
echo '<input type="hidden" name="csrf_token" value="'.manage_escape($csrf_token).'">';
echo '<input type="hidden" name="action" value="delete_repository">';
echo '<input type="hidden" name="repository_name" value="'
.manage_escape($repository['repository_name']).'">';
echo '<label class="confirm"><input name="confirmation" type="checkbox" value="'
.manage_escape($repository['repository_name']).'" required> '.manage_escape(t('manage.confirm_permanent_delete')).'</label>';
echo '<button class="button-danger" type="submit">'.manage_escape(t('manage.button_delete_repository')).'</button></form>';
}
echo '</td></tr>' ."\n";
}
echo '</tbody></table></div></section>' ."\n";
}
function manage_send_configured_repositories($url_base, $definitions) {
echo '<section aria-labelledby="configured-title"><h2 id="configured-title">'.manage_escape(t('manage.configured_title')).'</h2>' ."\n";
echo '<p class="section-lead">'.t('manage.configured_lead').'</p>' ."\n";
$repositories = array();
foreach ($definitions as $definition) {
$repository = normalize_repository($definition, $url_base);
if ($repository !== FALSE) {
$repositories[] = $repository;
}
}
if (empty($repositories)) {
echo '<p class="empty">'.manage_escape(t('manage.configured_empty')).'</p></section>' ."\n";
return;
}
echo '<div class="table-wrap"><table><thead><tr><th>'.manage_escape(t('manage.th_url')).'</th><th>'.manage_escape(t('manage.th_path')).'</th><th>'.manage_escape(t('manage.th_owner_visibility')).'</th>'
.'<th>'.manage_escape(t('manage.th_visibility')).'</th><th>'.manage_escape(t('manage.th_read_write')).'</th></tr></thead><tbody>' ."\n";
foreach ($repositories as $repository) {
$owner = $repository['options']['owner'] === NULL
? t('manage.value_unset') : $repository['options']['owner'];
echo '<tr><td><code>'.manage_escape($repository['url']).'</code></td>';
echo '<td><code>'.manage_escape($repository['path']).'</code></td>';
echo '<td><code>'.manage_escape($owner).'</code></td>';
echo '<td>'.manage_escape(repository_is_private($repository) ? t('manage.private') : t('manage.public')).'</td>';
echo '<td>'.manage_escape($repository['options']['read'] ? t('manage.value_read') : t('manage.value_read_disabled')).' / '
.manage_escape($repository['options']['push'] ? t('manage.value_write') : t('manage.value_read_only')).'</td></tr>' ."\n";
}
echo '</tbody></table></div></section>' ."\n";
}
function manage_render(
$url_base,
$definitions,
$configuration,
$administrator,
$notice) {
$csrf_token = manage_csrf_token();
if ($csrf_token === FALSE) {
send_error(500, 'Internal Server Error', 'Unable to initialize a secure session.');
}
$users = auth_list_users();
$repositories = auth_list_repository_metadata();
header_nocache();
header('Content-Type: text/html; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Content-Security-Policy: default-src \'none\'; style-src \'unsafe-inline\'; '
.'form-action \'self\'; base-uri \'none\'; frame-ancestors \'none\'');
ob_start();
manage_send_head($url_base, $administrator);
echo '<main>' ."\n";
if ($notice !== NULL && isset($notice['type'], $notice['message'])) {
echo '<p class="notice notice-'.manage_escape($notice['type']).'" role="status">'
.manage_escape($notice['message']).'</p>' ."\n";
}
manage_send_create_user($url_base, $csrf_token);
manage_send_users($url_base, $csrf_token, $users, $administrator);
manage_send_repositories(
$url_base,
$csrf_token,
$repositories,
$users,
$configuration,
$definitions);
manage_send_configured_repositories($url_base, $definitions);
echo '</main></body></html>' ."\n";
$markup = ob_get_clean();
$markup = str_replace('__LANG__', manage_escape(i18n_html_lang()), $markup);
$markup = str_replace('__TITLE__', manage_escape(t('manage.title')), $markup);
echo i18n_translate_markup($markup);
}
if (!isset($url_base)) {
$url_base = '';
}
if (!isset($repos) || !is_array($repos)) {
send_error(500, 'Internal Server Error', 'The repository configuration is invalid.');
}
if (!isset($auth) || !is_array($auth)) {
send_error(500, 'Internal Server Error', 'The authentication configuration is invalid.');
}
if (!isset($managed_repositories)) {
$managed_repositories = array();
}
if (!is_array($managed_repositories)) {
send_error(500, 'Internal Server Error', 'The managed repository configuration is invalid.');
}
i18n_configure(isset($language) ? $language : NULL, $url_base);
auth_configure($auth, $url_base);
if (!auth_is_enabled()) {
send_error(404, 'Not Found', 'Account authentication is disabled.');
}
$administrator = auth_session_user();
if ($administrator === NULL || !auth_user_is_administrator($administrator)) {
send_error(403, 'Forbidden', 'An administrator login is required.');
}
$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET';
if ($method === 'POST') {
manage_handle_action($url_base, $repos, $managed_repositories, $administrator);
}
if ($method !== 'GET' && $method !== 'HEAD') {
send_status(405, 'Method Not Allowed');
header('Allow: GET, HEAD, POST');
header('Content-Type: text/plain; charset=utf-8');
echo 'Method Not Allowed';
die();
}
$notice = $method === 'GET' ? manage_take_notice() : NULL;
manage_render(
$url_base,
$repos,
$managed_repositories,
$administrator,
$notice);
+15
View File
@@ -0,0 +1,15 @@
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ ^/(index|manage|install)\.php$ {
fastcgi_pass unix:/var/run/php/php-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
include fastcgi_params;
}
location ~ \.php$ {
return 404;
}
+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.');
+12 -3
View File
@@ -19,8 +19,12 @@ function push_require_access($repository, $request) {
send_error(403, 'Forbidden', 'Repository pushes are disabled.');
}
if ($repository['options']['require_auth'] && $request['user'] === NULL) {
send_error(403, 'Forbidden', 'Authenticated access is required for push.');
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.');
}
}
@@ -227,6 +231,11 @@ function push_run_receive_pack($repository, $request, $application) {
send_error(403, 'Forbidden', $validation);
}
git_service_rpc($application, $repository, $request, 'git-receive-pack', $input);
$exit_code = git_service_rpc(
$application, $repository, $request, 'git-receive-pack', $input);
if ($exit_code === 0
&& !git_service_update_unborn_head($repository['path'], $updates)) {
error_log('Unable to update unborn HEAD for '.$repository['url'].'.');
}
fclose($input);
}
+41
View File
@@ -0,0 +1,41 @@
CREATE TABLE pgit_users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
password_hash VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
is_active 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_users_username_unique (username)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE pgit_access_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TIMESTAMP NULL DEFAULT NULL,
expires_at TIMESTAMP NULL DEFAULT NULL,
revoked_at TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY pgit_access_tokens_hash_unique (token_hash),
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;
+111
View File
@@ -0,0 +1,111 @@
<?php
require_once dirname(__DIR__).'/lib/repository.php';
require_once dirname(__DIR__).'/lib/git_protocol.php';
require_once dirname(__DIR__).'/lib/git_object_store.php';
require_once dirname(__DIR__).'/lib/git_upload_pack.php';
require_once dirname(__DIR__).'/lib/git_receive_pack.php';
require_once dirname(__DIR__).'/lib/git_service.php';
if (!isset($GLOBALS['test_results'])) {
$GLOBALS['test_results'] = array('passed' => 0, 'failed' => 0);
}
function test_case($name, $callback) {
try {
$callback();
$GLOBALS['test_results']['passed'] += 1;
fwrite(STDOUT, "ok - ".$name."\n");
} catch (Throwable $error) {
$GLOBALS['test_results']['failed'] += 1;
fwrite(STDERR, "not ok - ".$name."\n ".$error->getMessage()."\n");
}
}
function test_fail($message) {
throw new RuntimeException($message);
}
function test_assert_true($value, $message='Expected value to be TRUE.') {
if ($value !== TRUE) {
test_fail($message);
}
}
function test_assert_false($value, $message='Expected value to be FALSE.') {
if ($value !== FALSE) {
test_fail($message);
}
}
function test_assert_same($expected, $actual, $message='') {
if ($expected !== $actual) {
$detail = 'Expected '.var_export($expected, TRUE).', got '.var_export($actual, TRUE).'.';
test_fail($message === '' ? $detail : $message.' '.$detail);
}
}
function test_assert_contains($needle, $haystack, $message='') {
if (strpos($haystack, $needle) === FALSE) {
$detail = 'Expected output to contain '.var_export($needle, TRUE).'.';
test_fail($message === '' ? $detail : $message.' '.$detail);
}
}
function test_capture_output($callback) {
ob_start();
$result = NULL;
$output = '';
try {
$result = $callback();
$output = ob_get_contents();
} finally {
ob_end_clean();
}
if ($result !== TRUE) {
test_fail('Protocol function returned failure.');
}
return $output;
}
function test_stream($contents) {
$stream = fopen('php://temp', 'w+b');
if ($stream === FALSE || fwrite($stream, $contents) !== strlen($contents)) {
test_fail('Unable to create test input stream.');
}
rewind($stream);
return $stream;
}
function test_remove_directory($path) {
if (is_link($path) || is_file($path)) {
@unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
foreach (scandir($path) as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
test_remove_directory($path.DIRECTORY_SEPARATOR.$entry);
}
@rmdir($path);
}
function test_protocol_packets($buffer) {
$stream = test_stream($buffer);
$packets = array();
while (!feof($stream)) {
$position = ftell($stream);
$packet = git_protocol_read_packet($stream);
if ($packet === FALSE) {
fseek($stream, $position);
break;
}
$packets[] = $packet;
}
fclose($stream);
return $packets;
}
+120
View File
@@ -0,0 +1,120 @@
<?php
function git_fixture_create() {
$path = sys_get_temp_dir().'/php-git-server-test-'.bin2hex(random_bytes(8)).'.git';
if (!git_service_init_bare_repository($path)) {
test_remove_directory($path);
test_fail('Unable to initialize fixture repository.');
}
return $path;
}
function git_fixture_repository($path, $options=array()) {
return array(
'url' => '/fixture.git',
'path' => $path,
'options' => array_merge(repository_default_options(), $options));
}
function git_fixture_object($path, $type, $body, $write=TRUE) {
$contents = $type.' '.strlen($body)."\0".$body;
$oid = sha1($contents);
if ($write) {
$directory = $path.'/objects/'.substr($oid, 0, 2);
if (!is_dir($directory) && !mkdir($directory, 0777, TRUE)) {
test_fail('Unable to create fixture object directory.');
}
$compressed = gzcompress($contents);
if ($compressed === FALSE
|| file_put_contents($directory.'/'.substr($oid, 2), $compressed) !== strlen($compressed)) {
test_fail('Unable to write fixture object.');
}
}
return array('type' => $type, 'body' => $body, 'oid' => $oid);
}
function git_fixture_history($path, $messages) {
$blob = git_fixture_object($path, 'blob', "fixture\n");
$tree = git_fixture_object($path, 'tree', "100644 fixture.txt\0".hex2bin($blob['oid']));
$commits = array();
$parent = NULL;
foreach ($messages as $index => $message) {
$body = 'tree '.$tree['oid']."\n";
if ($parent !== NULL) {
$body .= 'parent '.$parent."\n";
}
$timestamp = 1700000000 + $index;
$body .= 'author Fixture <[email protected]> '.$timestamp." +0000\n";
$body .= 'committer Fixture <[email protected]> '.$timestamp." +0000\n\n".$message."\n";
$commit = git_fixture_object($path, 'commit', $body);
$commits[] = $commit['oid'];
$parent = $commit['oid'];
}
return $commits;
}
function git_fixture_tag($path, $target, $name='v1.0.0') {
$body = 'object '.$target."\n";
$body .= "type commit\n";
$body .= 'tag '.$name."\n";
$body .= "tagger Fixture <[email protected]> 1700000100 +0000\n\nfixture tag\n";
$tag = git_fixture_object($path, 'tag', $body);
return $tag['oid'];
}
function git_fixture_write_ref($path, $ref, $oid) {
$ref_path = $path.'/'.$ref;
if (!is_dir(dirname($ref_path)) && !mkdir(dirname($ref_path), 0777, TRUE)) {
test_fail('Unable to create fixture ref directory.');
}
if (file_put_contents($ref_path, $oid."\n") !== 41) {
test_fail('Unable to write fixture ref.');
}
}
function git_fixture_pack_objects($objects) {
$indexed = array();
foreach ($objects as $object) {
$indexed[$object['oid']] = $object;
}
$pack = git_object_store_build_pack($indexed);
if ($pack === FALSE) {
test_fail('Unable to build fixture pack.');
}
return $pack;
}
function git_fixture_pack($path, $oids) {
$store = git_object_store_create($path);
$objects = git_object_store_collect($store, $oids);
if ($objects === FALSE) {
test_fail('Unable to collect objects for fixture pack.');
}
return git_fixture_pack_objects(array_values($objects));
}
function git_fixture_remove_loose_objects($path) {
foreach (scandir($path.'/objects') as $entry) {
if (!preg_match('~^[0-9a-f]{2}$~D', $entry)) {
continue;
}
test_remove_directory($path.'/objects/'.$entry);
}
}
function git_fixture_has_object($path, $oid) {
$store = git_object_store_create($path);
return $store !== FALSE && git_object_store_read($store, $oid) !== FALSE;
}
function git_fixture_receive_input($updates, $pack='', $capabilities='report-status') {
$input = '';
foreach ($updates as $index => $update) {
$payload = $update['old'].' '.$update['new'].' '.$update['ref'];
if ($index === 0 && $capabilities !== '') {
$payload .= "\0".$capabilities;
}
$input .= git_protocol_format_packet($payload."\n");
}
return $input.'0000'.$pack;
}
+366
View File
@@ -0,0 +1,366 @@
<?php
require_once __DIR__.'/bootstrap.php';
require_once __DIR__.'/git_fixture.php';
function receive_test_run($fixture, $updates, $pack='', $options=array()) {
$result = receive_test_run_result($fixture, $updates, $pack, $options);
return $result[1];
}
function receive_test_run_result($fixture, $updates, $pack='', $options=array()) {
$input = test_stream(git_fixture_receive_input($updates, $pack));
ob_start();
try {
$result = git_receive_pack_rpc_native(git_fixture_repository($fixture, $options), $input);
$output = ob_get_contents();
} finally {
ob_end_clean();
fclose($input);
}
return array($result, $output);
}
test_case('receive-pack does not advertise atomic before crash recovery exists', function () {
test_assert_false(strpos(git_receive_pack_capabilities(), 'atomic') !== FALSE);
});
test_case('receive-pack accepts the first branch push', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
$pack = git_fixture_pack($fixture, array($history[0]));
git_fixture_remove_loose_objects($fixture);
test_assert_false(git_fixture_has_object($fixture, $history[0]));
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $history[0],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack ok\n", $output);
test_assert_contains("ok refs/heads/main\n", $output);
test_assert_same($history[0], resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack accepts a fast-forward update', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$output = receive_test_run($fixture, array(array(
'old' => $history[0],
'new' => $history[1],
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($history[1])));
test_assert_contains("unpack ok\n", $output);
test_assert_same($history[1], resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a non-fast-forward update', function () {
$fixture = git_fixture_create();
try {
$main = git_fixture_history($fixture, array('main'))[0];
$other = git_fixture_history($fixture, array('other'))[0];
git_fixture_write_ref($fixture, 'refs/heads/main', $main);
$output = receive_test_run($fixture, array(array(
'old' => $main,
'new' => $other,
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($other)));
test_assert_contains("unpack non-fast-forward\n", $output);
test_assert_contains("ng refs/heads/main non-fast-forward\n", $output);
test_assert_same($main, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack deletes an existing ref', function () {
$fixture = git_fixture_create();
try {
$oid = git_fixture_history($fixture, array('one'))[0];
git_fixture_write_ref($fixture, 'refs/heads/topic', $oid);
$output = receive_test_run($fixture, array(array(
'old' => $oid,
'new' => str_repeat('0', 40),
'ref' => 'refs/heads/topic')));
test_assert_contains("unpack ok\n", $output);
test_assert_contains("ok refs/heads/topic\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/topic')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack accepts an annotated tag', function () {
$fixture = git_fixture_create();
try {
$commit = git_fixture_history($fixture, array('one'))[0];
$tag = git_fixture_tag($fixture, $commit);
$pack = git_fixture_pack($fixture, array($tag));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $tag,
'ref' => 'refs/tags/v1.0.0')),
$pack);
test_assert_contains("unpack ok\n", $output);
test_assert_same($tag, resolve_ref($fixture, 'refs/tags/v1.0.0')[1]);
test_assert_true(git_fixture_has_object($fixture, $tag));
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a corrupt pack without changing refs', function () {
$fixture = git_fixture_create();
try {
$oid = git_fixture_history($fixture, array('one'))[0];
$pack = git_fixture_pack($fixture, array($oid));
git_fixture_remove_loose_objects($fixture);
$pack[strlen($pack) - 1] = chr(ord($pack[strlen($pack) - 1]) ^ 1);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $oid,
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid pack\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
test_assert_false(git_fixture_has_object($fixture, $oid));
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects an object graph with a missing object', function () {
$fixture = git_fixture_create();
try {
$missing_tree = str_repeat('a', 40);
$body = 'tree '.$missing_tree."\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\nmissing tree\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a commit without a tree header', function () {
$fixture = git_fixture_create();
try {
$body = "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\nmissing tree\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a malformed tree entry mode', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "fixture\n");
$tree = git_fixture_object($fixture, 'tree', "100600 fixture.txt\0".hex2bin($blob['oid']));
$body = 'tree '.$tree['oid']."\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\ninvalid tree\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($blob, $tree, $commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects an annotated tag without a declared type', function () {
$fixture = git_fixture_create();
try {
$commit = git_fixture_history($fixture, array('one'))[0];
$body = 'object '.$commit."\n";
$body .= "tag v1.0.0\n";
$body .= "tagger Fixture <[email protected]> 1700000100 +0000\n\ninvalid tag\n";
$tag = git_fixture_object($fixture, 'tag', $body);
$pack = git_fixture_pack_objects(array($tag));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $tag['oid'],
'ref' => 'refs/tags/v1.0.0')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/tags/v1.0.0')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a commit whose tree points to a blob', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "not a tree\n");
$body = 'tree '.$blob['oid']."\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\nwrong type\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($blob, $commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a malformed parent header', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "fixture\n");
$tree = git_fixture_object($fixture, 'tree', "100644 fixture.txt\0".hex2bin($blob['oid']));
$body = 'tree '.$tree['oid']."\n";
$body .= "parent not-an-object-id\n";
$body .= "author Fixture <[email protected]> 1700000000 +0000\n";
$body .= "committer Fixture <[email protected]> 1700000000 +0000\n\ninvalid parent\n";
$commit = git_fixture_object($fixture, 'commit', $body);
$pack = git_fixture_pack_objects(array($blob, $tree, $commit));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $commit['oid'],
'ref' => 'refs/heads/main')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a tag whose declared type does not match its target', function () {
$fixture = git_fixture_create();
try {
$blob = git_fixture_object($fixture, 'blob', "fixture\n");
$body = 'object '.$blob['oid']."\n";
$body .= "type commit\n";
$body .= "tag v1.0.0\n";
$body .= "tagger Fixture <[email protected]> 1700000100 +0000\n\nwrong type\n";
$tag = git_fixture_object($fixture, 'tag', $body);
$pack = git_fixture_pack_objects(array($blob, $tag));
git_fixture_remove_loose_objects($fixture);
$output = receive_test_run($fixture, array(array(
'old' => str_repeat('0', 40),
'new' => $tag['oid'],
'ref' => 'refs/tags/v1.0.0')),
$pack);
test_assert_contains("unpack invalid object graph\n", $output);
test_assert_same(NULL, resolve_ref($fixture, 'refs/tags/v1.0.0')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rejects a stale old OID without changing the ref', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two', 'three'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$output = receive_test_run($fixture, array(array(
'old' => $history[0],
'new' => $history[2],
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($history[2])));
test_assert_contains("unpack stale or locked ref\n", $output);
test_assert_contains("ng refs/heads/main stale or locked ref\n", $output);
test_assert_same($history[1], resolve_ref($fixture, 'refs/heads/main')[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack returns failure when refs are rejected', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$result = receive_test_run_result($fixture, array(array(
'old' => $history[0],
'new' => $history[1],
'ref' => 'refs/heads/main')),
git_fixture_pack($fixture, array($history[1])));
test_assert_false($result[0]);
test_assert_contains("ng refs/heads/main stale or locked ref\n", $result[1]);
} finally {
test_remove_directory($fixture);
}
});
test_case('receive-pack rolls back earlier refs when a later commit fails', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
git_fixture_write_ref($fixture, 'refs/heads/topic', $history[0]);
$updates = array(
array('old' => $history[0], 'new' => $history[1], 'ref' => 'refs/heads/main'),
array('old' => $history[0], 'new' => $history[1], 'ref' => 'refs/heads/topic'));
$locks = git_receive_pack_lock_updates($fixture, $updates);
test_assert_true(is_array($locks), 'Expected both refs to lock.');
$locks[1]['path'] = $fixture.'/missing/refs/heads/topic';
test_assert_false(git_receive_pack_commit_updates($fixture, $locks));
test_assert_same($history[0], resolve_ref($fixture, 'refs/heads/main')[1]);
test_assert_same($history[0], resolve_ref($fixture, 'refs/heads/topic')[1]);
} finally {
test_remove_directory($fixture);
}
});
+173
View File
@@ -0,0 +1,173 @@
<?php
require_once __DIR__.'/bootstrap.php';
require_once __DIR__.'/git_fixture.php';
test_case('upload-pack advertises refs and capabilities', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$output = test_capture_output(function () use ($fixture) {
return git_upload_pack_advertise_native(git_fixture_repository($fixture));
});
test_assert_contains($history[0].' HEAD', $output);
test_assert_contains($history[0].' refs/heads/main', $output);
test_assert_contains('side-band-64k', $output);
test_assert_contains('multi_ack_detailed', $output);
test_assert_contains('no-done', $output);
test_assert_contains('thin-pack', $output);
test_assert_contains('include-tag', $output);
test_assert_same('0000', substr($output, -4));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack returns NAK and a valid pack for clone', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$request = git_protocol_format_packet('want '.$history[0]."\n").'0000'
.git_protocol_format_packet("done\n");
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
$nak = git_protocol_format_packet("NAK\n");
test_assert_same($nak, substr($output, 0, strlen($nak)));
$pack = substr($output, strlen($nak));
test_assert_same('PACK', substr($pack, 0, 4));
test_assert_same(hash('sha1', substr($pack, 0, -20), TRUE), substr($pack, -20));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack ACKs a common commit and excludes its history', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$request = git_protocol_format_packet('want '.$history[1]."\n").'0000'
.git_protocol_format_packet('have '.$history[0]."\n")
.git_protocol_format_packet("done\n");
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
test_assert_same(
git_protocol_format_packet('ACK '.$history[0]."\n"),
substr($output, 0, 49));
test_assert_same('PACK', substr($output, 49, 4));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack sends a final ACK after done with detailed negotiation', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$request = git_protocol_format_packet(
'want '.$history[1]." multi_ack_detailed side-band-64k\n").'0000'
.git_protocol_format_packet('have '.$history[0]."\n")
.git_protocol_format_packet("done\n");
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
$ack = git_protocol_format_packet('ACK '.$history[0]."\n");
test_assert_same($ack, substr($output, 0, strlen($ack)));
test_assert_same(chr(1).'PACK', substr($output, strlen($ack) + 4, 5));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack rejects an unadvertised want', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('advertised', 'hidden'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
$request = git_protocol_format_packet('want '.$history[1]."\n").'0000'
.git_protocol_format_packet("done\n");
$input = test_stream($request);
$result = NULL;
$output = '';
ob_start();
try {
$result = git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
$output = ob_get_contents();
} finally {
ob_end_clean();
fclose($input);
}
test_assert_false($result);
test_assert_same('', $output);
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack reports detailed common and ready acknowledgments', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one', 'two'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[1]);
$request = git_protocol_format_packet(
'want '.$history[1]." multi_ack_detailed no-done\n").'0000'
.git_protocol_format_packet('have '.$history[0]."\n").'0000';
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
$common = git_protocol_format_packet('ACK '.$history[0]." common\n");
$ready = git_protocol_format_packet('ACK '.$history[0]." ready\n");
$nak = git_protocol_format_packet("NAK\n");
$ack = git_protocol_format_packet('ACK '.$history[0]."\n");
$prefix = $common.$ready.$nak.$ack;
test_assert_same($prefix, substr($output, 0, strlen($prefix)));
test_assert_same('PACK', substr($output, strlen($prefix), 4));
} finally {
test_remove_directory($fixture);
}
});
test_case('upload-pack include-tag sends an annotated tag for a wanted commit', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('one'));
$tag = git_fixture_tag($fixture, $history[0]);
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
git_fixture_write_ref($fixture, 'refs/tags/v1.0.0', $tag);
$request = git_protocol_format_packet(
'want '.$history[0]." include-tag\n").'0000'.git_protocol_format_packet("done\n");
$input = test_stream($request);
$output = test_capture_output(function () use ($fixture, $input) {
return git_upload_pack_rpc_native(git_fixture_repository($fixture), $input);
});
fclose($input);
$nak = git_protocol_format_packet("NAK\n");
$pack = substr($output, strlen($nak));
$store = git_object_store_create($fixture);
$objects = git_receive_pack_parse($pack, $store, 0, 0);
test_assert_true(is_array($objects));
test_assert_true(isset($objects[$tag]), 'Expected include-tag to add the tag object.');
} finally {
test_remove_directory($fixture);
}
});
+35
View File
@@ -0,0 +1,35 @@
<?php
require_once __DIR__.'/bootstrap.php';
require_once __DIR__.'/git_fixture.php';
test_case('unborn HEAD follows the first pushed branch', function () {
$fixture = git_fixture_create();
try {
$oid = git_fixture_history($fixture, array('one'))[0];
git_fixture_write_ref($fixture, 'refs/heads/master', $oid);
test_assert_true(git_service_update_unborn_head(
$fixture, array('refs/heads/master')));
test_assert_same(
"ref: refs/heads/master\n",
file_get_contents($fixture.'/HEAD'));
} finally {
test_remove_directory($fixture);
}
});
test_case('an established HEAD remains unchanged', function () {
$fixture = git_fixture_create();
try {
$history = git_fixture_history($fixture, array('main', 'master'));
git_fixture_write_ref($fixture, 'refs/heads/main', $history[0]);
git_fixture_write_ref($fixture, 'refs/heads/master', $history[1]);
test_assert_true(git_service_update_unborn_head(
$fixture, array('refs/heads/master')));
test_assert_same(
"ref: refs/heads/main\n",
file_get_contents($fixture.'/HEAD'));
} finally {
test_remove_directory($fixture);
}
});
+17
View File
@@ -0,0 +1,17 @@
<?php
require_once __DIR__.'/bootstrap.php';
$test_files = array(
__DIR__.'/protocol_upload_pack.php',
__DIR__.'/protocol_receive_pack.php',
__DIR__.'/regression_unborn_head.php');
foreach ($test_files as $test_file) {
require $test_file;
}
$passed = $GLOBALS['test_results']['passed'];
$failed = $GLOBALS['test_results']['failed'];
fwrite(STDOUT, "\n".$passed.' passed, '.$failed." failed\n");
exit($failed === 0 ? 0 : 1);
+160 -89
View File
@@ -2,12 +2,12 @@
本项目通过 PHP 发布显式配置的 Git 仓库,同时支持 **Dumb HTTP****Smart HTTP**
- `clone`支持 Smart HTTP;服务器没有 Git/`proc_open` 时由 PHP 原生 upload-pack 提供,也保留 Dumb HTTP 兼容路径。
- `pull` / `fetch`通过 `git-upload-pack --stateless-rpc` 提供。
- `push`有 Git 时通过 `git-receive-pack --stateless-rpc` 提供;无 Git 时由 PHP 原生 receive-pack 提供,默认关闭。
- `clone`由 PHP 实现 Smart HTTP upload-pack,并保留 Dumb HTTP 兼容路径。
- `pull` / `fetch`由 PHP 实现的 upload-pack 提供。
- `push`:由 PHP 实现的 receive-pack 提供,默认关闭。
- `branch`:远程分支以 `refs/heads/*` 表示,可通过 push 创建、更新和删除。
- `tag`:远程标签以 `refs/tags/*` 表示,可通过 push 创建、更新和删除。
- `create`:可从主界面创建受控目录内的 bare 仓库Git 不可用时由 PHP 直接初始化。
- `create`:可从主界面创建受控目录内的 bare 仓库由 PHP 直接初始化。
Git 协议不会向服务器发送名为“branch”或“tag”的独立命令:本地 `git branch``git tag` 不访问服务器;远程分支和标签通过 fetch/pull 获取,通过 push 更新。
@@ -18,9 +18,10 @@ Git 协议不会向服务器发送名为“branch”或“tag”的独立命令
```text
index.php 主入口,加载配置并注册路由
lib/http.php HTTP 状态、响应头和认证用户读取
lib/auth.php MySQL 用户、网页登录会话与 Access Token 验证
lib/repository.php 仓库配置、安全路径和 Dumb HTTP refs
lib/router.php 请求路由
lib/git_service.php Smart HTTP Git 子进程与流式传输
lib/git_service.php Smart HTTP 服务分发与 bare 仓库初始化
operations/clone.php Dumb HTTP clone/object 资源
operations/pull.php upload-packclone/fetch/pull
operations/push.php receive-packpush 请求、大小及 refs 校验
@@ -34,16 +35,17 @@ operations/tag.php refs/tags/* 标签更新规则
- Apache HTTP Server。
- PHP 7.4 或更新版本。
- MySQL 5.7+/MariaDB 10.2+ 与 PHP PDO MySQL 扩展(`pdo_mysql`)。
- Apache `mod_rewrite` 模块。
- 允许项目目录中的 `.htaccess` 使用重写规则。
- Smart HTTP 需要服务器安装 Git,并允许 PHP 使用 `proc_open`
- Smart HTTP 需要 PHP zlib 与 hash 扩展
- Web 服务器进程对仓库具有读取权限;启用 push 时还需要写入权限。
项目没有 Composer 依赖,也不需要构建。
项目没有 Composer 依赖,也不需要构建。若系统尚未启用 PDO MySQL,先安装对应 PHP 扩展并重启 Apache/PHP-FPM。
服务器可以不安装 Git。Git 或 `proc_open` 不可用时,应用使用纯 PHP 实现的 Smart HTTP 服务端协议,支持普通 SHA-1 仓库的 clone、fetch、pull、push、delta、分支和标签PHP 必须启用 zlib 与 hash 扩展
服务器不需要安装 Git。应用使用纯 PHP 实现的 Smart HTTP 服务端协议,支持普通 SHA-1 仓库的 clone、fetch、pull、push、delta、分支和标签。
原生 PHP 后端当前不支持 SHA-256 仓库、shallow clone、partial clone/filter、push certificate、Git hooks 和仅 protocol v2 提供的功能。需要这些能力时仍应安装 Git 并允许 `proc_open`
PHP 后端当前不支持 SHA-256 仓库、shallow clone、partial clone/filter、push certificate、Git hooks 和仅 protocol v2 提供的功能。
## 3. 安装
@@ -61,6 +63,18 @@ cp config.php.sample config.php
不要提交真实的 `config.php`,其中可能包含服务器目录结构和安全策略。
创建数据库、低权限应用用户并导入表结构。下面的密码必须替换为随机强密码:
```sql
CREATE DATABASE php_git_server CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'php_git_server'@'127.0.0.1' IDENTIFIED BY 'replace-with-a-long-random-password';
GRANT SELECT, INSERT, UPDATE, DELETE ON php_git_server.* TO 'php_git_server'@'127.0.0.1';
```
```sh
mysql -u root -p php_git_server < schema.mysql.sql
```
确认 Apache 已启用重写模块:
```sh
@@ -95,16 +109,21 @@ https://git.example.com/php-git-server/
<?php
$url_base = '/php-git-server';
$git_executable = 'git';
$auth = array(
'enabled' => TRUE,
'registration_enabled' => TRUE,
'administrators' => array('alice'),
'session_cookie_secure' => TRUE,
'database' => array(
'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=php_git_server;charset=utf8mb4',
'username' => 'php_git_server',
'password' => 'replace-with-a-long-random-password'));
$managed_repositories = array(
'path' => '/srv/git',
'require_auth' => TRUE,
'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE,
'branches' => TRUE,
'tags' => TRUE,
'other_refs' => FALSE,
@@ -114,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,
@@ -154,28 +174,25 @@ array('/self.git', '.git')
### 从主界面创建仓库
`$managed_repositories` 用于启用主界面的创建入口。`path` 必须是已经存在、由 PHP/Web 服务器进程可读、可写且可进入的目录,并应由本应用独占应用只会发现该目录直属的、名称以 `.git` 结尾的 bare 仓库,不递归扫描,也不会改写 `config.php`
`$managed_repositories` 用于启用主界面的创建入口。托管目录固定为本项目下的 `repos` 文件夹,不能通过 `config.php` 修改。目录不存在时应用会自动创建,此时 PHP/Web 服务器进程必须能写入项目根目录;创建后的目录必须可读、可写且可进入,并应由本应用独占应用只会发现该目录直属的、名称以 `.git` 结尾的 bare 仓库,不递归扫描。
```php
$managed_repositories = array(
'path' => '/srv/git',
'require_auth' => TRUE,
'session_cookie_secure' => TRUE,
'options' => array(
'read' => TRUE,
'push' => TRUE,
'require_auth' => TRUE));
'push' => TRUE));
```
- 顶层 `require_auth` 控制谁能从主界面创建仓库,默认是 `TRUE`
- `session_cookie_secure` 控制创建表单会话 Cookie 的 Secure 属性。应用直连 HTTPS 时会自动识别;TLS 在可信反向代理终止时应显式设为 `TRUE`,并确保外部流量只能通过 HTTPS 访问
- 创建仓库必须登录;当前账号自动成为仓库所有者,并可在表单中选择“公开”或“私有”
- 托管仓库所有者可在首页勾选确认后永久删除自己的仓库。删除同时移除 `pgit_repositories` 记录和对应 bare 仓库目录,不能撤销;静态 `$repos` 条目不会显示删除操作
- 启用账号认证时,`$auth['session_cookie_secure']` 控制登录与表单共用 Session Cookie 的 Secure 属性。应用直连 HTTPS 时会自动识别;TLS 在可信反向代理终止时应显式设为 `TRUE`,并确保外部流量只能通过 HTTPS 访问。
- `options` 是所有主界面新建仓库共同继承的仓库选项,其含义与 `$repos` 条目相同。
- 设置 `$managed_repositories = array();` 可完全关闭主界面创建功能。
- 仓库名称仅允许字母、数字、点、短横线和下划线,长度最多 64 个字符;`.git` 后缀可省略。
- 新仓库是 bare 仓库,默认分支为 `main`。应用内的创建请求使用锁、暂存目录和原子改名,不会互相覆盖;托管目录不应由其他进程同时写入。
- Git 或 `proc_open` 不可用时,应用以纯 PHP 创建标准 SHA-1 格式的空 bare 仓库;之后可以通过 Dumb HTTP clone,但首次写入仍需在其他具备 Git 的环境中生成仓库内容并同步到服务器
- 应用以纯 PHP 创建标准 SHA-1 格式的空 bare 仓库,可以直接通过 Smart HTTP 完成首次 push
静态 `$repos` 条目与托管目录中的仓库 URL 冲突时,以静态条目为准。生产环境应让创建页面受到 Web 服务器认证保护,并保持顶层 `require_auth => TRUE`;表单本身还使用会话 CSRF 令牌
静态 `$repos` 条目与托管目录中的仓库 URL 冲突时,以静态条目为准。仓库创建要求已登录 Session,所有修改表单还使用会话 CSRF 令牌。托管仓库的所有者和可见性保存在 `pgit_repositories`;缺少元数据的旧托管仓库按私有、无所有者处理,在完成迁移前不可 push
## 5. 仓库选项
@@ -185,7 +202,8 @@ $managed_repositories = array(
| --- | --- | --- |
| `read` | `TRUE` | 允许 clone、fetch、pull 和 Dumb HTTP 对象读取 |
| `push` | `FALSE` | 启用 Smart HTTP receive-pack |
| `require_auth` | `TRUE` | push 前必须存在由 Web 服务器验证并设置的 `REMOTE_USER` |
| `owner` | `NULL` | 允许 push 的账号用户名;未设置时任何人都不能 push |
| `private` | `FALSE` | 是否要求有效 Access Token 才能进行任何 Git 读取 |
| `branches` | `TRUE` | 允许更新 `refs/heads/*` |
| `tags` | `TRUE` | 允许更新 `refs/tags/*` |
| `other_refs` | `FALSE` | 允许 notes、replace 等其他 ref 命名空间 |
@@ -194,47 +212,81 @@ $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` 设置上限。
push 请求会先写入系统临时目录,以便 PHP 在处理对象前检查 ref 命名空间。启用较大的 push 时,应保证 PHP 系统临时目录具有足够空间;也可以使用 `max_request_bytes` 设置上限。
## 6. 身份认证
本项目不保存用户、密码或访问令牌。`require_auth => TRUE` 只信任 Web 服务器认证完成后设置的 `REMOTE_USER`;普通客户端提交的用户名不会被当作已认证身份。
### 账号配置
最简单的方式是让 Apache 保护整个仓库 URL:
`$auth` 启用后,首页提供注册和登录。用户密码通过 PHP `password_hash()` 保存;应用不会保存明文密码。`registration_enabled => FALSE` 可关闭新用户注册,已有用户仍可登录。生产部署完成首批账号注册后,建议关闭公开注册,或在反向代理/WAF 中为注册和登录请求配置速率限制。
```apache
<LocationMatch "^/php-git-server/project\.git(?:/|$)">
AuthType Basic
AuthName "Private Git"
AuthUserFile /etc/apache2/git.htpasswd
Require valid-user
</LocationMatch>
```
### 管理员配置与管理界面
启用主界面创建时,还必须让认证覆盖应用首页。例如保护整个应用路径
```apache
<Location "/php-git-server/">
AuthType Basic
AuthName "Git server"
AuthUserFile /etc/apache2/git.htpasswd
Require valid-user
</Location>
```
如果只希望认证创建入口而允许匿名 clone,可在 Web 服务器中按请求方法和路径制定更细的规则,但必须确认首页 `POST` 最终能向 PHP 提供可信的 `REMOTE_USER`
生产环境必须配合 HTTPS。也可以使用反向代理、单点登录或其他认证模块,但需要确认认证结果最终以可信的 `REMOTE_USER` 传给 PHP。
如果设置:
管理员由 `config.php` 中的账号用户名列表决定
```php
'require_auth' => FALSE
$auth = array(
'enabled' => TRUE,
'registration_enabled' => FALSE,
'administrators' => array(
'alice',
'release-admin'),
'database' => array(
// ...
));
```
则任何能访问 URL 的用户都可 push。此设置只适合隔离的本地开发环境或已经由其他网络边界严格保护的服务
列表按大小写精确匹配,且账号必须已经存在并保持启用。修改配置后无需更新数据库角色;管理员退出并重新登录后即可从首页进入 `manage.php`。配置中的管理员用户名不能通过公开注册创建:部署首个管理员时,应先注册可信账号,再把它加入配置;其他管理员可由现有管理员创建账号后再加入配置
管理界面提供以下操作:
- 创建、启用和停用用户,重置用户密码,撤销该用户的全部有效 Access Token。
- 删除不再拥有任何托管仓库的非管理员用户;当前登录账号和配置中的管理员账号不能被停用或删除。
- 转移托管仓库所有者,在公开与私有之间切换,并永久删除任意托管仓库。
- 查看静态 `$repos` 配置仓库,但不修改 `config.php`,也不删除其路径。
以下两项约束由服务端强制,构造请求同样无效:
- 若某个静态 `$repos` 条目指向 `repos` 目录中的同一路径,该仓库标记为“静态配置”且不可删除。要删除它,应先从 `config.php` 移除对应条目。
- 仍被静态 `$repos` 条目列为 `owner` 的账号不能删除。用户名删除后可被重新注册,若此时仍留在配置中,新账号会直接获得该仓库的 push 权限;应先在配置中更换 `owner`
停用用户后,其浏览器 Session 在下一次请求时失效,Access Token 也不再通过认证。删除仓库和用户均为不可撤销操作;执行前应确认已有可恢复备份。管理请求只接受网页登录 Session,并使用独立的 CSRF TokenGit Access Token 不能用于登录管理页。
`session_cookie_secure` 在应用直连 HTTPS 时会自动推断。TLS 在可信反向代理终止时必须显式设为 `TRUE`,并确保外部只能通过 HTTPS 访问。不要再给应用路径配置 Apache `AuthType Basic`,否则 Apache 会在请求到达 PHP 前拦截应用注册、登录及 token 验证。
### 创建和使用 Access Token
登录首页后填写 Token 名称并点击“创建 Token”。明文 token 仅显示一次,格式为 `pgs_` 加 64 个十六进制字符;数据库仅保存 SHA-256 摘要。页面可以查看最后使用时间并随时撤销 token。
Git 通过 HTTP Basic 发送凭据:用户名填写注册用户名,密码填写 access token,不能填写网页登录密码。例如:
```sh
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 历史、仓库配置或脚本。
公开仓库允许匿名 clone/fetch/pull;私有仓库只接受 access token。浏览器登录 Session 只用于首页、创建仓库和显示私有仓库列表,不能代替 Git access token。Token 验证成功后,应用使用该用户名执行仓库所有者权限检查。
### 现有数据库与仓库迁移
从旧版本升级时,先执行独立迁移文件:
```sh
mysql -u root -p php_git_server < migration.repository-ownership.mysql.sql
```
随后为每个已有托管仓库写入所有者和可见性,仓库名必须包含 `.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. 创建和授权仓库
@@ -244,25 +296,25 @@ push 请求会先写入系统临时目录,以便在交给 `git-receive-pack`
git init --bare /srv/git/project.git
```
启用主界面创建时,应先创建并授权整个托管目录,例如:
启用主界面创建后,缺失的 `repos` 目录会自动创建。生产环境可预先创建并授权该目录,以明确设置属主和权限,例如:
```sh
sudo install -d -o git -g www-data -m 2770 /srv/git
sudo install -d -o git -g www-data -m 2770 /var/www/php-git-server/repos
```
这里的用户和组只是示例,应按 PHP/Web 服务器的实际运行身份调整。
这里的项目路径、用户和组只是示例,应按实际部署位置以及 PHP/Web 服务器的运行身份调整。托管目录必须是项目根目录下的 `repos`
只读仓库只需要让 Apache/PHP 用户可读取目录和文件。启用 push 时,运行 PHP 的用户必须能够创建和修改 objects、refs、日志及锁文件。例如可把仓库交给专用组管理:
```sh
sudo chown -R git:www-data /srv/git/project.git
sudo find /srv/git/project.git -type d -exec chmod 2770 {} \;
sudo find /srv/git/project.git -type f -exec chmod 660 {} \;
sudo chown -R git:www-data /var/www/php-git-server/repos/project.git
sudo find /var/www/php-git-server/repos/project.git -type d -exec chmod 2770 {} \;
sudo find /var/www/php-git-server/repos/project.git -type f -exec chmod 660 {} \;
```
权限策略应根据服务器实际用户、组和备份方案调整。不要使用 `chmod -R 777`
仓库中的 hooks 会在 `git-receive-pack` 处理 push 时以 PHP/Web 服务器用户身份执行。只能发布受信任的仓库和 hooks,并确保 hooks 不接受未经校验的外部参数去执行任意命令
PHP 服务端不会执行仓库中的 Git hooks
## 8. 操作示例
@@ -349,8 +401,8 @@ Smart HTTP 支持:
- `POST /git-upload-pack`
- `GET /info/refs?service=git-receive-pack`
- `POST /git-receive-pack`
- upload-pack 的 Git protocol v0/v1/v2 协商
- Git 自身支持的 SHA-1 或 SHA-256 仓库格式
- upload-pack 的 Git protocol v0/v1 基本协商
- SHA-1 仓库格式
Dumb HTTP 支持:
@@ -364,7 +416,7 @@ Dumb HTTP 支持:
- loose refs、packed refs、packed annotated tag 的 peeled refs
- SHA-1 和 SHA-256 长度的对象名称
安装 Git 时,Smart HTTP 的具体协商、对象校验、fast-forward 规则和仓库 hooks 由服务器 Git 负责。无 Git 时,PHP 后端执行 SHA-1 对象哈希、pack 校验、OFS/REF delta、对象连通性、ref 锁和默认 fast-forward 检查,但不会执行 hooks。
PHP 后端执行 SHA-1 对象哈希、pack 校验、OFS/REF delta、对象连通性、ref 锁和默认 fast-forward 检查,但不会执行 hooks。
## 10. 验证部署
@@ -379,6 +431,12 @@ for file in lib/*.php operations/*.php; do
done
```
确认 PDO MySQL 已启用:
```sh
php -m | grep pdo_mysql
```
### 查看远程 refs
```sh
@@ -391,13 +449,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 状态码
@@ -413,34 +473,45 @@ 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 未启用或 ref 命名空间被禁止时应返回 `403`
私有仓库读取或 push 未提供有效 token 时应返回 `401``WWW-Authenticate`;非所有者 push、push 未启用或 ref 命名空间被禁止时应返回 `403`
## 11. 常见问题
### clone / pull 返回 503
检查
1. `$git_executable` 是否指向可执行的 Git。
2. Web 服务器进程的 `PATH` 是否包含 Git。
3. PHP 是否允许 `proc_open`
4. Web 服务器进程是否可读取仓库。
若 Git 不可用,服务使用原生 PHP Smart HTTP 后端。确认 PHP 启用了 zlib 与 hash,并确认仓库是 SHA-1 格式;浅克隆、filter、SHA-256 或 hooks 需求必须改用 Git 后端。
检查 PHP 是否启用了 zlib 与 hash、仓库是否为 SHA-1 格式,以及 Web 服务器进程是否可读取仓库。当前服务不支持浅克隆、filter、SHA-256 或 hooks。
### push 返回 403
依次检查:
1. 仓库是否设置 `'push' => TRUE`
2. `require_auth``TRUE` 时,Web 服务器是否设置了可信 `REMOTE_USER`
2. access token 所属用户名是否与仓库 `owner` 完全一致
3. 分支更新是否启用了 `branches`
4. 标签更新是否启用了 `tags`
5. 目标是否属于其他 ref 命名空间,而 `other_refs` 仍为 `FALSE`
### clone / pull / push 返回 401 或反复询问密码
依次检查:
1. 密码位置输入的是首页生成的 access token,而不是网页登录密码。
2. 用户名与创建 token 的账号完全一致;用户名区分大小写。
3. token 是否已撤销或被凭据助手缓存为旧值。
4. PHP 是否启用 `pdo_mysql``$auth['database']` 是否能连接数据库。
5. Apache/FastCGI 是否保留 `Authorization` 头;项目 `.htaccess` 已包含对应重写环境变量规则。
### 首页显示认证数据库不可用
检查 PHP/Apache 错误日志、`pdo_mysql` 扩展、MySQL 地址和账号权限,并确认已导入 `schema.mysql.sql`。应用数据库账号需要 `SELECT``INSERT``UPDATE``DELETE`,但不需要运行时建表权限。
### 管理页返回 403
确认已经使用浏览器登录应用账号,并且 `config.php``$auth['administrators']` 包含该账号的精确用户名。用户名区分大小写;已停用用户、Git HTTP Basic 凭据和 Access Token 都不能直接进入管理页。
### push 返回 500 或远端断开
检查 PHP/Apache 错误日志、Git hooks 输出、仓库写权限、磁盘空间和临时目录空间。还应确认 `max_request_bytes` 没有设置得过小。
检查 PHP/Apache 错误日志、仓库写权限、磁盘空间和临时目录空间。还应确认 `max_request_bytes` 没有设置得过小。
### non-fast-forward 被拒绝
@@ -450,7 +521,7 @@ push 未启用或 ref 命名空间被禁止时应返回 `403`。
git push --force-with-lease origin main
```
仓库本地配置和 hooks 仍可进一步禁止 force push、删除或特定提交
应用的仓库选项还可禁止 force push、删除或特定 ref 命名空间
### 浏览器可以访问,但 Git 操作失败
@@ -467,13 +538,13 @@ GIT_TRACE=1 GIT_CURL_VERBOSE=1 git clone \
- 对所有生产流量使用 HTTPS。
- push 默认保持关闭,只为确实需要写入的仓库启用。
- 主界面创建默认要求可信的 `REMOTE_USER`,托管目录不要放置其他文件。
- 使用 Apache、反向代理或统一身份系统完成真实认证
- 不要把用户自行提供的 HTTP 头直接映射成可信 `REMOTE_USER`
- 主界面创建默认要求已登录应用账号,托管目录不要放置其他文件。
- 公开注册应配合速率限制;不需要公开注册时设置 `registration_enabled => FALSE`
- 数据库账号只授予 `pgit_users``pgit_access_tokens``pgit_repositories` 所需的最小读写权限,并单独备份
- 定期撤销不再使用的 token;不要记录 `Authorization` 头或 token 明文。
- 仓库路径必须来自静态配置或受控托管目录,不根据 URL 拼接任意文件系统路径。
- 只给 Web 服务器最小必要的文件权限。
- 将 `other_refs` 保持为 `FALSE`,除非确实需要 notes、replace 或自定义 refs。
- 使用 `max_request_bytes`、Web 服务器请求体限制和磁盘配额防止超大 push。
- 审查仓库 hookspush 会执行 receive-pack hooks。
- 不提交 `config.php`,也不要把敏感信息写入 Git 历史。
- 确保 `.htaccess` 或等价的虚拟主机规则生效,避免绕过 `index.php`