diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..eb09a7d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# 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. + +There is no dedicated automated test directory. + +## Build, Test, and Development Commands + +PHP is interpreted directly, so there is no build step. Run syntax checks from the repository root: + +```sh +php -l index.php +php -l install.php +php -l manage.php +for file in lib/*.php operations/*.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 ` $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 'git_unavailable': return array(503, 'error', t('notice.repository_git_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', '仓库 '.$name.' 已删除。'); - case 'record_deleted': - return array(303, 'success', '仓库记录 '.$name.' 已删除,非 bare 路径未作改动。'); - case 'invalid_repository': - return array(422, 'error', '仓库名称格式无效。'); - case 'forbidden': - return array(403, 'error', '只有仓库所有者可以删除该仓库。'); - case 'configured_repository': - return array(403, 'error', '该路径由 config.php 静态配置,不能从网页删除。'); - case 'not_found': - return array(404, 'error', '托管仓库不存在或尚未创建完成。'); - case 'repository_busy': - return array(409, 'error', '仓库正在执行其他操作,请稍后重试。'); - case 'root_unavailable': - return array(503, 'error', '仓库存放目录不可用或不可写。'); - case 'metadata_unavailable': - return array(503, 'error', '仓库所有权信息当前不可用。'); - case 'cleanup_failed': - return array(500, 'error', '仓库记录已删除,但残留目录清理失败,请检查服务器日志。'); - case 'restore_failed': - return array(500, 'error', '删除失败且仓库目录无法恢复,请立即检查服务器日志。'); - default: - return array(500, 'error', '仓库删除失败,请检查服务器日志。'); + 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')); } } @@ -338,7 +306,7 @@ function home_create_repository( if ($result['status'] === 'created') { home_set_notice( - $url_base, $configuration, 'success', '仓库 '.$result['name'].' 已创建。'); + $url_base, $configuration, 'success', t('notice.repository_created', array('name' => $result['name']))); home_redirect($url_base); } @@ -476,14 +444,16 @@ 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 ''."\n" + .''."\n" + .''."\n" + .''."\n" + .''."\n" + .''.$title.''."\n"; echo <<<'HTML' - - - - - -PHP Git 服务器 -

PHP Git 服务器

-

通过 HTTP 发布下列 Git 仓库,支持 clone、fetch、pull,并可按仓库启用 push。

HTML; + echo '

'.home_escape(t('home.title')).'

'."\n"; + echo '

'.home_escape(t('home.lead')).'

'."\n"; + echo i18n_language_switcher(home_page_url($url_base))."\n"; } function home_send_authentication($url_base, $configuration, $notice) { @@ -592,14 +565,14 @@ function home_send_authentication($url_base, $configuration, $notice) { } echo '
' ."\n"; - echo '

账号与 Access Token

' ."\n"; + echo '

'.home_escape(t('home.account_title')).'

' ."\n"; if ($notice !== NULL && isset($notice['token']) && is_string($notice['token'])) { echo '
'.home_escape($notice['token']).'
' ."\n"; } $csrf_token = home_csrf_token($url_base, $configuration); if ($csrf_token === FALSE) { - echo '

当前无法初始化安全表单。

' ."\n"; + echo '

'.home_escape(t('home.form_unavailable')).'

' ."\n"; echo '
' ."\n"; return; } @@ -612,56 +585,62 @@ function home_send_authentication($url_base, $configuration, $notice) { echo '
' ."\n"; echo '
' ."\n".$csrf_field; echo '' ."\n"; - echo '
' ."\n"; + echo '
' ."\n"; echo '
' ."\n"; - echo '
' ."\n"; + echo '
' ."\n"; echo '
' ."\n"; - echo '' ."\n"; + echo '' ."\n"; if (auth_registration_is_enabled()) { echo '
' ."\n".$csrf_field; echo '' ."\n"; - echo '
' ."\n"; + echo '
' ."\n"; echo '
' ."\n"; - echo '
' ."\n"; + echo '
' ."\n"; echo '
' ."\n"; - echo '
' ."\n"; + echo '
' ."\n"; echo '
' ."\n"; - echo '' ."\n"; + echo '' ."\n"; } echo '
' ."\n"; - echo '

网页登录使用密码;Git clone、pull 和 push 使用用户名与 access token。

' ."\n"; + echo '

'.home_escape(t('home.auth_hint')).'

' ."\n"; echo '' ."\n"; return; } - echo '
' ."\n"; echo '
'.$csrf_field; echo '' ."\n"; - echo '
' ."\n"; - echo '
' ."\n"; - echo '
' ."\n"; + echo '
' ."\n"; + echo '
' ."\n"; + echo '' ."\n"; $tokens = auth_list_access_tokens($user['id']); if ($tokens === FALSE) { - echo '

当前无法读取 access token 列表。

' ."\n"; + echo '

'.home_escape(t('home.token_list_unavailable')).'

' ."\n"; } else if (!empty($tokens)) { echo '
    ' ."\n"; foreach ($tokens as $token) { - $last_used = $token['last_used_at'] === NULL ? '从未使用' : '最后使用 '.$token['last_used_at']; + $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 '
  • '.home_escape($token['name']).'
    '; - echo '创建于 '.home_escape($token['created_at']).';'.home_escape($last_used).'
    ' ."\n"; + echo ''.home_escape($created).' · '.home_escape($last_used).'' ."\n"; echo '
    '.$csrf_field; echo '' ."\n"; echo '' ."\n"; - echo '
  • ' ."\n"; + echo '' ."\n"; } echo '
' ."\n"; } @@ -674,21 +653,21 @@ function home_send_creation($url_base, $configuration, $notice, $value, $visibil } echo '
' ."\n"; - echo '

创建仓库

' ."\n"; + echo '

'.home_escape(t('home.create_repository')).'

' ."\n"; if ($notice !== NULL) { echo '

' .home_escape($notice['message']).'

' ."\n"; } if (!home_creation_is_authorized($configuration)) { - echo '

需要先登录应用账号,才能创建仓库。

' ."\n"; + echo '

'.home_escape(t('home.create_login_required')).'

' ."\n"; echo '
' ."\n"; return; } $token = home_csrf_token($url_base, $configuration); if ($token === FALSE) { - echo '

当前无法初始化安全表单。

' ."\n"; + echo '

'.home_escape(t('home.form_unavailable')).'

' ."\n"; echo '' ."\n"; return; } @@ -696,19 +675,19 @@ function home_send_creation($url_base, $configuration, $notice, $value, $visibil echo '
' ."\n"; echo '' ."\n"; echo '' ."\n"; - echo '
' ."\n"; + echo '
' ."\n"; echo '' ."\n"; - echo '

可使用字母、数字、点、短横线和下划线;.git 后缀可省略。

' ."\n"; - echo '
可见性
' ."\n"; + echo '

'.t('home.repository_name_hint').'

' ."\n"; + echo '
'.home_escape(t('home.visibility')).'
' ."\n"; echo '' ."\n"; + .($visibility !== 'private' ? ' checked' : '').'>'.home_escape(t('home.public')).'' ."\n"; echo '' ."\n"; + .($visibility === 'private' ? ' checked' : '').'>'.home_escape(t('home.private')).'' ."\n"; echo '
' ."\n"; - echo '' ."\n"; + echo '' ."\n"; echo '' ."\n"; echo '' ."\n"; } @@ -723,10 +702,14 @@ function home_send_repository_table( $csrf_token = $user === NULL ? FALSE : home_csrf_token($url_base, $configuration); echo ''."\n"; echo ''."\n"; - echo '' - .'' - .'' - .'' ."\n"; + echo '' + .'' + .'' + .'' + .'' + .'' + .'' + .'' ."\n"; echo ''."\n"; foreach ($repositories as $repository) { @@ -734,16 +717,16 @@ function home_send_repository_table( if ($summary['head'] !== NULL) { $head = ''.home_escape($summary['head']).''; } else if ($summary['branches'] === 0) { - $head = '空仓库'; + $head = ''.home_escape(t('home.badge_empty')).''; } else { - $head = '未指向分支'; + $head = ''.home_escape(t('home.badge_no_branch')).''; } $owner = $repository['options']['owner'] === NULL - ? '未设置' + ? ''.home_escape(t('home.badge_unset')).'' : ''.home_escape($repository['options']['owner']).''; $access = $repository['options']['push'] && $repository['options']['owner'] !== NULL - ? '所有者可推送' - : '只读'; + ? ''.home_escape(t('home.badge_owner_push')).'' + : ''.home_escape(t('home.badge_read_only')).''; echo ''; echo ''; @@ -763,10 +746,10 @@ function home_send_repository_table( echo '' ."\n"; echo '' ."\n"; - echo ''; + .'value="delete" required> '.home_escape(t('home.confirm_delete')).'' ."\n"; + echo ''; } else { - echo ''; + echo ''.home_escape(t('home.badge_none')).''; } echo ''; echo ''."\n"; @@ -799,13 +782,10 @@ function home_send_repository_section( } function home_send_empty_notice() { - echo <<<'HTML' -
-

当前没有可读取的仓库。

-

请复制 config.php.sampleconfig.php,在 $repos -中配置仓库路径,并确认仓库目录存在且 read 选项为 TRUE

-
-HTML; + echo '
'."\n"; + echo '

'.home_escape(t('home.empty_title')).'

'."\n"; + echo '

'.t('home.empty_body').'

'."\n"; + echo '
'."\n"; } function home_send_usage($repositories, $prefix) { @@ -813,12 +793,11 @@ function home_send_usage($repositories, $prefix) { ? $prefix.'/project.git' : $prefix.$repositories[0]['url']; - echo '

使用方法

'."\n"; + echo '

'.home_escape(t('home.usage_title')).'

'."\n"; echo '
git clone '.home_escape($example)."\n"
         .'git pull'."\n"
         .'git push origin main
'."\n"; - echo '

push 需要仓库启用 push 选项;启用认证时还需要' - .'使用账号用户名和 access token。

'."\n"; + echo '

'.t('home.usage_hint').'

'."\n"; } function home_render( @@ -837,7 +816,7 @@ 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_head($url_base); if ($notice !== NULL) { echo '

' .home_escape($notice['message']).'

' ."\n"; @@ -847,26 +826,26 @@ function home_render( 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(array_merge($repositories['public'], $repositories['private']), $prefix); - echo '
详细安装、配置和安全说明见 usage.md
'."\n"; + echo '
'.t('home.footer').'
'."\n"; echo ''."\n".''."\n"; } @@ -914,6 +893,7 @@ if (!isset($auth)) { 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)) { diff --git a/install.php b/install.php index 42a6ad0..750ee3c 100644 --- a/install.php +++ b/install.php @@ -5,6 +5,10 @@ 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'); @@ -115,34 +119,34 @@ function install_validate($input) { if ($input['url_base'] !== '' && !preg_match('~^/[A-Za-z0-9._~/-]*[A-Za-z0-9._~-]$~D', $input['url_base'])) { - $errors[] = '基础路径必须以 / 开头,且不能以 / 结尾;部署在域名根路径时请留空。'; + $errors[] = t('install.error_url_base'); } if ($input['git_executable'] === '' || strlen($input['git_executable']) > 255 || preg_match('~[\x00-\x1F\x7F]~', $input['git_executable'])) { - $errors[] = 'Git 可执行文件路径无效。'; + $errors[] = t('install.error_git_executable'); } if (!install_host_is_valid($input['db_host'])) { - $errors[] = '数据库主机名无效。'; + $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[] = '数据库端口无效。'; + $errors[] = t('install.error_db_port'); } if (!install_identifier_is_valid($input['db_name'])) { - $errors[] = '数据库名只能包含字母、数字、下划线和短横线,最多 64 个字符。'; + $errors[] = t('install.error_db_name'); } if (!install_identifier_is_valid($input['db_user'])) { - $errors[] = '数据库用户名只能包含字母、数字、下划线和短横线,最多 64 个字符。'; + $errors[] = t('install.error_db_user'); } if ($input['db_password'] === '' || strlen($input['db_password']) > 255) { - $errors[] = '数据库密码不能为空,且最多 255 个字符。'; + $errors[] = t('install.error_db_password'); } if (auth_normalize_username($input['admin_username']) === FALSE) { - $errors[] = '管理员用户名须为 3 至 64 个字母、数字、点、短横线或下划线。'; + $errors[] = t('install.error_admin_username'); } if (!auth_password_is_valid($input['admin_password'])) { - $errors[] = '管理员密码长度须为 8 至 72 个字符,且不能超过 72 字节。'; + $errors[] = t('install.error_admin_password'); } else if (!hash_equals($input['admin_password'], $input['admin_password_confirmation'])) { - $errors[] = '两次输入的管理员密码不一致。'; + $errors[] = t('install.error_admin_password_mismatch'); } return $errors; @@ -219,7 +223,8 @@ function install_config_contents($input) { return " TRUE,\n" ." 'registration_enabled' => ".($input['registration_enabled'] ? 'TRUE' : 'FALSE').",\n" @@ -278,51 +283,45 @@ function install_run($input) { try { $connection = install_connect( install_database_dsn($input), $input['db_user'], $input['db_password']); - $steps[] = array('success', '已使用数据库账号连接成功。'); + $steps[] = array('success', t('install.step_connected')); $import = install_import_schema($connection); if ($import['status'] === 'schema_unreadable') { - return array('errors' => array('无法读取 schema.mysql.sql。'), 'steps' => $steps); + return array('errors' => array(t('install.error_schema_unreadable')), 'steps' => $steps); } if ($import['status'] === 'schema_denied') { return array( - 'errors' => array( - '该数据库账号没有建表权限。请用主机面板的 phpMyAdmin 或数据库导入功能' - .'导入项目根目录的 schema.mysql.sql,然后重新提交本页面;' - .'安装器会自动识别已存在的表并跳过导入。'), + 'errors' => array(t('install.error_schema_denied')), 'steps' => $steps); } $steps[] = $import['status'] === 'schema_imported' - ? array('success', '已导入 '.$import['tables'].' 张表。') - : array('success', '检测到已存在的 pgit_ 表,跳过导入。'); + ? 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( - '该数据库账号缺少 DELETE 权限,删除仓库和用户会失败。' - .'请在主机面板或数据库中为该账号授予 DELETE 权限后重试。'), + 'errors' => array(t('install.error_delete_privilege')), 'steps' => $steps); } - $steps[] = array('success', '已确认账号具备 SELECT、INSERT、UPDATE 和 DELETE 权限。'); + $steps[] = array('success', t('install.step_privileges')); $administrator = install_create_administrator( $connection, $input['admin_username'], $input['admin_password']); $steps[] = $administrator === 'administrator_created' - ? array('success', '管理员账号 '.$input['admin_username'].' 已创建。') - : array('success', '账号 '.$input['admin_username'].' 已存在,将其设为管理员。'); + ? 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('数据库操作失败:'.$exception->getMessage()), + 'errors' => array(t('install.error_database', array('message' => $exception->getMessage()))), 'steps' => $steps); } if (!install_write_config($input)) { return array( - 'errors' => array( - 'config.php 写入失败。请确认项目目录可写,或该文件是否已存在。'), + 'errors' => array(t('install.error_config_write')), 'steps' => $steps); } - $steps[] = array('success', 'config.php 已写入,权限设为 0600。'); + $steps[] = array('success', t('install.step_config_written')); return array('errors' => array(), 'steps' => $steps, 'complete' => TRUE); } @@ -330,11 +329,11 @@ function install_run($input) { function install_send_head() { echo <<<'HTML' - + -安装 - PHP Git 服务器 +__TITLE__
'.home_escape($caption).'
仓库所有者克隆地址默认分支分支标签权限操作
'.home_escape(t('home.th_repository')).''.home_escape(t('home.th_owner')).''.home_escape(t('home.th_clone_url')).''.home_escape(t('home.th_default_branch')).''.home_escape(t('home.th_branches')).''.home_escape(t('home.th_tags')).''.home_escape(t('home.th_access')).''.home_escape(t('home.th_actions')).'
'.home_escape(basename($repository['url'])).'