1
0
2025-05-21 11:20:11 +08:00

663 lines
27 KiB
C#

using System.Text.Json;
using Flawless.Communication.Request;
using Flawless.Communication.Response;
using Flawless.Communication.Shared;
using Flawless.Core.BinaryDataFormat;
using Flawless.Core.Modal;
using Flawless.Server.Models;
using Flawless.Server.Services;
using Flawless.Server.Utility;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Flawless.Server.Controllers;
[ApiController, Microsoft.AspNetCore.Authorization.Authorize, Route("api/repo/{userName}/{repositoryName}")]
public class RepositoryInnieController(
UserManager<AppUser> userManager,
AppDbContext dbContext,
WebhookService webhookService,
PathTransformer transformer) : ControllerBase
{
public class FormCommitRequest : CommitRequest
{
public IFormFile? Depot { get; set; }
}
#region Unresoted
[HttpPost("delete_repo")]
public async Task<IActionResult> DeleteRepositoryAsync(string userName, string repositoryName)
{
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, u, RepositoryRole.Guest);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
await using (var t = await dbContext.Database.BeginTransactionAsync())
{
var set = await dbContext.Repositories
.Include(x => x.Commits)
.Include(x => x.Depots)
.Include(x => x.Members)
.Include(x => x.Locked)
.FirstAsync(x => x == rp);
try
{
await dbContext.Webhooks
.Where(x => x.Repository == rp)
.ExecuteDeleteAsync();
await dbContext.RepositoryIssues
.Where(x => x.Repository == rp)
.SelectMany(x => x.Contents).ExecuteDeleteAsync();
await dbContext.RepositoryIssues
.Where(x => x.Repository == rp)
.ExecuteDeleteAsync();
set.Commits.Clear();
set.Depots.Clear();
set.Members.Clear();
set.Locked.Clear();
set.Owner = null!;
dbContext.Remove(set);
await t.CommitAsync();
}
catch (Exception)
{
await t.RollbackAsync();
throw;
}
}
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpGet("get_info")]
public async Task<ActionResult<RepositoryInfoResponse>> IsRepositoryArchiveAsync(string userName, string repositoryName)
{
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, u, RepositoryRole.Guest);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
return Ok(new RepositoryInfoResponse
{
RepositoryName = rp.Name,
OwnerUsername = rp.Owner.UserName!,
Description = rp.Description,
IsArchived = rp.IsArchived,
Role = rp.Members.First(m => m.User == u).Role
});
}
[HttpPost("archive_repo")]
public async Task<IActionResult> ArchiveRepositoryAsync(string userName, string repositoryName)
{
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, u, RepositoryRole.Guest);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
if (rp.IsArchived) return BadRequest(new FailedResponse("Repository is archived!"));
rp.IsArchived = true;
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpPost("unarchive_repo")]
public async Task<IActionResult> UnarchiveRepositoryAsync(string userName, string repositoryName)
{
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, u, RepositoryRole.Guest);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
if (!rp.IsArchived) return BadRequest(new FailedResponse("Repository is not archived!"));
rp.IsArchived = false;
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpGet("get_users")]
public async Task<ActionResult<ListingResponse<RepoUserRole>>> GetUsersAsync(string userName, string repositoryName)
{
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, u, RepositoryRole.Guest);
if (grantIssue is not Repository) return (ActionResult) grantIssue;
var rp = await dbContext.Repositories
.Include(repository => repository.Members)
.ThenInclude(repositoryMember => repositoryMember.User)
.FirstOrDefaultAsync(rp => rp.Name == repositoryName && userName == rp.Owner.UserName);
return Ok(new ListingResponse<RepoUserRole>(rp.Members.Select(pm => new RepoUserRole
{
Username = pm.User.UserName!,
Role = pm.Role
}).ToArray()));
}
#endregion
private bool UserNotGranted(out IActionResult? rsp, AppUser user, Repository repo, RepositoryRole minRole)
{
if (repo.Owner == user || repo.Members.Any(m => m.User == user && m.Role >= minRole))
{
rsp = null;
return false;
}
rsp = Unauthorized(new FailedResponse("You are not allowed to do this operation"));
return true;
}
private async ValueTask<object> ValidateRepositoryAsync(
string userName, string repositoryName, AppUser user, RepositoryRole role)
{
var rp = await dbContext.Repositories
.Include(r => r.Members)
.ThenInclude(m => m.User)
.Include(r => r.Owner)
.FirstOrDefaultAsync(r => r.Owner.UserName == userName && r.Name == repositoryName);
if (rp == null) return NotFound(new FailedResponse($"Could not find repository {userName}:{repositoryName}"));
if (UserNotGranted(out var rsp, user, rp, role)) return rsp!;
return rp;
}
[HttpGet("stats")]
public async Task<ActionResult<RepoStatisticResponse>> GetStatisticAsync(string userName, string repositoryName)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Owner);
if (grantIssue is not Repository _) return (ActionResult) grantIssue;
var response = new RepoStatisticResponse();
var rp = await dbContext.Repositories
.Include(r => r.Members)
.ThenInclude(m => m.User)
.Include(r => r.Owner).Include(repository => repository.Depots).Include(repository => repository.Commits)
.ThenInclude(repositoryCommit => repositoryCommit.Author)
.FirstAsync(r => r.Owner.UserName == userName && r.Name == repositoryName);
// 获取Depot大小统计
response.Depots = rp.Depots
.Select(d => new RepoStatisticResponse.DepotDetail
{
DepotName = d.DepotId.ToString(),
DepotSize = d.Length
}).ToArray();
// 获取提交者提交数量统计
response.CommitByPerson = rp.Commits
.GroupBy(c => c.Author.UserName)
.Select(g => new RepoStatisticResponse.CommitByPersonDetail
{
Username = g.Key!,
Count = g.Count()
}).ToArray();
// 获取每日提交数量统计
response.CommitByDay = rp.Commits
.GroupBy(c => c.CommittedOn.Date)
.Select(g => new RepoStatisticResponse.CommitByDayDetail
{
Day = g.Key,
Count = g.Count()
}).ToArray();
return Ok(response);
}
[HttpPost("webhooks/create")]
public async Task<IActionResult> CreateWebhook(
string userName,
string repositoryName,
[FromBody] WebhookCreateRequest request)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Owner);
if (grantIssue is not Repository rp) return (IActionResult) grantIssue;
await webhookService.AddWebhookAsync(rp, request.TargetUrl, (WebhookEventType) request.EventType, request.Secret);
return Created();
}
[HttpGet("webhooks")]
public async Task<ActionResult<IEnumerable<WebhookResponse>>> GetWebhooks(string userName, string repositoryName)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Developer);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
return Ok((await webhookService.GetWebhooksAsync(rp)).Select(x => new WebhookResponse(
x.Id, x.TargetUrl, (int) x.EventType, x.IsActive, x.CreatedAt)));
}
[HttpPost("webhooks/{webhookId}/toggle")]
public async Task<IActionResult> ToggleWebhook(string userName, string repositoryName, int webhookId, bool activate)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Owner);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
await webhookService.ToggleWebhookAsync(rp, webhookId, activate);
return Ok();
}
[HttpDelete("webhooks/{webhookId}")]
public async Task<IActionResult> DeleteWebhook(string userName, string repositoryName, int webhookId)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Owner);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
await webhookService.DeleteWebhookAsync(rp, webhookId);
return NoContent();
}
[HttpPost("fetch_manifest")]
public async Task<ActionResult<CommitManifest>> DownloadManifestAsync(string userName, string repositoryName, [FromQuery] string commitId)
{
if (!Guid.TryParse(commitId, out var commitGuid)) return BadRequest(new FailedResponse("Invalid commit id"));
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Guest);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
// Start checkout file.
var target = transformer.GetCommitManifestPath(rp.Id, commitGuid);
if (!System.IO.File.Exists(target)) return NotFound(new FailedResponse($"Could not find commit manifest {target}"));
await using var manifest = System.IO.File.OpenRead(target);
return await JsonSerializer.DeserializeAsync<CommitManifest>(manifest);
}
[HttpPost("fetch_depot")]
public async Task<ActionResult<Stream>> DownloadDepotAsync(string userName, string repositoryName, [FromQuery] string depotId)
{
if (!Guid.TryParse(depotId, out var depotGuid)) return BadRequest(new FailedResponse("Invalid depot id"));
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Guest);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
// Start checkout file.
var target = transformer.GetDepotPath(rp.Id, depotGuid);
if (System.IO.File.Exists(target)) return File(System.IO.File.OpenRead(target), "application/octet-stream");
return NotFound(new FailedResponse($"Could not find depot {target}"));
}
[HttpPost("list_commit")]
public async Task<ActionResult<ListingResponse<RepositoryCommitResponse>>> ListCommitsAsync(string userName, string repositoryName)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Guest);
if (grantIssue is not Repository) return (ActionResult) grantIssue;
var commit = dbContext.Repositories
.Where(r => r.Owner.UserName == userName && r.Name == repositoryName)
.SelectMany(r => r.Commits)
.Include(r => r.Author).Include(repositoryCommit => repositoryCommit.MainDepot)
.OrderByDescending(r => r.CommittedOn)
.AsAsyncEnumerable();
List<RepositoryCommitResponse> r = new();
await foreach (var cm in commit)
r.Add(new RepositoryCommitResponse(
cm.Id, cm.Author.UserName!, cm.CommittedOn, cm.Message, cm.MainDepot.DepotId));
return Ok(new ListingResponse<RepositoryCommitResponse>(r.ToArray()));
}
[HttpPost("list_locked_files")]
public async Task<ActionResult<ListingResponse<LockFileInfo>>> ListLocksAsync(string userName, string repositoryName)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Developer);
if (grantIssue is not Repository) return (ActionResult) grantIssue;
var lockers = await dbContext.Repositories
.Where(r => r.Owner.UserName == userName && r.Name == repositoryName)
.SelectMany(r => r.Locked)
.Select(l => new LockFileInfo(l.Path, l.LockDownUser.UserName))
.ToArrayAsync();
return Ok(new ListingResponse<LockFileInfo>(lockers));
}
[HttpGet("peek_commit")]
public async Task<ActionResult<PeekResponse<Guid>>> PeekCommitAsync(string userName, string repositoryName)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Guest);
if (grantIssue is not Repository rp) return (ActionResult) grantIssue;
var commit = await dbContext.Repositories
.Where(r => r.Owner.UserName == userName && r.Name == repositoryName)
.SelectMany(r => r.Commits)
.Include(r => r.Author)
.OrderByDescending(r => r.CommittedOn)
.FirstOrDefaultAsync();
return Ok(new PeekResponse<Guid>(commit?.Id ?? Guid.Empty));
}
[HttpPost("lock_file")]
public async Task<IActionResult> LockAsync(string userName, string repositoryName, string path)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Developer);
if (grantIssue is not Repository) return (IActionResult) grantIssue;
var lockers = await dbContext.Repositories
.Include(repository => repository.Locked)
.FirstAsync(r => r.Owner.UserName == userName && r.Name == repositoryName);
try
{
switch (RepoLocktableUtility.AddFileLockOwner(lockers.Locked, path, user))
{
case RepoLocktableOptResult.Expand:
case RepoLocktableOptResult.IsChild:
return Ok();
case RepoLocktableOptResult.Success:
return Created();
case RepoLocktableOptResult.Rejected:
return Conflict("It was locked by others");
case RepoLocktableOptResult.Already:
return Accepted("Already been locked by you");
}
}
finally
{
await dbContext.SaveChangesAsync();
}
return BadRequest("Unknown error");
}
[HttpPost("unlock_file")]
public async Task<IActionResult> UnockAsync(string userName, string repositoryName, string path)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Developer);
if (grantIssue is not Repository) return (IActionResult) grantIssue;
var lockers = await dbContext.Repositories
.Include(repository => repository.Locked)
.FirstAsync(r => r.Owner.UserName == userName && r.Name == repositoryName);
try
{
switch (RepoLocktableUtility.RemoveFileLockOwner(lockers.Locked, path, user))
{
case RepoLocktableOptResult.Expand:
case RepoLocktableOptResult.IsChild:
return Ok();
case RepoLocktableOptResult.Success:
return Created();
case RepoLocktableOptResult.Rejected:
return Conflict("It was locked by others");
case RepoLocktableOptResult.Already:
return Accepted("Already been unlocked");
}
}
finally
{
await dbContext.SaveChangesAsync();
}
return BadRequest("Unknown error");
}
[HttpPost("create_commit")]
[RequestSizeLimit(long.MaxValue)]
[ProducesResponseType<CommitSuccessResponse>(200)]
public async Task<IActionResult> CommitAsync(string userName, string repositoryName, [FromForm] FormCommitRequest req)
{
var user = (await userManager.GetUserAsync(HttpContext.User))!;
var grantIssue = await ValidateRepositoryAsync(userName, repositoryName, user, RepositoryRole.Developer);
if (grantIssue is not Repository rp) return (IActionResult) grantIssue;
// Appoint or upload a commit - two in one choice.
if (req.Depot != null ^ req.MainDepotId != null) return await CommitInternalAsync(rp, user, req);
// Not valid response
if (req.Depot != null) return BadRequest(new FailedResponse("You can not choose a existed depot while upload a new depot!"));
return BadRequest(new FailedResponse("You must choose a existed depot upload a new depot as baseline!"));
}
private async Task<IActionResult> CommitInternalAsync(Repository rp, AppUser user, FormCommitRequest req)
{
var actualFs = req.WorkspaceSnapshot.Select(s =>
{
var idx = s.IndexOf('$');
if (idx < 0) throw new InvalidDataException($"WorkPath '{s}' is invalid!");
var dateTimeStr = s.Substring(0, idx);
var pathStr = s.Substring(idx + 1);
return new WorkspaceFile(DateTime.FromBinary(long.Parse(dateTimeStr)), pathStr, 0);
}).ToArray();
var sizeMap = new Dictionary<WorkspaceFile, long>();
var test = new HashSet<WorkspaceFile>(actualFs);
RepositoryDepot mainDepot;
List<DepotLabel> depotLabels = new();
// Judge if receive new depot or use old one...
if (req.Depot == null)
{
// Use existed depots
var mainDepotId = Guid.Parse(req.MainDepotId!);
var preDepot = await StencilWorkspaceSnapshotViaDatabaseAsync(rp.Id, mainDepotId, test, sizeMap, null);
if (preDepot == null)
return BadRequest(new FailedResponse("Not a valid main depot!", "NoMainDepot"));
// If still not able to resolve
if (test.Count > 0)
return BadRequest(new FailedResponse(test, "DependencyUnsolved"));
// Set this depot label...
mainDepot = preDepot;
depotLabels.Add(new DepotLabel(mainDepot.DepotId, mainDepot.Length));
depotLabels.AddRange(mainDepot.Dependencies.Select(d => new DepotLabel(d.DepotId, d.Length)));
}
else
{
// Get a new depot from request - this will flat to direct depot reference to avoid deep search that
// raise memory issue.
var directDependDepots = new HashSet<Guid>();
// Read and create a new depot
using var cacheStream = new MemoryStream(new byte[req.Depot.Length], true);
await req.Depot.CopyToAsync(cacheStream, HttpContext.RequestAborted);
// Look if main depot can do this...
await StencilWorkspaceSnapshotAsync(cacheStream, test, sizeMap);
// Oh no, will we need to load their parents?
var unresolvedCount = test.Count;
if (unresolvedCount != 0)
{
// Yes
foreach (var subDepot in req.RequiredDepots?.Select(Guid.Parse) ?? [])
{
await StencilWorkspaceSnapshotViaDatabaseAsync(rp.Id, subDepot, test, sizeMap, directDependDepots);
var rest = test.Count;
// Quit if nothing to do.
unresolvedCount = rest;
if (rest == 0) break;
}
// If still not able to resolve
if (unresolvedCount > 0)
return BadRequest(new
{
Reason = "Unable to resolve workspace snapshot! May lost some essential dependency.",
Unresolved = test
});
}
// Great, a valid depot, so create it in db.
mainDepot = new RepositoryDepot { Length = req.Depot.Length };
mainDepot.Dependencies.AddRange(
await dbContext.Repositories
.Where(r => r.Id == rp.Id)
.SelectMany(r => r.Depots)
.Where(cm => directDependDepots.Contains(cm.DepotId))
.ToArrayAsync());
// Create commit and let it alloc a main key
rp.Depots.Add(mainDepot);
await dbContext.SaveChangesAsync();
// Then write depot into disk
var depotPath = transformer.GetDepotPath(rp.Id, mainDepot.DepotId);
Directory.CreateDirectory(Path.GetDirectoryName(depotPath)!);
await using (var depotStream = System.IO.File.Create(depotPath))
{
cacheStream.Seek(0, SeekOrigin.Begin);
await cacheStream.CopyToAsync(depotStream, HttpContext.RequestAborted);
}
// Everything alright, so make response
depotLabels.Add(new DepotLabel(mainDepot.DepotId, mainDepot.Length));
depotLabels.AddRange(mainDepot.Dependencies.Select(d => new DepotLabel(d.DepotId, d.Length)));
}
// Update size info of FilePaths
for (var i = 0; i < actualFs.Length; i++)
{
var ws = actualFs[i];
ws.Size = (ulong)sizeMap.GetValueOrDefault(ws, 0);
actualFs[i] = ws;
}
// Create commit info
var commit = new RepositoryCommit
{
Author = user,
CommittedOn = DateTime.UtcNow,
MainDepot = mainDepot,
Message = req.Message,
};
try
{
// Write changes into db.
rp.Commits.Add(commit);
await dbContext.SaveChangesAsync();
}
catch (Exception)
{
// Revert depot create operation
System.IO.File.Delete(transformer.GetDepotPath(rp.Id, mainDepot.DepotId));
throw;
}
// Create manifest file and write to disk
var manifestPath = transformer.GetCommitManifestPath(rp.Id, commit.Id);
var manifest = new CommitManifest
{
ManifestId = commit.Id,
Depot = depotLabels.ToArray(),
FilePaths = actualFs
};
Directory.CreateDirectory(Path.GetDirectoryName(manifestPath)!);
await using (var manifestStream = System.IO.File.Create(manifestPath))
await JsonSerializer.SerializeAsync(manifestStream, manifest);
return Ok(new CommitSuccessResponse(commit.CommittedOn, commit.Id, mainDepot.DepotId));
}
private static async ValueTask StencilWorkspaceSnapshotAsync(Stream depotStream,
HashSet<WorkspaceFile> unresolved, Dictionary<WorkspaceFile, long> sizeMap)
{
// Get version
depotStream.Seek(0, SeekOrigin.Begin); // Fix: Get an invalid version code due to offset is bad.
var version = DataTransformer.GuessStandardDepotHeaderVersion(depotStream);
if (version != 1) throw new InvalidDataException($"Unable to get depot header version, feedback is {version}.");
// Get header
depotStream.Seek(0, SeekOrigin.Begin);
var header = DataTransformer.ExtractStandardDepotHeaderV1(depotStream);
// Start validation
depotStream.Seek(0, SeekOrigin.Begin);
await foreach (var inf in DataTransformer.ExtractDepotFileInfoMapAsync(depotStream, header.FileMapSize))
{
var test = new WorkspaceFile
{
ModifyTime = inf.ModifyTime,
WorkPath = inf.Path,
Size = 0
};
if (!sizeMap.TryAdd(test, (long)inf.Size)) sizeMap[test] = (long)inf.Size;
unresolved.Remove(test);
if (unresolved.Count == 0) break; // Early quit to avoid extra performance loss.
}
}
private async Task<RepositoryDepot?> StencilWorkspaceSnapshotViaDatabaseAsync(
Guid rpId, Guid depotId,
HashSet<WorkspaceFile> unresolved,
Dictionary<WorkspaceFile, long> sizeMap,
HashSet<Guid>? directDependDepots)
{
var depot = await dbContext.Repositories
.SelectMany(r => r.Depots)
.Include(r => r.Dependencies)
.ThenInclude(repositoryDepot => repositoryDepot.Dependencies)
.FirstOrDefaultAsync(r => r.DepotId == depotId);
await RecurringAsync(transformer, rpId, depot, unresolved, sizeMap, directDependDepots);
return depot;
static async ValueTask RecurringAsync(
PathTransformer transformer, Guid rpId,
RepositoryDepot? depot, HashSet<WorkspaceFile> unresolved,
Dictionary<WorkspaceFile, long> sizeMap,
HashSet<Guid>? directDependDepots)
{
if (depot == null) return;
var path = transformer.GetDepotPath(rpId, depot.DepotId);
// require do not extend lifetime scope.
await using (var fs = new BufferedStream(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)))
{
var before = unresolved.Count;
await StencilWorkspaceSnapshotAsync(fs, unresolved, sizeMap);
if (before != unresolved.Count && directDependDepots != null) directDependDepots.Add(depot.DepotId);
}
for (var i = 0; i < depot.Dependencies.Count && unresolved.Count > 0; i++)
await RecurringAsync(transformer, rpId, depot.Dependencies[i], unresolved, sizeMap, directDependDepots);
}
}
}