main
rb 158 lines 5.06 KB
Raw
1 # frozen_string_literal: true
2
3 require "open3"
4
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 #
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 #
13 # The git token is the scoped, short-lived credential minted by
14 # Api::V1::GitCredentialsController (HTTP Basic password).
15 #
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
22
23 # GET /:user/:repo.git/info/refs?service=git-(upload|receive)-pack
24 def info_refs
25 service = params[:service]
26 return head(:forbidden) unless SERVICES.include?(service)
27 return if reject_mirror_push!(service)
28 return unless authorize!(service)
29
30 advertise = git_run([ service.delete_prefix("git-"), "--stateless-rpc", "--advertise-refs", @repo.disk_path ])
31 return head(:internal_server_error) if advertise.nil?
32
33 no_cache
34 response.content_type = "application/x-#{service}-advertisement"
35 render body: pkt_line("# service=#{service}\n") + "0000" + advertise
36 end
37
38 # POST /:user/:repo.git/git-upload-pack (clone/fetch)
39 def upload_pack
40 return unless authorize!("git-upload-pack")
41 rpc("upload-pack", "application/x-git-upload-pack-result")
42 end
43
44 # POST /:user/:repo.git/git-receive-pack (push)
45 def receive_pack
46 return if reject_mirror_push!("git-receive-pack")
47 return unless authorize!("git-receive-pack")
48 rpc("receive-pack", "application/x-git-receive-pack-result")
49 end
50
51 private
52
53 # Mirrors are read-only: their upstream is the source of truth, so accepting a
54 # push would let siGit silently diverge. Reject any receive-pack (push) with a
55 # message the user actually sees — a git `ERR` pkt-line in the ref
56 # advertisement, which the client prints as `fatal: remote error: …`. Returns
57 # true when it handled (rejected) the request. Applies to everyone, including
58 # the owner, until the mirror is detached.
59 def reject_mirror_push!(service)
60 return false unless service == "git-receive-pack"
61 return false unless @repo&.mirror?
62
63 message = "siGit: #{@repo.full_name} is a read-only mirror of #{@repo.upstream_url}. " \
64 "Detach it in the siGit UI to make it writable."
65
66 no_cache
67 response.content_type = "application/x-git-receive-pack-advertisement"
68 render body: pkt_line("# service=git-receive-pack\n") + "0000" + pkt_line("ERR #{message}")
69 true
70 end
71
72 def rpc(service, content_type)
73 input = request.body.read.to_s
74 input = ActiveSupport::Gzip.decompress(input) if gzip_request?
75
76 out = git_run([ service, "--stateless-rpc", @repo.disk_path ], stdin: input)
77 return head(:internal_server_error) if out.nil?
78
79 no_cache
80 response.content_type = content_type
81 render body: out
82 end
83
84 def git_run(args, stdin: nil)
85 out, _err, status = Open3.capture3("git", *args, stdin_data: stdin.to_s, binmode: true)
86 status.success? ? out : nil
87 end
88
89 # Read: public open, private owner-only. Write: owner-only.
90 def authorize!(service)
91 service == "git-receive-pack" ? authorize_write! : authorize_read!
92 end
93
94 def authorize_read!
95 return true unless @repo.is_private?
96
97 user = git_token_user
98 return challenge! if user.nil?
99 return true if @repo.user_id == user.id
100
101 git_not_found
102 end
103
104 def authorize_write!
105 user = git_token_user
106 return challenge! if user.nil?
107 return true if @repo.user_id == user.id
108
109 # Authenticated but not the owner. Keep private repos invisible.
110 @repo.is_private? ? git_not_found : (head(:forbidden) && false)
111 end
112
113 def challenge!
114 response.headers["WWW-Authenticate"] = 'Basic realm="siGit"'
115 head :unauthorized
116 false
117 end
118
119 def load_repo
120 owner = User.find_by(username: params[:user])
121 return git_not_found unless owner
122
123 name = params[:repo].to_s.sub(/\.git\z/, "")
124 @repo = owner.repositories.find_by(name: name)
125 git_not_found unless @repo
126 end
127
128 # Resolves the user from the HTTP Basic password (the scoped git token).
129 def git_token_user
130 match = request.authorization.to_s.match(/\ABasic (.+)\z/)
131 return nil unless match
132
133 _username, token = Base64.decode64(match[1]).split(":", 2)
134 return nil if token.to_s.empty?
135
136 user_id = Rails.application.message_verifier("git_access").verify(token)
137 User.find_by(id: user_id)
138 rescue ActiveSupport::MessageVerifier::InvalidSignature
139 nil
140 end
141
142 def git_not_found
143 head :not_found
144 false
145 end
146
147 def gzip_request?
148 request.headers["Content-Encoding"].to_s.include?("gzip")
149 end
150
151 def pkt_line(str)
152 format("%04x", str.bytesize + 4) + str
153 end
154
155 def no_cache
156 response.headers["Cache-Control"] = "no-cache"
157 end
158 end