tgstation-server 6.16.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
RepositoryController.cs
Go to the documentation of this file.
1using System;
3using System.Linq;
5using System.Net;
9
13
14using StrawberryShake;
15
31
33{
38#pragma warning disable CA1506 // TODO: Decomplexify
40 {
45
50
55
60
95
104 [HttpPut]
105 [TgsAuthorize(RepositoryRights.SetOrigin)]
109 {
110 ArgumentNullException.ThrowIfNull(model);
111
112 if (model.Origin == null)
113 return BadRequest(ErrorCode.ModelValidationFailure);
114
115 if (model.AccessUser == null ^ model.AccessToken == null)
116 return BadRequest(ErrorCode.RepoMismatchUserAndAccessToken);
117
119 if (((model.AccessUser ?? model.AccessToken) != null && !userRights.HasFlag(RepositoryRights.ChangeCredentials))
120 || ((model.CommitterEmail ?? model.CommitterName) != null && !userRights.HasFlag(RepositoryRights.ChangeCommitter)))
121 return Forbid();
122
125 .AsQueryable()
126 .Where(x => x.InstanceId == Instance.Id)
127 .FirstOrDefaultAsync(cancellationToken);
128
129 if (currentModel == default)
130 return this.Gone();
131
133
135 if (earlyOut != null)
136 return earlyOut;
137
138 currentModel.AccessToken = model.AccessToken;
139 currentModel.AccessUser = model.AccessUser;
140
143
144 var cloneBranch = model.Reference;
145 var origin = model.Origin;
146
148 async instance =>
149 {
150 var repoManager = instance.RepositoryManager;
151
152 if (repoManager.CloneInProgress)
153 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
154
155 if (repoManager.InUse)
156 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
157
158 using var repo = await repoManager.LoadRepository(cancellationToken);
159
160 // clone conflict
161 if (repo != null)
162 return Conflict(new ErrorMessageResponse(ErrorCode.RepoExists));
163
164 var description = String.Format(
165 CultureInfo.InvariantCulture,
166 "Clone{1} repository {0}",
167 origin,
168 cloneBranch != null
169 ? $"\"{cloneBranch}\" branch of"
170 : String.Empty);
171 var job = Job.Create(JobCode.RepositoryClone, AuthenticationContext.User, Instance, RepositoryRights.CancelClone);
173 var api = currentModel.ToApi();
174
177 job,
178 async (core, databaseContextFactory, paramJob, progressReporter, ct) =>
179 {
180 var repoManager = core!.RepositoryManager;
181 using var repos = await repoManager.CloneRepository(
182 origin,
184 currentModel.AccessUser,
185 currentModel.AccessToken,
187 currentModel.UpdateSubmodules.Value,
188 ct)
189 ?? throw new JobException(ErrorCode.RepoExists);
190
191 var instance = new Models.Instance
192 {
193 Id = Instance.Id,
194 };
195 await databaseContextFactory.UseContext(
196 async databaseContext =>
197 {
198 databaseContext.Instances.Attach(instance);
199 if (await PopulateApi(api, repos, databaseContext, instance, ct))
200 await databaseContext.Save(ct);
201 });
202 },
204
205 api.Origin = model.Origin;
206 api.Reference = model.Reference;
207 api.ActiveJob = job.ToApi();
208
209 return this.Created(api);
210 });
211 }
212
220 [HttpDelete]
225 {
228 .AsQueryable()
229 .Where(x => x.InstanceId == Instance.Id)
230 .FirstOrDefaultAsync(cancellationToken);
231
232 if (currentModel == default)
233 return this.Gone();
234
237
239
240 Logger.LogInformation("Instance {instanceId} repository delete initiated by user {userId}", Instance.Id, AuthenticationContext.User.Require(x => x.Id));
241
243 var api = currentModel.ToApi();
245 job,
246 (core, databaseContextFactory, paramJob, progressReporter, ct) => core!.RepositoryManager.DeleteRepository(ct),
248 api.ActiveJob = job.ToApi();
249 return Accepted(api);
250 }
251
259 [HttpPatch]
264 {
267 .AsQueryable()
268 .Where(x => x.InstanceId == Instance.Id)
269 .FirstOrDefaultAsync(cancellationToken);
270
271 if (currentModel == default)
272 return this.Gone();
273
274 Logger.LogInformation("Instance {instanceId} repository reclone initiated by user {userId}", Instance.Id, AuthenticationContext.User.Require(x => x.Id));
275
277
279 var api = currentModel.ToApi();
281 job,
282 (core, databaseContextFactory, paramJob, progressReporter, ct) => repositoryUpdater.RepositoryRecloneJob(core, databaseContextFactory, progressReporter, ct),
284 api.ActiveJob = job.ToApi();
285 return Accepted(api);
286 }
287
296 [HttpGet]
302 {
305 .AsQueryable()
306 .Where(x => x.InstanceId == Instance.Id)
307 .FirstOrDefaultAsync(cancellationToken);
308
309 if (currentModel == default)
310 return this.Gone();
311
312 var api = currentModel.ToApi();
313
315 async instance =>
316 {
317 var repoManager = instance.RepositoryManager;
318
319 if (repoManager.CloneInProgress)
320 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
321
322 if (repoManager.InUse)
323 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
324
325 using var repo = await repoManager.LoadRepository(cancellationToken);
327 {
328 // user may have fucked with the repo manually, do what we can
330 return this.Created(api);
331 }
332
333 return Json(api);
334 });
335 }
336
346 [HttpPost]
348 RepositoryRights.ChangeAutoUpdateSettings
349 | RepositoryRights.ChangeCommitter
350 | RepositoryRights.ChangeCredentials
351 | RepositoryRights.ChangeTestMergeCommits
352 | RepositoryRights.MergePullRequest
353 | RepositoryRights.SetReference
354 | RepositoryRights.SetSha
355 | RepositoryRights.UpdateBranch
356 | RepositoryRights.ChangeSubmoduleUpdate)]
360#pragma warning disable CA1502 // TODO: Decomplexify
362#pragma warning restore CA1502
363 {
364 ArgumentNullException.ThrowIfNull(model);
365
366 if (model.AccessUser == null ^ model.AccessToken == null)
367 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchUserAndAccessToken));
368
369 if (model.CheckoutSha != null && model.Reference != null)
370 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchShaAndReference));
371
372 if (model.CheckoutSha != null && model.UpdateFromOrigin == true)
373 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchShaAndUpdate));
374
375 if (model.NewTestMerges?.Any(x => model.NewTestMerges.Any(y => x != y && x.Number == y.Number)) == true)
376 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoDuplicateTestMerge));
377
378 if (model.CommitterName?.Length == 0)
379 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoWhitespaceCommitterName));
380
381 if (model.CommitterEmail?.Length == 0)
382 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoWhitespaceCommitterEmail));
383
386 if (newTestMerges && !userRights.HasFlag(RepositoryRights.MergePullRequest))
387 return Forbid();
388
391 .AsQueryable()
392 .Where(x => x.InstanceId == Instance.Id)
393 .FirstOrDefaultAsync(cancellationToken);
394
395 if (currentModel == default)
396 return this.Gone();
397
399 {
401 var property = (PropertyInfo)memberSelectorExpression.Member;
402
403 var newVal = property.GetValue(model);
404 if (newVal == null)
405 return false;
406 if (!userRights.HasFlag(requiredRight) && property.GetValue(currentModel) != newVal)
407 return true;
408
409 property.SetValue(currentModel, newVal);
410 return false;
411 }
412
413 if (CheckModified(x => x.AccessToken, RepositoryRights.ChangeCredentials)
414 || CheckModified(x => x.AccessUser, RepositoryRights.ChangeCredentials)
415 || CheckModified(x => x.AutoUpdatesKeepTestMerges, RepositoryRights.ChangeAutoUpdateSettings)
416 || CheckModified(x => x.AutoUpdatesSynchronize, RepositoryRights.ChangeAutoUpdateSettings)
417 || CheckModified(x => x.CommitterEmail, RepositoryRights.ChangeCommitter)
418 || CheckModified(x => x.CommitterName, RepositoryRights.ChangeCommitter)
419 || CheckModified(x => x.PushTestMergeCommits, RepositoryRights.ChangeTestMergeCommits)
420 || CheckModified(x => x.CreateGitHubDeployments, RepositoryRights.ChangeTestMergeCommits)
421 || CheckModified(x => x.ShowTestMergeCommitters, RepositoryRights.ChangeTestMergeCommits)
422 || CheckModified(x => x.PostTestMergeComment, RepositoryRights.ChangeTestMergeCommits)
423 || CheckModified(x => x.UpdateSubmodules, RepositoryRights.ChangeSubmoduleUpdate)
424 || (model.UpdateFromOrigin == true && !userRights.HasFlag(RepositoryRights.UpdateBranch))
425 || (model.CheckoutSha != null && !userRights.HasFlag(RepositoryRights.SetSha))
426 || (model.Reference != null && model.UpdateFromOrigin != true && !userRights.HasFlag(RepositoryRights.SetReference))) // don't care if it's the same reference, we want to forbid them before starting the job
427 return Forbid();
428
429 if (model.AccessToken?.Length == 0 && model.AccessUser?.Length == 0)
430 {
431 // setting an empty string clears everything
434 }
435
436 var canRead = userRights.HasFlag(RepositoryRights.Read);
437
438 var api = canRead ? currentModel.ToApi() : new RepositoryResponse();
439 if (canRead)
440 {
442 async instance =>
443 {
444 var repoManager = instance.RepositoryManager;
445 if (repoManager.CloneInProgress)
446 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
447
448 if (repoManager.InUse)
449 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
450
451 using var repo = await repoManager.LoadRepository(cancellationToken);
452 if (repo == null)
453 return Conflict(new ErrorMessageResponse(ErrorCode.RepoMissing));
454
456 if (credAuthFailure != null)
457 return credAuthFailure;
458
460
461 return null;
462 });
463
464 if (earlyOut != null)
465 return earlyOut;
466 }
467
468 // this is just db stuf so stow it away
470
471 // format the job description
472 string? description = null;
473 if (model.UpdateFromOrigin == true)
474 if (model.Reference != null)
475 description = String.Format(CultureInfo.InvariantCulture, "Fetch and hard reset repository to origin/{0}", model.Reference);
476 else if (model.CheckoutSha != null)
477 description = String.Format(CultureInfo.InvariantCulture, "Fetch and checkout {0} in repository", model.CheckoutSha);
478 else
479 description = "Pull current repository reference";
480 else if (model.Reference != null || model.CheckoutSha != null)
481 description = String.Format(CultureInfo.InvariantCulture, "Checkout repository {0} {1}", model.Reference != null ? "reference" : "SHA", model.Reference ?? model.CheckoutSha);
482
483 if (newTestMerges)
484 description = String.Format(
485 CultureInfo.InvariantCulture,
486 "{0}est merge(s) {1}{2}",
487 description != null
488 ? String.Format(CultureInfo.InvariantCulture, "{0} and t", description)
489 : "T",
490 String.Join(
491 ", ",
492 model.NewTestMerges!.Select(
493 x => String.Format(
494 CultureInfo.InvariantCulture,
495 "#{0}{1}",
496 x.Number,
497 x.TargetCommitSha != null
498 ? String.Format(
499 CultureInfo.InvariantCulture,
500 " at {0}",
501 x.TargetCommitSha[..7])
502 : String.Empty))),
503 description != null
504 ? String.Empty
505 : " in repository");
506
507 if (description == null)
508 return Json(api); // no git changes
509
510 var job = Job.Create(JobCode.RepositoryUpdate, AuthenticationContext.User, Instance, RepositoryRights.CancelPendingChanges);
512
514
515 // Time to access git, do it in a job
517 job,
518 (instance, databaseContextFactory, _, progressReporter, jobToken) => repositoryUpdater.RepositoryUpdateJob(model, instance, databaseContextFactory, progressReporter, jobToken),
520
521 api.ActiveJob = job.ToApi();
522 return Accepted(api);
523 }
524
536 IRepository repository,
537 IDatabaseContext databaseContext,
538 Models.Instance instance,
539 CancellationToken cancellationToken)
540 {
544
545 apiResponse.Origin = repository.Origin;
546 apiResponse.Reference = repository.Reference;
547
548 // rev info stuff
549 var needsDbUpdate = await RepositoryUpdateService.LoadRevisionInformation(
550 repository,
551 databaseContext,
552 Logger,
553 instance,
554 null,
555 newRevInfo => apiResponse.RevisionInformation = newRevInfo.ToApi(),
557 return needsDbUpdate;
558 }
559
566 => new(
570 Instance.Require(x => x.Id));
571
580 {
581 if (String.IsNullOrWhiteSpace(model.AccessToken))
582 return null;
583
584 Logger.LogDebug("Repository access token updated, performing auth check...");
586 switch (remoteFeatures.RemoteGitProvider!.Value)
587 {
588 case RemoteGitProvider.GitHub:
589 {
591 model.AccessToken,
593 remoteFeatures.RemoteRepositoryOwner!,
594 remoteFeatures.RemoteRepositoryName!),
596 if (gitHubClient == null)
597 {
598 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
599 {
600 AdditionalData = "GitHub authentication failed!",
601 });
602 }
603
604 try
605 {
606 string username;
607 if (!model.AccessToken.StartsWith(Api.Models.RepositorySettings.TgsAppPrivateKeyPrefix))
608 {
609 var user = await gitHubClient.User.Current();
610 username = user.Login;
611 }
612 else
613 {
614 // we literally need to app auth again to get the damn bot username
616 var app = await appClient.GitHubApps.GetCurrent();
617 username = app.Name;
618 }
619
620 if (username != model.AccessUser)
621 return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch));
622 }
623 catch (Exception ex)
624 {
625 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
626 {
627 AdditionalData = $"GitHub Authentication Failure: {ex.Message}",
628 });
629 }
630
631 break;
632 }
633
634 case RemoteGitProvider.GitLab:
635 {
636 // need to abstract this eventually
638 try
639 {
640 var operationResult = await gitLabClient.GraphQL.GetCurrentUser.ExecuteAsync(cancellationToken);
641
642 operationResult.EnsureNoErrors();
643
644 var user = operationResult.Data?.CurrentUser;
645 if (user == null || user.Username != model.AccessUser)
646 {
647 return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch));
648 }
649 }
650 catch (Exception ex)
651 {
652 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
653 {
654 AdditionalData = $"GitLab Authentication Failure: {ex.Message}",
655 });
656 }
657
658 break;
659 }
660
661 case RemoteGitProvider.Unknown:
662 default:
663 {
664 Logger.LogWarning("RemoteGitProvider is {provider}, no auth check implemented!", remoteFeatures.RemoteGitProvider.Value);
665 break;
666 }
667 }
668
669 return null;
670 }
671 }
672}
virtual ? long Id
The ID of the entity.
Definition EntityId.cs:14
Metadata about a server instance.
Definition Instance.cs:9
Represents an error message returned by the server.
Routes to a server actions.
Definition Routes.cs:9
const string Repository
The git repository controller.
Definition Routes.cs:58
async ValueTask DeleteRepository(CancellationToken cancellationToken)
Delete the current repository.A ValueTask representing the running operation.
StatusCodeResult StatusCode(HttpStatusCode statusCode)
Strongly type calls to ControllerBase.StatusCode(int).
ILogger< ApiController > Logger
The ILogger for the ApiController.
async ValueTask< IActionResult?> WithComponentInstanceNullable(Func< IInstanceCore, ValueTask< IActionResult?> > action, Models.Instance? instance=null)
Run a given action with the relevant IInstance.
readonly IInstanceManager instanceManager
The IInstanceManager for the ComponentInterfacingController.
async ValueTask< IActionResult > WithComponentInstance(Func< IInstanceCore, ValueTask< IActionResult > > action, Models.Instance? instance=null)
Run a given action with the relevant IInstance.
ComponentInterfacingController for operations that require an instance.
ApiController for managing the git repository.
async ValueTask< bool > PopulateApi(RepositoryResponse apiResponse, IRepository repository, IDatabaseContext databaseContext, Models.Instance instance, CancellationToken cancellationToken)
Populate a given apiResponse with the current state of a given repository .
async ValueTask< IActionResult > Read(CancellationToken cancellationToken)
Get the repository's status.
async ValueTask< IActionResult > Reclone(CancellationToken cancellationToken)
Delete the repository.
readonly IGitHubClientFactory gitHubClientFactory
The IGitHubClientFactory for the RepositoryController.
async ValueTask< IActionResult?> ValidateCredentials(Api.Models.RepositorySettings model, Uri origin, CancellationToken cancellationToken)
Validates the Api.Models.RepositorySettings.AccessToken of a given model if it is set.
readonly ILoggerFactory loggerFactory
The ILoggerFactory for the RepositoryController.
async ValueTask< IActionResult > Update([FromBody] RepositoryUpdateRequest model, CancellationToken cancellationToken)
Perform updates to the repository.
RepositoryUpdateService CreateRepositoryUpdateService(Models.RepositorySettings currentModel)
Creates a RepositoryUpdateService.
async ValueTask< IActionResult > Create([FromBody] RepositoryCreateRequest model, CancellationToken cancellationToken)
Begin cloning the repository if it doesn't exist.
readonly IJobManager jobManager
The IJobManager for the RepositoryController.
async ValueTask< IActionResult > Delete(CancellationToken cancellationToken)
Delete the repository.
RepositoryController(IDatabaseContext databaseContext, IAuthenticationContext authenticationContext, ILogger< RepositoryController > logger, IInstanceManager instanceManager, ILoggerFactory loggerFactory, IJobManager jobManager, IGitRemoteFeaturesFactory gitRemoteFeaturesFactory, IGitHubClientFactory gitHubClientFactory, IApiHeadersProvider apiHeaders)
Initializes a new instance of the RepositoryController class.
readonly IGitRemoteFeaturesFactory gitRemoteFeaturesFactory
The IGitRemoteFeaturesFactory for the RepositoryController.
Backend abstract implementation of IDatabaseContext.
DbSet< RepositorySettings > RepositorySettings
The Models.RepositorySettings in the DatabaseContext.
Task Save(CancellationToken cancellationToken)
Saves changes made to the IDatabaseContext.A Task representing the running operation.
Operation exceptions thrown from the context of a Models.Job.
Represents an Api.Models.Instance in the database.
Definition Instance.cs:11
static Job Create(JobCode code, User? startedBy, Api.Models.Instance instance)
Creates a new job for registering in the Jobs.IJobService.
ulong GetRight(RightsType rightsType)
Get the value of a given rightsType .The value of rightsType . Note that if InstancePermissionSet is ...
Identifies a repository either by its RepositoryId or Owner and Name.
static async ValueTask< IGraphQLGitLabClient > CreateClient(string? bearerToken=null)
Sets up a IGraphQLGitLabClient.
string? RemoteRepositoryName
If RemoteGitProvider is not RemoteGitProvider.Unknown this will be set with the name of the repositor...
RemoteGitProvider? RemoteGitProvider
The Models.RemoteGitProvider in use by the repository.
string? RemoteRepositoryOwner
If RemoteGitProvider is not RemoteGitProvider.Unknown this will be set with the owner of the reposito...
IGitRemoteFeatures CreateGitRemoteFeatures(IRepository repository)
Create the IGitRemoteFeatures for a given repository .
Represents an on-disk git repository.
string Reference
The current reference the IRepository HEAD is using. This can be a branch or tag.
Uri Origin
The current origin remote the IRepository is using.
Manages the runtime of Jobs.
ValueTask RegisterOperation(Job job, JobEntrypoint operation, CancellationToken cancellationToken)
Registers a given Job and begins running it.
For creating and accessing authentication contexts.
ValueTask< IGitHubClient?> CreateClientForRepository(string accessString, RepositoryIdentifier repositoryIdentifier, CancellationToken cancellationToken)
Creates a GitHub client that will only be used for a given repositoryIdentifier .
IGitHubClient? CreateAppClient(string tgsEncodedAppPrivateKey)
Create an App (not installation) authenticated IGitHubClient.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition ErrorCode.cs:12
JobCode
The different types of Response.JobResponse.
Definition JobCode.cs:9
RemoteGitProvider
Indicates the remote git host.
@ List
User may list files if the Models.Instance allows it.
RightsType
The type of rights a model uses.
Definition RightsType.cs:7
RepositoryRights
Rights for the git repository.
@ Api
The ApiHeaders.ApiVersionHeader header is missing or invalid.