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
index 4b1c159..ad0047f 100644
--- a/app/controllers/api/v1/repos_controller.rb
+++ b/app/controllers/api/v1/repos_controller.rb
@@ -1,11 +1,15 @@
# frozen_string_literal: true
+require "open3"
+
module Api
module V1
- # Lists the authenticated user's repositories hosted on sigit.si.
+ # Lists and creates the authenticated user's repositories hosted on sigit.si.
#
# This replaces the desktop app's old dependency on smbCloud platform
- # projects: siGit is a git app, so it lists the user's own sigit-si repos.
+ # projects: siGit is a git app, so it lists/creates the user's own sigit-si
+ # repos. Creating one is the "publish to sigit.si" target — an empty bare
+ # repo the desktop then pushes its local content to.
class ReposController < Api::BaseController
before_action :authenticate_token!
@@ -16,6 +20,35 @@ module Api
render json: repos.map { |repo| repo_json(repo) }, status: :ok
end
+ # POST /api/v1/repos
+ # Params: name (required), is_private (default false), description
+ # Creates an empty bare repo for the user to push to.
+ def create
+ repo = current_user.repositories.new(
+ name: params[:name].to_s.strip,
+ is_private: ActiveModel::Type::Boolean.new.cast(params[:is_private]) || false,
+ description: params[:description],
+ default_branch: "main"
+ )
+ repo.disk_path = GitRepositoryService.repo_path(current_user.username, repo.name)
+
+ unless repo.save
+ return render_error(ERR_INVALID, repo.errors.full_messages.to_sentence,
+ status: :unprocessable_entity)
+ end
+
+ GitRepositoryService.create_bare_repo(current_user.username, repo.name)
+ # Point HEAD at the default branch so the first push lands on it.
+ Open3.capture3("git", "--git-dir", repo.disk_path,
+ "symbolic-ref", "HEAD", "refs/heads/#{repo.default_branch}")
+
+ render json: repo_json(repo), status: :created
+ rescue StandardError => e
+ Rails.logger.error("API repo create failed for #{params[:name].inspect}: #{e.message}")
+ repo&.destroy # roll back the row if the on-disk init blew up
+ render_error(ERR_UNKNOWN, "Failed to create the repository.", status: :internal_server_error)
+ end
+
private
def repo_json(repo)
app/controllers/git_http_controller.rb
+79
-45
index 5460b76..6bae39c 100644
--- a/app/controllers/git_http_controller.rb
+++ b/app/controllers/git_http_controller.rb
@@ -2,56 +2,98 @@
require "open3"
-# Git Smart HTTP — read-only (clone/fetch) over token-authenticated HTTPS.
+# Git Smart HTTP over token-authenticated HTTPS — clone/fetch (read) and push
+# (write). Authorization lives here, in the app, where User + Repository +
+# is_private already exist — no SSH gateway, no system git user, no keys.
#
-# Authorization lives here, in the app, where User + Repository + is_private
-# already exist — no SSH gateway, no system git user, no key management.
+# - read (upload-pack): public repos anonymous; private repos require a git
+# token whose user owns the repo. Unauthorized private → 404 (no enumeration).
+# - write (receive-pack): always requires a git token whose user owns the repo.
#
-# - public repos: anonymous read
-# - private repos: HTTP Basic auth where the password is a scoped git token
-# (minted by Api::V1::GitCredentialsController); the token's user must own the
-# repo. Unauthorized private repos return 404, never 403, so their existence
-# isn't leaked.
+# The git token is the scoped, short-lived credential minted by
+# Api::V1::GitCredentialsController (HTTP Basic password).
#
-# Note: this buffers the packfile in memory (fine for a baseline / dev). In
-# production, offload streaming to nginx `git-http-backend` + `fcgiwrap` with an
-# `auth_request` to a tiny authz endpoint.
+# Note: buffers in memory (fine for a baseline / dev). In production, offload
+# streaming to nginx `git-http-backend` + `fcgiwrap` with an `auth_request`.
class GitHttpController < ActionController::API
+ SERVICES = %w[git-upload-pack git-receive-pack].freeze
+
before_action :load_repo
- before_action :authorize_git_read!
- # GET /:user/:repo.git/info/refs?service=git-upload-pack
+ # GET /:user/:repo.git/info/refs?service=git-(upload|receive)-pack
def info_refs
- return head(:forbidden) unless params[:service] == "git-upload-pack"
+ service = params[:service]
+ return head(:forbidden) unless SERVICES.include?(service)
+ return unless authorize!(service)
- advertise, _err, status = Open3.capture3(
- "git", "upload-pack", "--stateless-rpc", "--advertise-refs", @repo.disk_path,
- binmode: true
- )
- return head(:internal_server_error) unless status.success?
+ advertise = git_run([service.delete_prefix("git-"), "--stateless-rpc", "--advertise-refs", @repo.disk_path])
+ return head(:internal_server_error) if advertise.nil?
- response.headers["Cache-Control"] = "no-cache"
- response.content_type = "application/x-git-upload-pack-advertisement"
- render body: pkt_line("# service=git-upload-pack\n") + "0000" + advertise
+ no_cache
+ response.content_type = "application/x-#{service}-advertisement"
+ render body: pkt_line("# service=#{service}\n") + "0000" + advertise
end
- # POST /:user/:repo.git/git-upload-pack
+ # POST /:user/:repo.git/git-upload-pack (clone/fetch)
def upload_pack
+ return unless authorize!("git-upload-pack")
+ rpc("upload-pack", "application/x-git-upload-pack-result")
+ end
+
+ # POST /:user/:repo.git/git-receive-pack (push)
+ def receive_pack
+ return unless authorize!("git-receive-pack")
+ rpc("receive-pack", "application/x-git-receive-pack-result")
+ end
+
+ private
+
+ def rpc(service, content_type)
input = request.body.read.to_s
input = ActiveSupport::Gzip.decompress(input) if gzip_request?
- out, _err, status = Open3.capture3(
- "git", "upload-pack", "--stateless-rpc", @repo.disk_path,
- stdin_data: input, binmode: true
- )
- return head(:internal_server_error) unless status.success?
+ out = git_run([service, "--stateless-rpc", @repo.disk_path], stdin: input)
+ return head(:internal_server_error) if out.nil?
- response.headers["Cache-Control"] = "no-cache"
- response.content_type = "application/x-git-upload-pack-result"
+ no_cache
+ response.content_type = content_type
render body: out
end
- private
+ def git_run(args, stdin: nil)
+ out, _err, status = Open3.capture3("git", *args, stdin_data: stdin.to_s, binmode: true)
+ status.success? ? out : nil
+ end
+
+ # Read: public open, private owner-only. Write: owner-only.
+ def authorize!(service)
+ service == "git-receive-pack" ? authorize_write! : authorize_read!
+ end
+
+ def authorize_read!
+ return true unless @repo.is_private?
+
+ user = git_token_user
+ return challenge! if user.nil?
+ return true if @repo.user_id == user.id
+
+ git_not_found
+ end
+
+ def authorize_write!
+ user = git_token_user
+ return challenge! if user.nil?
+ return true if @repo.user_id == user.id
+
+ # Authenticated but not the owner. Keep private repos invisible.
+ @repo.is_private? ? git_not_found : (head(:forbidden) && false)
+ end
+
+ def challenge!
+ response.headers["WWW-Authenticate"] = 'Basic realm="siGit"'
+ head :unauthorized
+ false
+ end
def load_repo
owner = User.find_by(username: params[:user])
@@ -59,20 +101,7 @@ class GitHttpController < ActionController::API
name = params[:repo].to_s.sub(/\.git\z/, "")
@repo = owner.repositories.find_by(name: name)
- return git_not_found unless @repo&.initialized?
- end
-
- # Public repos: open. Private repos: require a git token whose user owns it.
- def authorize_git_read!
- return unless @repo.is_private?
-
- user = git_token_user
- if user.nil?
- response.headers["WWW-Authenticate"] = 'Basic realm="siGit"'
- return head(:unauthorized)
- end
-
- git_not_found unless @repo.user_id == user.id
+ git_not_found unless @repo
end
# Resolves the user from the HTTP Basic password (the scoped git token).
@@ -91,6 +120,7 @@ class GitHttpController < ActionController::API
def git_not_found
head :not_found
+ false
end
def gzip_request?
@@ -100,4 +130,8 @@ class GitHttpController < ActionController::API
def pkt_line(str)
format("%04x", str.bytesize + 4) + str
end
+
+ def no_cache
+ response.headers["Cache-Control"] = "no-cache"
+ end
end
config/routes.rb
+5
-2
index a8e2926..17e8862 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -47,6 +47,7 @@ Rails.application.routes.draw do
get "me", to: "me#show"
delete "me", to: "me#destroy"
get "repos", to: "repos#index"
+ post "repos", to: "repos#create"
post "git_credentials", to: "git_credentials#create"
end
end
@@ -54,9 +55,11 @@ Rails.application.routes.draw do
# Git Smart HTTP — clone/fetch over token-authenticated HTTPS. Declared before
# the catch-all "/:username" routes; the `*.git` constraint keeps them from
# matching normal repo-browsing URLs.
- get "/:user/:repo/info/refs", to: "git_http#info_refs",
+ get "/:user/:repo/info/refs", to: "git_http#info_refs",
constraints: { repo: /[^\/]+\.git/ }
- post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack",
+ post "/:user/:repo/git-upload-pack", to: "git_http#upload_pack",
+ constraints: { repo: /[^\/]+\.git/ }
+ post "/:user/:repo/git-receive-pack", to: "git_http#receive_pack",
constraints: { repo: /[^\/]+\.git/ }
# User profile (must come before repository routes)