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
@@ -1,11 +1,15 @@
1
# frozen_string_literal: true
2
3
+require "open3"
4
+
5
module Api
6
module V1
5
- # Lists the authenticated user's repositories hosted on sigit.si.
7
+ # Lists and creates the authenticated user's repositories hosted on sigit.si.
8
#
9
# This replaces the desktop app's old dependency on smbCloud platform
8
- # projects: siGit is a git app, so it lists the user's own sigit-si repos.
10
+ # projects: siGit is a git app, so it lists/creates the user's own sigit-si
11
+ # repos. Creating one is the "publish to sigit.si" target — an empty bare
12
+ # repo the desktop then pushes its local content to.
13
class ReposController < Api::BaseController
14
before_action :authenticate_token!
15
@@ -16,6 +20,35 @@ module Api
20
render json: repos.map { |repo| repo_json(repo) }, status: :ok
21
end
22
23
+ # POST /api/v1/repos
24
+ # Params: name (required), is_private (default false), description
25
+ # Creates an empty bare repo for the user to push to.
26
+ def create
27
+ repo = current_user.repositories.new(
28
+ name: params[:name].to_s.strip,
29
+ is_private: ActiveModel::Type::Boolean.new.cast(params[:is_private]) || false,
30
+ description: params[:description],
31
+ default_branch: "main"
32
+ )
33
+ repo.disk_path = GitRepositoryService.repo_path(current_user.username, repo.name)
34
+
35
+ unless repo.save
36
+ return render_error(ERR_INVALID, repo.errors.full_messages.to_sentence,
37
+ status: :unprocessable_entity)
38
+ end
39
+
40
+ GitRepositoryService.create_bare_repo(current_user.username, repo.name)
41
+ # Point HEAD at the default branch so the first push lands on it.
42
+ Open3.capture3("git", "--git-dir", repo.disk_path,
43
+ "symbolic-ref", "HEAD", "refs/heads/#{repo.default_branch}")
44
+
45
+ render json: repo_json(repo), status: :created
46
+ rescue StandardError => e
47
+ Rails.logger.error("API repo create failed for #{params[:name].inspect}: #{e.message}")
48
+ repo&.destroy # roll back the row if the on-disk init blew up
49
+ render_error(ERR_UNKNOWN, "Failed to create the repository.", status: :internal_server_error)
50
+ end
51
+
52
private
53
54
def repo_json(repo)
app/controllers/git_http_controller.rb
+79
-45
@@ -2,56 +2,98 @@
2
3
require "open3"
4
5
-# Git Smart HTTP — read-only (clone/fetch) over token-authenticated HTTPS.
5
+# Git Smart HTTP over token-authenticated HTTPS — clone/fetch (read) and push
6
+# (write). Authorization lives here, in the app, where User + Repository +
7
+# is_private already exist — no SSH gateway, no system git user, no keys.
8
#
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
+# - read (upload-pack): public repos anonymous; private repos require a git
10
+# token whose user owns the repo. Unauthorized private → 404 (no enumeration).
11
+# - write (receive-pack): always requires a git token whose user owns the repo.
12
#
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.
13
+# The git token is the scoped, short-lived credential minted by
14
+# Api::V1::GitCredentialsController (HTTP Basic password).
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.
16
+# Note: buffers in memory (fine for a baseline / dev). In production, offload
17
+# streaming to nginx `git-http-backend` + `fcgiwrap` with an `auth_request`.
18
class GitHttpController < ActionController::API
19
+ SERVICES = %w[git-upload-pack git-receive-pack].freeze
20
+
21
before_action :load_repo
21
- before_action :authorize_git_read!
22
23
- # GET /:user/:repo.git/info/refs?service=git-upload-pack
23
+ # GET /:user/:repo.git/info/refs?service=git-(upload|receive)-pack
24
def info_refs
25
- return head(:forbidden) unless params[:service] == "git-upload-pack"
25
+ service = params[:service]
26
+ return head(:forbidden) unless SERVICES.include?(service)
27
+ return unless authorize!(service)
28
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?
29
+ advertise = git_run([service.delete_prefix("git-"), "--stateless-rpc", "--advertise-refs", @repo.disk_path])
30
+ return head(:internal_server_error) if advertise.nil?
31
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
32
+ no_cache
33
+ response.content_type = "application/x-#{service}-advertisement"
34
+ render body: pkt_line("# service=#{service}\n") + "0000" + advertise
35
end
36
38
- # POST /:user/:repo.git/git-upload-pack
37
+ # POST /:user/:repo.git/git-upload-pack (clone/fetch)
38
def upload_pack
39
+ return unless authorize!("git-upload-pack")
40
+ rpc("upload-pack", "application/x-git-upload-pack-result")
41
+ end
42
+
43
+ # POST /:user/:repo.git/git-receive-pack (push)
44
+ def receive_pack
45
+ return unless authorize!("git-receive-pack")
46
+ rpc("receive-pack", "application/x-git-receive-pack-result")
47
+ end
48
+
49
+ private
50
+
51
+ def rpc(service, content_type)
52
input = request.body.read.to_s
53
input = ActiveSupport::Gzip.decompress(input) if gzip_request?
54
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?
55
+ out = git_run([service, "--stateless-rpc", @repo.disk_path], stdin: input)
56
+ return head(:internal_server_error) if out.nil?
57
49
- response.headers["Cache-Control"] = "no-cache"
50
- response.content_type = "application/x-git-upload-pack-result"
58
+ no_cache
59
+ response.content_type = content_type
60
render body: out
61
end
62
54
- private
63
+ def git_run(args, stdin: nil)
64
+ out, _err, status = Open3.capture3("git", *args, stdin_data: stdin.to_s, binmode: true)
65
+ status.success? ? out : nil
66
+ end
67
+
68
+ # Read: public open, private owner-only. Write: owner-only.
69
+ def authorize!(service)
70
+ service == "git-receive-pack" ? authorize_write! : authorize_read!
71
+ end
72
+
73
+ def authorize_read!
74
+ return true unless @repo.is_private?
75
+
76
+ user = git_token_user
77
+ return challenge! if user.nil?
78
+ return true if @repo.user_id == user.id
79
+
80
+ git_not_found
81
+ end
82
+
83
+ def authorize_write!
84
+ user = git_token_user
85
+ return challenge! if user.nil?
86
+ return true if @repo.user_id == user.id
87
+
88
+ # Authenticated but not the owner. Keep private repos invisible.
89
+ @repo.is_private? ? git_not_found : (head(:forbidden) && false)
90
+ end
91
+
92
+ def challenge!
93
+ response.headers["WWW-Authenticate"] = 'Basic realm="siGit"'
94
+ head :unauthorized
95
+ false
96
+ end
97
98
def load_repo
99
owner = User.find_by(username: params[:user])
@@ -59,20 +101,7 @@ class GitHttpController < ActionController::API
101
102
name = params[:repo].to_s.sub(/\.git\z/, "")
103
@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
104
+ git_not_found unless @repo
105
end
106
107
# Resolves the user from the HTTP Basic password (the scoped git token).
@@ -91,6 +120,7 @@ class GitHttpController < ActionController::API
120
121
def git_not_found
122
head :not_found
123
+ false
124
end
125
126
def gzip_request?
@@ -100,4 +130,8 @@ class GitHttpController < ActionController::API
130
def pkt_line(str)
131
format("%04x", str.bytesize + 4) + str
132
end
133
+
134
+ def no_cache
135
+ response.headers["Cache-Control"] = "no-cache"
136
+ end
137
end
config/routes.rb
+5
-2
@@ -47,6 +47,7 @@ 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 "repos", to: "repos#create"
51
post "git_credentials", to: "git_credentials#create"
52
end
53
end
@@ -54,9 +55,11 @@ Rails.application.routes.draw do
55
# Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before
56
# the catch-all "/:username" routes; the `*.git` constraint keeps them from
57
# matching normal repo-browsing URLs.
57
- get "/:user/:repo/info/refs", to: "git_http#info_refs",
58
+ get "/:user/:repo/info/refs", to: "git_http#info_refs",
59
constraints: { repo: /[^\/]+\.git/ }
59
- post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack",
60
+ post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack",
61
+ constraints: { repo: /[^\/]+\.git/ }
62
+ post "/:user/:repo/git-receive-pack", to: "git_http#receive_pack",
63
constraints: { repo: /[^\/]+\.git/ }
64
65
# User profile (must come before repository routes)