Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
539 changes: 539 additions & 0 deletions freenit/api/git.py

Large diffs are not rendered by default.

204 changes: 204 additions & 0 deletions freenit/api/git_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import base64
import logging
import subprocess # nosec: B404
from typing import Optional

import pydantic
from fastapi import APIRouter, Header, HTTPException, Request
from fastapi.responses import Response as FastAPIResponse

from freenit.config import getConfig
from freenit.git import store
from freenit.models.user import User

config = getConfig()
log = logging.getLogger("git_http")

router = APIRouter()


class Credentials(pydantic.BaseModel):
email: str
password: str


def _packet_line(line: str) -> bytes:
data = line.encode("utf-8")
length = len(data) + 4
return f"{length:04x}".encode("ascii") + data


def _service_command(service: str) -> str:
if service == "git-upload-pack":
return "upload-pack"
if service == "git-receive-pack":
return "receive-pack"
raise HTTPException(status_code=400, detail="Invalid git service")


def _required_access(service: str) -> str:
if service == "git-upload-pack":
return "read"
if service == "git-receive-pack":
return "write"
raise HTTPException(status_code=400, detail="Invalid git service")


async def _authenticate(request: Request) -> Optional[str]:
auth_header = request.headers.get("authorization")
if not auth_header:
return None
scheme, _, token = auth_header.partition(" ")
if scheme.lower() != "basic" or not token:
return None
try:
decoded = base64.b64decode(token).decode("utf-8")
except Exception:
return None
email, _, password = decoded.partition(":")
if not email or not password:
return None
try:
user = await User.login(Credentials(email=email, password=password))
except HTTPException:
return None
return user.email


def _basic_auth_challenge() -> FastAPIResponse:
return FastAPIResponse(
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="git"'},
content=b"Authentication required",
)


async def _get_repo(repo_name: str):
return await store.get_repo_by_name(repo_name)


async def _check_service_access(
repo,
service: str,
user_email: Optional[str],
) -> None:
required = _required_access(service)
if required == "read" and repo.public and not user_email:
return
if not user_email:
raise HTTPException(status_code=401, detail="Authentication required")
allowed = await store.check_access(repo, user_email, required)
if not allowed:
raise HTTPException(status_code=403, detail="Access denied")


@router.get("/git/{repo_name}/info/refs")
async def git_info_refs(
repo_name: str,
service: str,
request: Request,
):
if not service:
return FastAPIResponse(status_code=400, content=b"Service required")
repo = await _get_repo(repo_name)
user_email = await _authenticate(request)
try:
await _check_service_access(repo, service, user_email)
except HTTPException as exc:
if exc.status_code == 401:
return _basic_auth_challenge()
raise

command = _service_command(service)
try:
proc = await __import__("asyncio").to_thread(
subprocess.run,
["git", "-C", repo.path, command, "--advertise-refs", "."],
capture_output=True,
check=False,
) # nosec
except Exception as exc:
log.error("Failed to advertise refs for %s: %s", repo_name, exc)
raise HTTPException(status_code=502, detail="Git backend error")

if proc.returncode != 0:
log.error("git advertise-refs failed: %s", proc.stderr.decode("utf-8", errors="replace"))
raise HTTPException(status_code=502, detail="Git backend error")

body = (
_packet_line(f"# service={service}\n")
+ b"0000"
+ proc.stdout
)
return FastAPIResponse(
content=body,
media_type=f"application/x-{service}-advertisement",
)


@router.post("/git/{repo_name}/git-upload-pack")
async def git_upload_pack(
repo_name: str,
request: Request,
content_type: Optional[str] = Header(default=None),
):
repo = await _get_repo(repo_name)
user_email = await _authenticate(request)
try:
await _check_service_access(repo, "git-upload-pack", user_email)
except HTTPException as exc:
if exc.status_code == 401:
return _basic_auth_challenge()
raise

body = await request.body()
try:
proc = await __import__("asyncio").to_thread(
subprocess.run,
["git", "-C", repo.path, "upload-pack", "--stateless-rpc", "."],
input=body,
capture_output=True,
check=False,
) # nosec
except Exception as exc:
log.error("Failed upload-pack for %s: %s", repo_name, exc)
raise HTTPException(status_code=502, detail="Git backend error")

return FastAPIResponse(
content=proc.stdout,
media_type="application/x-git-upload-pack-result",
)


@router.post("/git/{repo_name}/git-receive-pack")
async def git_receive_pack(
repo_name: str,
request: Request,
content_type: Optional[str] = Header(default=None),
):
repo = await _get_repo(repo_name)
user_email = await _authenticate(request)
try:
await _check_service_access(repo, "git-receive-pack", user_email)
except HTTPException as exc:
if exc.status_code == 401:
return _basic_auth_challenge()
raise

body = await request.body()
try:
proc = await __import__("asyncio").to_thread(
subprocess.run,
["git", "-C", repo.path, "receive-pack", "--stateless-rpc", "."],
input=body,
capture_output=True,
check=False,
) # nosec
except Exception as exc:
log.error("Failed receive-pack for %s: %s", repo_name, exc)
raise HTTPException(status_code=502, detail="Git backend error")

return FastAPIResponse(
content=proc.stdout,
media_type="application/x-git-receive-pack-result",
)
5 changes: 5 additions & 0 deletions freenit/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ async def lifespan(_: FastAPI):

app = FastAPI(lifespan=lifespan)
app.mount(config.api_root, api)

if "git" in config.modules:
from .api.git_http import router as git_http_router

app.include_router(git_http_router)
20 changes: 20 additions & 0 deletions freenit/base_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ def __init__(
mlidNextClass="freenitMailingListIdNext",
mlidNextDN="cn=mlidnext,dc=ldap",
mlidNextField="mlidNumber",
gitBase="ou=git,dc=ldap",
gitRepoDN="cn={}",
gitRepoClasses=["freenitGitRepo"],
gitPermissionClasses=["freenitGitPermission"],
gitPushLogClasses=["freenitGitPushLog"],
gitCommitTaskRefClasses=["freenitGitCommitTaskRef"],
gitIdNextClass="freenitGitIdNext",
gitIdNextDN="cn=gitidnext,dc=ldap",
gitIdNextField="gitIdNumber",
):
self.host = host
self.tls = tls
Expand Down Expand Up @@ -124,6 +133,15 @@ def __init__(
self.mlidNextClass = mlidNextClass
self.mlidNextDN = mlidNextDN
self.mlidNextField = mlidNextField
self.gitBase = gitBase
self.gitRepoDN = f"{gitRepoDN},{gitBase}"
self.gitRepoClasses = gitRepoClasses
self.gitPermissionClasses = gitPermissionClasses
self.gitPushLogClasses = gitPushLogClasses
self.gitCommitTaskRefClasses = gitCommitTaskRefClasses
self.gitIdNextClass = gitIdNextClass
self.gitIdNextDN = gitIdNextDN
self.gitIdNextField = gitIdNextField


class BaseConfig:
Expand All @@ -141,6 +159,7 @@ class BaseConfig:
mailinglist = "freenit.models.sql.mailinglist"
project = "freenit.models.sql.project"
lms = "freenit.models.sql.lms"
git = "freenit.models.sql.git"
modules = ["auth"]
meta = None
auth = Auth()
Expand Down Expand Up @@ -208,6 +227,7 @@ class TestConfig(BaseConfig):
"sieve",
"jabber",
"omemo",
"git",
]


Expand Down
Empty file added freenit/git/__init__.py
Empty file.
Loading
Loading