diff --git a/Pyro.Api/Pyro/Endpoints/GitBackendEndpoints.cs b/Pyro.Api/Pyro/Endpoints/GitBackendEndpoints.cs index 785fef0e..96524546 100644 --- a/Pyro.Api/Pyro/Endpoints/GitBackendEndpoints.cs +++ b/Pyro.Api/Pyro/Endpoints/GitBackendEndpoints.cs @@ -1,58 +1,129 @@ // Copyright (c) Dmytro Kyshchenko. All rights reserved. // Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information. -using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.Mvc; using Pyro.Extensions; -using Pyro.Infrastructure; using Pyro.Services; namespace Pyro.Endpoints; internal static class GitBackendEndpoints { - public static IEndpointRouteBuilder MapGitBackendEndpoints(this IEndpointRouteBuilder app) + public static IEndpointRouteBuilder MapGitBackendEndpoint(this IEndpointRouteBuilder app) { - var options = app.ServiceProvider.GetRequiredService>(); - if (options.Value.UseNativeGitBackend) - return app.MapNativeGitBackendEndpoints(); - - return app.MapCsharpBackendEndpoint(); - } - - private static IEndpointRouteBuilder MapNativeGitBackendEndpoints(this IEndpointRouteBuilder app) - { - app - .Map("/{repositoryName}.git/{**path}", async ( - GitBackend backend, - string repositoryName, - CancellationToken cancellationToken) => - await backend.Handle(repositoryName, cancellationToken)) + var group = app + .MapGroup("/{repositoryName}.git") .RequireAuthorization(pb => pb .AddAuthenticationSchemes(AuthExtensions.BasicAuthenticationScheme) .RequireAuthenticatedUser()); - return app; - } - - private static IEndpointRouteBuilder MapCsharpBackendEndpoint(this IEndpointRouteBuilder app) - { - var group = app.MapGroup("/{repositoryName}.git"); - - group.MapGet("/HEAD", () => { throw new NotImplementedException(); }); - group.MapGet("/info/refs", () => { throw new NotImplementedException(); }); - group.MapGet("/info/alternates", () => { throw new NotImplementedException(); }); - group.MapGet("/info/http-alternates", () => { throw new NotImplementedException(); }); - group.MapGet("/info/packs", () => { throw new NotImplementedException(); }); - group.MapGet("/objects/{hash1:regex([[0-9a-f]]{{2}})}/{hash2:regex([[0-9a-f]]{{38}})}", () => { throw new NotImplementedException(); }); - group.MapGet("/objects/{hash1:regex([[0-9a-f]]{{2}})}/{hash2:regex([[0-9a-f]]{{62}})}", () => { throw new NotImplementedException(); }); - group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{40}})}.pack", () => { throw new NotImplementedException(); }); - group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{64}})}.pack", () => { throw new NotImplementedException(); }); - group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{40}})}.idx", () => { throw new NotImplementedException(); }); - group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{64}})}.idx", () => { throw new NotImplementedException(); }); + group.MapGet("/HEAD", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetHead(repositoryName, cancellationToken); + }); + group.MapGet("/info/refs", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetInfoRefs(repositoryName, cancellationToken); + }); + group.MapGet("/info/alternates", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetInfoAlternates(repositoryName, cancellationToken); + }); + group.MapGet("/info/http-alternates", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetInfoHttpAlternates(repositoryName, cancellationToken); + }); + group.MapGet("/info/packs", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetInfoPacks(repositoryName, cancellationToken); + }); + group.MapGet("/objects/{hash1:regex([[0-9a-f]]{{2}})}/{hash2:regex([[0-9a-f]]{{38}})}", async ( + [FromRoute] string repositoryName, + [FromRoute] string hash1, + [FromRoute] string hash2, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetObjects(repositoryName, hash1, hash2, cancellationToken); + }); + group.MapGet("/objects/{hash1:regex([[0-9a-f]]{{2}})}/{hash2:regex([[0-9a-f]]{{62}})}", async ( + [FromRoute] string repositoryName, + [FromRoute] string hash1, + [FromRoute] string hash2, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetObjects(repositoryName, hash1, hash2, cancellationToken); + }); + group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{40}})}.pack", async ( + [FromRoute] string repositoryName, + [FromRoute] string hash, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetObjectsPack(repositoryName, hash, cancellationToken); + }); + group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{64}})}.pack", async ( + [FromRoute] string repositoryName, + [FromRoute] string hash, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetObjectsPack(repositoryName, hash, cancellationToken); + }); + group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{40}})}.idx", async ( + [FromRoute] string repositoryName, + [FromRoute] string hash, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetObjectsPackIdx(repositoryName, hash, cancellationToken); + }); + group.MapGet("/objects/pack/pack-{hash:regex([[0-9a-f]]{{64}})}.idx", async ( + [FromRoute] string repositoryName, + [FromRoute] string hash, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GetObjectsPackIdx(repositoryName, hash, cancellationToken); + }); - group.MapPost("/git-upload-pack", () => { throw new NotImplementedException(); }); - group.MapPost("/git-upload-archive", () => { throw new NotImplementedException(); }); - group.MapPost("/git-receive-pack", () => { throw new NotImplementedException(); }); + group.MapPost("/git-upload-pack", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GitUploadPack(repositoryName, cancellationToken); + }); + group.MapPost("/git-upload-archive", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GitUploadArchive(repositoryName, cancellationToken); + }); + group.MapPost("/git-receive-pack", async ( + [FromRoute] string repositoryName, + [FromServices] NativeGitBackend gitBackend, + CancellationToken cancellationToken) => + { + await gitBackend.GitReceivePack(repositoryName, cancellationToken); + }); return app; } diff --git a/Pyro.Api/Pyro/Program.cs b/Pyro.Api/Pyro/Program.cs index 98b81d3c..6e579d3e 100644 --- a/Pyro.Api/Pyro/Program.cs +++ b/Pyro.Api/Pyro/Program.cs @@ -87,7 +87,7 @@ options => options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents) .RequireAuthorization(); -app.MapGitBackendEndpoints(); +app.MapGitBackendEndpoint(); app.MapOpenApi(); app.MapScalarApiReference(); diff --git a/Pyro.Api/Pyro/ServiceCollectionExtensions.cs b/Pyro.Api/Pyro/ServiceCollectionExtensions.cs index 3b2134c9..e2f8134b 100644 --- a/Pyro.Api/Pyro/ServiceCollectionExtensions.cs +++ b/Pyro.Api/Pyro/ServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static IHostApplicationBuilder AddPyroInfrastructure(this IHostApplicatio services .Configure(configuration.GetRequiredSection(ServiceOptions.Name)) .AddSingleton, ServiceOptions>() - .AddScoped() + .AddScoped() .AddHostedService() .Configure( configuration diff --git a/Pyro.Api/Pyro/Services/GitBackend.cs b/Pyro.Api/Pyro/Services/GitBackend.cs deleted file mode 100644 index 598c890c..00000000 --- a/Pyro.Api/Pyro/Services/GitBackend.cs +++ /dev/null @@ -1,135 +0,0 @@ -// Copyright (c) Dmytro Kyshchenko. All rights reserved. -// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information. - -using System.Buffers; -using System.Diagnostics; -using System.Globalization; -using System.IO.Pipelines; -using System.Text; -using Microsoft.Extensions.Options; -using Pyro.Domain.GitRepositories; -using Pyro.Domain.Identity; -using Pyro.Domain.Shared.CurrentUserProvider; -using Pyro.Domain.Shared.Exceptions; -using Pyro.Infrastructure; - -namespace Pyro.Services; - -public class GitBackend -{ - private readonly HttpContext httpContext; - private readonly IOptions gitOptions; - private readonly IGitRepositoryRepository repository; - private readonly ICurrentUserProvider currentUserProvider; - private readonly IUserRepository userRepository; - - // TODO: move to infrastructure? - // TODO: remove HttpContent dependency - public GitBackend( - IHttpContextAccessor httpContextAccessor, - IOptions gitOptions, - IGitRepositoryRepository repository, - ICurrentUserProvider currentUserProvider, - IUserRepository userRepository) - { - this.httpContext = httpContextAccessor.HttpContext ?? - throw new ArgumentNullException(null, "HttpContext is not available"); - - this.gitOptions = gitOptions; - this.repository = repository; - this.currentUserProvider = currentUserProvider; - this.userRepository = userRepository; - } - - public async Task Handle(string repositoryName, CancellationToken cancellationToken = default) - { - var gitRepository = await repository.GetGitRepository(repositoryName, cancellationToken) ?? - throw new NotFoundException("Repository not found"); - - var currentUser = currentUserProvider.GetCurrentUser(); - var user = await userRepository.GetUserById(currentUser.Id, cancellationToken) ?? - throw new NotFoundException($"User '{currentUser.Id}' not found"); - - var basePath = gitOptions.Value.BasePath; - var gitPath = Path.Combine(basePath, $"{gitRepository.Name}.git"); - - using var process = new Process(); - process.StartInfo = new ProcessStartInfo - { - FileName = "git", - Arguments = "http-backend --stateless-rpc --advertise-refs", - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true, - WorkingDirectory = gitPath, - EnvironmentVariables = - { - { "GIT_HTTP_EXPORT_ALL", "1" }, - { "HTTP_GIT_PROTOCOL", httpContext.Request.Headers["Git-Protocol"] }, - { "REQUEST_METHOD", httpContext.Request.Method }, - { "GIT_PROJECT_ROOT", gitPath }, - { "PATH_INFO", $"/{httpContext.Request.RouteValues["path"]}" }, - { "QUERY_STRING", httpContext.Request.QueryString.ToUriComponent().TrimStart('?') }, - { "CONTENT_TYPE", httpContext.Request.ContentType }, - { "CONTENT_LENGTH", httpContext.Request.ContentLength?.ToString(CultureInfo.InvariantCulture) }, - { "HTTP_CONTENT_ENCODING", httpContext.Request.Headers.ContentEncoding }, - { "REMOTE_USER", currentUser.Login }, - { "REMOTE_ADDR", httpContext.Connection.RemoteIpAddress?.ToString() }, - { "GIT_COMMITTER_NAME", user.DisplayName }, - { "GIT_COMMITTER_EMAIL", user.Email }, - }, - }; - - process.Start(); - - var pipeWriter = PipeWriter.Create(process.StandardInput.BaseStream); - await httpContext.Request.BodyReader.CopyToAsync(pipeWriter, cancellationToken); - - var pipeReader = PipeReader.Create(process.StandardOutput.BaseStream); - await ReadResponse(pipeReader, cancellationToken); - - await pipeReader.CopyToAsync(httpContext.Response.BodyWriter, cancellationToken); - await pipeReader.CompleteAsync(); - } - - private async Task ReadResponse(PipeReader pipeReader, CancellationToken cancellationToken) - { - while (true) - { - var result = await pipeReader.ReadAsync(cancellationToken); - var buffer = result.Buffer; - var (position, isFinished) = ReadHeaders(httpContext, buffer); - pipeReader.AdvanceTo(position, buffer.End); - - if (result.IsCompleted || isFinished) - break; - } - } - - private static (SequencePosition Position, bool IsFinished) ReadHeaders( - HttpContext httpContext, - in ReadOnlySequence sequence) - { - var reader = new SequenceReader(sequence); - while (!reader.End) - { - if (!reader.TryReadTo(out ReadOnlySpan line, (byte)'\n')) - break; - - if (line.Length == 1) - return (reader.Position, true); - - var colon = line.IndexOf((byte)':'); - if (colon == -1) - break; - - var headerName = Encoding.UTF8.GetString(line[..colon]); - var headerValue = Encoding.UTF8.GetString(line[(colon + 1)..]).Trim(); - httpContext.Response.Headers[headerName] = headerValue; - } - - return (reader.Position, false); - } -} \ No newline at end of file diff --git a/Pyro.Api/Pyro/Services/NativeGitBackend.cs b/Pyro.Api/Pyro/Services/NativeGitBackend.cs new file mode 100644 index 00000000..313ef016 --- /dev/null +++ b/Pyro.Api/Pyro/Services/NativeGitBackend.cs @@ -0,0 +1,364 @@ +// Copyright (c) Dmytro Kyshchenko. All rights reserved. +// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information. + +using System.Buffers; +using System.Diagnostics; +using System.Globalization; +using System.IO.Pipelines; +using System.Text; +using Microsoft.Extensions.Options; +using Pyro.Domain.GitRepositories; +using Pyro.Domain.Identity; +using Pyro.Domain.Shared.CurrentUserProvider; +using Pyro.Domain.Shared.Exceptions; +using Pyro.Infrastructure; + +namespace Pyro.Services; + +public class NativeGitBackend +{ + private readonly HttpContext httpContext; + private readonly IOptions gitOptions; + private readonly IGitRepositoryRepository repository; + private readonly ICurrentUserProvider currentUserProvider; + private readonly IUserRepository userRepository; + + // TODO: move to infrastructure? + // TODO: remove HttpContext dependency + public NativeGitBackend( + IHttpContextAccessor httpContextAccessor, + IOptions gitOptions, + IGitRepositoryRepository repository, + ICurrentUserProvider currentUserProvider, + IUserRepository userRepository) + { + this.httpContext = httpContextAccessor.HttpContext ?? + throw new ArgumentNullException(null, "HttpContext is not available"); + + this.gitOptions = gitOptions; + this.repository = repository; + this.currentUserProvider = currentUserProvider; + this.userRepository = userRepository; + } + + public async Task GetHead(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = "/HEAD", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GetInfoRefs(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = "/info/refs", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GetInfoAlternates(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = "/info/alternates", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GetInfoHttpAlternates(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = "/info/http-alternates", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GetInfoPacks(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = "/info/packs", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GetObjects( + string repositoryName, + string hash1, + string hash2, + CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = $"/objects/{hash1}/{hash2}", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GetObjectsPack( + string repositoryName, + string hash, + CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = $"/objects/pack/pack-{hash}.pack", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GetObjectsPackIdx( + string repositoryName, + string hash, + CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "GET", + PathInfo = $"/objects/pack/pack-{hash}.idx", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GitUploadPack(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "POST", + PathInfo = "/git-upload-pack", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GitUploadArchive(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "POST", + PathInfo = "/git-upload-archive", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + public async Task GitReceivePack(string repositoryName, CancellationToken cancellationToken = default) + { + var parameters = new GitRequestParameters + { + RepositoryName = repositoryName, + GitProtocol = httpContext.Request.Headers["Git-Protocol"].ToString(), + RequestMethod = "POST", + PathInfo = "/git-receive-pack", + QueryString = httpContext.Request.QueryString.ToUriComponent().TrimStart('?'), + ContentType = httpContext.Request.ContentType ?? string.Empty, + ContentLength = httpContext.Request.ContentLength ?? 0, + ContentEncoding = httpContext.Request.Headers.ContentEncoding.ToString(), + RemoteAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, + }; + + await Handle(parameters, cancellationToken); + } + + private async Task Handle(GitRequestParameters parameters, CancellationToken cancellationToken = default) + { + var gitRepository = await repository.GetGitRepository(parameters.RepositoryName, cancellationToken) ?? + throw new NotFoundException("Repository not found"); + + var currentUser = currentUserProvider.GetCurrentUser(); + var user = await userRepository.GetUserById(currentUser.Id, cancellationToken) ?? + throw new NotFoundException($"User '{currentUser.Id}' not found"); + + var basePath = gitOptions.Value.BasePath; + var gitPath = Path.Combine(basePath, $"{gitRepository.Name}.git"); + + using var process = new Process(); + process.StartInfo = new ProcessStartInfo + { + FileName = "git", + Arguments = "http-backend --stateless-rpc --advertise-refs", + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = gitPath, + EnvironmentVariables = + { + { "GIT_HTTP_EXPORT_ALL", "1" }, + { "HTTP_GIT_PROTOCOL", parameters.GitProtocol }, + { "REQUEST_METHOD", parameters.RequestMethod }, + { "GIT_PROJECT_ROOT", gitPath }, + { "PATH_INFO", parameters.PathInfo }, + { "QUERY_STRING", parameters.QueryString }, + { "CONTENT_TYPE", parameters.ContentType }, + { "CONTENT_LENGTH", parameters.ContentLength.ToString(CultureInfo.InvariantCulture) }, + { "HTTP_CONTENT_ENCODING", parameters.ContentEncoding }, + { "REMOTE_USER", currentUser.Login }, + { "REMOTE_ADDR", parameters.RemoteAddress }, + { "GIT_COMMITTER_NAME", user.DisplayName }, + { "GIT_COMMITTER_EMAIL", user.Email }, + }, + }; + + process.Start(); + + var pipeWriter = PipeWriter.Create(process.StandardInput.BaseStream); + await httpContext.Request.BodyReader.CopyToAsync(pipeWriter, cancellationToken); + + var pipeReader = PipeReader.Create(process.StandardOutput.BaseStream); + await ReadResponse(pipeReader, cancellationToken); + + await pipeReader.CopyToAsync(httpContext.Response.BodyWriter, cancellationToken); + await pipeReader.CompleteAsync(); + } + + private async Task ReadResponse(PipeReader pipeReader, CancellationToken cancellationToken) + { + while (true) + { + var result = await pipeReader.ReadAsync(cancellationToken); + var buffer = result.Buffer; + var (position, isFinished) = ReadHeaders(httpContext, buffer); + pipeReader.AdvanceTo(position, buffer.End); + + if (result.IsCompleted || isFinished) + break; + } + } + + private static (SequencePosition Position, bool IsFinished) ReadHeaders( + HttpContext httpContext, + in ReadOnlySequence sequence) + { + var reader = new SequenceReader(sequence); + while (!reader.End) + { + if (!reader.TryReadTo(out ReadOnlySpan line, (byte)'\n')) + break; + + if (line.Length == 1) + return (reader.Position, true); + + var colon = line.IndexOf((byte)':'); + if (colon == -1) + break; + + var headerName = Encoding.UTF8.GetString(line[..colon]); + var headerValue = Encoding.UTF8.GetString(line[(colon + 1)..]).Trim(); + httpContext.Response.Headers[headerName] = headerValue; + } + + return (reader.Position, false); + } + + private readonly struct GitRequestParameters + { + public required string RepositoryName { get; init; } + + public required string GitProtocol { get; init; } + + public required string RequestMethod { get; init; } + + public required string PathInfo { get; init; } + + public required string QueryString { get; init; } + + public required string ContentType { get; init; } + + public required long ContentLength { get; init; } + + public required string ContentEncoding { get; init; } + + public required string RemoteAddress { get; init; } + } +} \ No newline at end of file