tgstation-server 6.12.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
10using GitLabApiClient;
11
15
30
32{
37#pragma warning disable CA1506 // TODO: Decomplexify
39 {
44
49
54
59
94
103 [HttpPut]
104 [TgsAuthorize(RepositoryRights.SetOrigin)]
108 {
109 ArgumentNullException.ThrowIfNull(model);
110
111 if (model.Origin == null)
112 return BadRequest(ErrorCode.ModelValidationFailure);
113
114 if (model.AccessUser == null ^ model.AccessToken == null)
115 return BadRequest(ErrorCode.RepoMismatchUserAndAccessToken);
116
118 if (((model.AccessUser ?? model.AccessToken) != null && !userRights.HasFlag(RepositoryRights.ChangeCredentials))
119 || ((model.CommitterEmail ?? model.CommitterName) != null && !userRights.HasFlag(RepositoryRights.ChangeCommitter)))
120 return Forbid();
121
124 .AsQueryable()
125 .Where(x => x.InstanceId == Instance.Id)
126 .FirstOrDefaultAsync(cancellationToken);
127
128 if (currentModel == default)
129 return this.Gone();
130
132
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
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 },
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 .AsQueryable()
228 .Where(x => x.InstanceId == Instance.Id)
229 .FirstOrDefaultAsync(cancellationToken);
230
231 if (currentModel == default)
232 return this.Gone();
233
236
238
239 Logger.LogInformation("Instance {instanceId} repository delete initiated by user {userId}", Instance.Id, AuthenticationContext.User.Require(x => x.Id));
240
242 var api = currentModel.ToApi();
244 job,
245 (core, databaseContextFactory, paramJob, progressReporter, ct) => core!.RepositoryManager.DeleteRepository(ct),
247 api.ActiveJob = job.ToApi();
248 return Accepted(api);
249 }
250
258 [HttpPatch]
263 {
266 .AsQueryable()
267 .Where(x => x.InstanceId == Instance.Id)
268 .FirstOrDefaultAsync(cancellationToken);
269
270 if (currentModel == default)
271 return this.Gone();
272
273 Logger.LogInformation("Instance {instanceId} repository reclone initiated by user {userId}", Instance.Id, AuthenticationContext.User.Require(x => x.Id));
274
276
278 var api = currentModel.ToApi();
280 job,
281 (core, databaseContextFactory, paramJob, progressReporter, ct) => repositoryUpdater.RepositoryRecloneJob(core, databaseContextFactory, progressReporter, ct),
283 api.ActiveJob = job.ToApi();
284 return Accepted(api);
285 }
286
295 [HttpGet]
301 {
304 .AsQueryable()
305 .Where(x => x.InstanceId == Instance.Id)
306 .FirstOrDefaultAsync(cancellationToken);
307
308 if (currentModel == default)
309 return this.Gone();
310
311 var api = currentModel.ToApi();
312
314 async instance =>
315 {
316 var repoManager = instance.RepositoryManager;
317
318 if (repoManager.CloneInProgress)
319 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
320
321 if (repoManager.InUse)
322 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
323
324 using var repo = await repoManager.LoadRepository(cancellationToken);
326 {
327 // user may have fucked with the repo manually, do what we can
329 return this.Created(api);
330 }
331
332 return Json(api);
333 });
334 }
335
345 [HttpPost]
347 RepositoryRights.ChangeAutoUpdateSettings
348 | RepositoryRights.ChangeCommitter
349 | RepositoryRights.ChangeCredentials
350 | RepositoryRights.ChangeTestMergeCommits
351 | RepositoryRights.MergePullRequest
352 | RepositoryRights.SetReference
353 | RepositoryRights.SetSha
354 | RepositoryRights.UpdateBranch
355 | RepositoryRights.ChangeSubmoduleUpdate)]
359#pragma warning disable CA1502 // TODO: Decomplexify
361#pragma warning restore CA1502
362 {
363 ArgumentNullException.ThrowIfNull(model);
364
365 if (model.AccessUser == null ^ model.AccessToken == null)
366 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchUserAndAccessToken));
367
368 if (model.CheckoutSha != null && model.Reference != null)
369 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchShaAndReference));
370
371 if (model.CheckoutSha != null && model.UpdateFromOrigin == true)
372 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoMismatchShaAndUpdate));
373
374 if (model.NewTestMerges?.Any(x => model.NewTestMerges.Any(y => x != y && x.Number == y.Number)) == true)
375 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoDuplicateTestMerge));
376
377 if (model.CommitterName?.Length == 0)
378 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoWhitespaceCommitterName));
379
380 if (model.CommitterEmail?.Length == 0)
381 return BadRequest(new ErrorMessageResponse(ErrorCode.RepoWhitespaceCommitterEmail));
382
385 if (newTestMerges && !userRights.HasFlag(RepositoryRights.MergePullRequest))
386 return Forbid();
387
390 .AsQueryable()
391 .Where(x => x.InstanceId == Instance.Id)
392 .FirstOrDefaultAsync(cancellationToken);
393
394 if (currentModel == default)
395 return this.Gone();
396
398 {
400 var property = (PropertyInfo)memberSelectorExpression.Member;
401
402 var newVal = property.GetValue(model);
403 if (newVal == null)
404 return false;
405 if (!userRights.HasFlag(requiredRight) && property.GetValue(currentModel) != newVal)
406 return true;
407
408 property.SetValue(currentModel, newVal);
409 return false;
410 }
411
412 if (CheckModified(x => x.AccessToken, RepositoryRights.ChangeCredentials)
413 || CheckModified(x => x.AccessUser, RepositoryRights.ChangeCredentials)
414 || CheckModified(x => x.AutoUpdatesKeepTestMerges, RepositoryRights.ChangeAutoUpdateSettings)
415 || CheckModified(x => x.AutoUpdatesSynchronize, RepositoryRights.ChangeAutoUpdateSettings)
416 || CheckModified(x => x.CommitterEmail, RepositoryRights.ChangeCommitter)
417 || CheckModified(x => x.CommitterName, RepositoryRights.ChangeCommitter)
418 || CheckModified(x => x.PushTestMergeCommits, RepositoryRights.ChangeTestMergeCommits)
419 || CheckModified(x => x.CreateGitHubDeployments, RepositoryRights.ChangeTestMergeCommits)
420 || CheckModified(x => x.ShowTestMergeCommitters, RepositoryRights.ChangeTestMergeCommits)
421 || CheckModified(x => x.PostTestMergeComment, RepositoryRights.ChangeTestMergeCommits)
422 || CheckModified(x => x.UpdateSubmodules, RepositoryRights.ChangeSubmoduleUpdate)
423 || (model.UpdateFromOrigin == true && !userRights.HasFlag(RepositoryRights.UpdateBranch))
424 || (model.CheckoutSha != null && !userRights.HasFlag(RepositoryRights.SetSha))
425 || (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
426 return Forbid();
427
428 if (model.AccessToken?.Length == 0 && model.AccessUser?.Length == 0)
429 {
430 // setting an empty string clears everything
433 }
434
435 var canRead = userRights.HasFlag(RepositoryRights.Read);
436
437 var api = canRead ? currentModel.ToApi() : new RepositoryResponse();
438 if (canRead)
439 {
441 async instance =>
442 {
443 var repoManager = instance.RepositoryManager;
444 if (repoManager.CloneInProgress)
445 return Conflict(new ErrorMessageResponse(ErrorCode.RepoCloning));
446
447 if (repoManager.InUse)
448 return Conflict(new ErrorMessageResponse(ErrorCode.RepoBusy));
449
450 using var repo = await repoManager.LoadRepository(cancellationToken);
451 if (repo == null)
452 return Conflict(new ErrorMessageResponse(ErrorCode.RepoMissing));
453
455 if (credAuthFailure != null)
456 return credAuthFailure;
457
459
460 return null;
461 });
462
463 if (earlyOut != null)
464 return earlyOut;
465 }
466
467 // this is just db stuf so stow it away
469
470 // format the job description
471 string? description = null;
472 if (model.UpdateFromOrigin == true)
473 if (model.Reference != null)
474 description = String.Format(CultureInfo.InvariantCulture, "Fetch and hard reset repository to origin/{0}", model.Reference);
475 else if (model.CheckoutSha != null)
476 description = String.Format(CultureInfo.InvariantCulture, "Fetch and checkout {0} in repository", model.CheckoutSha);
477 else
478 description = "Pull current repository reference";
479 else if (model.Reference != null || model.CheckoutSha != null)
480 description = String.Format(CultureInfo.InvariantCulture, "Checkout repository {0} {1}", model.Reference != null ? "reference" : "SHA", model.Reference ?? model.CheckoutSha);
481
482 if (newTestMerges)
483 description = String.Format(
484 CultureInfo.InvariantCulture,
485 "{0}est merge(s) {1}{2}",
486 description != null
487 ? String.Format(CultureInfo.InvariantCulture, "{0} and t", description)
488 : "T",
489 String.Join(
490 ", ",
491 model.NewTestMerges!.Select(
492 x => String.Format(
493 CultureInfo.InvariantCulture,
494 "#{0}{1}",
495 x.Number,
496 x.TargetCommitSha != null
497 ? String.Format(
498 CultureInfo.InvariantCulture,
499 " at {0}",
500 x.TargetCommitSha[..7])
501 : String.Empty))),
502 description != null
503 ? String.Empty
504 : " in repository");
505
506 if (description == null)
507 return Json(api); // no git changes
508
509 var job = Job.Create(JobCode.RepositoryUpdate, AuthenticationContext.User, Instance, RepositoryRights.CancelPendingChanges);
511
513
514 // Time to access git, do it in a job
516 job,
517 (instance, databaseContextFactory, _, progressReporter, jobToken) => repositoryUpdater.RepositoryUpdateJob(model, instance, databaseContextFactory, progressReporter, jobToken),
519
520 api.ActiveJob = job.ToApi();
521 return Accepted(api);
522 }
523
535 IRepository repository,
536 IDatabaseContext databaseContext,
537 Models.Instance instance,
538 CancellationToken cancellationToken)
539 {
543
544 apiResponse.Origin = repository.Origin;
545 apiResponse.Reference = repository.Reference;
546
547 // rev info stuff
548 var needsDbUpdate = await RepositoryUpdateService.LoadRevisionInformation(
549 repository,
550 databaseContext,
551 Logger,
552 instance,
553 null,
554 newRevInfo => apiResponse.RevisionInformation = newRevInfo.ToApi(),
556 return needsDbUpdate;
557 }
558
565 => new(
569 Instance.Require(x => x.Id));
570
579 {
580 if (String.IsNullOrWhiteSpace(model.AccessToken))
581 return null;
582
583 Logger.LogDebug("Repository access token updated, performing auth check...");
585 switch (remoteFeatures.RemoteGitProvider!.Value)
586 {
587 case RemoteGitProvider.GitHub:
589 model.AccessToken,
591 remoteFeatures.RemoteRepositoryOwner!,
592 remoteFeatures.RemoteRepositoryName!),
594 if (gitHubClient == null)
595 {
596 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
597 {
598 AdditionalData = "GitHub authentication failed!",
599 });
600 }
601
602 try
603 {
604 string username;
605 if (!model.AccessToken.StartsWith(Api.Models.RepositorySettings.TgsAppPrivateKeyPrefix))
606 {
607 var user = await gitHubClient.User.Current();
608 username = user.Login;
609 }
610 else
611 {
612 // we literally need to app auth again to get the damn bot username
614 var app = await appClient.GitHubApps.GetCurrent();
615 username = app.Name;
616 }
617
618 if (username != model.AccessUser)
619 return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch));
620 }
621 catch (Exception ex)
622 {
623 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
624 {
625 AdditionalData = $"GitHub Authentication Failure: {ex.Message}",
626 });
627 }
628
629 break;
630 case RemoteGitProvider.GitLab:
631 // need to abstract this eventually
633 try
634 {
635 var user = await gitLabClient.Users.GetCurrentSessionAsync();
636 if (user.Username != model.AccessUser)
637 return Conflict(new ErrorMessageResponse(ErrorCode.RepoTokenUsernameMismatch));
638 }
639 catch (Exception ex)
640 {
641 return this.StatusCode(HttpStatusCode.FailedDependency, new ErrorMessageResponse(ErrorCode.RemoteApiError)
642 {
643 AdditionalData = $"GitLab Authentication Failure: {ex.Message}",
644 });
645 }
646
647 break;
648 case RemoteGitProvider.Unknown:
649 default:
650 Logger.LogWarning("RemoteGitProvider is {provider}, no auth check implemented!", remoteFeatures.RemoteGitProvider.Value);
651 break;
652 }
653
654 return null;
655 }
656 }
657}
virtual ? long Id
The ID of the entity.
Definition EntityId.cs:13
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.
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.