tgstation-server 6.19.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 .Where(x => x.InstanceId == Instance.Id)
126 .FirstOrDefaultAsync(cancellationToken);
127
128 if (currentModel == default)
129 return this.Gone();
130
132
133 var earlyOut = await ValidateCredentials(model, model.Origin, cancellationToken);
134 if (earlyOut != null)
135 return earlyOut;
136
137 currentModel.AccessToken = model.AccessToken;
138 currentModel.AccessUser = model.AccessUser;
139
142
143 var cloneBranch = model.Reference;
144 var origin = model.Origin;
145
147 async instance =>
148 {
149 var repoManager = instance.RepositoryManager;
150
151 if (repoManager.CloneInProgress)
152 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
153
154 if (repoManager.InUse)
155 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
156
157 using var repo = await repoManager.LoadRepository(cancellationToken);
158
159 // clone conflict
160 if (repo != null)
161 return Conflict(new ErrorMessageResponse(ErrorCode.RepoExists));
162
163 var description = String.Format(
164 CultureInfo.InvariantCulture,
165 "Clone{1} repository {0}",
166 origin,
167 cloneBranch != null
168 ? $"\"{cloneBranch}\" branch of"
169 : String.Empty);
170 var job = Job.Create(JobCode.RepositoryClone, AuthenticationContext.User, Instance, RepositoryRights.CancelClone);
172 var api = currentModel.ToApi();
173
174 await DatabaseContext.Save(cancellationToken);
176 job,
177 async (core, databaseContextFactory, paramJob, progressReporter, ct) =>
178 {
179 var repoManager = core!.RepositoryManager;
180 using var repos = await repoManager.CloneRepository(
181 origin,
183 currentModel.AccessUser,
184 currentModel.AccessToken,
186 currentModel.UpdateSubmodules.Value,
187 ct)
188 ?? throw new JobException(ErrorCode.RepoExists);
189
190 var instance = new Models.Instance
191 {
192 Id = Instance.Id,
193 };
194 await databaseContextFactory.UseContext(
195 async databaseContext =>
196 {
197 databaseContext.Instances.Attach(instance);
198 if (await PopulateApi(api, repos, databaseContext, instance, ct))
199 await databaseContext.Save(ct);
200 });
201 },
202 cancellationToken);
203
204 api.Origin = model.Origin;
205 api.Reference = model.Reference;
206 api.ActiveJob = job.ToApi();
207
208 return this.Created(api);
209 });
210 }
211
219 [HttpDelete]
224 {
227 .Where(x => x.InstanceId == Instance.Id)
228 .FirstOrDefaultAsync(cancellationToken);
229
230 if (currentModel == default)
231 return this.Gone();
232
235
236 await DatabaseContext.Save(cancellationToken);
237
238 Logger.LogInformation("Instance {instanceId} repository delete initiated by user {userId}", Instance.Id, AuthenticationContext.User.Require(x => x.Id));
239
241 var api = currentModel.ToApi();
243 job,
244 (core, databaseContextFactory, paramJob, progressReporter, ct) => core!.RepositoryManager.DeleteRepository(ct),
245 cancellationToken);
246 api.ActiveJob = job.ToApi();
247 return Accepted(api);
248 }
249
257 [HttpPatch]
262 {
265 .Where(x => x.InstanceId == Instance.Id)
266 .FirstOrDefaultAsync(cancellationToken);
267
268 if (currentModel == default)
269 return this.Gone();
270
271 Logger.LogInformation("Instance {instanceId} repository reclone initiated by user {userId}", Instance.Id, AuthenticationContext.User.Require(x => x.Id));
272
274
276 var api = currentModel.ToApi();
278 job,
279 (core, databaseContextFactory, paramJob, progressReporter, ct) => repositoryUpdater.RepositoryRecloneJob(core, databaseContextFactory, progressReporter, ct),
280 cancellationToken);
281 api.ActiveJob = job.ToApi();
282 return Accepted(api);
283 }
284
293 [HttpGet]
299 {
302 .Where(x => x.InstanceId == Instance.Id)
303 .FirstOrDefaultAsync(cancellationToken);
304
305 if (currentModel == default)
306 return this.Gone();
307
308 var api = currentModel.ToApi();
309
311 async instance =>
312 {
313 var repoManager = instance.RepositoryManager;
314
315 if (repoManager.CloneInProgress)
316 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
317
318 if (repoManager.InUse)
319 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
320
321 using var repo = await repoManager.LoadRepository(cancellationToken);
322 if (repo != null && await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken))
323 {
324 // user may have fucked with the repo manually, do what we can
325 await DatabaseContext.Save(cancellationToken);
326 return this.Created(api);
327 }
328
329 return Json(api);
330 });
331 }
332
342 [HttpPost]
344 RepositoryRights.ChangeAutoUpdateSettings
345 | RepositoryRights.ChangeCommitter
346 | RepositoryRights.ChangeCredentials
347 | RepositoryRights.ChangeTestMergeCommits
348 | RepositoryRights.MergePullRequest
349 | RepositoryRights.SetReference
350 | RepositoryRights.SetSha
351 | RepositoryRights.UpdateBranch
352 | RepositoryRights.ChangeSubmoduleUpdate)]
356#pragma warning disable CA1502 // TODO: Decomplexify
358#pragma warning restore CA1502
359 {
360 ArgumentNullException.ThrowIfNull(model);
361
362 if (model.AccessUser == null ^ model.AccessToken == null)
363 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchUserAndAccessToken));
364
365 if (model.CheckoutSha != null && model.Reference != null)
366 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchShaAndReference));
367
368 if (model.CheckoutSha != null && model.UpdateFromOrigin == true)
369 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchShaAndUpdate));
370
371 if (model.NewTestMerges?.Any(x => model.NewTestMerges.Any(y => x != y && x.Number == y.Number)) == true)
372 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoDuplicateTestMerge));
373
374 if (model.CommitterName?.Length == 0)
375 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoWhitespaceCommitterName));
376
377 if (model.CommitterEmail?.Length == 0)
378 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoWhitespaceCommitterEmail));
379
382 if (newTestMerges && !userRights.HasFlag(RepositoryRights.MergePullRequest))
383 return Forbid();
384
387 .Where(x => x.InstanceId == Instance.Id)
388 .FirstOrDefaultAsync(cancellationToken);
389
390 if (currentModel == default)
391 return this.Gone();
392
394 {
396 var property = (PropertyInfo)memberSelectorExpression.Member;
397
398 var newVal = property.GetValue(model);
399 if (newVal == null)
400 return false;
401 if (!userRights.HasFlag(requiredRight) && property.GetValue(currentModel) != newVal)
402 return true;
403
404 property.SetValue(currentModel, newVal);
405 return false;
406 }
407
408 if (CheckModified(x => x.AccessToken, RepositoryRights.ChangeCredentials)
409 || CheckModified(x => x.AccessUser, RepositoryRights.ChangeCredentials)
410 || CheckModified(x => x.AutoUpdatesKeepTestMerges, RepositoryRights.ChangeAutoUpdateSettings)
411 || CheckModified(x => x.AutoUpdatesSynchronize, RepositoryRights.ChangeAutoUpdateSettings)
412 || CheckModified(x => x.CommitterEmail, RepositoryRights.ChangeCommitter)
413 || CheckModified(x => x.CommitterName, RepositoryRights.ChangeCommitter)
414 || CheckModified(x => x.PushTestMergeCommits, RepositoryRights.ChangeTestMergeCommits)
415 || CheckModified(x => x.CreateGitHubDeployments, RepositoryRights.ChangeTestMergeCommits)
416 || CheckModified(x => x.ShowTestMergeCommitters, RepositoryRights.ChangeTestMergeCommits)
417 || CheckModified(x => x.PostTestMergeComment, RepositoryRights.ChangeTestMergeCommits)
418 || CheckModified(x => x.UpdateSubmodules, RepositoryRights.ChangeSubmoduleUpdate)
419 || (model.UpdateFromOrigin == true && !userRights.HasFlag(RepositoryRights.UpdateBranch))
420 || (model.CheckoutSha != null && !userRights.HasFlag(RepositoryRights.SetSha))
421 || (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
422 return Forbid();
423
424 if (model.AccessToken?.Length == 0 && model.AccessUser?.Length == 0)
425 {
426 // setting an empty string clears everything
429 }
430
431 var canRead = userRights.HasFlag(RepositoryRights.Read);
432
433 var api = canRead ? currentModel.ToApi() : new RepositoryResponse();
434 if (canRead)
435 {
437 async instance =>
438 {
439 var repoManager = instance.RepositoryManager;
440 if (repoManager.CloneInProgress)
441 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
442
443 if (repoManager.InUse)
444 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
445
446 using var repo = await repoManager.LoadRepository(cancellationToken);
447 if (repo == null)
448 return Conflict(new ErrorMessageResponse(ErrorCode.RepoMissing));
449
450 var credAuthFailure = await ValidateCredentials(model, repo.Origin, cancellationToken);
451 if (credAuthFailure != null)
452 return credAuthFailure;
453
454 await PopulateApi(api, repo, DatabaseContext, Instance, cancellationToken);
455
456 return null;
457 });
458
459 if (earlyOut != null)
460 return earlyOut;
461 }
462
463 // this is just db stuf so stow it away
464 await DatabaseContext.Save(cancellationToken);
465
466 // format the job description
467 string? description = null;
468 if (model.UpdateFromOrigin == true)
469 if (model.Reference != null)
470 description = String.Format(CultureInfo.InvariantCulture, "Fetch and hard reset repository to origin/{0}", model.Reference);
471 else if (model.CheckoutSha != null)
472 description = String.Format(CultureInfo.InvariantCulture, "Fetch and checkout {0} in repository", model.CheckoutSha);
473 else
474 description = "Pull current repository reference";
475 else if (model.Reference != null || model.CheckoutSha != null)
476 description = String.Format(CultureInfo.InvariantCulture, "Checkout repository {0} {1}", model.Reference != null ? "reference" : "SHA", model.Reference ?? model.CheckoutSha);
477
478 if (newTestMerges)
479 description = String.Format(
480 CultureInfo.InvariantCulture,
481 "{0}est merge(s) {1}{2}",
482 description != null
483 ? String.Format(CultureInfo.InvariantCulture, "{0} and t", description)
484 : "T",
485 String.Join(
486 ", ",
487 model.NewTestMerges!.Select(
488 x => String.Format(
489 CultureInfo.InvariantCulture,
490 "#{0}{1}",
491 x.Number,
492 x.TargetCommitSha != null
493 ? String.Format(
494 CultureInfo.InvariantCulture,
495 " at {0}",
496 x.TargetCommitSha[..7])
497 : String.Empty))),
498 description != null
499 ? String.Empty
500 : " in repository");
501
502 if (description == null)
503 return Json(api); // no git changes
504
505 var job = Job.Create(JobCode.RepositoryUpdate, AuthenticationContext.User, Instance, RepositoryRights.CancelPendingChanges);
507
509
510 // Time to access git, do it in a job
512 job,
513 (instance, databaseContextFactory, _, progressReporter, jobToken) => repositoryUpdater.RepositoryUpdateJob(model, instance, databaseContextFactory, progressReporter, jobToken),
514 cancellationToken);
515
516 api.ActiveJob = job.ToApi();
517 return Accepted(api);
518 }
519
531 IRepository repository,
532 IDatabaseContext databaseContext,
533 Models.Instance instance,
534 CancellationToken cancellationToken)
535 {
539
540 apiResponse.Origin = repository.Origin;
541 apiResponse.Reference = repository.Reference;
542
543 // rev info stuff
544 var needsDbUpdate = await RepositoryUpdateService.LoadRevisionInformation(
545 repository,
546 databaseContext,
547 Logger,
548 instance,
549 null,
550 newRevInfo => apiResponse.RevisionInformation = newRevInfo.ToApi(),
551 cancellationToken);
552 return needsDbUpdate;
553 }
554
561 => new(
565 Instance.Require(x => x.Id));
566
575 {
576 if (String.IsNullOrWhiteSpace(model.AccessToken))
577 return null;
578
579 Logger.LogDebug("Repository access token updated, performing auth check...");
581 switch (remoteFeatures.RemoteGitProvider!.Value)
582 {
583 case RemoteGitProvider.GitHub:
584 {
586 model.AccessToken,
588 remoteFeatures.RemoteRepositoryOwner!,
589 remoteFeatures.RemoteRepositoryName!),
590 cancellationToken);
591 if (gitHubClient == null)
592 {
593 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
594 {
595 AdditionalData = "GitHub authentication failed!",
596 });
597 }
598
599 try
600 {
601 string username;
602 if (!model.AccessToken.StartsWith(Api.Models.RepositorySettings.TgsAppPrivateKeyPrefix))
603 {
604 var user = await gitHubClient.User.Current();
605 username = user.Login;
606 }
607 else
608 {
609 // we literally need to app auth again to get the damn bot username
611 var app = await appClient.GitHubApps.GetCurrent();
612 username = app.Name;
613 }
614
615 if (username != model.AccessUser)
616 return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch));
617 }
618 catch (Exception ex)
619 {
620 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
621 {
622 AdditionalData = $"GitHub Authentication Failure: {ex.Message}",
623 });
624 }
625
626 break;
627 }
628
629 case RemoteGitProvider.GitLab:
630 {
631 // need to abstract this eventually
633 try
634 {
635 var operationResult = await gitLabClient.GraphQL.GetCurrentUser.ExecuteAsync(cancellationToken);
636
637 operationResult.EnsureNoErrors();
638
639 var user = operationResult.Data?.CurrentUser;
640 if (user == null || user.Username != model.AccessUser)
641 {
642 return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch));
643 }
644 }
645 catch (Exception ex)
646 {
647 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
648 {
649 AdditionalData = $"GitLab Authentication Failure: {ex.Message}",
650 });
651 }
652
653 break;
654 }
655
656 case RemoteGitProvider.Unknown:
657 default:
658 {
659 Logger.LogWarning("RemoteGitProvider is {provider}, no auth check implemented!", remoteFeatures.RemoteGitProvider.Value);
660 break;
661 }
662 }
663
664 return null;
665 }
666 }
667}
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.