// Copyright Epic Games, Inc. All Rights Reserved. using EpicGames.Core; using HordeServer.Api; using HordeServer.Collections; using HordeCommon; using HordeServer.Models; using HordeServer.Services; using HordeServer.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Server.Kestrel.Core.Features; using Microsoft.IdentityModel.Tokens; using MongoDB.Bson; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text; using System.Threading.Tasks; using StreamId = HordeServer.Utilities.StringId; namespace HordeServer.Controllers { /// /// Controller for the /api/v1/issues endpoint /// [Authorize] [ApiController] [Route("[controller]")] public class IssuesController : ControllerBase { /// /// Singleton instance of the issue service /// private readonly IIssueService IssueService; /// /// Singleton instance of the job service /// private readonly JobService JobService; /// /// Singleton instance of the stream service /// private readonly StreamService StreamService; /// /// /// private readonly IUserCollection UserCollection; /// /// Collection of events /// private readonly ILogEventCollection LogEventCollection; /// /// The log file service /// private readonly ILogFileService LogFileService; /// /// Constructor /// /// The issue service /// The job service /// The stream service /// /// The event collection /// The log file service public IssuesController(IIssueService IssueService, JobService JobService, StreamService StreamService, IUserCollection UserCollection, ILogEventCollection LogEventCollection, ILogFileService LogFileService) { this.IssueService = IssueService; this.JobService = JobService; this.StreamService = StreamService; this.UserCollection = UserCollection; this.LogEventCollection = LogEventCollection; this.LogFileService = LogFileService; } /// /// Retrieve information about a specific issue /// /// Set of issue ids to find /// The stream to query for /// The changelist to query /// The minimum changelist range to query, inclusive /// The minimum changelist range to query, inclusive /// Job id to filter by /// The batch to filter by /// The step to filter by /// The label within the job to filter by /// User to filter issues for /// Whether to include resolved issues /// Starting offset of the window of results to return /// Number of results to return /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues")] [ProducesResponseType(typeof(List), 200)] public async Task> FindIssuesAsync([FromQuery(Name = "Id")] int[]? Ids = null, [FromQuery] string? StreamId = null, [FromQuery] int? Change = null, [FromQuery] int? MinChange = null, [FromQuery] int? MaxChange = null, [FromQuery] string? JobId = null, [FromQuery] string? BatchId = null, [FromQuery] string? StepId = null, [FromQuery(Name = "label")] int? LabelIdx = null, [FromQuery] string? UserId = null, [FromQuery] bool? Resolved = null, [FromQuery] int Index = 0, [FromQuery] int Count = 10, [FromQuery] PropertyFilter? Filter = null) { if(Ids != null && Ids.Length == 0) { Ids = null; } ObjectId? UserIdValue = null; if (UserId != null) { UserIdValue = new ObjectId(UserId); } List Issues; if (JobId == null) { StreamId? StreamIdValue = null; if (StreamId != null) { StreamIdValue = new StreamId(StreamId); } Issues = await IssueService.FindIssuesAsync(Ids, UserIdValue, StreamIdValue, MinChange ?? Change, MaxChange ?? Change, Resolved, Index, Count); } else { ObjectId JobIdValue = JobId.ToObjectId(); IJob? Job = await JobService.GetJobAsync(JobIdValue); if (Job == null) { return NotFound(); } if(!await JobService.AuthorizeAsync(Job, AclAction.ViewJob, this.User, null)) { return Forbid(); } IGraph Graph = await JobService.GetGraphAsync(Job); Issues = await IssueService.FindIssuesForJobAsync(Ids, Job, Graph, StepId?.ToSubResourceId(), BatchId?.ToSubResourceId(), LabelIdx, UserIdValue, Resolved, Index, Count); } StreamPermissionsCache PermissionsCache = new StreamPermissionsCache(); List Responses = new List(); foreach (IIssue Issue in Issues) { IIssueDetails Details = await IssueService.GetIssueDetailsAsync(Issue); if (await AuthorizeIssue(Details, PermissionsCache)) { bool bShowDesktopAlerts = IssueService.ShowDesktopAlertsForIssue(Issue, Details.Spans); GetIssueResponse Response = await CreateIssueResponseAsync(Details, bShowDesktopAlerts); Responses.Add(PropertyFilter.Apply(Response, Filter)); } } return Responses; } /// /// Retrieve information about a specific issue /// /// Id of the issue to get information about /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{IssueId}")] [ProducesResponseType(typeof(GetIssueResponse), 200)] public async Task> GetIssueAsync(int IssueId, [FromQuery] PropertyFilter? Filter = null) { IIssueDetails? Details = await IssueService.GetIssueDetailsAsync(IssueId); if (Details == null) { return NotFound(); } if (!await AuthorizeIssue(Details, null)) { return Forbid(); } bool bShowDesktopAlerts = IssueService.ShowDesktopAlertsForIssue(Details.Issue, Details.Spans); return PropertyFilter.Apply(await CreateIssueResponseAsync(Details, bShowDesktopAlerts), Filter); } /// /// Create an issue response object /// /// /// /// async Task CreateIssueResponseAsync(IIssueDetails Details, bool ShowDesktopAlerts) { List AffectedStreams = new List(); foreach (IGrouping StreamSpans in Details.Spans.GroupBy(x => x.StreamId)) { IStream? Stream = await StreamService.GetCachedStream(StreamSpans.Key); AffectedStreams.Add(new GetIssueAffectedStreamResponse(Stream, StreamSpans)); } return new GetIssueResponse(Details, AffectedStreams, ShowDesktopAlerts); } /// /// Retrieve events for a specific issue /// /// Id of the issue to get information about /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{IssueId}/streams")] [ProducesResponseType(typeof(List), 200)] public async Task>> GetIssueStreamsAsync(int IssueId, [FromQuery] PropertyFilter? Filter = null) { IIssueDetails? Issue = await IssueService.GetIssueDetailsAsync(IssueId); if (Issue == null) { return NotFound(); } StreamPermissionsCache Cache = new StreamPermissionsCache(); if (!await AuthorizeIssue(Issue, Cache)) { return Forbid(); } List Responses = new List(); foreach (IGrouping SpanGroup in Issue.Spans.GroupBy(x => x.StreamId)) { if (await StreamService.AuthorizeAsync(SpanGroup.Key, AclAction.ViewStream, User, Cache)) { HashSet SpanIds = new HashSet(SpanGroup.Select(x => x.Id)); List Steps = Issue.Steps.Where(x => SpanIds.Contains(x.SpanId)).ToList(); Responses.Add(PropertyFilter.Apply(new GetIssueStreamResponse(SpanGroup.Key, SpanGroup.ToList(), Steps), Filter)); } } return Responses; } /// /// Retrieve events for a specific issue /// /// Id of the issue to get information about /// The stream id /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{IssueId}/streams/{StreamId}")] [ProducesResponseType(typeof(List), 200)] public async Task> GetIssueStreamAsync(int IssueId, string StreamId, [FromQuery] PropertyFilter? Filter = null) { IIssueDetails? Details = await IssueService.GetIssueDetailsAsync(IssueId); if (Details == null) { return NotFound(); } StreamId StreamIdValue = new StreamId(StreamId); if (!await StreamService.AuthorizeAsync(StreamIdValue, AclAction.ViewStream, User, null)) { return Forbid(); } List Spans = Details.Spans.Where(x => x.StreamId == StreamIdValue).ToList(); if(Spans.Count == 0) { return NotFound(); } HashSet SpanIds = new HashSet(Spans.Select(x => x.Id)); List Steps = Details.Steps.Where(x => SpanIds.Contains(x.SpanId)).ToList(); return PropertyFilter.Apply(new GetIssueStreamResponse(StreamIdValue, Spans, Steps), Filter); } /// /// Retrieve events for a specific issue /// /// Id of the issue to get information about /// The job id to filter for /// The batch to filter by /// The step to filter by /// The label within the job to filter by /// List of log ids to return issues for /// Index of the first event /// Number of events to return /// Filter for the properties to return /// List of matching agents [HttpGet] [Route("/api/v1/issues/{IssueId}/events")] [ProducesResponseType(typeof(List), 200)] public async Task>> GetIssueEventsAsync(int IssueId, [FromQuery] string? JobId = null, [FromQuery] string? BatchId = null, [FromQuery] string? StepId = null, [FromQuery(Name = "label")] int? LabelIdx = null, [FromQuery] string[]? LogIds = null, [FromQuery] int Index = 0, [FromQuery] int Count = 10, [FromQuery] PropertyFilter? Filter = null) { HashSet LogIdValues = new HashSet(); if(JobId != null) { IJob? Job = await JobService.GetJobAsync(JobId.ToObjectId()); if(Job == null) { return NotFound(); } if (StepId != null) { IJobStep? Step; if (Job.TryGetStep(StepId.ToSubResourceId(), out Step) && Step.Outcome != JobStepOutcome.Success && Step.LogId != null) { LogIdValues.Add(Step.LogId.Value); } } else if (BatchId != null) { IJobStepBatch? Batch; if (Job.TryGetBatch(BatchId.ToSubResourceId(), out Batch)) { LogIdValues.UnionWith(Batch.Steps.Where(x => x.Outcome != JobStepOutcome.Success && x.LogId != null).Select(x => x.LogId!.Value)); } } else if (LabelIdx != null) { IGraph Graph = await JobService.GetGraphAsync(Job); HashSet IncludedNodes = new HashSet(Graph.Labels[LabelIdx.Value].IncludedNodes); foreach (IJobStepBatch Batch in Job.Batches) { foreach (IJobStep Step in Batch.Steps) { NodeRef NodeRef = new NodeRef(Batch.GroupIdx, Step.NodeIdx); if (Step.Outcome != JobStepOutcome.Success && Step.LogId != null && IncludedNodes.Contains(NodeRef)) { LogIdValues.Add(Step.LogId.Value); } } } } else { LogIdValues.UnionWith(Job.Batches.SelectMany(x => x.Steps).Where(x => x.Outcome != JobStepOutcome.Success && x.LogId != null).Select(x => x.LogId!.Value)); } } if(LogIds != null) { LogIdValues.UnionWith(LogIds.Select(x => x.ToObjectId())); } List Events = await IssueService.FindEventsForIssueAsync(IssueId, LogIdValues.ToArray(), Index, Count); JobPermissionsCache PermissionsCache = new JobPermissionsCache(); Dictionary LogFiles = new Dictionary(); List Responses = new List(); foreach (ILogEvent Event in Events) { ILogFile? LogFile; if (!LogFiles.TryGetValue(Event.LogId, out LogFile)) { LogFile = await LogFileService.GetLogFileAsync(Event.LogId); LogFiles[Event.LogId] = LogFile; } if (LogFile != null && await JobService.AuthorizeAsync(LogFile.JobId, AclAction.ViewLog, User, PermissionsCache)) { ILogEventData Data = await LogFileService.GetEventDataAsync(LogFile, Event.LineIndex, Event.LineCount); GetLogEventResponse Response = new GetLogEventResponse(Event, Data, IssueId); Responses.Add(PropertyFilter.Apply(Response, Filter)); } } return Responses; } /// /// Authorize the current user to see an issue /// /// The issue to authorize /// Cache of permissions /// True if the user is authorized to see the issue private async Task AuthorizeIssue(IIssueDetails Issue, StreamPermissionsCache? PermissionsCache) { foreach (StreamId StreamId in Issue.Spans.Select(x => x.StreamId).Distinct()) { if (await StreamService.AuthorizeAsync(StreamId, AclAction.ViewStream, User, PermissionsCache)) { return true; } } return false; } /// /// Update an issue /// /// Id of the issue to get information about /// The update information /// List of matching agents [HttpPut] [Route("/api/v1/issues/{IssueId}")] public async Task UpdateIssueAsync(int IssueId, [FromBody] UpdateIssueRequest Request) { ObjectId? NewOwnerId = null; if (Request.OwnerId != null) { NewOwnerId = new ObjectId(Request.OwnerId); } ObjectId? NewNominatedById = null; if (Request.NominatedById != null) { NewNominatedById = new ObjectId(Request.NominatedById); } ObjectId? NewDeclinedById = null; if (Request.Declined ?? false) { NewDeclinedById = User.GetUserId(); } ObjectId? NewResolvedById = null; if (Request.Resolved.HasValue) { NewResolvedById = Request.Resolved.Value ? User.GetUserId() : ObjectId.Empty; } if (!await IssueService.UpdateIssueAsync(IssueId, Request.Summary, NewOwnerId, NewNominatedById, Request.Acknowledged, NewDeclinedById, Request.FixChange, NewResolvedById)) { return NotFound(); } return Ok(); } } }