1
0

600 lines
25 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,
PathTransformer transformer) : ControllerBase
{
public class FormCommitRequest : CommitRequest
{
public IFormFile? Depot { get; set; }
}
#region Unresoted
[HttpPost("delete_repo")]
public async Task<IActionResult> DeleteRepositoryAsync(string repositoryName)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var rp = await dbContext.Repositories.FirstOrDefaultAsync(rp => rp.Name == repositoryName && u == rp.Owner);
if (rp == null) return BadRequest(new FailedResponse("Repository not found!"));
dbContext.Repositories.Remove(rp);
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpGet("get_info")]
public async Task<ActionResult<RepositoryInfoResponse>> IsRepositoryArchiveAsync(string repositoryName)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var rp = await dbContext.Repositories
.Include(repository => repository.Owner)
.Include(repository => repository.Commits)
.Include(repository => repository.Members)
.ThenInclude(repositoryMember => repositoryMember.User)
.FirstOrDefaultAsync(rp => rp.Name == repositoryName && u == rp.Owner);
if (rp == null) return BadRequest(new FailedResponse("Repository not found!"));
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 repositoryName)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var rp = await dbContext.Repositories.FirstOrDefaultAsync(rp => rp.Name == repositoryName && u == rp.Owner);
if (rp == null) return BadRequest(new FailedResponse("Repository not found!"));
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 repositoryName)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var rp = await dbContext.Repositories.FirstOrDefaultAsync(rp => rp.Name == repositoryName && u == rp.Owner);
if (rp == null) return BadRequest(new FailedResponse("Repository not found!"));
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 repositoryName)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var rp = await dbContext.Repositories
.Include(repository => repository.Owner)
.Include(repository => repository.Members)
.ThenInclude(repositoryMember => repositoryMember.User)
.FirstOrDefaultAsync(rp => rp.Name == repositoryName && u == rp.Owner);
if (rp == null) return BadRequest(new FailedResponse("Repository not found!"));
return Ok(new ListingResponse<RepoUserRole>(rp.Members.Select(pm => new RepoUserRole
{
Username = pm.User.UserName!,
Role = pm.Role
}).ToArray()));
}
[HttpPost("update_user")]
public async Task<IActionResult> UpdateUserAsync(string repositoryName, [FromBody] RepoUserRole r)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var tu = await userManager.FindByNameAsync(r.Username);
if (tu == null) return BadRequest(new FailedResponse("User not found!"));
if (u == tu) return BadRequest(new FailedResponse("Not able to update the role on self-own repository!"));
var rp = await dbContext.Repositories
.Include(repository => repository.Members)
.ThenInclude(repositoryMember => repositoryMember.User)
.FirstOrDefaultAsync(rp => rp.Name == repositoryName && u == rp.Owner);
if (rp == null) return BadRequest(new FailedResponse("Repository not found!"));
var m = rp.Members.FirstOrDefault(m => m.User == tu);
if (m == null)
{
m = new RepositoryMember
{
User = tu,
Role = r.Role ?? RepositoryRole.Guest
};
rp.Members.Add(m);
}
else
{
m.Role = r.Role ?? RepositoryRole.Guest;
}
await dbContext.SaveChangesAsync();
return Ok();
}
[HttpPost("delete_user")]
public async Task<IActionResult> DeleteUserAsync(string repositoryName, [FromBody] RepoUserRole r)
{
if (string.IsNullOrWhiteSpace(repositoryName) || repositoryName.Length <= 3)
return BadRequest(new FailedResponse("Repository name is empty or too short!"));
var u = (await userManager.GetUserAsync(HttpContext.User))!;
var tu = await userManager.FindByNameAsync(r.Username);
if (tu == null) return BadRequest(new FailedResponse("User not found!"));
if (u == tu) return BadRequest(new FailedResponse("Not able to update the role on self-own repository!"));
var rp = await dbContext.Repositories
.Include(repository => repository.Members)
.ThenInclude(repositoryMember => repositoryMember.User)
.FirstOrDefaultAsync(rp => rp.Name == repositoryName && u == rp.Owner);
if (rp == null) return BadRequest(new FailedResponse("Repository not found!"));
var m = rp.Members.FirstOrDefault(m => m.User == tu);
if (m == null) return BadRequest(new FailedResponse("User is not being granted to this repository!"));
rp.Members.Remove(m);
await dbContext.SaveChangesAsync();
return Ok();
}
#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;
}
[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);
}).ToArray();
var test = new HashSet<WorkspaceFile>(actualFs);
var createNewDepot = false;
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);
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 actualRequiredDepots = 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);
// 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);
var rest = test.Count;
if (rest == 0)
{
actualRequiredDepots.Add(subDepot);
break;
}
// If test is changed?
if (unresolvedCount != rest)
{
actualRequiredDepots.Add(subDepot);
unresolvedCount = rest;
}
}
// 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 => actualRequiredDepots.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)));
}
// 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)
{
// 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,
};
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)
{
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);
return depot;
static async ValueTask RecurringAsync(PathTransformer transformer, Guid rpId, RepositoryDepot? depot, HashSet<WorkspaceFile> unresolved)
{
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)))
await StencilWorkspaceSnapshotAsync(fs, unresolved);
for (var i = 0; i < depot.Dependencies.Count && unresolved.Count > 0; i++)
await RecurringAsync(transformer, rpId, depot.Dependencies[i], unresolved);
}
}
}