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 new
+31
@@ -0,0 +1,31 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + # Mints a short-lived, read-scoped git credential for the authenticated user.
6 + #
7 + # The desktop uses its smbCloud access token only to mint this; the git
8 + # credential it actually clones with is a separate, signed, 1-hour token that
9 + # encodes just the user id. Leaking it cannot touch the account (no remove,
10 + # no me) — it only grants git read access to that user's repos for an hour.
11 + class GitCredentialsController < Api::BaseController
12 + GIT_TOKEN_TTL = 1.hour
13 +
14 + before_action :authenticate_token!
15 +
16 + # POST /api/v1/git_credentials
17 + # Header: Authorization: Bearer <access_token>
18 + def create
19 + git_token = Rails.application
20 + .message_verifier("git_access")
21 + .generate(current_user.id, expires_in: GIT_TOKEN_TTL)
22 +
23 + render json: {
24 + git_token: git_token,
25 + username: "x-access-token",
26 + expires_at: GIT_TOKEN_TTL.from_now
27 + }, status: :ok
28 + end
29 + end
30 + end
31 +end
app/controllers/api/v1/repos_controller.rb
+1 -7
@@ -31,15 +31,9 @@ module Api
31 initialized: repo.initialized?,
32 updated_at: repo.updated_at,
33 web_url: "#{request.base_url}/#{repo.full_name}",
34 - clone_url: "git@#{git_ssh_host}:#{repo.full_name}"
34 + clone_url: "#{request.base_url}/#{repo.full_name}.git"
35 }
36 end
37 -
38 - # SSH host for git access (matches the clone command shown in the web UI).
39 - # Override per environment with SIGITSI_GIT_SSH_HOST.
40 - def git_ssh_host
41 - ENV.fetch("SIGITSI_GIT_SSH_HOST", "sigitsi.com")
42 - end
37 end
38 end
39 end
app/controllers/git_http_controller.rb new
+103
@@ -0,0 +1,103 @@
1 +# frozen_string_literal: true
2 +
3 +require "open3"
4 +
5 +# Git Smart HTTP — read-only (clone/fetch) over token-authenticated HTTPS.
6 +#
7 +# Authorization lives here, in the app, where User + Repository + is_private
8 +# already exist — no SSH gateway, no system git user, no key management.
9 +#
10 +# - public repos: anonymous read
11 +# - private repos: HTTP Basic auth where the password is a scoped git token
12 +# (minted by Api::V1::GitCredentialsController); the token's user must own the
13 +# repo. Unauthorized private repos return 404, never 403, so their existence
14 +# isn't leaked.
15 +#
16 +# Note: this buffers the packfile in memory (fine for a baseline / dev). In
17 +# production, offload streaming to nginx `git-http-backend` + `fcgiwrap` with an
18 +# `auth_request` to a tiny authz endpoint.
19 +class GitHttpController < ActionController::API
20 + before_action :load_repo
21 + before_action :authorize_git_read!
22 +
23 + # GET /:user/:repo.git/info/refs?service=git-upload-pack
24 + def info_refs
25 + return head(:forbidden) unless params[:service] == "git-upload-pack"
26 +
27 + advertise, _err, status = Open3.capture3(
28 + "git", "upload-pack", "--stateless-rpc", "--advertise-refs", @repo.disk_path,
29 + binmode: true
30 + )
31 + return head(:internal_server_error) unless status.success?
32 +
33 + response.headers["Cache-Control"] = "no-cache"
34 + response.content_type = "application/x-git-upload-pack-advertisement"
35 + render body: pkt_line("# service=git-upload-pack\n") + "0000" + advertise
36 + end
37 +
38 + # POST /:user/:repo.git/git-upload-pack
39 + def upload_pack
40 + input = request.body.read.to_s
41 + input = ActiveSupport::Gzip.decompress(input) if gzip_request?
42 +
43 + out, _err, status = Open3.capture3(
44 + "git", "upload-pack", "--stateless-rpc", @repo.disk_path,
45 + stdin_data: input, binmode: true
46 + )
47 + return head(:internal_server_error) unless status.success?
48 +
49 + response.headers["Cache-Control"] = "no-cache"
50 + response.content_type = "application/x-git-upload-pack-result"
51 + render body: out
52 + end
53 +
54 + private
55 +
56 + def load_repo
57 + owner = User.find_by(username: params[:user])
58 + return git_not_found unless owner
59 +
60 + name = params[:repo].to_s.sub(/\.git\z/, "")
61 + @repo = owner.repositories.find_by(name: name)
62 + return git_not_found unless @repo&.initialized?
63 + end
64 +
65 + # Public repos: open. Private repos: require a git token whose user owns it.
66 + def authorize_git_read!
67 + return unless @repo.is_private?
68 +
69 + user = git_token_user
70 + if user.nil?
71 + response.headers["WWW-Authenticate"] = 'Basic realm="siGit"'
72 + return head(:unauthorized)
73 + end
74 +
75 + git_not_found unless @repo.user_id == user.id
76 + end
77 +
78 + # Resolves the user from the HTTP Basic password (the scoped git token).
79 + def git_token_user
80 + match = request.authorization.to_s.match(/\ABasic (.+)\z/)
81 + return nil unless match
82 +
83 + _username, token = Base64.decode64(match[1]).split(":", 2)
84 + return nil if token.to_s.empty?
85 +
86 + user_id = Rails.application.message_verifier("git_access").verify(token)
87 + User.find_by(id: user_id)
88 + rescue ActiveSupport::MessageVerifier::InvalidSignature
89 + nil
90 + end
91 +
92 + def git_not_found
93 + head :not_found
94 + end
95 +
96 + def gzip_request?
97 + request.headers["Content-Encoding"].to_s.include?("gzip")
98 + end
99 +
100 + def pkt_line(str)
101 + format("%04x", str.bytesize + 4) + str
102 + end
103 +end
config/routes.rb
+9
@@ -47,9 +47,18 @@ Rails.application.routes.draw do
47 get "me", to: "me#show"
48 delete "me", to: "me#destroy"
49 get "repos", to: "repos#index"
50 + post "git_credentials", to: "git_credentials#create"
51 end
52 end
53
54 + # Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before
55 + # the catch-all "/:username" routes; the `*.git` constraint keeps them from
56 + # matching normal repo-browsing URLs.
57 + get "/:user/:repo/info/refs", to: "git_http#info_refs",
58 + constraints: { repo: /[^\/]+\.git/ }
59 + post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack",
60 + constraints: { repo: /[^\/]+\.git/ }
61 +
62 # User profile (must come before repository routes)
63 get "/:username", to: "users#show", as: :user_profile,
64 constraints: { username: /[a-z0-9][a-z0-9\-]{0,38}/ }