Clone using git smart http

Seto Elkahfi committed Jun 17, 2026 at 14:22 UTC 60512146e7da5e85f03a9f5dd636e177cb041898
4 files changed +144 -7
app/controllers/api/v1/git_credentials_controller.rb
+31
new file mode 100644 index 0000000..75fa3d7 --- /dev/null +++ b/app/controllers/api/v1/git_credentials_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Api + module V1 + # Mints a short-lived, read-scoped git credential for the authenticated user. + # + # The desktop uses its smbCloud access token only to mint this; the git + # credential it actually clones with is a separate, signed, 1-hour token that + # encodes just the user id. Leaking it cannot touch the account (no remove, + # no me) — it only grants git read access to that user's repos for an hour. + class GitCredentialsController < Api::BaseController + GIT_TOKEN_TTL = 1.hour + + before_action :authenticate_token! + + # POST /api/v1/git_credentials + # Header: Authorization: Bearer <access_token> + def create + git_token = Rails.application + .message_verifier("git_access") + .generate(current_user.id, expires_in: GIT_TOKEN_TTL) + + render json: { + git_token: git_token, + username: "x-access-token", + expires_at: GIT_TOKEN_TTL.from_now + }, status: :ok + end + end + end +end
app/controllers/api/v1/repos_controller.rb
+1 -7
index 3e0cc1f..4b1c159 100644 --- a/app/controllers/api/v1/repos_controller.rb +++ b/app/controllers/api/v1/repos_controller.rb @@ -31,15 +31,9 @@ module Api initialized: repo.initialized?, updated_at: repo.updated_at, web_url: "#{request.base_url}/#{repo.full_name}", - clone_url: "git@#{git_ssh_host}:#{repo.full_name}" + clone_url: "#{request.base_url}/#{repo.full_name}.git" } end - - # SSH host for git access (matches the clone command shown in the web UI). - # Override per environment with SIGITSI_GIT_SSH_HOST. - def git_ssh_host - ENV.fetch("SIGITSI_GIT_SSH_HOST", "sigitsi.com") - end end end end
app/controllers/git_http_controller.rb
+103
new file mode 100644 index 0000000..5460b76 --- /dev/null +++ b/app/controllers/git_http_controller.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "open3" + +# Git Smart HTTP — read-only (clone/fetch) over token-authenticated HTTPS. +# +# Authorization lives here, in the app, where User + Repository + is_private +# already exist — no SSH gateway, no system git user, no key management. +# +# - 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. +# +# 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. +class GitHttpController < ActionController::API + before_action :load_repo + before_action :authorize_git_read! + + # GET /:user/:repo.git/info/refs?service=git-upload-pack + def info_refs + return head(:forbidden) unless params[:service] == "git-upload-pack" + + 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? + + 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 + end + + # POST /:user/:repo.git/git-upload-pack + def upload_pack + 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? + + response.headers["Cache-Control"] = "no-cache" + response.content_type = "application/x-git-upload-pack-result" + render body: out + end + + private + + def load_repo + owner = User.find_by(username: params[:user]) + return git_not_found unless owner + + 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 + end + + # Resolves the user from the HTTP Basic password (the scoped git token). + def git_token_user + match = request.authorization.to_s.match(/\ABasic (.+)\z/) + return nil unless match + + _username, token = Base64.decode64(match[1]).split(":", 2) + return nil if token.to_s.empty? + + user_id = Rails.application.message_verifier("git_access").verify(token) + User.find_by(id: user_id) + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + def git_not_found + head :not_found + end + + def gzip_request? + request.headers["Content-Encoding"].to_s.include?("gzip") + end + + def pkt_line(str) + format("%04x", str.bytesize + 4) + str + end +end
config/routes.rb
+9
index 64881cc..a8e2926 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -47,9 +47,18 @@ Rails.application.routes.draw do get "me", to: "me#show" delete "me", to: "me#destroy" get "repos", to: "repos#index" + post "git_credentials", to: "git_credentials#create" end end + # 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", + constraints: { repo: /[^\/]+\.git/ } + post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack", + constraints: { repo: /[^\/]+\.git/ } + # User profile (must come before repository routes) get "/:username", to: "users#show", as: :user_profile, constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }