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 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 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> 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 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 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>> 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(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 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> 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 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>> 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 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 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> 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(manifest); } [HttpPost("fetch_depot")] public async Task> 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>> 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 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(r.ToArray())); } [HttpPost("list_locked_files")] public async Task>> 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(lockers)); } [HttpGet("peek_commit")] public async Task>> 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(commit?.Id ?? Guid.Empty)); } [HttpPost("lock_file")] public async Task 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 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(200)] public async Task 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 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(); var test = new HashSet(actualFs); RepositoryDepot mainDepot; List 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(); // 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 unresolved, Dictionary 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 StencilWorkspaceSnapshotViaDatabaseAsync( Guid rpId, Guid depotId, HashSet unresolved, Dictionary sizeMap, HashSet? 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 unresolved, Dictionary sizeMap, HashSet? 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); } } }