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