git_http.py
Python script, ASCII text executable
1""" 2This module provides a Flask implementation of the Git smart HTTP protocol. 3 4Roundabout - git hosting for everyone <https://roundabout-host.com> 5Copyright (C) 2023-2025 Roundabout developers <root@roundabout-host.com> 6 7This program is free software: you can redistribute it and/or modify 8it under the terms of the GNU Affero General Public License as published by 9the Free Software Foundation, either version 3 of the License, or 10(at your option) any later version. 11 12This program is distributed in the hope that it will be useful, 13but WITHOUT ANY WARRANTY; without even the implied warranty of 14MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15GNU Affero General Public License for more details. 16 17You should have received a copy of the GNU Affero General Public License 18along with this program. If not, see <http://www.gnu.org/licenses/>. 19""" 20 21import uuid 22from models import * 23from app import app, db, bcrypt 24from misc_utils import * 25from common import git_command 26import os 27import shutil 28import config 29import flask 30import git 31import subprocess 32from flask_httpauth import HTTPBasicAuth 33import zlib 34import re 35import datetime 36 37auth = HTTPBasicAuth(realm=config.AUTH_REALM) 38 39auth_required = flask.Response("Unauthorized Access", 401, 40{"WWW-Authenticate": 'Basic realm="Login Required"'}) 41 42 43@auth.verify_password 44def verify_password(username, password): 45user = User.query.filter_by(username=username).first() 46 47if user and bcrypt.check_password_hash(user.password_hashed, password): 48flask.g.user = username 49return True 50 51return False 52 53 54@app.route("/<username>/<repository>/git-upload-pack", methods=["POST"]) 55@app.route("/git/<username>/<repository>/git-upload-pack", methods=["POST"]) 56@auth.login_required(optional=True) 57def git_upload_pack(username, repository): 58server_repo_location = os.path.join(config.REPOS_PATH, username, repository, ".git") 59if not os.path.exists(server_repo_location): 60flask.abort(404) 61 62if auth.current_user() is None and not get_visibility(username, repository): 63return auth_required 64if not (get_visibility(username, repository) or get_permission_level(flask.g.user, username, 65repository) is not None): 66flask.abort(403) 67 68text = git_command(server_repo_location, flask.request.data, "upload-pack", 69"--stateless-rpc", ".") 70 71return flask.Response(text, content_type="application/x-git-upload-pack-result") 72 73 74@app.route("/<username>/<repository>/git-receive-pack", methods=["POST"]) 75@app.route("/git/<username>/<repository>/git-receive-pack", methods=["POST"]) 76@auth.login_required 77def git_receive_pack(username, repository): 78server_repo_location = os.path.join(config.REPOS_PATH, username, repository, ".git") 79if not os.path.exists(server_repo_location): 80flask.abort(404) 81 82if not get_permission_level(flask.g.user, username, repository): 83flask.abort(403) 84 85server_repo_location = os.path.join(config.REPOS_PATH, username, repository, ".git") 86text = git_command(server_repo_location, flask.request.data, "receive-pack", 87"--stateless-rpc", ".") 88 89if flask.request.data == b"0000": 90return flask.Response("", content_type="application/x-git-receive-pack-result") 91 92push_info = flask.request.data.split(b"\x00")[0].decode() 93if not push_info: 94return flask.Response(text, content_type="application/x-git-receive-pack-result") 95 96old_sha, new_sha, ref = push_info[4:].split() # discard first 4 characters, used for line length which we don't need 97 98if old_sha == "0" * 40: 99commits_list = subprocess.check_output(["git", "rev-list", new_sha], 100cwd=server_repo_location).decode().strip().split("\n") 101else: 102commits_list = subprocess.check_output(["git", "rev-list", f"{old_sha}..{new_sha}"], 103cwd=server_repo_location).decode().strip().split("\n") 104 105for sha in reversed(commits_list): 106info = git_command(server_repo_location, None, "show", "-s", 107"--format='%H%n%at%n%cn <%ce>%n%B'", sha).decode() 108 109sha, time, identity, body = info.split("\n", 3) 110login = flask.g.user 111 112if not Commit.query.filter_by(identifier=f"/{username}/{repository}/{sha}").first(): 113logged_in_user = User.query.filter_by(username=login).first() 114user = get_commit_identity(identity, logged_in_user, db.session.get(Repo, f"/{username}/{repository}")) 115repo = Repo.query.filter_by(route=f"/{username}/{repository}").first() 116 117commit = Commit(sha, user, repo, time, body, identity, logged_in_user) 118 119db.session.add(commit) 120db.session.commit() 121 122if ref.startswith("refs/heads/"): # if the push is to a branch 123ref = ref.rpartition("/")[2] # get the branch name only 124repo_data = db.session.get(Repo, f"/{username}/{repository}") 125if ref == repo_data.site_branch: 126# Update the site 127from celery_tasks import copy_site 128copy_site.delay(repo_data.route) 129 130return flask.Response(text, content_type="application/x-git-receive-pack-result") 131 132 133@app.route("/<username>/<repository>/info/refs", methods=["GET", "POST"]) 134@app.route("/git/<username>/<repository>/info/refs", methods=["GET", "POST"]) 135@auth.login_required(optional=True) 136def git_info_refs(username, repository): 137server_repo_location = os.path.join(config.REPOS_PATH, username, repository, ".git") 138if not os.path.exists(server_repo_location): 139flask.abort(404) 140 141repo = git.Repo(server_repo_location) 142repo_data = Repo.query.filter_by(route=f"/{username}/{repository}").first() 143if not repo_data.default_branch: 144if repo.heads: 145repo_data.default_branch = repo.heads[0].name 146repo.git.checkout("-f", repo_data.default_branch) 147 148if auth.current_user() is None and ( 149not get_visibility(username, repository) or flask.request.args.get( 150"service") == "git-receive-pack"): 151return auth_required 152try: 153if not (get_visibility(username, repository) or get_permission_level(flask.g.user, 154username, 155repository) is not None): 156flask.abort(403) 157except AttributeError: 158return auth_required 159 160service = flask.request.args.get("service") 161 162if service.startswith("git"): 163service = service[4:] 164else: 165flask.abort(403) 166 167if service == "receive-pack": 168try: 169if not get_permission_level(flask.g.user, username, repository): 170flask.abort(403) 171except AttributeError: 172return auth_required 173 174service_line = f"# service=git-{service}\n" 175service_line = (f"{len(service_line) + 4:04x}" + service_line).encode() 176 177if service == "upload-pack": 178text = service_line + b"0000" + git_command(server_repo_location, None, "upload-pack", 179"--stateless-rpc", 180"--advertise-refs", 181"--http-backend-info-refs", ".") 182elif service == "receive-pack": 183refs = git_command(server_repo_location, None, "receive-pack", 184"--http-backend-info-refs", ".") 185text = service_line + b"0000" + refs 186else: 187flask.abort(403) 188 189response = flask.Response(text, content_type=f"application/x-git-{service}-advertisement") 190response.headers["Cache-Control"] = "no-cache" 191 192return response 193