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}/ }