Git Smart HTTP endpoints

Seto Elkahfi committed Jun 17, 2026 at 14:57 UTC 4a2997cf3926883afc792e73b4719ebc226074df
3 files changed +119 -49
app/controllers/api/v1/repos_controller.rb
+35 -2
index 4b1c159..ad0047f 100644 --- a/app/controllers/api/v1/repos_controller.rb +++ b/app/controllers/api/v1/repos_controller.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true +require "open3" + module Api module V1 - # Lists the authenticated user's repositories hosted on sigit.si. + # Lists and creates the authenticated user's repositories hosted on sigit.si. # # This replaces the desktop app's old dependency on smbCloud platform - # projects: siGit is a git app, so it lists the user's own sigit-si repos. + # projects: siGit is a git app, so it lists/creates the user's own sigit-si + # repos. Creating one is the "publish to sigit.si" target — an empty bare + # repo the desktop then pushes its local content to. class ReposController < Api::BaseController before_action :authenticate_token! @@ -16,6 +20,35 @@ module Api render json: repos.map { |repo| repo_json(repo) }, status: :ok end + # POST /api/v1/repos + # Params: name (required), is_private (default false), description + # Creates an empty bare repo for the user to push to. + def create + repo = current_user.repositories.new( + name: params[:name].to_s.strip, + is_private: ActiveModel::Type::Boolean.new.cast(params[:is_private]) || false, + description: params[:description], + default_branch: "main" + ) + repo.disk_path = GitRepositoryService.repo_path(current_user.username, repo.name) + + unless repo.save + return render_error(ERR_INVALID, repo.errors.full_messages.to_sentence, + status: :unprocessable_entity) + end + + GitRepositoryService.create_bare_repo(current_user.username, repo.name) + # Point HEAD at the default branch so the first push lands on it. + Open3.capture3("git", "--git-dir", repo.disk_path, + "symbolic-ref", "HEAD", "refs/heads/#{repo.default_branch}") + + render json: repo_json(repo), status: :created + rescue StandardError => e + Rails.logger.error("API repo create failed for #{params[:name].inspect}: #{e.message}") + repo&.destroy # roll back the row if the on-disk init blew up + render_error(ERR_UNKNOWN, "Failed to create the repository.", status: :internal_server_error) + end + private def repo_json(repo)
app/controllers/git_http_controller.rb
+79 -45
index 5460b76..6bae39c 100644 --- a/app/controllers/git_http_controller.rb +++ b/app/controllers/git_http_controller.rb @@ -2,56 +2,98 @@ require "open3" -# Git Smart HTTP — read-only (clone/fetch) over token-authenticated HTTPS. +# Git Smart HTTP over token-authenticated HTTPS — clone/fetch (read) and push +# (write). Authorization lives here, in the app, where User + Repository + +# is_private already exist — no SSH gateway, no system git user, no keys. # -# Authorization lives here, in the app, where User + Repository + is_private -# already exist — no SSH gateway, no system git user, no key management. +# - read (upload-pack): public repos anonymous; private repos require a git +# token whose user owns the repo. Unauthorized private → 404 (no enumeration). +# - write (receive-pack): always requires a git token whose user owns the repo. # -# - public repos: anonymous read -# - private repos: HTTP Basic auth where the password is a scoped git token -# (minted by Api::V1::GitCredentialsController); the token's user must own the -# repo. Unauthorized private repos return 404, never 403, so their existence -# isn't leaked. +# The git token is the scoped, short-lived credential minted by +# Api::V1::GitCredentialsController (HTTP Basic password). # -# Note: this buffers the packfile in memory (fine for a baseline / dev). In -# production, offload streaming to nginx `git-http-backend` + `fcgiwrap` with an -# `auth_request` to a tiny authz endpoint. +# Note: buffers in memory (fine for a baseline / dev). In production, offload +# streaming to nginx `git-http-backend` + `fcgiwrap` with an `auth_request`. class GitHttpController < ActionController::API + SERVICES = %w[git-upload-pack git-receive-pack].freeze + before_action :load_repo - before_action :authorize_git_read! - # GET /:user/:repo.git/info/refs?service=git-upload-pack + # GET /:user/:repo.git/info/refs?service=git-(upload|receive)-pack def info_refs - return head(:forbidden) unless params[:service] == "git-upload-pack" + service = params[:service] + return head(:forbidden) unless SERVICES.include?(service) + return unless authorize!(service) - advertise, _err, status = Open3.capture3( - "git", "upload-pack", "--stateless-rpc", "--advertise-refs", @repo.disk_path, - binmode: true - ) - return head(:internal_server_error) unless status.success? + advertise = git_run([service.delete_prefix("git-"), "--stateless-rpc", "--advertise-refs", @repo.disk_path]) + return head(:internal_server_error) if advertise.nil? - response.headers["Cache-Control"] = "no-cache" - response.content_type = "application/x-git-upload-pack-advertisement" - render body: pkt_line("# service=git-upload-pack\n") + "0000" + advertise + no_cache + response.content_type = "application/x-#{service}-advertisement" + render body: pkt_line("# service=#{service}\n") + "0000" + advertise end - # POST /:user/:repo.git/git-upload-pack + # POST /:user/:repo.git/git-upload-pack (clone/fetch) def upload_pack + return unless authorize!("git-upload-pack") + rpc("upload-pack", "application/x-git-upload-pack-result") + end + + # POST /:user/:repo.git/git-receive-pack (push) + def receive_pack + return unless authorize!("git-receive-pack") + rpc("receive-pack", "application/x-git-receive-pack-result") + end + + private + + def rpc(service, content_type) input = request.body.read.to_s input = ActiveSupport::Gzip.decompress(input) if gzip_request? - out, _err, status = Open3.capture3( - "git", "upload-pack", "--stateless-rpc", @repo.disk_path, - stdin_data: input, binmode: true - ) - return head(:internal_server_error) unless status.success? + out = git_run([service, "--stateless-rpc", @repo.disk_path], stdin: input) + return head(:internal_server_error) if out.nil? - response.headers["Cache-Control"] = "no-cache" - response.content_type = "application/x-git-upload-pack-result" + no_cache + response.content_type = content_type render body: out end - private + def git_run(args, stdin: nil) + out, _err, status = Open3.capture3("git", *args, stdin_data: stdin.to_s, binmode: true) + status.success? ? out : nil + end + + # Read: public open, private owner-only. Write: owner-only. + def authorize!(service) + service == "git-receive-pack" ? authorize_write! : authorize_read! + end + + def authorize_read! + return true unless @repo.is_private? + + user = git_token_user + return challenge! if user.nil? + return true if @repo.user_id == user.id + + git_not_found + end + + def authorize_write! + user = git_token_user + return challenge! if user.nil? + return true if @repo.user_id == user.id + + # Authenticated but not the owner. Keep private repos invisible. + @repo.is_private? ? git_not_found : (head(:forbidden) && false) + end + + def challenge! + response.headers["WWW-Authenticate"] = 'Basic realm="siGit"' + head :unauthorized + false + end def load_repo owner = User.find_by(username: params[:user]) @@ -59,20 +101,7 @@ class GitHttpController < ActionController::API name = params[:repo].to_s.sub(/\.git\z/, "") @repo = owner.repositories.find_by(name: name) - return git_not_found unless @repo&.initialized? - end - - # Public repos: open. Private repos: require a git token whose user owns it. - def authorize_git_read! - return unless @repo.is_private? - - user = git_token_user - if user.nil? - response.headers["WWW-Authenticate"] = 'Basic realm="siGit"' - return head(:unauthorized) - end - - git_not_found unless @repo.user_id == user.id + git_not_found unless @repo end # Resolves the user from the HTTP Basic password (the scoped git token). @@ -91,6 +120,7 @@ class GitHttpController < ActionController::API def git_not_found head :not_found + false end def gzip_request? @@ -100,4 +130,8 @@ class GitHttpController < ActionController::API def pkt_line(str) format("%04x", str.bytesize + 4) + str end + + def no_cache + response.headers["Cache-Control"] = "no-cache" + end end
config/routes.rb
+5 -2
index a8e2926..17e8862 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -47,6 +47,7 @@ Rails.application.routes.draw do get "me", to: "me#show" delete "me", to: "me#destroy" get "repos", to: "repos#index" + post "repos", to: "repos#create" post "git_credentials", to: "git_credentials#create" end end @@ -54,9 +55,11 @@ Rails.application.routes.draw do # Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before # the catch-all "/:username" routes; the `*.git` constraint keeps them from # matching normal repo-browsing URLs. - get "/:user/:repo/info/refs", to: "git_http#info_refs", + get "/:user/:repo/info/refs", to: "git_http#info_refs", constraints: { repo: /[^\/]+\.git/ } - post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack", + post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack", + constraints: { repo: /[^\/]+\.git/ } + post "/:user/:repo/git-receive-pack", to: "git_http#receive_pack", constraints: { repo: /[^\/]+\.git/ } # User profile (must come before repository routes)