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)