diff --git a/.github/workflows/apphost-build.yml b/.github/workflows/apphost-build.yml index 60ca62522..e608718a2 100644 --- a/.github/workflows/apphost-build.yml +++ b/.github/workflows/apphost-build.yml @@ -8,36 +8,48 @@ permissions: jobs: build: - name: AppHost Build + name: ${{ matrix.apphost.name }} Build runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + apphost: + - name: AppHost + project_name: Aspire.Dev.AppHost + project_path: src/apphost/Aspire.Dev.AppHost + artifact_name: apphost-release + - name: Preview AppHost + project_name: Aspire.Dev.Preview.AppHost + project_path: src/apphost/Aspire.Dev.Preview.AppHost + artifact_name: preview-apphost-release steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 - with: - global-json-file: global.json - - - name: Restore - run: cd src/apphost/Aspire.Dev.AppHost && dotnet restore - - - name: Build - run: cd src/apphost/Aspire.Dev.AppHost && dotnet build --no-restore --configuration Release - - - name: Verify output - run: | - APPHOST_DLL=$(ls -1 src/apphost/Aspire.Dev.AppHost/bin/Release/*/Aspire.Dev.AppHost.dll 2>/dev/null | head -n 1) - if [ -z "$APPHOST_DLL" ]; then - echo "AppHost build failed - output assembly not found" - ls -R src/apphost/Aspire.Dev.AppHost/bin/Release || true - exit 1 - fi - echo "Found $APPHOST_DLL" - - - name: Upload artifact - if: ${{ always() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: apphost-release - path: src/apphost/Aspire.Dev.AppHost/bin/Release/*/ - if-no-files-found: warn - retention-days: 7 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + global-json-file: global.json + + - name: Restore + run: cd ${{ matrix.apphost.project_path }} && dotnet restore + + - name: Build + run: cd ${{ matrix.apphost.project_path }} && dotnet build --no-restore --configuration Release + + - name: Verify output + run: | + APPHOST_DLL=$(ls -1 ${{ matrix.apphost.project_path }}/bin/Release/*/${{ matrix.apphost.project_name }}.dll 2>/dev/null | head -n 1) + if [ -z "$APPHOST_DLL" ]; then + echo "AppHost build failed - output assembly not found" + ls -R ${{ matrix.apphost.project_path }}/bin/Release || true + exit 1 + fi + echo "Found $APPHOST_DLL" + + - name: Upload artifact + if: ${{ always() }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.apphost.artifact_name }} + path: ${{ matrix.apphost.project_path }}/bin/Release/*/ + if-no-files-found: warn + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e1870ca..8e0264e0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,9 @@ jobs: uses: ./.github/workflows/frontend-build.yml with: node_version: '24.x' + artifact_name: ${{ github.event_name == 'pull_request' && format('frontend-dist-pr-{0}', github.event.pull_request.number) || 'frontend-dist' }} + artifact_retention_days: ${{ github.event_name == 'pull_request' && 30 || 7 }} + site_base_path: ${{ github.event_name == 'pull_request' && format('/prs/{0}', github.event.pull_request.number) || '/' }} apphost-build: needs: changes @@ -82,12 +85,14 @@ jobs: - name: Verify CI results shell: bash env: + IS_PULL_REQUEST: ${{ github.event_name == 'pull_request' }} CHANGES_RESULT: ${{ needs.changes.result }} FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend }} APPHOST_CHANGED: ${{ needs.changes.outputs.apphost }} FRONTEND_RESULT: ${{ needs['frontend-build'].result }} APPHOST_RESULT: ${{ needs['apphost-build'].result }} run: | + echo "is pull_request event: $IS_PULL_REQUEST" echo "changes result: $CHANGES_RESULT" echo "frontend changed: $FRONTEND_CHANGED" echo "frontend-build result: $FRONTEND_RESULT" diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index 580c2ed6c..6827bbfc2 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -8,6 +8,21 @@ on: required: true default: "24.x" type: string + artifact_name: + description: Artifact name to publish + required: false + default: "frontend-dist" + type: string + artifact_retention_days: + description: Number of days to keep the uploaded artifact + required: false + default: 7 + type: number + site_base_path: + description: Path base to use for the Astro build + required: false + default: "/" + type: string permissions: contents: read @@ -41,6 +56,7 @@ jobs: env: MODE: production ASTRO_TELEMETRY_DISABLED: 1 + ASTRO_BASE_PATH: ${{ inputs.site_base_path }} run: pnpm build:production - name: Check dist @@ -61,7 +77,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: frontend-dist + name: ${{ inputs.artifact_name }} path: src/frontend/dist if-no-files-found: warn - retention-days: 7 + retention-days: ${{ inputs.artifact_retention_days }} diff --git a/Aspire.Dev.slnx b/Aspire.Dev.slnx index de7801a77..1369bd7d0 100644 --- a/Aspire.Dev.slnx +++ b/Aspire.Dev.slnx @@ -1,9 +1,11 @@ + + diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs new file mode 100644 index 000000000..5233efa73 --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/AppHost.cs @@ -0,0 +1,43 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var extractionMode = builder.AddParameter("extraction-mode", value: "command-line"); +var githubOAuthClientId = builder.AddParameter("github-oauth-client-id"); +var githubOAuthClientSecret = builder.AddParameter("github-oauth-client-secret", secret: true); +var previewControlBaseUrl = builder.AddParameter("preview-control-base-url", value: string.Empty); +var previewContentBaseUrl = builder.AddParameter("preview-content-base-url", value: string.Empty); +var previewAuthCookieDomain = builder.AddParameter("preview-auth-cookie-domain", value: string.Empty); + +var previewHost = builder.AddProject("previewhost") + .PublishAsDockerFile() + .WithExternalHttpEndpoints() + .WithEnvironment("PreviewHost__ExtractionMode", extractionMode) + .WithEnvironment("PreviewHost__GitHubOAuthClientId", githubOAuthClientId) + .WithEnvironment("PreviewHost__GitHubOAuthClientSecret", githubOAuthClientSecret) + .WithEnvironment("PreviewHost__ControlBaseUrl", previewControlBaseUrl) + .WithEnvironment("PreviewHost__ContentBaseUrl", previewContentBaseUrl) + .WithEnvironment("PreviewHost__AuthCookieDomain", previewAuthCookieDomain); + +if (!string.IsNullOrWhiteSpace(builder.Configuration["PreviewHost:GitHubToken"])) +{ + var githubToken = builder.AddParameterFromConfiguration("github-token", "PreviewHost:GitHubToken", secret: true); + previewHost.WithEnvironment("PreviewHost__GitHubToken", githubToken); +} +else +{ + var githubAppId = builder.AddParameter("github-app-id"); + var githubAppInstallationId = builder.AddParameter("github-app-installation-id"); + var githubAppPrivateKey = builder.AddParameter("github-app-private-key", secret: true); + + previewHost + .WithEnvironment("PreviewHost__GitHubAppId", githubAppId) + .WithEnvironment("PreviewHost__GitHubAppInstallationId", githubAppInstallationId) + .WithEnvironment("PreviewHost__GitHubAppPrivateKey", githubAppPrivateKey); +} + +if (!builder.ExecutionContext.IsRunMode) +{ + previewHost.WithEnvironment("PreviewHost__ContentRoot", "/tmp/pr-preview-data"); + builder.AddAzureAppServiceEnvironment("preview"); +} + +builder.Build().Run(); diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj b/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj new file mode 100644 index 000000000..4359c112f --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/Aspire.Dev.Preview.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + enable + enable + cde7765d-59fe-4ea8-a20a-c1eb1ced5cbf + + + + + + + + + + + diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json b/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..43294c9d1 --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17156;http://localhost:15082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21055", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22207" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15082", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19290", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20046" + } + } + } +} diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/src/apphost/Aspire.Dev.Preview.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index 98cf1de7c..b5f462eb4 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -21,9 +21,16 @@ import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightPageActions from 'starlight-page-actions'; import jopSoftwarecookieconsent from '@jop-software/astro-cookieconsent'; +const configuredBasePath = process.env.ASTRO_BASE_PATH ?? '/'; +const normalizedBasePath = + configuredBasePath === '/' + ? '/' + : `/${configuredBasePath.replace(/^\/+|\/+$/g, '')}`; + // https://astro.build/config export default defineConfig({ prefetch: true, + base: normalizedBasePath, site: 'https://aspire.dev', trailingSlash: 'always', redirects: redirects, diff --git a/src/statichost/PreviewHost/.dockerignore b/src/statichost/PreviewHost/.dockerignore new file mode 100644 index 000000000..cd42ee34e --- /dev/null +++ b/src/statichost/PreviewHost/.dockerignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/src/statichost/PreviewHost/Dockerfile b/src/statichost/PreviewHost/Dockerfile new file mode 100644 index 000000000..e1aaecb3c --- /dev/null +++ b/src/statichost/PreviewHost/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1.7 + +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build +WORKDIR /src + +COPY . ./ + +RUN dotnet publish ./PreviewHost.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS unzip-tools +RUN apt-get update \ + && apt-get install -y --no-install-recommends unzip \ + && rm -rf /var/lib/apt/lists/* + +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final +WORKDIR /app + +COPY --from=unzip-tools /usr/bin/unzip /usr/bin/unzip +COPY --from=unzip-tools /lib/x86_64-linux-gnu/libbz2.so.1.0* /lib/x86_64-linux-gnu/ +COPY --from=build /app/publish/ ./ + +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "PreviewHost.dll"] diff --git a/src/statichost/PreviewHost/PreviewHost.csproj b/src/statichost/PreviewHost/PreviewHost.csproj new file mode 100644 index 000000000..95e47648c --- /dev/null +++ b/src/statichost/PreviewHost/PreviewHost.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + false + false + true + noble-chiseled + + + + + + diff --git a/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs b/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs new file mode 100644 index 000000000..bb0f96298 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/CommandLineExtractionSupport.cs @@ -0,0 +1,77 @@ +namespace PreviewHost.Previews; + +internal static class CommandLineExtractionSupport +{ + public static bool IsConfigurationSupported(PreviewHostOptions options) => + !options.UseCommandLineExtraction || TryResolveConfiguredTool(options, out _); + + public static string GetConfigurationValidationMessage() => + OperatingSystem.IsWindows() + ? $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting is 'command-line', but 'tar.exe' is not available on PATH. Switch back to 'managed' or deploy PreviewHost on a Windows image that includes tar.exe." + : $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting is 'command-line', but 'unzip' is not available on PATH. Chiseled .NET runtime images do not include unzip by default; switch back to 'managed' or deploy PreviewHost from a custom image with unzip installed."; + + public static bool TryResolveConfiguredTool(PreviewHostOptions options, out string? resolvedPath) + { + resolvedPath = null; + + if (!options.UseCommandLineExtraction) + { + return true; + } + + return TryResolveCommand(options.CommandLineExtractionCommandName!, out resolvedPath); + } + + private static bool TryResolveCommand(string commandName, out string? resolvedPath) + { + resolvedPath = null; + + if (Path.IsPathRooted(commandName) && File.Exists(commandName)) + { + resolvedPath = commandName; + return true; + } + + var pathValue = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(pathValue)) + { + return false; + } + + var searchNames = GetSearchNames(commandName); + foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + foreach (var searchName in searchNames) + { + var candidate = Path.Combine(directory, searchName); + if (File.Exists(candidate)) + { + resolvedPath = candidate; + return true; + } + } + } + + return false; + } + + private static IEnumerable GetSearchNames(string commandName) + { + yield return commandName; + + if (!OperatingSystem.IsWindows() || Path.HasExtension(commandName)) + { + yield break; + } + + var pathExt = Environment.GetEnvironmentVariable("PATHEXT"); + var extensions = string.IsNullOrWhiteSpace(pathExt) + ? [".exe", ".cmd", ".bat"] + : pathExt.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var extension in extensions) + { + yield return $"{commandName}{extension}"; + } + } +} diff --git a/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs b/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs new file mode 100644 index 000000000..4f1d142cb --- /dev/null +++ b/src/statichost/PreviewHost/Previews/GitHubArtifactClient.cs @@ -0,0 +1,846 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Diagnostics; +using GitHubJwt; +using Microsoft.Extensions.Options; +using Octokit; + +namespace PreviewHost.Previews; + +internal sealed class GitHubArtifactClient(IOptions options, ILogger logger) +{ + private static readonly ApiOptions SinglePageApiOptions = new() + { + StartPage = 1, + PageCount = 1, + PageSize = 100 + }; + + private static readonly TimeSpan DownloadProgressPublishInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan OpenPullRequestCatalogCacheDuration = TimeSpan.FromMinutes(2); + private const string CiWorkflowPath = ".github/workflows/ci.yml"; + private const string DefaultPreviewArtifactName = "frontend-dist"; + private readonly SemaphoreSlim _installationTokenGate = new(1, 1); + private readonly SemaphoreSlim _pullRequestCatalogGate = new(1, 1); + private readonly PreviewHostOptions _options = options.Value; + private readonly ILogger _logger = logger; + private AccessToken? _cachedInstallationToken; + private long? _cachedInstallationId; + private IReadOnlyList? _cachedOpenPullRequests; + private DateTimeOffset _cachedOpenPullRequestsExpiresAtUtc; + private Task>? _pullRequestCatalogRefreshTask; + + public async Task TryResolveLatestPreviewRegistrationAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + EnsureRepositoryConfigured(); + + var repositoryClient = await CreateRepositoryClientAsync( + _options.RepositoryOwner, + _options.RepositoryName, + cancellationToken); + + PullRequest? pullRequest; + try + { + pullRequest = await repositoryClient.PullRequest.Get( + _options.RepositoryOwner, + _options.RepositoryName, + pullRequestNumber); + } + catch (NotFoundException) + { + return null; + } + + if (pullRequest is null || string.IsNullOrWhiteSpace(pullRequest.Head?.Sha)) + { + return null; + } + + var workflowRun = await GetLatestSuccessfulPreviewRunAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + pullRequest.Head.Sha, + cancellationToken); + + if (workflowRun is null) + { + return null; + } + + var artifacts = await GetArtifactsAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + workflowRun.Id, + cancellationToken); + + var previewArtifact = ResolvePreviewArtifact(artifacts.Artifacts, pullRequestNumber); + if (previewArtifact is null) + { + return null; + } + + return new PreviewRegistrationRequest + { + RepositoryOwner = _options.RepositoryOwner, + RepositoryName = _options.RepositoryName, + PullRequestNumber = pullRequestNumber, + HeadSha = pullRequest.Head.Sha, + RunId = workflowRun.Id, + RunAttempt = checked((int)workflowRun.RunAttempt), + ArtifactName = previewArtifact.Name, + CompletedAtUtc = workflowRun.UpdatedAt + }; + } + + public async Task> ListOpenPullRequestsAsync(CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + EnsureRepositoryConfigured(); + + IReadOnlyList? cachedOpenPullRequests = null; + Task>? refreshTask = null; + + await _pullRequestCatalogGate.WaitAsync(cancellationToken); + try + { + if (_cachedOpenPullRequests is not null + && _cachedOpenPullRequestsExpiresAtUtc > DateTimeOffset.UtcNow) + { + return _cachedOpenPullRequests; + } + + cachedOpenPullRequests = _cachedOpenPullRequests; + + if (_pullRequestCatalogRefreshTask is null || _pullRequestCatalogRefreshTask.IsCompleted) + { + _pullRequestCatalogRefreshTask = StartPullRequestCatalogRefreshTask(); + } + + refreshTask = _pullRequestCatalogRefreshTask; + } + finally + { + _pullRequestCatalogGate.Release(); + } + + if (cachedOpenPullRequests is not null) + { + _logger.LogDebug("Serving stale open PR catalog while GitHub previewability data refreshes in the background."); + return cachedOpenPullRequests; + } + + return await refreshTask!.WaitAsync(cancellationToken); + } + + private Task> StartPullRequestCatalogRefreshTask() + { + Task>? refreshTask = null; + + refreshTask = Task.Run( + async () => + { + try + { + return await RefreshOpenPullRequestsAsync(CancellationToken.None); + } + finally + { + await _pullRequestCatalogGate.WaitAsync(CancellationToken.None); + try + { + if (ReferenceEquals(_pullRequestCatalogRefreshTask, refreshTask)) + { + _pullRequestCatalogRefreshTask = null; + } + } + finally + { + _pullRequestCatalogGate.Release(); + } + } + }, + CancellationToken.None); + + _ = refreshTask.ContinueWith( + task => + { + if (task.Exception is null) + { + return; + } + + _logger.LogWarning( + task.Exception.GetBaseException(), + "Failed to refresh the open PR catalog. Continuing to serve the last cached catalog until the next refresh succeeds."); + }, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + + return refreshTask; + } + + private async Task> RefreshOpenPullRequestsAsync(CancellationToken cancellationToken) + { + var refreshedPullRequests = await QueryOpenPullRequestsAsync(cancellationToken); + + await _pullRequestCatalogGate.WaitAsync(CancellationToken.None); + try + { + _cachedOpenPullRequests = refreshedPullRequests; + _cachedOpenPullRequestsExpiresAtUtc = DateTimeOffset.UtcNow.Add(OpenPullRequestCatalogCacheDuration); + return _cachedOpenPullRequests; + } + finally + { + _pullRequestCatalogGate.Release(); + } + } + + private async Task> QueryOpenPullRequestsAsync(CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + var repositoryClient = await CreateRepositoryClientAsync( + _options.RepositoryOwner, + _options.RepositoryName, + cancellationToken); + + var request = new PullRequestRequest + { + State = ItemStateFilter.Open, + SortProperty = PullRequestSort.Created, + SortDirection = SortDirection.Descending + }; + + var pullRequests = new List(); + + for (var page = 1; ; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var pageItems = await repositoryClient.PullRequest.GetAllForRepository( + _options.RepositoryOwner, + _options.RepositoryName, + request, + new ApiOptions + { + StartPage = page, + PageCount = 1, + PageSize = 100 + }); + + if (pageItems.Count == 0) + { + break; + } + + pullRequests.AddRange(pageItems); + + if (pageItems.Count < 100) + { + break; + } + } + + var previewablePullRequests = await GetPullRequestNumbersWithSuccessfulPreviewBuildsAsync( + repositoryClient, + pullRequests, + cancellationToken); + + IReadOnlyList summaries = [.. pullRequests + .OrderByDescending(static pullRequest => pullRequest.CreatedAt) + .ThenByDescending(static pullRequest => pullRequest.Number) + .Select(pullRequest => new GitHubPullRequestSummary( + pullRequest.Number, + pullRequest.Title, + pullRequest.HtmlUrl, + pullRequest.Head?.Sha ?? string.Empty, + pullRequest.User?.Login, + pullRequest.Draft, + pullRequest.CreatedAt, + pullRequest.UpdatedAt, + previewablePullRequests.Contains(pullRequest.Number)))]; + + stopwatch.Stop(); + _logger.LogInformation( + "Refreshed the open PR catalog with {PullRequestCount} pull requests in {ElapsedMilliseconds} ms", + summaries.Count, + stopwatch.ElapsedMilliseconds); + + return summaries; + } + + public async Task GetArtifactDescriptorAsync(PreviewWorkItem workItem, CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + + var repositoryClient = await CreateRepositoryClientAsync( + workItem.RepositoryOwner, + workItem.RepositoryName, + cancellationToken); + + var payload = await GetArtifactsAsync( + repositoryClient, + workItem.RepositoryOwner, + workItem.RepositoryName, + workItem.RunId, + cancellationToken); + + var artifact = payload.Artifacts.FirstOrDefault(candidate => + !candidate.Expired + && string.Equals(candidate.Name, workItem.ArtifactName, StringComparison.Ordinal)); + + if (artifact is null) + { + throw new InvalidOperationException( + $"Could not find a non-expired GitHub Actions artifact named '{workItem.ArtifactName}' on run {workItem.RunId}."); + } + + if (_options.MaxArtifactSizeBytes > 0 + && artifact.SizeInBytes > 0 + && artifact.SizeInBytes > _options.MaxArtifactSizeBytes) + { + throw new InvalidOperationException( + "The preview artifact exceeds the preview host safety limits."); + } + + return new GitHubArtifactDescriptor( + workItem.RepositoryOwner, + workItem.RepositoryName, + artifact.Id, + artifact.Name, + artifact.ExpiresAt == default ? DateTimeOffset.UtcNow : new DateTimeOffset(artifact.ExpiresAt, TimeSpan.Zero), + artifact.SizeInBytes > 0 ? artifact.SizeInBytes : null); + } + + public async Task DownloadArtifactAsync( + GitHubArtifactDescriptor artifact, + string destinationZipPath, + Func progressCallback, + CancellationToken cancellationToken) + { + EnsureCredentialsConfigured(); + Directory.CreateDirectory(Path.GetDirectoryName(destinationZipPath)!); + var bufferSettings = PreviewBufferSettings.Resolve(); + + var repositoryClient = await CreateRepositoryClientAsync( + artifact.RepositoryOwner, + artifact.RepositoryName, + cancellationToken); + + await using var sourceStream = await repositoryClient.Actions.Artifacts.DownloadArtifact( + artifact.RepositoryOwner, + artifact.RepositoryName, + artifact.ArtifactId, + "zip"); + + var totalBytes = artifact.SizeInBytes; + if (totalBytes is null && sourceStream.CanSeek) + { + totalBytes = sourceStream.Length; + } + + await using var destinationStream = new FileStream( + destinationZipPath, + System.IO.FileMode.Create, + System.IO.FileAccess.Write, + System.IO.FileShare.None, + bufferSize: bufferSettings.DownloadFileBufferSize, + options: System.IO.FileOptions.Asynchronous); + + _logger.LogInformation( + "Downloading preview artifact {ArtifactName} with adaptive buffers: copy {CopyBufferSizeMiB} MiB, file {FileBufferSizeMiB} MiB (~{AvailableMemoryMiB} MiB headroom)", + artifact.ArtifactName, + bufferSettings.DownloadCopyBufferMiB, + bufferSettings.DownloadFileBufferMiB, + bufferSettings.AvailableMemoryMiB); + + await using var progressPublisher = new DownloadProgressPublisher( + totalBytes, + progressCallback, + cancellationToken); + + var buffer = ArrayPool.Shared.Rent(bufferSettings.DownloadCopyBufferSize); + long downloadedBytes = 0; + + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken); + if (read == 0) + { + break; + } + + await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + downloadedBytes += read; + progressPublisher.Report(downloadedBytes); + } + + await destinationStream.FlushAsync(cancellationToken); + await progressPublisher.CompleteAsync(downloadedBytes); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public async Task ReviewCollaboratorPermissionAsync(string login, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(login); + EnsureCredentialsConfigured(); + EnsureRepositoryConfigured(); + + var repositoryClient = await CreateRepositoryClientAsync( + _options.RepositoryOwner, + _options.RepositoryName, + cancellationToken); + + try + { + var response = await repositoryClient.Repository.Collaborator.ReviewPermission( + _options.RepositoryOwner, + _options.RepositoryName, + login); + + var roleName = NormalizePermissionValue(response.RoleName); + var permission = NormalizePermissionValue(response.Permission); + var hasWriteAccess = HasWriteAccess(roleName) || HasWriteAccess(permission); + + return new PreviewUserAccessResult(login, hasWriteAccess, roleName, permission); + } + catch (NotFoundException) + { + return new PreviewUserAccessResult(login, false, null, null); + } + } + + private static async Task GetArtifactsAsync( + GitHubClient repositoryClient, + string repositoryOwner, + string repositoryName, + long runId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return await repositoryClient.Actions.Artifacts.ListWorkflowArtifacts( + repositoryOwner, + repositoryName, + runId, + new ListArtifactsRequest + { + Page = 1, + PerPage = 100 + }); + } + + private static async Task GetLatestSuccessfulPreviewRunAsync( + GitHubClient repositoryClient, + string repositoryOwner, + string repositoryName, + string headSha, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var request = new WorkflowRunsRequest + { + Event = "pull_request", + HeadSha = headSha, + Status = CheckRunStatusFilter.Completed + }; + + var payload = await repositoryClient.Actions.Workflows.Runs.List( + repositoryOwner, + repositoryName, + request, + SinglePageApiOptions); + + return payload.WorkflowRuns + .Where(IsSuccessfulPreviewRun) + .OrderByDescending(static run => run.RunAttempt) + .ThenByDescending(static run => run.UpdatedAt) + .ThenByDescending(static run => run.Id) + .FirstOrDefault(); + } + + private async Task> GetPullRequestNumbersWithSuccessfulPreviewBuildsAsync( + GitHubClient repositoryClient, + IReadOnlyList pullRequests, + CancellationToken cancellationToken) + { + var headShas = pullRequests + .Select(static pullRequest => pullRequest.Head?.Sha) + .OfType() + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (headShas.Length == 0) + { + return []; + } + + var latestRunsByHeadSha = await GetLatestSuccessfulPreviewRunsByHeadShaAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + headShas, + cancellationToken); + + if (latestRunsByHeadSha.Count == 0) + { + return []; + } + + var previewablePullRequests = new ConcurrentDictionary(); + + await Parallel.ForEachAsync( + pullRequests, + new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = 4 + }, + async (pullRequest, ct) => + { + var headSha = pullRequest.Head?.Sha; + if (string.IsNullOrWhiteSpace(headSha) + || !latestRunsByHeadSha.TryGetValue(headSha, out var workflowRun)) + { + return; + } + + var artifacts = await GetArtifactsAsync( + repositoryClient, + _options.RepositoryOwner, + _options.RepositoryName, + workflowRun.Id, + ct); + + if (ResolvePreviewArtifact(artifacts.Artifacts, pullRequest.Number) is not null) + { + previewablePullRequests.TryAdd(pullRequest.Number, 0); + } + }); + + return [.. previewablePullRequests.Keys]; + } + + private static async Task> GetLatestSuccessfulPreviewRunsByHeadShaAsync( + GitHubClient repositoryClient, + string repositoryOwner, + string repositoryName, + IReadOnlyCollection headShas, + CancellationToken cancellationToken) + { + var remainingHeadShas = headShas + .Where(static headSha => !string.IsNullOrWhiteSpace(headSha)) + .ToHashSet(StringComparer.Ordinal); + var workflowRunsByHeadSha = new Dictionary(StringComparer.Ordinal); + + if (remainingHeadShas.Count == 0) + { + return workflowRunsByHeadSha; + } + + for (var page = 1; remainingHeadShas.Count > 0; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var payload = await repositoryClient.Actions.Workflows.Runs.List( + repositoryOwner, + repositoryName, + new WorkflowRunsRequest + { + Event = "pull_request", + Status = CheckRunStatusFilter.Completed + }, + new ApiOptions + { + StartPage = page, + PageCount = 1, + PageSize = 100 + }); + + if (payload.WorkflowRuns.Count == 0) + { + break; + } + + foreach (var workflowRun in payload.WorkflowRuns.Where(IsSuccessfulPreviewRun)) + { + if (string.IsNullOrWhiteSpace(workflowRun.HeadSha) + || !remainingHeadShas.Remove(workflowRun.HeadSha)) + { + continue; + } + + workflowRunsByHeadSha[workflowRun.HeadSha] = workflowRun; + } + + if (payload.WorkflowRuns.Count < 100) + { + break; + } + } + + return workflowRunsByHeadSha; + } + + private async Task CreateRepositoryClientAsync( + string repositoryOwner, + string repositoryName, + CancellationToken cancellationToken) + { + if (_options.HasGitHubToken) + { + return CreateClient(new Credentials(_options.GitHubToken)); + } + + var installationToken = await GetInstallationTokenAsync(repositoryOwner, repositoryName, cancellationToken); + return CreateClient(new Credentials(installationToken.Token)); + } + + private async Task GetInstallationTokenAsync( + string repositoryOwner, + string repositoryName, + CancellationToken cancellationToken) + { + EnsureGitHubAppConfigured(); + + await _installationTokenGate.WaitAsync(cancellationToken); + try + { + if (_cachedInstallationToken is not null + && _cachedInstallationToken.ExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2)) + { + return _cachedInstallationToken; + } + + var appClient = CreateGitHubAppClient(); + var installationId = _options.GitHubAppInstallationId > 0 + ? _options.GitHubAppInstallationId + : _cachedInstallationId.GetValueOrDefault(); + + if (installationId <= 0) + { + cancellationToken.ThrowIfCancellationRequested(); + installationId = (await appClient.GitHubApps.GetRepositoryInstallationForCurrent( + repositoryOwner, + repositoryName)).Id; + } + + _cachedInstallationId = installationId; + cancellationToken.ThrowIfCancellationRequested(); + _cachedInstallationToken = await appClient.GitHubApps.CreateInstallationToken(installationId); + return _cachedInstallationToken; + } + finally + { + _installationTokenGate.Release(); + } + } + + private GitHubClient CreateGitHubAppClient() + { + EnsureGitHubAppConfigured(); + + var jwtFactory = new GitHubJwtFactory( + new StringPrivateKeySource(NormalizePrivateKey(_options.GitHubAppPrivateKey)), + new GitHubJwtFactoryOptions + { + AppIntegrationId = _options.GitHubAppId, + ExpirationSeconds = 540 + }); + + var jwt = jwtFactory.CreateEncodedJwtToken(TimeSpan.FromMinutes(9)); + return CreateClient(new Credentials(jwt, AuthenticationType.Bearer)); + } + + private GitHubClient CreateClient(Credentials credentials) + { + var client = new GitHubClient( + new ProductHeaderValue("aspire-dev-preview-host"), + new Uri(_options.GitHubApiBaseUrl)) + { + Credentials = credentials + }; + + return client; + } + + private void EnsureCredentialsConfigured() + { + if (_options.HasGitHubToken) + { + return; + } + + EnsureGitHubAppConfigured(); + } + + private void EnsureGitHubAppConfigured() + { + if (!_options.HasGitHubAppConfiguration) + { + throw new InvalidOperationException( + $"Either '{PreviewHostOptions.SectionName}:GitHubToken' or both '{PreviewHostOptions.SectionName}:GitHubAppId' and '{PreviewHostOptions.SectionName}:GitHubAppPrivateKey' must be configured before GitHub authentication can be used."); + } + } + + private void EnsureRepositoryConfigured() + { + if (string.IsNullOrWhiteSpace(_options.RepositoryOwner) || string.IsNullOrWhiteSpace(_options.RepositoryName)) + { + throw new InvalidOperationException( + $"The '{PreviewHostOptions.SectionName}:RepositoryOwner' and '{PreviewHostOptions.SectionName}:RepositoryName' settings must be configured before previews can be discovered."); + } + } + + private static Artifact? ResolvePreviewArtifact(IEnumerable artifacts, int pullRequestNumber) + { + string[] preferredNames = + [ + $"frontend-dist-pr-{pullRequestNumber}", + DefaultPreviewArtifactName + ]; + + foreach (var artifactName in preferredNames) + { + var artifact = artifacts.FirstOrDefault(candidate => + !candidate.Expired + && string.Equals(candidate.Name, artifactName, StringComparison.Ordinal)); + + if (artifact is not null) + { + return artifact; + } + } + + return null; + } + + private static string? NormalizePermissionValue(string? value) => + string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToLowerInvariant(); + + private static bool HasWriteAccess(string? permission) => + permission is "admin" or "maintain" or "write" or "push"; + + private static bool IsSuccessfulPreviewRun(WorkflowRun run) => + string.Equals(run.Conclusion?.StringValue, "success", StringComparison.OrdinalIgnoreCase) + && (string.Equals(run.Path, CiWorkflowPath, StringComparison.Ordinal) + || string.Equals(run.Name, "CI", StringComparison.Ordinal)); + + private sealed class DownloadProgressPublisher : IAsyncDisposable + { + private readonly Func _progressCallback; + private readonly CancellationTokenSource _publisherCancellationSource; + private readonly SemaphoreSlim _publishGate = new(1, 1); + private readonly Task _publisherTask; + private readonly long? _totalBytes; + private long _latestBytes; + private long _publishedBytes = -1; + private int _completed; + + public DownloadProgressPublisher( + long? totalBytes, + Func progressCallback, + CancellationToken cancellationToken) + { + _totalBytes = totalBytes; + _progressCallback = progressCallback; + _publisherCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _publisherTask = Task.Run(() => PublishLoopAsync(_publisherCancellationSource.Token), CancellationToken.None); + } + + public void Report(long downloadedBytes) => Interlocked.Exchange(ref _latestBytes, downloadedBytes); + + public async Task CompleteAsync(long downloadedBytes) + { + Report(downloadedBytes); + + if (Interlocked.Exchange(ref _completed, 1) != 0) + { + return; + } + + await PublishLatestAsync(CancellationToken.None); + await StopAsync(); + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + _publishGate.Dispose(); + _publisherCancellationSource.Dispose(); + } + + private async Task PublishLoopAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(DownloadProgressPublishInterval); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await PublishLatestAsync(cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task PublishLatestAsync(CancellationToken cancellationToken) + { + await _publishGate.WaitAsync(cancellationToken); + try + { + var latestBytes = Interlocked.Read(ref _latestBytes); + if (latestBytes == _publishedBytes) + { + return; + } + + await _progressCallback(new PreviewDownloadProgress(latestBytes, _totalBytes), cancellationToken); + _publishedBytes = latestBytes; + } + finally + { + _publishGate.Release(); + } + } + + private async Task StopAsync() + { + _publisherCancellationSource.Cancel(); + + try + { + await _publisherTask; + } + catch (OperationCanceledException) when (_publisherCancellationSource.IsCancellationRequested) + { + } + } + } + + private static string NormalizePrivateKey(string privateKey) + { + var normalized = privateKey.Trim(); + normalized = normalized.Replace("\\r\\n", "\n", StringComparison.Ordinal); + normalized = normalized.Replace("\\n", "\n", StringComparison.Ordinal); + return normalized.Replace("\r\n", "\n", StringComparison.Ordinal); + } +} diff --git a/src/statichost/PreviewHost/Previews/PreviewAuth.cs b/src/statichost/PreviewHost/Previews/PreviewAuth.cs new file mode 100644 index 000000000..19fababaf --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewAuth.cs @@ -0,0 +1,87 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Caching.Memory; + +namespace PreviewHost.Previews; + +internal static class PreviewAuthenticationDefaults +{ + public const string CookieScheme = "PreviewHostCookie"; + public const string GitHubScheme = "GitHub"; + public const string WriterPolicy = "PreviewWriter"; + public const string CsrfHeaderName = "X-Preview-Csrf"; + public const string CsrfRequestTokenCookieName = "previewhost-csrf"; + public const string UserLoginClaimType = "urn:github:login"; + public const string UserDisplayNameClaimType = "urn:github:name"; + public const string UserAvatarUrlClaimType = "urn:github:avatar_url"; + public const string UserProfileUrlClaimType = "urn:github:html_url"; +} + +internal sealed class PreviewWriterRequirement : IAuthorizationRequirement; + +internal sealed class PreviewWriterAuthorizationHandler(PreviewUserAccessService accessService) + : AuthorizationHandler +{ + private readonly PreviewUserAccessService _accessService = accessService; + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + PreviewWriterRequirement requirement) + { + var login = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserLoginClaimType) + ?? context.User.FindFirstValue(ClaimTypes.Name); + + if (string.IsNullOrWhiteSpace(login)) + { + return; + } + + var access = await _accessService.GetAccessAsync(login, CancellationToken.None); + if (access.HasWriteAccess) + { + context.Succeed(requirement); + } + } +} + +internal sealed class PreviewUserAccessService( + IMemoryCache cache, + GitHubArtifactClient artifactClient, + ILogger logger) +{ + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + private readonly IMemoryCache _cache = cache; + private readonly GitHubArtifactClient _artifactClient = artifactClient; + private readonly ILogger _logger = logger; + + public async Task GetAccessAsync(string login, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(login); + + var cacheKey = $"preview-user-access:{login}"; + if (_cache.TryGetValue(cacheKey, out PreviewUserAccessResult? cachedAccess) && cachedAccess is not null) + { + return cachedAccess; + } + + var access = await _artifactClient.ReviewCollaboratorPermissionAsync(login, cancellationToken); + _cache.Set(cacheKey, access, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = CacheDuration + }); + + _logger.LogInformation( + "Preview host access review for @{Login}: write access {HasWriteAccess} ({RoleName})", + login, + access.HasWriteAccess, + access.RoleName ?? access.Permission ?? "unknown"); + + return access; + } +} + +internal sealed record PreviewUserAccessResult( + string Login, + bool HasWriteAccess, + string? RoleName, + string? Permission); diff --git a/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs b/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs new file mode 100644 index 000000000..b049fc85b --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewBufferSettings.cs @@ -0,0 +1,92 @@ +namespace PreviewHost.Previews; + +internal readonly record struct PreviewBufferSettings( + int DownloadCopyBufferSize, + int DownloadFileBufferSize, + int ManagedZipReadBufferSize, + long AvailableMemoryBytes) +{ + private const int OneMiB = 1024 * 1024; + private const long DefaultAvailableMemoryBytes = 2L * 1024 * OneMiB; + + public static PreviewBufferSettings Resolve() + { + var availableMemoryBytes = GetAvailableMemoryBytes(); + + return availableMemoryBytes switch + { + <= 768L * OneMiB => new( + DownloadCopyBufferSize: 4 * OneMiB, + DownloadFileBufferSize: 1 * OneMiB, + ManagedZipReadBufferSize: 4 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + <= 1536L * OneMiB => new( + DownloadCopyBufferSize: 8 * OneMiB, + DownloadFileBufferSize: 2 * OneMiB, + ManagedZipReadBufferSize: 8 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + <= 3072L * OneMiB => new( + DownloadCopyBufferSize: 16 * OneMiB, + DownloadFileBufferSize: 4 * OneMiB, + ManagedZipReadBufferSize: 16 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + <= 6144L * OneMiB => new( + DownloadCopyBufferSize: 32 * OneMiB, + DownloadFileBufferSize: 8 * OneMiB, + ManagedZipReadBufferSize: 24 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes), + + _ => new( + DownloadCopyBufferSize: 64 * OneMiB, + DownloadFileBufferSize: 16 * OneMiB, + ManagedZipReadBufferSize: 32 * OneMiB, + AvailableMemoryBytes: availableMemoryBytes) + }; + } + + public long AvailableMemoryMiB => AvailableMemoryBytes / OneMiB; + + public int DownloadCopyBufferMiB => DownloadCopyBufferSize / OneMiB; + + public int DownloadFileBufferMiB => DownloadFileBufferSize / OneMiB; + + public int ManagedZipReadBufferMiB => ManagedZipReadBufferSize / OneMiB; + + private static long GetAvailableMemoryBytes() + { + var gcInfo = GC.GetGCMemoryInfo(); + var gcBudgetBytes = NormalizePositive(gcInfo.TotalAvailableMemoryBytes); + var managedHeadroomBytes = gcBudgetBytes > 0 + ? Math.Max(gcBudgetBytes - GC.GetTotalMemory(forceFullCollection: false), 0) + : 0; + var loadHeadroomBytes = gcInfo.HighMemoryLoadThresholdBytes > 0 && gcInfo.MemoryLoadBytes > 0 + ? Math.Max(gcInfo.HighMemoryLoadThresholdBytes - gcInfo.MemoryLoadBytes, 0) + : 0; + + long availableMemoryBytes = 0; + + foreach (var candidate in new[] { managedHeadroomBytes, loadHeadroomBytes, gcBudgetBytes }) + { + if (candidate <= 0) + { + continue; + } + + availableMemoryBytes = availableMemoryBytes == 0 + ? candidate + : Math.Min(availableMemoryBytes, candidate); + } + + return availableMemoryBytes > 0 + ? availableMemoryBytes + : DefaultAvailableMemoryBytes; + } + + private static long NormalizePositive(long value) => + value > 0 && value < long.MaxValue + ? value + : 0; +} diff --git a/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs new file mode 100644 index 000000000..fdb78e434 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewCoordinator.cs @@ -0,0 +1,1138 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO.Compression; +using System.Net; +using Microsoft.Extensions.Options; + +namespace PreviewHost.Previews; + +internal sealed class PreviewCoordinator( + PreviewStateStore stateStore, + GitHubArtifactClient artifactClient, + IOptions options, + ILogger logger) +{ + private static readonly TimeSpan ProgressUpdateInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan ExtractionProgressReconciliationInterval = TimeSpan.FromSeconds(15); + private const long DownloadProgressByteStride = 32L * 1024 * 1024; + private const int ExtractionProgressUnitStride = 100; + private const int PreparingWeight = 6; + private const int DownloadingWeight = 52; + private const int ExtractingWeight = 38; + private const int ValidatingWeight = 2; + private const int ActivatingWeight = 2; + private const int PreparingStart = 0; + private const int DownloadingStart = PreparingStart + PreparingWeight; + private const int ExtractingStart = DownloadingStart + DownloadingWeight; + private const int ValidatingStart = ExtractingStart + ExtractingWeight; + private const int ActivatingStart = ValidatingStart + ValidatingWeight; + private readonly ConcurrentDictionary> _activeDiscovery = []; + private readonly ConcurrentDictionary _activeLoadCancellations = []; + private readonly ConcurrentDictionary _activeLoads = []; + private readonly SemaphoreSlim _loadConcurrencyGate = new(Math.Max(1, options.Value.MaxConcurrentLoads)); + private readonly PreviewStateStore _stateStore = stateStore; + private readonly GitHubArtifactClient _artifactClient = artifactClient; + private readonly ILogger _logger = logger; + private readonly PreviewHostOptions _options = options.Value; + + public void EnsureLoading(int pullRequestNumber) + { + _activeLoads.GetOrAdd( + pullRequestNumber, + static (prNumber, state) => + { + var cancellationSource = new CancellationTokenSource(); + state._activeLoadCancellations[prNumber] = cancellationSource; + return Task.Run(() => state.LoadAsync(prNumber, cancellationSource.Token), CancellationToken.None); + }, + this); + } + + public async Task BootstrapAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "This preview hasn't been enabled yet. Open it from the catalog or retry this page to prepare the latest successful frontend build."); + } + + if (snapshot.State is PreviewLoadState.Evicted) + { + snapshot = await _stateStore.RequeueAsync( + pullRequestNumber, + "Reloading the preview after it was evicted from the warm window.", + cancellationToken) ?? snapshot; + } + + if (snapshot.State is PreviewLoadState.Registered or PreviewLoadState.Loading) + { + EnsureLoading(pullRequestNumber); + snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; + } + + return new PreviewDiscoveryResult(snapshot); + } + + public async Task PrepareAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + try + { + var registrationRequest = await _artifactClient.TryResolveLatestPreviewRegistrationAsync(pullRequestNumber, cancellationToken); + PreviewStatusSnapshot? snapshot; + + if (registrationRequest is not null) + { + var registrationResult = await _stateStore.RegisterAsync(registrationRequest, cancellationToken); + snapshot = registrationResult.Snapshot; + } + else + { + snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not find a successful frontend artifact for this pull request yet."); + } + } + + if (snapshot.State is PreviewLoadState.Cancelled or PreviewLoadState.Failed or PreviewLoadState.Evicted) + { + snapshot = await _stateStore.RequeueAsync( + pullRequestNumber, + "Preparing the latest preview build.", + cancellationToken) ?? snapshot; + } + + if (!snapshot.IsReady) + { + EnsureLoading(pullRequestNumber); + snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken) ?? snapshot; + } + + return new PreviewDiscoveryResult(snapshot); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to prepare preview metadata for PR #{PullRequestNumber}", pullRequestNumber); + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not look up the latest successful build for this pull request."); + } + } + + public async Task RetryAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + return await PrepareAsync(pullRequestNumber, cancellationToken); + } + + public async Task CancelAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return null; + } + + if (_activeLoadCancellations.TryGetValue(pullRequestNumber, out var cancellationSource)) + { + cancellationSource.Cancel(); + } + + if (!snapshot.IsReady) + { + await _stateStore.MarkCancelledAsync(pullRequestNumber, "Preview preparation was cancelled.", CancellationToken.None); + return await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + } + + return snapshot; + } + + public async Task ResetAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var snapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + return false; + } + + if (_activeLoadCancellations.TryGetValue(pullRequestNumber, out var cancellationSource)) + { + cancellationSource.Cancel(); + } + + if (_activeLoads.TryGetValue(pullRequestNumber, out var activeLoad)) + { + try + { + await activeLoad.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogDebug(exception, "Preview load task ended while resetting PR #{PullRequestNumber}", pullRequestNumber); + } + } + + await _stateStore.RemoveAsync(pullRequestNumber, cancellationToken); + _logger.LogInformation("Reset preview for PR #{PullRequestNumber}", pullRequestNumber); + return true; + } + + public async Task EnsureRegisteredAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + var existingSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (existingSnapshot is not null) + { + return new PreviewDiscoveryResult(existingSnapshot); + } + + var discoveryTask = _activeDiscovery.GetOrAdd( + pullRequestNumber, + static (prNumber, state) => Task.Run(() => state.DiscoverAsync(prNumber), CancellationToken.None), + this); + + return await discoveryTask.WaitAsync(cancellationToken); + } + + private async Task DiscoverAsync(int pullRequestNumber) + { + try + { + var existingSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, CancellationToken.None); + if (existingSnapshot is not null) + { + return new PreviewDiscoveryResult(existingSnapshot); + } + + var registrationRequest = await _artifactClient.TryResolveLatestPreviewRegistrationAsync(pullRequestNumber, CancellationToken.None); + if (registrationRequest is null) + { + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not find a successful frontend artifact for this pull request yet."); + } + + var registrationResult = await _stateStore.RegisterAsync(registrationRequest, CancellationToken.None); + return new PreviewDiscoveryResult(registrationResult.Snapshot); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to discover preview metadata for PR #{PullRequestNumber}", pullRequestNumber); + return new PreviewDiscoveryResult( + Snapshot: null, + FailureMessage: "The preview host could not look up the latest successful build for this pull request."); + } + finally + { + _activeDiscovery.TryRemove(pullRequestNumber, out _); + } + } + + private async Task LoadAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + string? temporaryZipPath = null; + string? stagingDirectoryPath = null; + var downloadProgress = new ProgressThrottle(); + + await _loadConcurrencyGate.WaitAsync(cancellationToken); + + try + { + var workItem = await _stateStore.GetWorkItemAsync(pullRequestNumber, cancellationToken); + if (workItem is null) + { + return; + } + + var currentSnapshot = await _stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (currentSnapshot?.State == PreviewLoadState.Ready) + { + return; + } + + await _stateStore.MarkLoadingAsync( + pullRequestNumber, + stage: "Preparing", + message: "Preparing the preview workspace.", + percent: CalculateWeightedPercent(PreparingStart, PreparingWeight, 15), + stagePercent: 15, + cancellationToken: cancellationToken); + + await EnsureCapacityAsync(pullRequestNumber, cancellationToken); + + temporaryZipPath = _stateStore.GetTemporaryZipPath(workItem); + stagingDirectoryPath = _stateStore.GetStagingDirectoryPath(workItem); + + if (File.Exists(temporaryZipPath)) + { + File.Delete(temporaryZipPath); + } + + if (Directory.Exists(stagingDirectoryPath)) + { + Directory.Delete(stagingDirectoryPath, recursive: true); + } + + Directory.CreateDirectory(Path.GetDirectoryName(temporaryZipPath)!); + Directory.CreateDirectory(stagingDirectoryPath); + + var artifact = await _artifactClient.GetArtifactDescriptorAsync(workItem, cancellationToken); + await _stateStore.UpdateProgressAsync( + pullRequestNumber, + stage: "Downloading", + message: $"Downloading {artifact.ArtifactName} from GitHub Actions.", + percent: CalculateWeightedPercent(DownloadingStart, DownloadingWeight, 0), + stagePercent: 0, + bytesDownloaded: 0, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null, + cancellationToken: cancellationToken); + + await _artifactClient.DownloadArtifactAsync( + artifact, + temporaryZipPath, + async (progress, progressCancellationToken) => + { + var stagePercent = CalculateDownloadStagePercent(progress); + var percent = CalculateWeightedPercent(DownloadingStart, DownloadingWeight, stagePercent); + var isComplete = progress.BytesTotal is > 0 && progress.BytesDownloaded >= progress.BytesTotal.Value; + if (!downloadProgress.ShouldPublish( + stage: "Downloading", + percent: percent, + bytesDownloaded: progress.BytesDownloaded, + isTerminal: isComplete)) + { + return; + } + + await _stateStore.UpdateProgressAsync( + pullRequestNumber, + stage: "Downloading", + message: "Downloading the latest preview artifact.", + percent: percent, + stagePercent: stagePercent, + bytesDownloaded: progress.BytesDownloaded, + bytesTotal: progress.BytesTotal, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null, + progressCancellationToken); + }, + cancellationToken); + + if (!await _stateStore.MatchesBuildAsync(pullRequestNumber, workItem, cancellationToken)) + { + return; + } + + var extractedFileCount = await ExtractArchiveAsync(workItem, temporaryZipPath, stagingDirectoryPath, cancellationToken); + + var activationSourceDirectory = ResolveActivationSourceDirectory(stagingDirectoryPath); + var entryIndexPath = Path.Combine(activationSourceDirectory, "index.html"); + if (!File.Exists(entryIndexPath)) + { + throw new InvalidOperationException("The downloaded preview artifact did not contain an index.html entry."); + } + + if (!await _stateStore.MatchesBuildAsync(pullRequestNumber, workItem, cancellationToken)) + { + return; + } + + await _stateStore.UpdateProgressAsync( + pullRequestNumber, + stage: "Activating", + message: "Activating the preview and switching traffic to the new build.", + percent: CalculateWeightedPercent(ActivatingStart, ActivatingWeight, 50), + stagePercent: 100, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: extractedFileCount, + itemsTotal: extractedFileCount, + itemsLabel: "files", + cancellationToken); + + var activeDirectoryPath = _stateStore.GetActiveDirectoryPath(pullRequestNumber); + if (Directory.Exists(activeDirectoryPath)) + { + Directory.Delete(activeDirectoryPath, recursive: true); + } + + Directory.CreateDirectory(Path.GetDirectoryName(activeDirectoryPath)!); + Directory.Move(activationSourceDirectory, activeDirectoryPath); + stagingDirectoryPath = null; + + await _stateStore.MarkReadyAsync(pullRequestNumber, activeDirectoryPath, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation("Cancelled preview preparation for PR #{PullRequestNumber}", pullRequestNumber); + await _stateStore.MarkCancelledAsync(pullRequestNumber, "Preview preparation was cancelled.", CancellationToken.None); + } + catch (Exception exception) + { + _logger.LogError(exception, "Failed to prepare preview for PR #{PullRequestNumber}", pullRequestNumber); + await _stateStore.MarkFailedAsync(pullRequestNumber, BuildFriendlyErrorMessage(exception), CancellationToken.None); + } + finally + { + _loadConcurrencyGate.Release(); + + if (_activeLoadCancellations.TryRemove(pullRequestNumber, out var cancellationSource)) + { + cancellationSource.Dispose(); + } + + _activeLoads.TryRemove(pullRequestNumber, out _); + + DeleteFileIfPresent(temporaryZipPath); + DeleteDirectoryIfPresent(stagingDirectoryPath); + } + } + + private async Task EnsureCapacityAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + if (_options.MaxActivePreviews <= 0) + { + return; + } + + var readyCandidates = await _stateStore.ListReadyCandidatesAsync(pullRequestNumber, cancellationToken); + var excessCount = readyCandidates.Count - (_options.MaxActivePreviews - 1); + + if (excessCount <= 0) + { + return; + } + + foreach (var candidate in readyCandidates.Take(excessCount)) + { + await _stateStore.EvictAsync( + candidate.PullRequestNumber, + reason: "Evicted to make room for a more recently requested preview.", + cancellationToken: cancellationToken); + } + } + + private async Task ExtractArchiveAsync( + PreviewWorkItem workItem, + string zipPath, + string destinationDirectory, + CancellationToken cancellationToken) + { + var extractionToolDescription = _options.ExtractionToolDescription; + var extractionStopwatch = Stopwatch.StartNew(); + var extractionMessage = $"Extracting preview files with {extractionToolDescription}."; + var archiveInspection = InspectArchive(zipPath); + var totalFileCount = archiveInspection.FileCount; + var bufferSettings = PreviewBufferSettings.Resolve(); + using var extractionProgressState = new ExtractionProgressState(totalFileCount); + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Extracting", + message: extractionMessage, + percent: CalculateWeightedPercent(ExtractingStart, ExtractingWeight, 0), + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: 0, + itemsTotal: totalFileCount, + itemsLabel: "files", + cancellationToken); + + Directory.CreateDirectory(destinationDirectory); + + using var extractionWatcher = CreateExtractionFileWatcher(destinationDirectory, extractionProgressState); + using var extractionReportingCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var extractionReportingTask = ReportExtractionProgressAsync( + workItem, + extractionMessage, + destinationDirectory, + extractionProgressState, + extractionReportingCancellation.Token); + + try + { + if (_options.UseCommandLineExtraction) + { + await ExtractArchiveWithCommandLineAsync(zipPath, destinationDirectory, cancellationToken); + } + else + { + _logger.LogInformation( + "Extracting preview artifact with adaptive managed buffer: {ManagedZipReadBufferMiB} MiB (~{AvailableMemoryMiB} MiB headroom)", + bufferSettings.ManagedZipReadBufferMiB, + bufferSettings.AvailableMemoryMiB); + + using var zipStream = new FileStream( + zipPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: bufferSettings.ManagedZipReadBufferSize, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: false); + await archive.ExtractToDirectoryAsync(destinationDirectory, overwriteFiles: true, cancellationToken); + } + } + finally + { + extractionProgressState.ReconcileWithFilesystem(destinationDirectory, force: true); + extractionReportingCancellation.Cancel(); + await AwaitBackgroundWorkAsync(extractionReportingTask); + } + + extractionStopwatch.Stop(); + _logger.LogInformation( + "Extracted preview artifact for PR #{PullRequestNumber} using {ExtractionTool} in {ElapsedMilliseconds} ms", + workItem.PullRequestNumber, + extractionToolDescription, + extractionStopwatch.ElapsedMilliseconds); + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Extracting", + message: "Preview artifact extracted.", + percent: CalculateWeightedPercent(ExtractingStart, ExtractingWeight, 100), + stagePercent: 100, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: totalFileCount, + itemsTotal: totalFileCount, + itemsLabel: "files", + cancellationToken); + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Validating", + message: "Validating the extracted preview output.", + percent: CalculateWeightedPercent(ValidatingStart, ValidatingWeight, 100), + stagePercent: 100, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: totalFileCount, + itemsTotal: totalFileCount, + itemsLabel: "files", + cancellationToken); + + return totalFileCount; + } + + private async Task ExtractArchiveWithCommandLineAsync( + string zipPath, + string destinationDirectory, + CancellationToken cancellationToken) + { + var toolDescription = _options.ExtractionToolDescription; + _logger.LogInformation("Extracting preview artifact via {ExtractionTool}", toolDescription); + + if (OperatingSystem.IsWindows()) + { + await RunExtractionProcessAsync( + "tar.exe", + toolDescription, + cancellationToken, + "-xf", + zipPath, + "-C", + destinationDirectory); + return; + } + + await RunExtractionProcessAsync( + "unzip", + toolDescription, + cancellationToken, + "-o", + "-qq", + zipPath, + "-d", + destinationDirectory); + } + + private async Task RunExtractionProcessAsync( + string fileName, + string toolDescription, + CancellationToken cancellationToken, + params string[] arguments) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + foreach (var argument in arguments) + { + process.StartInfo.ArgumentList.Add(argument); + } + + try + { + if (!process.Start()) + { + throw new InvalidOperationException($"The preview host could not start {toolDescription} for command-line extraction."); + } + } + catch (Exception exception) when (exception is FileNotFoundException or System.ComponentModel.Win32Exception) + { + throw new InvalidOperationException( + $"The preview host is configured for command-line extraction, but {toolDescription} is not available on this machine.", + exception); + } + + var commandDisplay = BuildCommandDisplayString(fileName, arguments); + var outputTail = new ProcessOutputTail(maxLines: 32); + _logger.LogInformation("Starting extraction command for {ExtractionTool}: {Command}", toolDescription, commandDisplay); + + var standardOutputTask = PumpProcessStreamAsync( + process.StandardOutput, + toolDescription, + "stdout", + LogLevel.Information, + outputTail, + emitLiveLogs: false); + var standardErrorTask = PumpProcessStreamAsync( + process.StandardError, + toolDescription, + "stderr", + LogLevel.Warning, + outputTail, + emitLiveLogs: true); + using var cancellationRegistration = cancellationToken.Register(static state => TryKillProcess((Process)state!), process); + + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + TryKillProcess(process); + await process.WaitForExitAsync(CancellationToken.None); + throw; + } + + await Task.WhenAll(standardOutputTask, standardErrorTask); + + if (process.ExitCode != 0) + { + throw new InvalidOperationException( + $"The preview host could not extract the artifact using {toolDescription}. Exit code {process.ExitCode}. {BuildProcessFailureOutput(outputTail.BuildSummary())}"); + } + + _logger.LogInformation("Extraction command completed successfully via {ExtractionTool}", toolDescription); + } + + private async Task PumpProcessStreamAsync( + StreamReader reader, + string toolDescription, + string streamName, + LogLevel logLevel, + ProcessOutputTail outputTail, + bool emitLiveLogs) + { + while (await reader.ReadLineAsync() is { } line) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + outputTail.Add(streamName, line); + if (emitLiveLogs) + { + _logger.Log(logLevel, "{ExtractionTool} {StreamName}: {Line}", toolDescription, streamName, line); + } + } + } + + private static string BuildProcessFailureOutput(string detail) + { + var normalizedDetail = string.IsNullOrWhiteSpace(detail) + ? "The extraction tool did not emit any additional output." + : detail.Trim(); + + const int maxLength = 600; + return normalizedDetail.Length <= maxLength + ? normalizedDetail + : $"{normalizedDetail[..maxLength]}..."; + } + + private async Task ReportExtractionProgressAsync( + PreviewWorkItem workItem, + string extractionMessage, + string destinationDirectory, + ExtractionProgressState extractionProgressState, + CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(ProgressUpdateInterval); + var progressThrottle = new ProgressThrottle(); + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + extractionProgressState.ReconcileWithFilesystem(destinationDirectory, force: false); + var completedFiles = extractionProgressState.GetCompletedFiles(); + var stagePercent = CalculateExtractionStagePercent(completedFiles, extractionProgressState.TotalFiles); + var percent = CalculateWeightedPercent(ExtractingStart, ExtractingWeight, stagePercent); + var isComplete = completedFiles >= extractionProgressState.TotalFiles; + + if (!progressThrottle.ShouldPublish( + stage: "Extracting", + percent: percent, + isTerminal: isComplete, + unitsCompleted: completedFiles, + unitStride: ExtractionProgressUnitStride)) + { + continue; + } + + await _stateStore.UpdateProgressAsync( + workItem.PullRequestNumber, + stage: "Extracting", + message: extractionMessage, + percent: percent, + stagePercent: stagePercent, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: completedFiles, + itemsTotal: extractionProgressState.TotalFiles, + itemsLabel: "files", + cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private (int FileCount, long TotalUncompressedBytes) InspectArchive(string zipPath) + { + using var archive = ZipFile.OpenRead(zipPath); + var fileCount = 0; + long totalUncompressedBytes = 0; + + foreach (var entry in archive.Entries) + { + ValidateArchiveEntry(entry); + + if (string.IsNullOrEmpty(entry.Name)) + { + continue; + } + + fileCount++; + if (fileCount > _options.MaxExtractedFileCount) + { + throw new InvalidOperationException( + "The preview artifact exceeds the preview host extraction safety limits."); + } + + checked + { + totalUncompressedBytes += entry.Length; + } + + if (totalUncompressedBytes > _options.MaxExtractedUncompressedBytes) + { + throw new InvalidOperationException( + "The preview artifact exceeds the preview host extraction safety limits."); + } + } + + return (fileCount, totalUncompressedBytes); + } + + private static void ValidateArchiveEntry(ZipArchiveEntry entry) + { + var normalizedFullName = entry.FullName.Replace('\\', '/'); + if (string.IsNullOrWhiteSpace(normalizedFullName)) + { + return; + } + + if (normalizedFullName.StartsWith("/", StringComparison.Ordinal) + || Path.IsPathRooted(normalizedFullName)) + { + throw new InvalidOperationException( + "The preview artifact failed security validation."); + } + + var pathSegments = normalizedFullName + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (pathSegments.Any(static segment => segment == "..")) + { + throw new InvalidOperationException( + "The preview artifact failed security validation."); + } + + const int unixFileTypeMask = 0xF000; + const int unixSymlinkType = 0xA000; + var unixMode = (entry.ExternalAttributes >> 16) & unixFileTypeMask; + if (unixMode == unixSymlinkType) + { + throw new InvalidOperationException( + "The preview artifact failed security validation."); + } + } + + private static FileSystemWatcher CreateExtractionFileWatcher(string destinationDirectory, ExtractionProgressState extractionProgressState) + { + var watcher = new FileSystemWatcher(destinationDirectory) + { + IncludeSubdirectories = true, + NotifyFilter = NotifyFilters.FileName, + InternalBufferSize = 64 * 1024 + }; + + watcher.Created += (_, eventArgs) => extractionProgressState.TryTrackFile(eventArgs.FullPath); + watcher.Renamed += (_, eventArgs) => extractionProgressState.TryTrackFile(eventArgs.FullPath); + watcher.Error += (_, _) => extractionProgressState.MarkNeedsReconciliation(); + watcher.EnableRaisingEvents = true; + return watcher; + } + + private static string BuildCommandDisplayString(string fileName, IEnumerable arguments) => + string.Join( + ' ', + [QuoteCommandSegment(fileName), .. arguments.Select(QuoteCommandSegment)]); + + private static string QuoteCommandSegment(string value) => + string.IsNullOrWhiteSpace(value) || value.IndexOfAny([' ', '\t', '"']) >= 0 + ? $"\"{value.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" + : value; + + private static int CalculateExtractionStagePercent(int completedFiles, int totalFiles) + { + if (totalFiles <= 0) + { + return 0; + } + + var rawPercent = (double)completedFiles / totalFiles; + return Math.Clamp((int)Math.Round(rawPercent * 100d), 0, 100); + } + + private static int CalculateDownloadStagePercent(PreviewDownloadProgress progress) + { + if (progress.BytesTotal is > 0) + { + var rawPercent = (double)progress.BytesDownloaded / progress.BytesTotal.Value; + return Math.Clamp((int)Math.Round(rawPercent * 100d), 0, 100); + } + + return 0; + } + + private static int CalculateWeightedPercent(int stageStart, int stageWeight, int stagePercent) + { + var clampedStagePercent = Math.Clamp(stagePercent, 0, 100); + var weightedProgress = (int)Math.Round(stageWeight * (clampedStagePercent / 100d)); + return Math.Clamp(stageStart + weightedProgress, 0, 99); + } + + private static string BuildFriendlyErrorMessage(Exception exception) + { + if (exception is InvalidOperationException invalidOperationException) + { + if (invalidOperationException.Message.Contains("GitHubToken", StringComparison.Ordinal) + || invalidOperationException.Message.Contains("GitHubAppId", StringComparison.Ordinal) + || invalidOperationException.Message.Contains("GitHubAppPrivateKey", StringComparison.Ordinal)) + { + return "The preview host is missing its GitHub artifact-read credential."; + } + + if (invalidOperationException.Message.Contains("RepositoryOwner", StringComparison.Ordinal) + || invalidOperationException.Message.Contains("RepositoryName", StringComparison.Ordinal)) + { + return "The preview host is missing its GitHub repository configuration."; + } + + if (invalidOperationException.Message.Contains("safety limit", StringComparison.OrdinalIgnoreCase)) + { + return "The preview artifact exceeds the preview host safety limits."; + } + + if (invalidOperationException.Message.Contains("security validation", StringComparison.OrdinalIgnoreCase)) + { + return "The preview artifact failed security validation."; + } + + if (invalidOperationException.Message.Contains("artifact", StringComparison.OrdinalIgnoreCase)) + { + return "The requested preview artifact could not be found or is no longer available."; + } + + if (invalidOperationException.Message.Contains("tar.exe", StringComparison.OrdinalIgnoreCase) + || invalidOperationException.Message.Contains("unzip", StringComparison.OrdinalIgnoreCase) + || invalidOperationException.Message.Contains("command-line extraction", StringComparison.OrdinalIgnoreCase)) + { + return "The preview host could not extract the artifact using its configured extraction tool."; + } + } + + if (exception is HttpRequestException httpRequestException && httpRequestException.StatusCode == HttpStatusCode.Unauthorized) + { + return "The preview host could not authenticate with GitHub to download this artifact."; + } + + if (exception is HttpRequestException { StatusCode: HttpStatusCode.NotFound or HttpStatusCode.Gone }) + { + return "The requested preview artifact could not be found or has already expired."; + } + + return "The preview could not be prepared. Check the preview host logs for more details."; + } + + private static string ResolveActivationSourceDirectory(string extractedRoot) + { + var topLevelIndexPath = Path.Combine(extractedRoot, "index.html"); + if (File.Exists(topLevelIndexPath)) + { + return extractedRoot; + } + + var nestedDirectory = Directory.EnumerateDirectories(extractedRoot) + .Select(static directory => new + { + Directory = directory, + IndexPath = Path.Combine(directory, "index.html") + }) + .FirstOrDefault(static candidate => File.Exists(candidate.IndexPath)); + + return nestedDirectory?.Directory ?? extractedRoot; + } + + private void DeleteFileIfPresent(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete temporary preview file {Path}", path); + } + catch (UnauthorizedAccessException exception) + { + _logger.LogWarning(exception, "Failed to delete temporary preview file {Path}", path); + } + } + + private void DeleteDirectoryIfPresent(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete temporary preview directory {Directory}", path); + } + catch (UnauthorizedAccessException exception) + { + _logger.LogWarning(exception, "Failed to delete temporary preview directory {Directory}", path); + } + } + + private static void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (InvalidOperationException) + { + } + } + + private static async Task AwaitBackgroundWorkAsync(Task task) + { + try + { + await task; + } + catch (OperationCanceledException) + { + } + } + + private sealed class ProcessOutputTail(int maxLines) + { + private readonly object _gate = new(); + private readonly Queue _lines = new(); + private readonly int _maxLines = maxLines; + + public void Add(string streamName, string line) + { + lock (_gate) + { + _lines.Enqueue($"{streamName}: {line}"); + while (_lines.Count > _maxLines) + { + _lines.Dequeue(); + } + } + } + + public string BuildSummary() + { + lock (_gate) + { + return string.Join(Environment.NewLine, _lines); + } + } + } + + private sealed class ExtractionProgressState(int totalFiles) : IDisposable + { + private readonly object _gate = new(); + private readonly HashSet _seenFiles = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private int _needsReconciliation; + private DateTimeOffset _lastReconciledAtUtc = DateTimeOffset.MinValue; + + public int TotalFiles { get; } = totalFiles; + + public int GetCompletedFiles() + { + lock (_gate) + { + return _seenFiles.Count; + } + } + + public void TryTrackFile(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return; + } + + lock (_gate) + { + _seenFiles.Add(path); + } + } + + public void MarkNeedsReconciliation() => Interlocked.Exchange(ref _needsReconciliation, 1); + + public void ReconcileWithFilesystem(string destinationDirectory, bool force) + { + var needsReconciliation = Interlocked.Exchange(ref _needsReconciliation, 0) != 0; + var now = DateTimeOffset.UtcNow; + + if (!force + && !needsReconciliation + && now - _lastReconciledAtUtc < ExtractionProgressReconciliationInterval) + { + return; + } + + if (!force && GetCompletedFiles() >= TotalFiles) + { + return; + } + + var files = Directory.Exists(destinationDirectory) + ? Directory.EnumerateFiles(destinationDirectory, "*", SearchOption.AllDirectories) + : []; + + lock (_gate) + { + _seenFiles.Clear(); + foreach (var file in files) + { + _seenFiles.Add(file); + } + } + + _lastReconciledAtUtc = now; + } + + public void Dispose() + { + } + } + + private sealed class ProgressThrottle + { + private string? _lastStage; + private int _lastPercent = -1; + private long _lastBytesDownloaded = -1; + private int _lastUnitsCompleted; + private DateTimeOffset _lastPublishedAtUtc = DateTimeOffset.MinValue; + + public bool ShouldPublish( + string stage, + int percent, + bool isTerminal, + long? bytesDownloaded = null, + int unitsCompleted = 0, + int unitStride = 0) + { + var now = DateTimeOffset.UtcNow; + if (_lastPublishedAtUtc == DateTimeOffset.MinValue) + { + Publish(stage, percent, bytesDownloaded, unitsCompleted, now); + return true; + } + + if (!string.Equals(stage, _lastStage, StringComparison.Ordinal) + || isTerminal + || percent > _lastPercent + || now - _lastPublishedAtUtc >= ProgressUpdateInterval + || bytesDownloaded is >= 0 && bytesDownloaded.Value - _lastBytesDownloaded >= DownloadProgressByteStride + || unitStride > 0 && unitsCompleted - _lastUnitsCompleted >= unitStride) + { + Publish(stage, percent, bytesDownloaded, unitsCompleted, now); + return true; + } + + return false; + } + + private void Publish(string stage, int percent, long? bytesDownloaded, int unitsCompleted, DateTimeOffset publishedAtUtc) + { + _lastStage = stage; + _lastPercent = percent; + _lastBytesDownloaded = bytesDownloaded ?? _lastBytesDownloaded; + _lastUnitsCompleted = unitsCompleted; + _lastPublishedAtUtc = publishedAtUtc; + } + } +} diff --git a/src/statichost/PreviewHost/Previews/PreviewModels.cs b/src/statichost/PreviewHost/Previews/PreviewModels.cs new file mode 100644 index 000000000..194830e9c --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewModels.cs @@ -0,0 +1,493 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; + +namespace PreviewHost.Previews; + +internal sealed class PreviewHostOptions +{ + public const string SectionName = "PreviewHost"; + + public string? DataRoot { get; set; } + + public string? ContentRoot { get; set; } + + public string RepositoryOwner { get; set; } = "microsoft"; + + public string RepositoryName { get; set; } = "aspire.dev"; + + public int MaxActivePreviews { get; set; } = 10; + + public string GitHubToken { get; set; } = string.Empty; + + public int GitHubAppId { get; set; } + + public long GitHubAppInstallationId { get; set; } + + public string GitHubAppPrivateKey { get; set; } = string.Empty; + + public string GitHubApiBaseUrl { get; set; } = "https://api.github.com/"; + + public string GitHubOAuthClientId { get; set; } = string.Empty; + + public string GitHubOAuthClientSecret { get; set; } = string.Empty; + + public string ControlBaseUrl { get; set; } = string.Empty; + + public string ContentBaseUrl { get; set; } = string.Empty; + + public string AuthCookieDomain { get; set; } = string.Empty; + + public string ExtractionMode { get; set; } = "managed"; + + public int MaxConcurrentLoads { get; set; } = 2; + + public long MaxArtifactSizeBytes { get; set; } = 1L * 1024 * 1024 * 1024; + + public int MaxExtractedFileCount { get; set; } = 100_000; + + public long MaxExtractedUncompressedBytes { get; set; } = 4L * 1024 * 1024 * 1024; + + [JsonIgnore] + public bool HasGitHubToken => !string.IsNullOrWhiteSpace(GitHubToken); + + [JsonIgnore] + public bool HasGitHubAppConfiguration => + GitHubAppId > 0 + && !string.IsNullOrWhiteSpace(GitHubAppPrivateKey); + + [JsonIgnore] + public bool HasGitHubOAuthConfiguration => + !string.IsNullOrWhiteSpace(GitHubOAuthClientId) + && !string.IsNullOrWhiteSpace(GitHubOAuthClientSecret); + + [JsonIgnore] + public bool HasValidExtractionMode => + string.Equals(ExtractionMode, "managed", StringComparison.OrdinalIgnoreCase) + || string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); + + [JsonIgnore] + public bool HasValidConfiguredBaseUrls => + TryParseAbsoluteBaseUri(ControlBaseUrl, out _) + && TryParseAbsoluteBaseUri(ContentBaseUrl, out _); + + [JsonIgnore] + public bool HasValidSafetyLimits => + MaxConcurrentLoads > 0 + && MaxArtifactSizeBytes > 0 + && MaxExtractedFileCount > 0 + && MaxExtractedUncompressedBytes > 0; + + [JsonIgnore] + public bool UseCommandLineExtraction => + string.Equals(ExtractionMode, "command-line", StringComparison.OrdinalIgnoreCase); + + [JsonIgnore] + public bool HasSeparatedContentOrigin => + TryGetControlBaseUri(out var controlBaseUri) + && controlBaseUri is not null + && TryGetContentBaseUri(out var contentBaseUri) + && contentBaseUri is not null + && !UrisShareOrigin(controlBaseUri, contentBaseUri); + + [JsonIgnore] + public bool CanAuthenticateContentRequests => + !HasSeparatedContentOrigin + || !string.IsNullOrWhiteSpace(AuthCookieDomain); + + [JsonIgnore] + public string ExtractionToolDescription => + UseCommandLineExtraction + ? OperatingSystem.IsWindows() + ? "tar.exe" + : "unzip" + : "ZipArchive.ExtractToDirectoryAsync"; + + [JsonIgnore] + public string? CommandLineExtractionCommandName => + UseCommandLineExtraction + ? OperatingSystem.IsWindows() + ? "tar.exe" + : "unzip" + : null; + + public string GetGitHubAuthenticationMode() => + HasGitHubToken + ? "personal-access-token" + : HasGitHubAppConfiguration + ? "github-app" + : "unconfigured"; + + public string GetExtractionModeDescription() => + UseCommandLineExtraction + ? $"command-line ({ExtractionToolDescription})" + : $"managed ({ExtractionToolDescription})"; + + public bool TryGetControlBaseUri(out Uri? baseUri) => TryParseAbsoluteBaseUri(ControlBaseUrl, out baseUri); + + public bool TryGetContentBaseUri(out Uri? baseUri) => TryParseAbsoluteBaseUri(ContentBaseUrl, out baseUri); + + public bool IsContentRequest(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + return HasSeparatedContentOrigin + && TryGetContentBaseUri(out var contentBaseUri) + && contentBaseUri is not null + && HostMatches(contentBaseUri, request); + } + + public bool IsControlRequest(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + if (TryGetControlBaseUri(out var controlBaseUri) && controlBaseUri is not null) + { + return HostMatches(controlBaseUri, request); + } + + return !IsContentRequest(request); + } + + public string BuildControlUrl(HttpRequest request, string relativeUrl) + { + ArgumentNullException.ThrowIfNull(request); + return BuildAbsoluteUrl( + TryGetControlBaseUri(out var controlBaseUri) ? controlBaseUri : null, + request, + relativeUrl); + } + + public string BuildContentUrl(HttpRequest request, string relativeUrl) + { + ArgumentNullException.ThrowIfNull(request); + + Uri? baseUri = null; + if (TryGetContentBaseUri(out var contentBaseUri)) + { + baseUri = contentBaseUri; + } + else if (TryGetControlBaseUri(out var controlBaseUri)) + { + baseUri = controlBaseUri; + } + + return BuildAbsoluteUrl(baseUri, request, relativeUrl); + } + + public string GetControlBaseUrl(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return GetBaseUrlString(TryGetControlBaseUri(out var controlBaseUri) ? controlBaseUri : null, request); + } + + public string GetContentBaseUrl(HttpRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + Uri? baseUri = null; + if (TryGetContentBaseUri(out var contentBaseUri)) + { + baseUri = contentBaseUri; + } + else if (TryGetControlBaseUri(out var controlBaseUri)) + { + baseUri = controlBaseUri; + } + + return GetBaseUrlString(baseUri, request); + } + + private static bool TryParseAbsoluteBaseUri(string? value, out Uri? baseUri) + { + if (string.IsNullOrWhiteSpace(value)) + { + baseUri = null; + return true; + } + + if (!Uri.TryCreate(value, UriKind.Absolute, out var parsed) + || !(string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + || string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + baseUri = null; + return false; + } + + var builder = new UriBuilder(parsed) + { + Query = string.Empty, + Fragment = string.Empty + }; + + if (string.IsNullOrWhiteSpace(builder.Path)) + { + builder.Path = "/"; + } + else if (!builder.Path.EndsWith("/", StringComparison.Ordinal)) + { + builder.Path = $"{builder.Path}/"; + } + + baseUri = builder.Uri; + return true; + } + + private static bool UrisShareOrigin(Uri left, Uri right) => + string.Equals(left.Scheme, right.Scheme, StringComparison.OrdinalIgnoreCase) + && string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase) + && left.Port == right.Port; + + private static bool HostMatches(Uri baseUri, HttpRequest request) => + string.Equals(baseUri.Host, request.Host.Host, StringComparison.OrdinalIgnoreCase) + && GetPort(baseUri) == GetPort(request); + + private static int GetPort(Uri baseUri) => baseUri.IsDefaultPort + ? string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80 + : baseUri.Port; + + private static int GetPort(HttpRequest request) + { + if (request.Host.Port is { } port) + { + return port; + } + + return string.Equals(request.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) ? 80 : 443; + } + + private static string BuildAbsoluteUrl(Uri? baseUri, HttpRequest request, string relativeUrl) + { + if (string.IsNullOrWhiteSpace(relativeUrl)) + { + relativeUrl = "/"; + } + else if (!relativeUrl.StartsWith("/", StringComparison.Ordinal)) + { + relativeUrl = $"/{relativeUrl}"; + } + + if (baseUri is null) + { + return relativeUrl; + } + + return new Uri(baseUri, relativeUrl).ToString(); + } + + private static string GetBaseUrlString(Uri? baseUri, HttpRequest request) + { + if (baseUri is not null) + { + return baseUri.ToString(); + } + + return $"{request.Scheme}://{request.Host}/"; + } +} + +internal sealed class PreviewRegistrationRequest +{ + [Required] + public string RepositoryOwner { get; set; } = string.Empty; + + [Required] + public string RepositoryName { get; set; } = string.Empty; + + [Range(1, int.MaxValue)] + public int PullRequestNumber { get; set; } + + [Required] + public string HeadSha { get; set; } = string.Empty; + + [Range(1, long.MaxValue)] + public long RunId { get; set; } + + [Range(1, int.MaxValue)] + public int RunAttempt { get; set; } + + [Required] + public string ArtifactName { get; set; } = string.Empty; + + public DateTimeOffset CompletedAtUtc { get; set; } +} + +internal enum PreviewLoadState +{ + Registered, + Loading, + Ready, + Failed, + Evicted, + Cancelled +} + +internal sealed class PreviewProgress +{ + public long Version { get; set; } + + public string Stage { get; set; } = "Registered"; + + public string Message { get; set; } = "A preview has been registered and will be prepared on demand."; + + public int Percent { get; set; } + + public int StagePercent { get; set; } + + public long? BytesDownloaded { get; set; } + + public long? BytesTotal { get; set; } + + public int? ItemsCompleted { get; set; } + + public int? ItemsTotal { get; set; } + + public string? ItemsLabel { get; set; } + + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +internal sealed class PreviewRecord +{ + public string RepositoryOwner { get; set; } = string.Empty; + + public string RepositoryName { get; set; } = string.Empty; + + public int PullRequestNumber { get; set; } + + public string HeadSha { get; set; } = string.Empty; + + public long RunId { get; set; } + + public int RunAttempt { get; set; } + + public string ArtifactName { get; set; } = string.Empty; + + public DateTimeOffset CompletedAtUtc { get; set; } + + public PreviewLoadState State { get; set; } = PreviewLoadState.Registered; + + public DateTimeOffset RegisteredAtUtc { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset LastUpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? LastAccessedAtUtc { get; set; } + + public DateTimeOffset? ReadyAtUtc { get; set; } + + public string? ActiveDirectoryPath { get; set; } + + public string? LastError { get; set; } + + public PreviewProgress Progress { get; set; } = new(); + + [JsonIgnore] + public string PreviewPath => PreviewRoute.BuildPath(PullRequestNumber); + + public PreviewStatusSnapshot ToSnapshot() => + new() + { + RepositoryOwner = RepositoryOwner, + RepositoryName = RepositoryName, + PullRequestNumber = PullRequestNumber, + HeadSha = HeadSha, + State = State, + Stage = Progress.Stage, + Message = Progress.Message, + Percent = Progress.Percent, + StagePercent = Progress.StagePercent, + Version = Progress.Version, + BytesDownloaded = Progress.BytesDownloaded, + BytesTotal = Progress.BytesTotal, + ItemsCompleted = Progress.ItemsCompleted, + ItemsTotal = Progress.ItemsTotal, + ItemsLabel = Progress.ItemsLabel, + Error = LastError, + UpdatedAtUtc = Progress.UpdatedAtUtc, + PreviewPath = PreviewPath, + ActiveDirectoryPath = ActiveDirectoryPath + }; +} + +internal sealed class PreviewStatusSnapshot +{ + public string RepositoryOwner { get; init; } = string.Empty; + + public string RepositoryName { get; init; } = string.Empty; + + public int PullRequestNumber { get; init; } + + public string HeadSha { get; init; } = string.Empty; + + public PreviewLoadState State { get; init; } + + public string Stage { get; init; } = string.Empty; + + public string Message { get; init; } = string.Empty; + + public int Percent { get; init; } + + public int StagePercent { get; init; } + + public long Version { get; init; } + + public long? BytesDownloaded { get; init; } + + public long? BytesTotal { get; init; } + + public int? ItemsCompleted { get; init; } + + public int? ItemsTotal { get; init; } + + public string? ItemsLabel { get; init; } + + public string? Error { get; init; } + + public DateTimeOffset UpdatedAtUtc { get; init; } + + public string PreviewPath { get; init; } = string.Empty; + + public string? ActiveDirectoryPath { get; init; } + + public bool IsReady => State == PreviewLoadState.Ready; +} + +internal sealed record PreviewRegistrationResult(bool Accepted, PreviewStatusSnapshot Snapshot); + +internal sealed record PreviewWorkItem( + string RepositoryOwner, + string RepositoryName, + int PullRequestNumber, + string HeadSha, + long RunId, + int RunAttempt, + string ArtifactName, + DateTimeOffset CompletedAtUtc); + +internal sealed record ReadyPreviewCandidate( + int PullRequestNumber, + string ActiveDirectoryPath, + DateTimeOffset SortKey); + +internal sealed record PreviewDownloadProgress(long BytesDownloaded, long? BytesTotal); + +internal sealed record GitHubArtifactDescriptor( + string RepositoryOwner, + string RepositoryName, + long ArtifactId, + string ArtifactName, + DateTimeOffset ExpiresAtUtc, + long? SizeInBytes = null); + +internal sealed record PreviewDiscoveryResult(PreviewStatusSnapshot? Snapshot, string? FailureMessage = null); + +internal sealed record GitHubPullRequestSummary( + int PullRequestNumber, + string Title, + string HtmlUrl, + string HeadSha, + string? AuthorLogin, + bool IsDraft, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc, + bool HasSuccessfulPreviewBuild); diff --git a/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs b/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs new file mode 100644 index 000000000..18fefa9ba --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewRequestDispatcher.cs @@ -0,0 +1,1276 @@ +using System.Net; +using System.Globalization; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace PreviewHost.Previews; + +internal sealed class PreviewRequestDispatcher( + PreviewStateStore stateStore, + IWebHostEnvironment environment, + IOptions options) +{ + private static readonly JsonSerializerOptions WebJsonOptions = CreateJsonOptions(); + private static readonly string AspireFaviconDataUri = $"data:image/svg+xml,{Uri.EscapeDataString(AspireLogoSvg)}"; + private readonly PreviewHostOptions _options = options.Value; + private readonly string _previewShellRoot = Path.Combine( + string.IsNullOrWhiteSpace(environment.WebRootPath) + ? Path.Combine(AppContext.BaseDirectory, "wwwroot") + : environment.WebRootPath, + "_preview"); + + public async Task DispatchIndexAsync(HttpContext context, CancellationToken cancellationToken) + { + if (_options.IsContentRequest(context.Request)) + { + context.Response.Redirect(_options.BuildControlUrl(context.Request, PreviewRoute.CollectionPath), permanent: false); + return; + } + + await WritePreviewShellAsync(context, "index.html", cancellationToken); + } + + public async Task DispatchAsync(HttpContext context, int pullRequestNumber, string relativePath, CancellationToken cancellationToken) + { + var snapshot = await stateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + var contentRequest = _options.IsContentRequest(context.Request); + var separatedContentOrigin = _options.HasSeparatedContentOrigin; + + if (separatedContentOrigin && !contentRequest) + { + if (!string.IsNullOrEmpty(relativePath)) + { + context.Response.Redirect( + _options.BuildContentUrl(context.Request, PreviewRoute.BuildPath(pullRequestNumber, relativePath)), + permanent: false); + return; + } + + await WritePreviewShellAsync(context, "status.html", cancellationToken); + return; + } + + if (snapshot is null || !snapshot.IsReady || string.IsNullOrWhiteSpace(snapshot.ActiveDirectoryPath)) + { + if (separatedContentOrigin && contentRequest) + { + context.Response.Redirect( + _options.BuildControlUrl(context.Request, PreviewRoute.BuildPath(pullRequestNumber)), + permanent: false); + return; + } + + if (!string.IsNullOrEmpty(relativePath)) + { + context.Response.Redirect( + _options.BuildControlUrl(context.Request, PreviewRoute.BuildPath(pullRequestNumber)), + permanent: false); + return; + } + + await WritePreviewShellAsync(context, "status.html", cancellationToken); + return; + } + + await stateStore.TouchAsync(pullRequestNumber, cancellationToken); + + var resolvedFile = ResolvePreviewFile(snapshot.ActiveDirectoryPath, relativePath); + if (resolvedFile is null) + { + var fallback404Path = Path.Combine(snapshot.ActiveDirectoryPath, "404.html"); + if (File.Exists(fallback404Path)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await ServeFileAsync(context, fallback404Path, "text/html; charset=utf-8", "no-cache, no-store, must-revalidate", cancellationToken); + return; + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + await WriteHtmlAsync(context, BuildInfoPage( + title: $"PR #{pullRequestNumber} file not found", + body: "The requested page was not found in the prepared preview artifact."), cancellationToken); + return; + } + + await ServeResolvedFileAsync(context, snapshot, resolvedFile, cancellationToken); + } + + private static async Task ServeFileAsync( + HttpContext context, + string filePath, + string contentType, + string cacheControl, + CancellationToken cancellationToken) + { + context.Response.Headers.CacheControl = cacheControl; + context.Response.ContentType = contentType; + await context.Response.SendFileAsync(filePath, cancellationToken); + } + + private async Task ServeResolvedFileAsync( + HttpContext context, + PreviewStatusSnapshot snapshot, + ResolvedPreviewFile resolvedFile, + CancellationToken cancellationToken) + { + if (!ShouldRewritePreviewContent(resolvedFile.ContentType, snapshot.PreviewPath)) + { + await ServeFileAsync(context, resolvedFile.FilePath, resolvedFile.ContentType, resolvedFile.CacheControl, cancellationToken); + return; + } + + var originalContent = await File.ReadAllTextAsync(resolvedFile.FilePath, cancellationToken); + var rewrittenContent = RewritePreviewContent(originalContent, resolvedFile.ContentType, snapshot.PreviewPath); + + context.Response.Headers.CacheControl = resolvedFile.CacheControl; + context.Response.ContentType = resolvedFile.ContentType; + await context.Response.WriteAsync(rewrittenContent, cancellationToken); + } + + private static ResolvedPreviewFile? ResolvePreviewFile(string previewRoot, string relativePath) + { + var normalizedPath = string.IsNullOrWhiteSpace(relativePath) + ? "index.html" + : relativePath.Replace('\\', '/').TrimStart('/'); + + if (string.IsNullOrWhiteSpace(normalizedPath)) + { + normalizedPath = "index.html"; + } + + if (normalizedPath.EndsWith("/", StringComparison.Ordinal)) + { + normalizedPath = $"{normalizedPath}index.html"; + } + + if (normalizedPath.Contains("..", StringComparison.Ordinal)) + { + return null; + } + + var candidatePath = Path.GetFullPath(Path.Combine(previewRoot, normalizedPath.Replace('/', Path.DirectorySeparatorChar))); + var fullPreviewRoot = Path.GetFullPath(previewRoot + Path.DirectorySeparatorChar); + + if (!candidatePath.StartsWith(fullPreviewRoot, StringComparison.Ordinal)) + { + return null; + } + + if (!File.Exists(candidatePath) && !Path.HasExtension(candidatePath)) + { + var directoryIndexPath = Path.Combine(candidatePath, "index.html"); + if (File.Exists(directoryIndexPath)) + { + candidatePath = directoryIndexPath; + } + } + + if (!File.Exists(candidatePath)) + { + return null; + } + + var contentTypeProvider = new FileExtensionContentTypeProvider(); + if (!contentTypeProvider.TryGetContentType(candidatePath, out var contentType)) + { + contentType = "application/octet-stream"; + } + + var relativeFilePath = Path.GetRelativePath(previewRoot, candidatePath).Replace('\\', '/'); + var cacheControl = candidatePath.EndsWith(".html", StringComparison.OrdinalIgnoreCase) + ? "no-cache, no-store, must-revalidate" + : relativeFilePath.StartsWith("_astro/", StringComparison.Ordinal) + ? "max-age=31536000, public, immutable" + : "public, max-age=300"; + + return new ResolvedPreviewFile(candidatePath, contentType, cacheControl); + } + + private static async Task WriteHtmlAsync(HttpContext context, string html, CancellationToken cancellationToken) + { + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + await context.Response.WriteAsync(html, cancellationToken); + } + + private async Task WritePreviewShellAsync(HttpContext context, string fileName, CancellationToken cancellationToken) + { + var filePath = Path.Combine(_previewShellRoot, fileName); + if (!File.Exists(filePath)) + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await WriteHtmlAsync( + context, + BuildInfoPage( + title: "Preview shell is unavailable", + body: $"The preview host could not find the '{fileName}' shell asset under '/_preview/'."), + cancellationToken); + return; + } + + await ServeFileAsync( + context, + filePath, + contentType: "text/html; charset=utf-8", + cacheControl: "no-cache, no-store, must-revalidate", + cancellationToken); + } + + private static string BuildLoaderPage(PreviewStatusSnapshot snapshot) + { + var serializedSnapshot = JsonSerializer.Serialize(snapshot, WebJsonOptions); + var escapedMessage = HtmlEncoder.Default.Encode(snapshot.Message); + var escapedStage = HtmlEncoder.Default.Encode(snapshot.Stage); + var escapedPreviewPath = HtmlEncoder.Default.Encode(snapshot.PreviewPath); + var escapedHint = HtmlEncoder.Default.Encode(BuildHintText(snapshot)); + var escapedPullRequestUrl = HtmlEncoder.Default.Encode(BuildPullRequestUrl(snapshot)); + var cancelButtonHidden = snapshot.State is PreviewLoadState.Ready or PreviewLoadState.Failed or PreviewLoadState.Cancelled ? "hidden" : string.Empty; + + return $$""" + + + + + + + Aspire PR #{{snapshot.PullRequestNumber}} preview + + + +
+
+
+ +
+
Aspire PR Preview
+

Preparing PR #{{snapshot.PullRequestNumber}}

+
+
+ +
+

{{escapedMessage}}

+
+
+
+ Overall progress + {{snapshot.Percent}}% +
+ +
+
+
+ {{escapedStage}} progress + {{snapshot.StagePercent}}% +
+ +
+
+
+
+
Stage
+
{{escapedStage}}
+
+
+
Overall
+
{{snapshot.Percent}}%
+
+
+
Preview path
+
{{escapedPreviewPath}}
+
+
+
Updated
+
{{snapshot.UpdatedAtUtc:O}}
+
+
+

{{escapedHint}}

+
+ + + + """; + } + + private static string BuildIndexPage(IReadOnlyList snapshots, int maxActivePreviews) + { + var previewCards = snapshots.Count == 0 + ? """ +
+

No recent PR previews yet

+

Open a route like /prs/{number}/ to resolve a PR, load its latest frontend artifact, and add it to this window.

+
+ """ + : string.Join(Environment.NewLine, snapshots.Select(BuildPreviewCard)); + + return $$""" + + + + + + + Aspire PR previews + + + +
+
+
+
+ +
+
Aspire PR Preview
+

Recent preview window

+
+
+

These are the most recent preview routes the host is currently tracking. Open any route like /prs/{number}/ to start or resume that PR's preview flow.

+
+

+ Up to {{maxActivePreviews.ToString("N0", CultureInfo.InvariantCulture)}} PRs + Current entries: {{snapshots.Count.ToString("N0", CultureInfo.InvariantCulture)}} +

+
+
+ {{previewCards}} +
+
+ + + + """; + } + + private static string BuildPreviewCard(PreviewStatusSnapshot snapshot) + { + var escapedPreviewPath = HtmlEncoder.Default.Encode(snapshot.PreviewPath); + var escapedState = HtmlEncoder.Default.Encode(snapshot.State.ToString()); + var escapedStage = HtmlEncoder.Default.Encode(snapshot.Stage); + var escapedSummary = HtmlEncoder.Default.Encode(BuildPreviewSummary(snapshot)); + + return $$""" +
+
+
+
PR #{{snapshot.PullRequestNumber}}
+ {{escapedPreviewPath}} +
+ {{escapedState}} +
+

{{escapedSummary}}

+
+
+
Stage
+
{{escapedStage}}
+
+
+
Overall
+
{{snapshot.Percent}}%
+
+
+
Updated
+
+
+
+
Status
+
{{BuildPreviewStatusText(snapshot)}}
+
+
+
+ """; + } + + private static string BuildPreviewSummary(PreviewStatusSnapshot snapshot) => + snapshot.State switch + { + PreviewLoadState.Ready => "The preview is ready to serve.", + PreviewLoadState.Loading => $"{snapshot.Stage} is in progress for this preview.", + PreviewLoadState.Registered => "A fresh preview was registered and will start loading on demand.", + PreviewLoadState.Cancelled => "The last preparation run was cancelled. Opening the route starts it again.", + PreviewLoadState.Failed => snapshot.Error ?? snapshot.Message, + PreviewLoadState.Evicted => "This preview was evicted from the active window and will reload on the next visit.", + _ => snapshot.Message + }; + + private static string BuildPreviewStatusText(PreviewStatusSnapshot snapshot) => + snapshot.State switch + { + PreviewLoadState.Ready => "Ready", + PreviewLoadState.Loading => $"{snapshot.Percent}% overall", + PreviewLoadState.Registered => "Queued", + PreviewLoadState.Cancelled => "Cancelled", + PreviewLoadState.Failed => "Attention needed", + PreviewLoadState.Evicted => "Will reload", + _ => snapshot.State.ToString() + }; + + private static string BuildStateClass(PreviewLoadState state) => + state switch + { + PreviewLoadState.Ready => "state-ready", + PreviewLoadState.Loading => "state-loading", + PreviewLoadState.Registered => "state-registered", + PreviewLoadState.Failed => "state-failed", + PreviewLoadState.Cancelled => "state-cancelled", + PreviewLoadState.Evicted => "state-evicted", + _ => string.Empty + }; + + private static string BuildInfoPage(string title, string body) + { + var safeTitle = WebUtility.HtmlEncode(title); + var safeBody = WebUtility.HtmlEncode(body); + + return $$""" + + + + + + + {{safeTitle}} + + + +
+

{{safeTitle}}

+

{{safeBody}}

+
+ + + """; + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } + + private static string BuildPullRequestUrl(PreviewStatusSnapshot snapshot) + { + if (string.IsNullOrWhiteSpace(snapshot.RepositoryOwner) || string.IsNullOrWhiteSpace(snapshot.RepositoryName)) + { + return "#"; + } + + return $"https://github.com/{snapshot.RepositoryOwner}/{snapshot.RepositoryName}/pull/{snapshot.PullRequestNumber}"; + } + + private static bool ShouldRewritePreviewContent(string contentType, string previewPath) + { + if (string.IsNullOrWhiteSpace(previewPath) || string.Equals(previewPath, "/", StringComparison.Ordinal)) + { + return false; + } + + return contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) + || contentType.StartsWith("text/css", StringComparison.OrdinalIgnoreCase); + } + + private static string RewritePreviewContent(string content, string contentType, string previewPath) + { + var previewPrefix = NormalizePreviewPrefix(previewPath); + if (string.IsNullOrEmpty(previewPrefix)) + { + return content; + } + + var previewPrefixPattern = Regex.Escape(previewPrefix.TrimStart('/')); + var cssRootPathPattern = $@"(?\burl\(([""']?))/(?!(?:/|{previewPrefixPattern})(?:/|$))"; + content = Regex.Replace( + content, + cssRootPathPattern, + "${prefix}" + previewPrefix + "/", + RegexOptions.IgnoreCase); + + var cssImportPattern = $@"(?@import\s+([""']))/(?!(?:/|{previewPrefixPattern})(?:/|$))"; + content = Regex.Replace( + content, + cssImportPattern, + "${prefix}" + previewPrefix + "/", + RegexOptions.IgnoreCase); + + if (!contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + { + return content; + } + + var htmlRootPathPattern = $@"(?\b(?:href|src|action|poster|content|data|imagesrcset)=[""'])/(?!(?:/|{previewPrefixPattern})(?:/|$))"; + content = Regex.Replace( + content, + htmlRootPathPattern, + "${attr}" + previewPrefix + "/", + RegexOptions.IgnoreCase); + + return InjectPreviewCompatibilityScript(content, previewPrefix); + } + + private static string InjectPreviewCompatibilityScript(string html, string previewPrefix) + { + const string Marker = "data-aspire-preview-bootstrap"; + if (html.Contains(Marker, StringComparison.Ordinal)) + { + return html; + } + + var bootstrapScript = $$""" + + """; + + return html.Replace("", $"{bootstrapScript}", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizePreviewPrefix(string previewPath) + { + if (string.IsNullOrWhiteSpace(previewPath) || string.Equals(previewPath, "/", StringComparison.Ordinal)) + { + return string.Empty; + } + + return previewPath.EndsWith("/", StringComparison.Ordinal) + ? previewPath[..^1] + : previewPath; + } + + private static string BuildHintText(PreviewStatusSnapshot snapshot) => + snapshot.State == PreviewLoadState.Failed + ? "The preview host could not finish preparing this build. Fix the backing configuration or register a newer successful build, then refresh." + : snapshot.State == PreviewLoadState.Cancelled + ? "Preview preparation was cancelled. Refresh this page to start again." + : "The preview will automatically open as soon as the artifact download and extraction finish."; + + private const string AspireLogoSvg = """ + + + + + + + + + """; +} + +internal static class PreviewRoute +{ + public const string CollectionPath = "/prs/"; + private const string Prefix = "/prs/"; + private const string LegacyPrefix = "/pr/"; + + public static string BuildPath(int pullRequestNumber, string? relativePath = null) + { + var path = $"{Prefix}{pullRequestNumber}/"; + if (string.IsNullOrWhiteSpace(relativePath)) + { + return path; + } + + return $"{path}{relativePath.TrimStart('/')}"; + } + + public static bool IsCollectionPath(PathString path) => + string.Equals(path.Value, CollectionPath, StringComparison.OrdinalIgnoreCase) + || string.Equals(path.Value, CollectionPath.TrimEnd('/'), StringComparison.OrdinalIgnoreCase); + + public static bool TryParse(PathString path, out int pullRequestNumber, out string relativePath) + => TryParse(path.Value, Prefix, out pullRequestNumber, out relativePath); + + public static bool TryParse(string? path, out int pullRequestNumber, out string relativePath) + => TryParse(path, Prefix, out pullRequestNumber, out relativePath); + + public static bool TryParseLegacy(PathString path, out int pullRequestNumber, out string relativePath) + => TryParse(path.Value, LegacyPrefix, out pullRequestNumber, out relativePath); + + public static bool TryParseLegacy(string? path, out int pullRequestNumber, out string relativePath) + => TryParse(path, LegacyPrefix, out pullRequestNumber, out relativePath); + + private static bool TryParse(string? pathValue, string prefix, out int pullRequestNumber, out string relativePath) + { + pullRequestNumber = default; + relativePath = string.Empty; + + if (string.IsNullOrWhiteSpace(pathValue) || !pathValue.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var remaining = pathValue[prefix.Length..]; + var nextSlashIndex = remaining.IndexOf('/'); + var pullRequestSegment = nextSlashIndex >= 0 + ? remaining[..nextSlashIndex] + : remaining; + + if (!int.TryParse(pullRequestSegment, out pullRequestNumber)) + { + return false; + } + + if (nextSlashIndex < 0) + { + return true; + } + + relativePath = remaining[(nextSlashIndex + 1)..]; + if (pathValue.EndsWith('/') && !string.IsNullOrEmpty(relativePath) && !relativePath.EndsWith('/')) + { + relativePath = $"{relativePath}/"; + } + + return true; + } +} + +internal sealed record ResolvedPreviewFile(string FilePath, string ContentType, string CacheControl); diff --git a/src/statichost/PreviewHost/Previews/PreviewStateStore.cs b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs new file mode 100644 index 000000000..2051177e4 --- /dev/null +++ b/src/statichost/PreviewHost/Previews/PreviewStateStore.cs @@ -0,0 +1,730 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; + +namespace PreviewHost.Previews; + +internal sealed class PreviewStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() } + }; + + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Dictionary _records = []; + private readonly ILogger _logger; + private readonly string _registryPath; + + public PreviewStateStore(IOptions options, ILogger logger) + { + _logger = logger; + + var configuredRoot = options.Value.DataRoot; + StateRoot = string.IsNullOrWhiteSpace(configuredRoot) + ? ResolveDefaultStateRoot() + : configuredRoot; + var configuredContentRoot = options.Value.ContentRoot; + ContentRoot = string.IsNullOrWhiteSpace(configuredContentRoot) + ? ResolveDefaultContentRoot(StateRoot) + : configuredContentRoot; + + _registryPath = Path.Combine(StateRoot, "registry.json"); + } + + public string StateRoot { get; } + + public string ContentRoot { get; } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(StateRoot); + Directory.CreateDirectory(ContentRoot); + + if (!File.Exists(_registryPath)) + { + return; + } + + await _gate.WaitAsync(cancellationToken); + try + { + await using var stream = File.OpenRead(_registryPath); + var records = await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken); + _records.Clear(); + + if (records is null) + { + return; + } + + var requiresSave = false; + foreach (var pair in records) + { + requiresSave |= NormalizeLoadedRecord(pair.Value); + _records[pair.Key] = pair.Value; + } + + if (requiresSave) + { + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + } + + public async Task RegisterAsync(PreviewRegistrationRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var completedAtUtc = request.CompletedAtUtc == default + ? DateTimeOffset.UtcNow + : request.CompletedAtUtc; + + string? directoryToDelete = null; + PreviewStatusSnapshot snapshot; + var accepted = false; + + await _gate.WaitAsync(cancellationToken); + try + { + if (_records.TryGetValue(request.PullRequestNumber, out var existing) && !IsNewer(existing, request, completedAtUtc)) + { + return new PreviewRegistrationResult(false, existing.ToSnapshot()); + } + + if (existing?.ActiveDirectoryPath is { Length: > 0 } activeDirectoryPath) + { + directoryToDelete = activeDirectoryPath; + } + + var record = existing ?? new PreviewRecord + { + PullRequestNumber = request.PullRequestNumber, + RegisteredAtUtc = DateTimeOffset.UtcNow + }; + + record.RepositoryOwner = request.RepositoryOwner; + record.RepositoryName = request.RepositoryName; + record.HeadSha = request.HeadSha; + record.RunId = request.RunId; + record.RunAttempt = request.RunAttempt; + record.ArtifactName = request.ArtifactName; + record.CompletedAtUtc = completedAtUtc; + record.State = PreviewLoadState.Registered; + record.ActiveDirectoryPath = null; + record.LastError = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: "A fresh preview build has been registered. Loading will start on the next request.", + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + + _records[request.PullRequestNumber] = record; + await SaveLockedAsync(cancellationToken); + snapshot = record.ToSnapshot(); + accepted = true; + } + finally + { + _gate.Release(); + } + + if (!string.IsNullOrWhiteSpace(directoryToDelete)) + { + DeleteDirectoryIfPresent(directoryToDelete); + } + + return new PreviewRegistrationResult(accepted, snapshot); + } + + public async Task GetSnapshotAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return _records.TryGetValue(pullRequestNumber, out var record) + ? record.ToSnapshot() + : null; + } + finally + { + _gate.Release(); + } + } + + public async Task> ListRecentSnapshotsAsync(int limit, CancellationToken cancellationToken) + { + if (limit <= 0) + { + return []; + } + + await _gate.WaitAsync(cancellationToken); + try + { + return [.. _records.Values + .OrderByDescending(static record => record.LastAccessedAtUtc ?? record.ReadyAtUtc ?? record.LastUpdatedAtUtc) + .ThenByDescending(static record => record.PullRequestNumber) + .Take(limit) + .Select(static record => record.ToSnapshot())]; + } + finally + { + _gate.Release(); + } + } + + public async Task> ListSnapshotsAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return _records.Values + .Select(static record => record.ToSnapshot()) + .ToDictionary(static snapshot => snapshot.PullRequestNumber); + } + finally + { + _gate.Release(); + } + } + + public async Task GetWorkItemAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return _records.TryGetValue(pullRequestNumber, out var record) + ? new PreviewWorkItem( + record.RepositoryOwner, + record.RepositoryName, + record.PullRequestNumber, + record.HeadSha, + record.RunId, + record.RunAttempt, + record.ArtifactName, + record.CompletedAtUtc) + : null; + } + finally + { + _gate.Release(); + } + } + + public async Task MatchesBuildAsync(int pullRequestNumber, PreviewWorkItem workItem, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (!_records.TryGetValue(pullRequestNumber, out var record)) + { + return false; + } + + return record.RunId == workItem.RunId + && record.RunAttempt == workItem.RunAttempt + && string.Equals(record.HeadSha, workItem.HeadSha, StringComparison.Ordinal); + } + finally + { + _gate.Release(); + } + } + + public async Task MarkLoadingAsync(int pullRequestNumber, string stage, string message, int percent, int stagePercent, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Loading; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, stage, message, percent, stagePercent, null, null, null, null, null); + }, + cancellationToken, + persist: false); + } + + public async Task UpdateProgressAsync( + int pullRequestNumber, + string stage, + string message, + int percent, + int stagePercent, + long? bytesDownloaded, + long? bytesTotal, + int? itemsCompleted, + int? itemsTotal, + string? itemsLabel, + CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Loading; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, stage, message, percent, stagePercent, bytesDownloaded, bytesTotal, itemsCompleted, itemsTotal, itemsLabel); + }, + cancellationToken, + persist: false); + } + + public async Task MarkReadyAsync(int pullRequestNumber, string activeDirectoryPath, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Ready; + record.ActiveDirectoryPath = activeDirectoryPath; + record.LastError = null; + record.ReadyAtUtc = DateTimeOffset.UtcNow; + record.LastAccessedAtUtc = record.ReadyAtUtc; + record.LastUpdatedAtUtc = record.ReadyAtUtc.Value; + record.Progress = NextProgress( + record, + stage: "Ready", + message: "The preview is ready to serve.", + percent: 100, + stagePercent: 100, + bytesDownloaded: record.Progress.BytesDownloaded, + bytesTotal: record.Progress.BytesTotal, + itemsCompleted: record.Progress.ItemsCompleted, + itemsTotal: record.Progress.ItemsTotal, + itemsLabel: record.Progress.ItemsLabel); + }, + cancellationToken); + } + + public async Task MarkFailedAsync(int pullRequestNumber, string error, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Failed; + record.LastError = error; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, "Failed", error, record.Progress.Percent, record.Progress.StagePercent, record.Progress.BytesDownloaded, record.Progress.BytesTotal, record.Progress.ItemsCompleted, record.Progress.ItemsTotal, record.Progress.ItemsLabel); + }, + cancellationToken); + } + + public async Task MarkCancelledAsync(int pullRequestNumber, string message, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Cancelled; + record.LastError = null; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Cancelled", + message: message, + percent: record.Progress.Percent, + stagePercent: record.Progress.StagePercent, + bytesDownloaded: record.Progress.BytesDownloaded, + bytesTotal: record.Progress.BytesTotal, + itemsCompleted: record.Progress.ItemsCompleted, + itemsTotal: record.Progress.ItemsTotal, + itemsLabel: record.Progress.ItemsLabel); + }, + cancellationToken); + } + + public async Task RequeueAsync(int pullRequestNumber, string message, CancellationToken cancellationToken) + { + PreviewStatusSnapshot? snapshot = null; + + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.State = PreviewLoadState.Registered; + record.LastError = null; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: message, + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + snapshot = record.ToSnapshot(); + }, + cancellationToken); + + return snapshot; + } + + public async Task TouchAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + await UpdateRecordAsync( + pullRequestNumber, + record => + { + record.LastAccessedAtUtc = DateTimeOffset.UtcNow; + record.LastUpdatedAtUtc = record.LastAccessedAtUtc.Value; + }, + cancellationToken, + persist: false); + } + + public async Task> ListReadyCandidatesAsync(int excludedPullRequestNumber, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + return [.. _records.Values + .Where(static record => record.State == PreviewLoadState.Ready && !string.IsNullOrWhiteSpace(record.ActiveDirectoryPath)) + .Where(record => record.PullRequestNumber != excludedPullRequestNumber) + .Select(record => new ReadyPreviewCandidate( + record.PullRequestNumber, + record.ActiveDirectoryPath!, + record.LastAccessedAtUtc ?? record.ReadyAtUtc ?? record.RegisteredAtUtc)) + .OrderBy(static candidate => candidate.SortKey)]; + } + finally + { + _gate.Release(); + } + } + + public async Task EvictAsync(int pullRequestNumber, string reason, CancellationToken cancellationToken) + { + string? directoryToDelete = null; + + await _gate.WaitAsync(cancellationToken); + try + { + if (!_records.TryGetValue(pullRequestNumber, out var record)) + { + return; + } + + directoryToDelete = record.ActiveDirectoryPath; + record.State = PreviewLoadState.Evicted; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress(record, "Evicted", reason, 0, 0, null, null, null, null, null); + await SaveLockedAsync(cancellationToken); + } + finally + { + _gate.Release(); + } + + if (!string.IsNullOrWhiteSpace(directoryToDelete)) + { + DeleteDirectoryIfPresent(directoryToDelete); + } + } + + public async Task RemoveAsync(int pullRequestNumber, CancellationToken cancellationToken) + { + string? directoryToDelete = null; + + await _gate.WaitAsync(cancellationToken); + try + { + if (_records.Remove(pullRequestNumber, out var record)) + { + directoryToDelete = record.ActiveDirectoryPath; + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + + if (!string.IsNullOrWhiteSpace(directoryToDelete)) + { + DeleteDirectoryIfPresent(directoryToDelete); + } + } + + public async Task RemoveMissingAsync(IReadOnlyCollection activePullRequestNumbers, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(activePullRequestNumbers); + + HashSet activeNumbers = activePullRequestNumbers.Count == 0 + ? [] + : [.. activePullRequestNumbers]; + List directoriesToDelete = []; + List removedPullRequests = []; + + await _gate.WaitAsync(cancellationToken); + try + { + foreach (var pullRequestNumber in _records.Keys.Where(number => !activeNumbers.Contains(number)).ToArray()) + { + if (!_records.Remove(pullRequestNumber, out var record)) + { + continue; + } + + if (!string.IsNullOrWhiteSpace(record.ActiveDirectoryPath)) + { + directoriesToDelete.Add(record.ActiveDirectoryPath); + } + + removedPullRequests.Add(pullRequestNumber); + } + + if (removedPullRequests.Count > 0) + { + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + + foreach (var directoryPath in directoriesToDelete) + { + DeleteDirectoryIfPresent(directoryPath); + } + + return removedPullRequests.Count; + } + + public string GetActiveDirectoryPath(int pullRequestNumber) => Path.Combine(ContentRoot, "active", $"pr-{pullRequestNumber}"); + + public string GetTemporaryZipPath(PreviewWorkItem workItem) => + Path.Combine(ContentRoot, "downloads", $"pr-{workItem.PullRequestNumber}-{workItem.RunId}-{workItem.RunAttempt}.zip"); + + public string GetStagingDirectoryPath(PreviewWorkItem workItem) => + Path.Combine(ContentRoot, "staging", $"pr-{workItem.PullRequestNumber}-{workItem.RunId}-{workItem.RunAttempt}"); + + private async Task UpdateRecordAsync(int pullRequestNumber, Action update, CancellationToken cancellationToken, bool persist = true) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (!_records.TryGetValue(pullRequestNumber, out var record)) + { + return; + } + + update(record); + if (persist) + { + await SaveLockedAsync(cancellationToken); + } + } + finally + { + _gate.Release(); + } + } + + private async Task SaveLockedAsync(CancellationToken cancellationToken) + { + Directory.CreateDirectory(StateRoot); + var json = JsonSerializer.Serialize(_records, JsonOptions); + var registryDirectory = Path.GetDirectoryName(_registryPath) ?? StateRoot; + Directory.CreateDirectory(registryDirectory); + + var tempFilePath = Path.Combine(registryDirectory, $"{Path.GetFileName(_registryPath)}.{Path.GetRandomFileName()}.tmp"); + try + { + await File.WriteAllTextAsync(tempFilePath, json, cancellationToken); + File.Move(tempFilePath, _registryPath, overwrite: true); + } + finally + { + DeleteFileIfPresent(tempFilePath); + } + } + + private bool NormalizeLoadedRecord(PreviewRecord record) + { + if (record.State == PreviewLoadState.Ready + && !string.IsNullOrWhiteSpace(record.ActiveDirectoryPath) + && !Directory.Exists(record.ActiveDirectoryPath)) + { + record.State = PreviewLoadState.Registered; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: "The local preview cache was cleared and will reload on the next request.", + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + return true; + } + + if (record.State == PreviewLoadState.Loading) + { + record.State = PreviewLoadState.Registered; + record.ActiveDirectoryPath = null; + record.ReadyAtUtc = null; + record.LastError = null; + record.LastUpdatedAtUtc = DateTimeOffset.UtcNow; + record.Progress = NextProgress( + record, + stage: "Registered", + message: "Preview preparation was interrupted and will restart on the next request.", + percent: 0, + stagePercent: 0, + bytesDownloaded: null, + bytesTotal: null, + itemsCompleted: null, + itemsTotal: null, + itemsLabel: null); + return true; + } + + return false; + } + + private static PreviewProgress NextProgress( + PreviewRecord record, + string stage, + string message, + int percent, + int stagePercent, + long? bytesDownloaded, + long? bytesTotal, + int? itemsCompleted, + int? itemsTotal, + string? itemsLabel) => + new() + { + Version = record.Progress.Version + 1, + Stage = stage, + Message = message, + Percent = Math.Clamp(percent, 0, 100), + StagePercent = Math.Clamp(stagePercent, 0, 100), + BytesDownloaded = bytesDownloaded, + BytesTotal = bytesTotal, + ItemsCompleted = itemsCompleted, + ItemsTotal = itemsTotal, + ItemsLabel = itemsLabel, + UpdatedAtUtc = DateTimeOffset.UtcNow + }; + + private static bool IsNewer(PreviewRecord existing, PreviewRegistrationRequest request, DateTimeOffset completedAtUtc) + { + if (completedAtUtc > existing.CompletedAtUtc) + { + return true; + } + + if (completedAtUtc < existing.CompletedAtUtc) + { + return false; + } + + if (request.RunId > existing.RunId) + { + return true; + } + + if (request.RunId < existing.RunId) + { + return false; + } + + return request.RunAttempt > existing.RunAttempt; + } + + private static string ResolveDefaultStateRoot() + { + var home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrWhiteSpace(home)) + { + return Path.Combine(home, "pr-preview-data"); + } + + return Path.Combine(AppContext.BaseDirectory, "pr-preview-data"); + } + + private static string ResolveDefaultContentRoot(string stateRoot) + { + if (string.Equals(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"), "true", StringComparison.OrdinalIgnoreCase)) + { + return Path.Combine(Path.GetTempPath(), "pr-preview-data"); + } + + return stateRoot; + } + + private void DeleteDirectoryIfPresent(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete preview directory {Directory}", path); + } + catch (UnauthorizedAccessException exception) + { + _logger.LogWarning(exception, "Failed to delete preview directory {Directory}", path); + } + } + + private void DeleteFileIfPresent(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException exception) + { + _logger.LogWarning(exception, "Failed to delete preview file {Path}", path); + } + catch (UnauthorizedAccessException exception) + { + _logger.LogWarning(exception, "Failed to delete preview file {Path}", path); + } + } +} diff --git a/src/statichost/PreviewHost/Program.cs b/src/statichost/PreviewHost/Program.cs new file mode 100644 index 000000000..e545ced53 --- /dev/null +++ b/src/statichost/PreviewHost/Program.cs @@ -0,0 +1,832 @@ +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Options; +using PreviewHost.Previews; + +var webJsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); +webJsonOptions.Converters.Add(new JsonStringEnumConverter()); + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddProblemDetails(); +builder.Services.AddHealthChecks(); +builder.Services.AddMemoryCache(); +builder.Services.ConfigureHttpJsonOptions(static options => +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(PreviewHostOptions.SectionName)) + .Validate( + static options => options.HasGitHubToken || options.HasGitHubAppConfiguration, + $"Either '{PreviewHostOptions.SectionName}:GitHubToken' or both '{PreviewHostOptions.SectionName}:GitHubAppId' and '{PreviewHostOptions.SectionName}:GitHubAppPrivateKey' must be configured.") + .Validate( + static options => options.HasGitHubOAuthConfiguration, + $"Both '{PreviewHostOptions.SectionName}:GitHubOAuthClientId' and '{PreviewHostOptions.SectionName}:GitHubOAuthClientSecret' must be configured.") + .Validate( + static options => options.HasValidExtractionMode, + $"The '{PreviewHostOptions.SectionName}:ExtractionMode' setting must be either 'managed' or 'command-line'.") + .Validate( + static options => options.HasValidConfiguredBaseUrls, + $"The '{PreviewHostOptions.SectionName}:ControlBaseUrl' and '{PreviewHostOptions.SectionName}:ContentBaseUrl' settings must be absolute http(s) URLs when provided.") + .Validate( + static options => options.HasValidSafetyLimits, + $"The '{PreviewHostOptions.SectionName}' safety limits must all be positive values.") + .Validate( + static options => CommandLineExtractionSupport.IsConfigurationSupported(options), + CommandLineExtractionSupport.GetConfigurationValidationMessage()) + .ValidateOnStart(); + +var authCookieDomain = builder.Configuration[$"{PreviewHostOptions.SectionName}:AuthCookieDomain"]; + +builder.Services + .AddAuthentication(options => + { + options.DefaultScheme = PreviewAuthenticationDefaults.CookieScheme; + options.DefaultChallengeScheme = PreviewAuthenticationDefaults.GitHubScheme; + options.DefaultSignInScheme = PreviewAuthenticationDefaults.CookieScheme; + }) + .AddCookie(PreviewAuthenticationDefaults.CookieScheme, options => + { + options.Cookie.Name = "previewhost-auth"; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + if (!string.IsNullOrWhiteSpace(authCookieDomain)) + { + options.Cookie.Domain = authCookieDomain; + } + + options.AccessDeniedPath = "/auth/access-denied"; + options.Events = new CookieAuthenticationEvents + { + OnRedirectToLogin = context => HandleCookieRedirectAsync(context, StatusCodes.Status401Unauthorized), + OnRedirectToAccessDenied = context => HandleCookieRedirectAsync(context, StatusCodes.Status403Forbidden) + }; + }) + .AddOAuth(PreviewAuthenticationDefaults.GitHubScheme, options => + { + options.ClientId = builder.Configuration[$"{PreviewHostOptions.SectionName}:GitHubOAuthClientId"] ?? string.Empty; + options.ClientSecret = builder.Configuration[$"{PreviewHostOptions.SectionName}:GitHubOAuthClientSecret"] ?? string.Empty; + options.CallbackPath = "/signin-github"; + options.AuthorizationEndpoint = "https://github.com/login/oauth/authorize"; + options.TokenEndpoint = "https://github.com/login/oauth/access_token"; + options.UserInformationEndpoint = "https://api.github.com/user"; + options.SaveTokens = false; + options.Scope.Add("read:user"); + options.CorrelationCookie.SameSite = SameSiteMode.Lax; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + if (!string.IsNullOrWhiteSpace(authCookieDomain)) + { + options.CorrelationCookie.Domain = authCookieDomain; + } + + options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id"); + options.ClaimActions.MapJsonKey(ClaimTypes.Name, "login"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserLoginClaimType, "login"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserDisplayNameClaimType, "name"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserAvatarUrlClaimType, "avatar_url"); + options.ClaimActions.MapJsonKey(PreviewAuthenticationDefaults.UserProfileUrlClaimType, "html_url"); + + options.Events = new OAuthEvents + { + OnCreatingTicket = async context => + { + using var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.AccessToken); + request.Headers.UserAgent.ParseAdd("aspire-dev-preview-host"); + + using var response = await context.Backchannel.SendAsync(request, context.HttpContext.RequestAborted); + response.EnsureSuccessStatusCode(); + + await using var userInfoStream = await response.Content.ReadAsStreamAsync(context.HttpContext.RequestAborted); + using var userDocument = await JsonDocument.ParseAsync(userInfoStream, cancellationToken: context.HttpContext.RequestAborted); + context.RunClaimActions(userDocument.RootElement); + } + }; + }); + +builder.Services.AddAuthorization(options => +{ + options.AddPolicy( + PreviewAuthenticationDefaults.WriterPolicy, + policy => policy + .RequireAuthenticatedUser() + .AddRequirements(new PreviewWriterRequirement())); +}); + +builder.Services.AddAntiforgery(options => +{ + options.HeaderName = PreviewAuthenticationDefaults.CsrfHeaderName; + options.FormFieldName = "__RequestVerificationToken"; +}); + +builder.Services.AddRateLimiter(options => +{ + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddPolicy( + "preview-read", + context => RateLimitPartition.GetFixedWindowLimiter( + GetRateLimitPartitionKey(context, "read"), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + options.AddPolicy( + "preview-write", + context => RateLimitPartition.GetFixedWindowLimiter( + GetRateLimitPartitionKey(context, "write"), + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); + options.AddPolicy( + "preview-events", + context => RateLimitPartition.GetConcurrencyLimiter( + GetRateLimitPartitionKey(context, "events"), + _ => new ConcurrencyLimiterOptions + { + PermitLimit = 2, + QueueLimit = 0 + })); +}); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +var previewHostOptions = app.Services.GetRequiredService>().Value; + +var previewStateStore = app.Services.GetRequiredService(); +await previewStateStore.InitializeAsync(CancellationToken.None); + +app.Logger.LogInformation( + "PreviewHost GitHub authentication mode: {GitHubAuthenticationMode}", + previewHostOptions.GetGitHubAuthenticationMode()); +app.Logger.LogInformation( + "PreviewHost artifact extraction mode: {ExtractionMode}", + previewHostOptions.GetExtractionModeDescription()); +app.Logger.LogInformation( + "PreviewHost storage roots: state {StateRoot}, content {ContentRoot}", + previewStateStore.StateRoot, + previewStateStore.ContentRoot); +app.Logger.LogInformation( + "PreviewHost content origin separation enabled: {HasSeparatedContentOrigin}", + previewHostOptions.HasSeparatedContentOrigin); + +if (previewHostOptions.HasSeparatedContentOrigin && !previewHostOptions.CanAuthenticateContentRequests) +{ + app.Logger.LogWarning( + "PreviewHost is configured with a separate content origin, but '{SectionName}:AuthCookieDomain' is empty. Control routes stay writer-gated, but preview content remains public on the content origin until a shared cookie domain is configured.", + PreviewHostOptions.SectionName); +} + +if (previewHostOptions.UseCommandLineExtraction + && CommandLineExtractionSupport.TryResolveConfiguredTool(previewHostOptions, out var extractionToolPath)) +{ + app.Logger.LogInformation( + "PreviewHost command-line extractor resolved to: {ExtractionToolPath}", + extractionToolPath); +} + +if (!app.Environment.IsDevelopment()) +{ + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseExceptionHandler(); +app.UseStaticFiles(new StaticFileOptions +{ + OnPrepareResponse = static context => + { + if (context.Context.Request.Path.StartsWithSegments("/_preview", StringComparison.OrdinalIgnoreCase)) + { + context.Context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + } + } +}); + +app.UseAuthentication(); +app.UseAuthorization(); +app.UseRateLimiter(); + +app.Use(async (context, next) => +{ + if (previewHostOptions.IsContentRequest(context.Request) + && context.Request.Path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase)) + { + context.Response.Redirect( + $"{previewHostOptions.BuildControlUrl(context.Request, context.Request.Path.Value ?? "/")}{context.Request.QueryString}", + permanent: false); + return; + } + + if (previewHostOptions.IsContentRequest(context.Request) + && context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + await next(); +}); + +app.Use(async (context, next) => +{ + if (ShouldIssueCsrfToken(context, previewHostOptions)) + { + var antiforgery = context.RequestServices.GetRequiredService(); + var tokens = antiforgery.GetAndStoreTokens(context); + if (!string.IsNullOrWhiteSpace(tokens.RequestToken)) + { + context.Response.Cookies.Append( + PreviewAuthenticationDefaults.CsrfRequestTokenCookieName, + tokens.RequestToken, + CreateCsrfCookieOptions(previewHostOptions)); + } + } + + await next(); +}); + +app.Use(async (context, next) => +{ + if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) + && PreviewRoute.TryParseLegacy(context.Request.Path, out var legacyPullRequestNumber, out var legacyRelativePath)) + { + var targetPath = PreviewRoute.BuildPath(legacyPullRequestNumber, legacyRelativePath); + context.Response.Redirect( + $"{previewHostOptions.BuildControlUrl(context.Request, targetPath)}{context.Request.QueryString}", + permanent: false); + return; + } + + if ((HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) + && PreviewRoute.IsCollectionPath(context.Request.Path)) + { + if (!previewHostOptions.IsContentRequest(context.Request) + && !await EnsurePreviewWriterAccessAsync(context)) + { + return; + } + + var indexDispatcher = context.RequestServices.GetRequiredService(); + await indexDispatcher.DispatchIndexAsync(context, context.RequestAborted); + return; + } + + if (!TryResolvePreviewRequest(context, out var pullRequestNumber, out var relativePath)) + { + await next(); + return; + } + + var requiresWriterAccess = !previewHostOptions.IsContentRequest(context.Request) + || previewHostOptions.CanAuthenticateContentRequests; + if (requiresWriterAccess && !await EnsurePreviewWriterAccessAsync(context)) + { + return; + } + + var dispatcher = context.RequestServices.GetRequiredService(); + await dispatcher.DispatchAsync(context, pullRequestNumber, relativePath, context.RequestAborted); +}); + +app.MapGet("/", (HttpContext context) => + Results.Redirect(previewHostOptions.BuildControlUrl(context.Request, PreviewRoute.CollectionPath))); + +app.MapGet( + "/auth/login", + (HttpContext context, string? returnUrl) => + Results.Challenge( + new AuthenticationProperties + { + RedirectUri = NormalizeReturnUrl(returnUrl) + }, + [PreviewAuthenticationDefaults.GitHubScheme])); + +app.MapGet( + "/auth/logout", + async (HttpContext context, string? returnUrl) => + { + await context.SignOutAsync(PreviewAuthenticationDefaults.CookieScheme); + return Results.LocalRedirect(NormalizeReturnUrl(returnUrl)); + }); + +app.MapGet( + "/auth/access-denied", + () => Results.Content( + BuildAccessDeniedPage(previewHostOptions), + contentType: "text/html; charset=utf-8")); + +app.MapHealthChecks("/healthz", new HealthCheckOptions +{ + AllowCachingResponses = false +}); + +var previewApi = app.MapGroup("/api/previews") + .RequireAuthorization(PreviewAuthenticationDefaults.WriterPolicy); + +previewApi.MapGet( + "/session", + (HttpContext context) => + { + var login = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserLoginClaimType) + ?? context.User.FindFirstValue(ClaimTypes.Name) + ?? string.Empty; + var displayName = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserDisplayNameClaimType); + var avatarUrl = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserAvatarUrlClaimType); + var profileUrl = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserProfileUrlClaimType); + + return Results.Json(new + { + viewer = new + { + login, + displayName = string.IsNullOrWhiteSpace(displayName) ? login : displayName, + avatarUrl, + profileUrl + }, + repositoryOwner = previewHostOptions.RepositoryOwner, + repositoryName = previewHostOptions.RepositoryName, + controlBaseUrl = previewHostOptions.GetControlBaseUrl(context.Request), + contentBaseUrl = previewHostOptions.GetContentBaseUrl(context.Request), + hasSeparatedContentOrigin = previewHostOptions.HasSeparatedContentOrigin, + canAuthenticateContentRequests = previewHostOptions.CanAuthenticateContentRequests, + signOutPath = $"/auth/logout?returnUrl={Uri.EscapeDataString(PreviewRoute.CollectionPath)}" + }); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/recent", + async (CancellationToken cancellationToken) => + { + var snapshots = await previewStateStore.ListRecentSnapshotsAsync( + previewHostOptions.MaxActivePreviews, + cancellationToken); + return Results.Json(new + { + updatedAtUtc = DateTimeOffset.UtcNow, + maxActivePreviews = previewHostOptions.MaxActivePreviews, + snapshots + }); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/catalog", + async (GitHubArtifactClient gitHubArtifactClient, CancellationToken cancellationToken) => + { + var openPullRequests = await gitHubArtifactClient.ListOpenPullRequestsAsync(cancellationToken); + var prunedCount = await previewStateStore.RemoveMissingAsync( + openPullRequests.Select(static pullRequest => pullRequest.PullRequestNumber).ToArray(), + cancellationToken); + + if (prunedCount > 0) + { + app.Logger.LogInformation("Removed {PrunedCount} closed pull request previews from the local preview state.", prunedCount); + } + + var trackedSnapshots = await previewStateStore.ListSnapshotsAsync(cancellationToken); + var activePreviewCount = trackedSnapshots.Values.Count(static snapshot => snapshot.State is PreviewLoadState.Loading or PreviewLoadState.Ready); + var entries = openPullRequests + .Select(pullRequest => new + { + pullRequestNumber = pullRequest.PullRequestNumber, + title = pullRequest.Title, + pullRequestUrl = pullRequest.HtmlUrl, + previewPath = PreviewRoute.BuildPath(pullRequest.PullRequestNumber), + authorLogin = pullRequest.AuthorLogin, + isDraft = pullRequest.IsDraft, + headSha = pullRequest.HeadSha, + hasSuccessfulPreviewBuild = pullRequest.HasSuccessfulPreviewBuild, + createdAtUtc = pullRequest.CreatedAtUtc, + updatedAtUtc = pullRequest.UpdatedAtUtc, + preview = trackedSnapshots.TryGetValue(pullRequest.PullRequestNumber, out var snapshot) ? snapshot : null + }) + .ToArray(); + + return Results.Json(new + { + updatedAtUtc = DateTimeOffset.UtcNow, + openPullRequestCount = entries.Length, + previewablePullRequestCount = entries.Count(static entry => entry.hasSuccessfulPreviewBuild), + maxActivePreviews = previewHostOptions.MaxActivePreviews, + activePreviewCount, + entries + }); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/{pullRequestNumber:int}", + async (int pullRequestNumber, CancellationToken cancellationToken) => + { + var snapshot = await previewStateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + return snapshot is null + ? Results.NotFound() + : Results.Json(snapshot); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/{pullRequestNumber:int}/bootstrap", + async (HttpContext context, int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + var result = await coordinator.BootstrapAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.NotFound(CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + result.FailureMessage ?? "This preview hasn't been enabled yet.")) + : Results.Json(result.Snapshot); + }) + .RequireRateLimiting("preview-read"); + +previewApi.MapGet( + "/{pullRequestNumber:int}/events", + async (HttpContext context, int pullRequestNumber, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache, no-store, must-revalidate"; + + long lastVersion = -1; + + while (!cancellationToken.IsCancellationRequested) + { + var snapshot = await previewStateStore.GetSnapshotAsync(pullRequestNumber, cancellationToken); + if (snapshot is null) + { + var missingSnapshot = JsonSerializer.Serialize( + CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + "This preview hasn't been enabled yet."), + webJsonOptions); + + await context.Response.WriteAsync($"data: {missingSnapshot}\n\n", cancellationToken); + break; + } + + if (snapshot.State is PreviewLoadState.Registered or PreviewLoadState.Loading) + { + coordinator.EnsureLoading(pullRequestNumber); + } + + if (snapshot.Version != lastVersion) + { + var payload = JsonSerializer.Serialize(snapshot, webJsonOptions); + await context.Response.WriteAsync($"data: {payload}\n\n", cancellationToken); + await context.Response.Body.FlushAsync(cancellationToken); + lastVersion = snapshot.Version; + + if (snapshot.IsReady || snapshot.State is PreviewLoadState.Failed or PreviewLoadState.Cancelled) + { + break; + } + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + }) + .RequireRateLimiting("preview-events"); + +previewApi.MapPost( + "/{pullRequestNumber:int}/prepare", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + await antiforgery.ValidateRequestAsync(context); + var result = await coordinator.PrepareAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.NotFound(CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) + : Results.Json(result.Snapshot); + }) + .RequireRateLimiting("preview-write"); + +previewApi.MapPost( + "/{pullRequestNumber:int}/cancel", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + await antiforgery.ValidateRequestAsync(context); + var snapshot = await coordinator.CancelAsync(pullRequestNumber, cancellationToken); + return snapshot is null + ? Results.NotFound() + : Results.Json(snapshot); + }) + .RequireRateLimiting("preview-write"); + +previewApi.MapPost( + "/{pullRequestNumber:int}/retry", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + await antiforgery.ValidateRequestAsync(context); + var result = await coordinator.RetryAsync(pullRequestNumber, cancellationToken); + return result.Snapshot is null + ? Results.NotFound(CreateUnavailablePreviewPayload( + pullRequestNumber, + previewHostOptions, + result.FailureMessage ?? "The preview host could not find a successful frontend build for this pull request yet.")) + : Results.Json(result.Snapshot); + }) + .RequireRateLimiting("preview-write"); + +previewApi.MapPost( + "/{pullRequestNumber:int}/reset", + async (HttpContext context, int pullRequestNumber, IAntiforgery antiforgery, PreviewCoordinator coordinator, CancellationToken cancellationToken) => + { + await antiforgery.ValidateRequestAsync(context); + var removed = await coordinator.ResetAsync(pullRequestNumber, cancellationToken); + return removed + ? Results.NoContent() + : Results.NotFound(); + }) + .RequireRateLimiting("preview-write"); + +await app.RunAsync(); + +static Task HandleCookieRedirectAsync(RedirectContext context, int statusCode) +{ + if (IsApiRequest(context.Request)) + { + context.Response.StatusCode = statusCode; + return Task.CompletedTask; + } + + context.Response.Redirect(context.RedirectUri); + return Task.CompletedTask; +} + +static bool IsApiRequest(HttpRequest request) => + request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) + || request.Headers.Accept.Any(static value => !string.IsNullOrEmpty(value) && value.Contains("application/json", StringComparison.OrdinalIgnoreCase)); + +static string NormalizeReturnUrl(string? returnUrl) +{ + if (string.IsNullOrWhiteSpace(returnUrl)) + { + return PreviewRoute.CollectionPath; + } + + return Uri.TryCreate(returnUrl, UriKind.Relative, out var relativeUri) + ? relativeUri.ToString() + : PreviewRoute.CollectionPath; +} + +static bool ShouldIssueCsrfToken(HttpContext context, PreviewHostOptions options) => + (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) + && context.User.Identity?.IsAuthenticated == true + && options.IsControlRequest(context.Request); + +static CookieOptions CreateCsrfCookieOptions(PreviewHostOptions options) +{ + var cookieOptions = new CookieOptions + { + HttpOnly = false, + IsEssential = true, + SameSite = SameSiteMode.Strict, + Secure = true, + Path = "/" + }; + + if (!string.IsNullOrWhiteSpace(options.AuthCookieDomain)) + { + cookieOptions.Domain = options.AuthCookieDomain; + } + + return cookieOptions; +} + +static async Task EnsurePreviewWriterAccessAsync(HttpContext context) +{ + var authorizationService = context.RequestServices.GetRequiredService(); + var authorizationResult = await authorizationService.AuthorizeAsync( + context.User, + resource: null, + PreviewAuthenticationDefaults.WriterPolicy); + + if (authorizationResult.Succeeded) + { + return true; + } + + if (context.User.Identity?.IsAuthenticated == true) + { + await context.ForbidAsync(PreviewAuthenticationDefaults.CookieScheme); + return false; + } + + await context.ChallengeAsync( + PreviewAuthenticationDefaults.GitHubScheme, + new AuthenticationProperties + { + RedirectUri = $"{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}" + }); + + return false; +} + +static object CreateUnavailablePreviewPayload(int pullRequestNumber, PreviewHostOptions options, string failureMessage) => + new + { + pullRequestNumber, + state = "Missing", + stage = "Missing", + failureMessage, + previewPath = PreviewRoute.BuildPath(pullRequestNumber), + updatedAtUtc = DateTimeOffset.UtcNow, + repositoryOwner = options.RepositoryOwner, + repositoryName = options.RepositoryName + }; + +static bool TryResolvePreviewRequest(HttpContext context, out int pullRequestNumber, out string relativePath) +{ + if (PreviewRoute.TryParse(context.Request.Path, out pullRequestNumber, out relativePath)) + { + return true; + } + + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + pullRequestNumber = default; + relativePath = string.Empty; + return false; + } + + if (context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/auth", StringComparison.OrdinalIgnoreCase) + || context.Request.Path.StartsWithSegments("/healthz", StringComparison.OrdinalIgnoreCase)) + { + pullRequestNumber = default; + relativePath = string.Empty; + return false; + } + + if (!TryGetPullRequestNumberFromReferer(context.Request, out pullRequestNumber)) + { + relativePath = string.Empty; + return false; + } + + relativePath = GetRelativePreviewPath(context.Request.Path); + return true; +} + +static bool TryGetPullRequestNumberFromReferer(HttpRequest request, out int pullRequestNumber) +{ + pullRequestNumber = default; + + var refererHeader = request.Headers.Referer.ToString(); + if (string.IsNullOrWhiteSpace(refererHeader) + || !Uri.TryCreate(refererHeader, UriKind.Absolute, out var refererUri)) + { + return false; + } + + var requestHost = request.Host.Host; + if (!string.Equals(refererUri.Host, requestHost, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (request.Host.Port is { } requestPort && !refererUri.IsDefaultPort && refererUri.Port != requestPort) + { + return false; + } + + return PreviewRoute.TryParse(refererUri.AbsolutePath, out pullRequestNumber, out _) + || PreviewRoute.TryParseLegacy(refererUri.AbsolutePath, out pullRequestNumber, out _); +} + +static string GetRelativePreviewPath(PathString requestPath) +{ + var pathValue = requestPath.Value; + if (string.IsNullOrEmpty(pathValue) || pathValue == "/") + { + return string.Empty; + } + + var relativePath = pathValue.TrimStart('/'); + if (pathValue.EndsWith('/') && !relativePath.EndsWith('/')) + { + relativePath = $"{relativePath}/"; + } + + return relativePath; +} + +static string GetRateLimitPartitionKey(HttpContext context, string bucket) +{ + var login = context.User.FindFirstValue(PreviewAuthenticationDefaults.UserLoginClaimType) + ?? context.User.FindFirstValue(ClaimTypes.Name) + ?? context.Connection.RemoteIpAddress?.ToString() + ?? "anonymous"; + + return $"{bucket}:{login}"; +} + +static string BuildAccessDeniedPage(PreviewHostOptions options) +{ + var repositoryName = $"{options.RepositoryOwner}/{options.RepositoryName}"; + return $$""" + + + + + + Preview access required + + + +
+

Preview access required

+

Sign in with GitHub using an account that has write access to {{repositoryName}} to browse, prepare, or reset previews.

+

Preview content is intentionally limited to trusted repository writers so the preview host no longer relies on unauthenticated control endpoints or automatic PR registration.

+ Sign in with GitHub +
+ + + """; +} diff --git a/src/statichost/PreviewHost/Properties/launchSettings.json b/src/statichost/PreviewHost/Properties/launchSettings.json new file mode 100644 index 000000000..18eff7506 --- /dev/null +++ b/src/statichost/PreviewHost/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17155;http://localhost:15081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15081", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/statichost/PreviewHost/appsettings.Development.json b/src/statichost/PreviewHost/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/src/statichost/PreviewHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/statichost/PreviewHost/appsettings.json b/src/statichost/PreviewHost/appsettings.json new file mode 100644 index 000000000..9558123fd --- /dev/null +++ b/src/statichost/PreviewHost/appsettings.json @@ -0,0 +1,11 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "PreviewHost": { + "MaxActivePreviews": 10 + } +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg b/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg new file mode 100644 index 000000000..89fe2a3fc --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/aspire-mark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg b/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg new file mode 100644 index 000000000..2c00a294f --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/github-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.html b/src/statichost/PreviewHost/wwwroot/_preview/index.html new file mode 100644 index 000000000..21afcef12 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.html @@ -0,0 +1,121 @@ + + + + + + Aspire PR Previews + + + + +
+
+
+ + Aspire logo + + Aspire + PR Preview + + +

Aspire PR Preview

+

Open PRs

+

Open any PR to warm its latest frontend preview. Only a small active window stays hot.

+
+
+ +
+ Loading capacity... + Loading PRs... +
+
+
+ +
+
+ Authors +
+ +
+
+
+ Status +
+ +
+
+
+ +
+ + + +
+
+ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/index.js b/src/statichost/PreviewHost/wwwroot/_preview/index.js new file mode 100644 index 000000000..835deb995 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/index.js @@ -0,0 +1,858 @@ +const previewGrid = document.getElementById("preview-grid"); +const windowCapacity = document.getElementById("window-capacity"); +const windowCount = document.getElementById("window-count"); +const availabilityFilterBar = document.getElementById("availability-filter-bar"); +const authorFilterBar = document.getElementById("author-filter-bar"); +const signoutLink = document.getElementById("signout-link"); +const viewerSummary = document.getElementById("viewer-summary"); +const viewerAvatar = document.getElementById("viewer-avatar"); +const viewerName = document.getElementById("viewer-name"); + +const numberFormatter = new Intl.NumberFormat(); +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); + +const initialFilters = readFiltersFromQuery(); + +let catalogEntries = []; +let authorOptions = []; +let showPreviewableOnly = initialFilters.previewable; +let selectedAuthors = new Set(initialFilters.authors); +let activePreviewCount = 0; +let maxActivePreviews = 0; +let openPullRequestCount = 0; +let previewablePullRequestCount = 0; +let openDropdown = ""; +let sessionInfo = null; +const resettingPreviews = new Set(); + +availabilityFilterBar.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest("[data-filter-trigger='availability']"); + if (!(button instanceof HTMLButtonElement)) { + return; + } + + toggleDropdown("availability"); +}); + +availabilityFilterBar.addEventListener("change", (event) => { + if (!(event.target instanceof HTMLInputElement)) { + return; + } + + if (event.target.dataset.previewableCheckbox !== "true") { + return; + } + + showPreviewableOnly = event.target.checked; + syncQueryString(); + syncAvailabilityFilter(); + renderCatalog(); +}); + +authorFilterBar.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest("[data-filter-trigger='author']"); + if (!(button instanceof HTMLButtonElement)) { + const clearButton = event.target.closest("[data-clear-authors]"); + if (!(clearButton instanceof HTMLButtonElement)) { + return; + } + + selectedAuthors = new Set(); + syncQueryString(); + syncAuthorFilter(catalogEntries); + renderCatalog(); + return; + } + + toggleDropdown("author"); +}); + +authorFilterBar.addEventListener("change", (event) => { + if (!(event.target instanceof HTMLInputElement)) { + return; + } + + if (event.target.dataset.authorCheckbox !== "true") { + return; + } + + const value = normalizeAuthorValue(event.target.value); + if (!value) { + return; + } + + if (event.target.checked) { + selectedAuthors.add(value); + } else { + selectedAuthors.delete(value); + } + + syncQueryString(); + syncAuthorFilter(catalogEntries); + renderCatalog(); +}); + +document.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const path = typeof event.composedPath === "function" + ? event.composedPath() + : []; + + if (path.includes(availabilityFilterBar) || path.includes(authorFilterBar)) { + return; + } + + closeDropdowns(); +}); + +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeDropdowns(); + } +}); + +previewGrid.addEventListener("click", (event) => { + if (!(event.target instanceof Element)) { + return; + } + + const button = event.target.closest("[data-reset-preview]"); + if (!(button instanceof HTMLButtonElement)) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const pullRequestNumber = Number(button.dataset.resetPreview); + if (!Number.isInteger(pullRequestNumber) || pullRequestNumber <= 0) { + return; + } + + void resetPreview(pullRequestNumber); +}); + +Promise.all([loadSession(), loadCatalog()]).catch((error) => { + previewGrid.setAttribute("aria-busy", "false"); + availabilityFilterBar.setAttribute("aria-busy", "false"); + authorFilterBar.setAttribute("aria-busy", "false"); + syncAvailabilityFilter(); + syncAuthorFilter([]); + renderEmptyState(error instanceof Error ? error.message : "Couldn't load open PRs."); +}); + +setInterval(() => { + loadCatalog().catch((error) => { + console.error(error); + }); +}, 15000); + +async function loadCatalog() { + const response = await fetch("/api/previews/catalog", { + cache: "no-store", + credentials: "same-origin", + }); + + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + return; + } + + if (!response.ok) { + throw new Error(`Open PR request failed with status ${response.status}.`); + } + + const payload = await response.json(); + catalogEntries = Array.isArray(payload.entries) ? payload.entries : []; + maxActivePreviews = payload.maxActivePreviews ?? 0; + activePreviewCount = payload.activePreviewCount ?? 0; + openPullRequestCount = payload.openPullRequestCount ?? catalogEntries.length; + previewablePullRequestCount = payload.previewablePullRequestCount + ?? catalogEntries.filter(isPreviewable).length; + + authorOptions = buildAuthorOptions(catalogEntries, selectedAuthors); + syncAvailabilityFilter(); + syncAuthorFilter(catalogEntries); + renderCatalog(); +} + +async function loadSession() { + const response = await fetch("/api/previews/session", { + cache: "no-store", + credentials: "same-origin", + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error("Sign in with GitHub to view previews."); + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + throw new Error("Preview access denied."); + } + + if (!response.ok) { + throw new Error(`Session request failed with status ${response.status}.`); + } + + sessionInfo = await response.json(); + applySession(sessionInfo); +} + +function applySession(session) { + if (signoutLink && typeof session?.signOutPath === "string" && session.signOutPath) { + signoutLink.href = session.signOutPath; + signoutLink.hidden = false; + } + + if (!viewerSummary || !viewerName) { + return; + } + + const displayName = session?.viewer?.displayName || session?.viewer?.login || "Signed in"; + const login = session?.viewer?.login ? `@${session.viewer.login}` : "GitHub repo writer"; + const profileUrl = session?.viewer?.profileUrl + || (session?.viewer?.login ? `https://github.com/${session.viewer.login}` : ""); + viewerName.textContent = displayName; + viewerName.title = login; + viewerSummary.title = login; + + if (viewerSummary instanceof HTMLAnchorElement && profileUrl) { + viewerSummary.href = profileUrl; + viewerSummary.setAttribute("aria-label", `${displayName} on GitHub`); + } + + const roleElement = viewerSummary.querySelector(".viewer-role"); + if (roleElement) { + roleElement.textContent = login; + } + + if (viewerAvatar && session?.viewer?.avatarUrl) { + viewerAvatar.src = session.viewer.avatarUrl; + viewerAvatar.alt = `${displayName} avatar`; + viewerAvatar.hidden = false; + } + + viewerSummary.hidden = false; +} + +function renderCatalog() { + const filteredEntries = applyFilters(catalogEntries); + + previewGrid.setAttribute("aria-busy", "false"); + availabilityFilterBar.setAttribute("aria-busy", "false"); + authorFilterBar.setAttribute("aria-busy", "false"); + + windowCapacity.textContent = `Warm window: ${numberFormatter.format(activePreviewCount)} / ${numberFormatter.format(maxActivePreviews)}`; + windowCount.textContent = buildWindowCountText(filteredEntries); + + if (filteredEntries.length === 0) { + renderEmptyState(buildEmptyStateMessage()); + return; + } + + previewGrid.innerHTML = filteredEntries.map(renderPreviewCard).join("\n"); +} + +function renderPreviewCard(entry) { + const preview = entry.preview ?? null; + const previewPath = escapeHtml(entry.previewPath ?? "/prs/"); + const title = escapeHtml(entry.title ?? `PR #${entry.pullRequestNumber}`); + const subtitle = escapeHtml(`${getAuthorLabel(entry.authorLogin)} · Created ${formatDateOnly(entry.createdAtUtc)}`); + const stateText = escapeHtml(buildChipLabel(preview, entry)); + const stateClass = escapeHtml(getStateClass(preview, entry)); + const statusDetail = escapeHtml(buildStatusDetail(preview, entry)); + const openPullRequestAction = entry.pullRequestUrl + ? ` + + + ` + : ""; + const draftBadge = entry.isDraft + ? 'Draft' + : ""; + const footer = renderCardFooter(entry, preview); + + return ` + `; +} + +function renderEmptyState(message) { + previewGrid.setAttribute("aria-busy", "false"); + previewGrid.innerHTML = ` +
+

${escapeHtml(message)}

+

Open /prs/{number}/ to sign in and warm the latest successful frontend build.

+
`; +} + +function syncAuthorFilter(entries) { + authorOptions = buildAuthorOptions(entries, selectedAuthors); + authorFilterBar.dataset.open = openDropdown === "author" ? "true" : "false"; + authorFilterBar.innerHTML = renderDropdown({ + kind: "author", + active: selectedAuthors.size > 0, + open: openDropdown === "author", + label: buildAuthorTriggerLabel(), + meta: buildAuthorTriggerMeta(), + menuHtml: renderAuthorFilterMenu(), + }); +} + +function syncAvailabilityFilter() { + availabilityFilterBar.dataset.open = openDropdown === "availability" ? "true" : "false"; + availabilityFilterBar.innerHTML = renderDropdown({ + kind: "availability", + active: showPreviewableOnly, + open: openDropdown === "availability", + label: showPreviewableOnly ? "Previewable only" : "All previews", + meta: showPreviewableOnly + ? `${numberFormatter.format(previewablePullRequestCount)} matching open PRs` + : `${numberFormatter.format(openPullRequestCount)} open PRs`, + menuHtml: renderAvailabilityFilterMenu(), + }); +} + +function renderDropdown({ kind, active, open, label, meta, menuHtml }) { + const activeClass = active ? " is-active" : ""; + return ` + +
+ ${menuHtml} +
`; +} + +function renderAvailabilityFilterMenu() { + const checked = showPreviewableOnly ? " checked" : ""; + return ` +
+ +
`; +} + +function renderAuthorFilterMenu() { + if (authorOptions.length === 0) { + return '
No authors available.
'; + } + + const footer = selectedAuthors.size > 0 + ? ` + ` + : ""; + + return ` +
+ ${authorOptions.map(renderAuthorOption).join("\n")} +
+ ${footer}`; +} + +function renderAuthorOption(option) { + const checked = selectedAuthors.has(option.value) ? " checked" : ""; + const emptyClass = option.count === 0 ? " is-empty" : ""; + return ` + `; +} + +function renderCardFooter(entry, preview) { + const pullRequestNumber = Number(entry.pullRequestNumber); + const previewPath = escapeHtml(entry.previewPath ?? `/prs/${pullRequestNumber}/`); + const primaryLabel = escapeHtml(buildPrimaryActionLabel(preview, entry)); + const primaryClass = isPreviewable(entry) + ? "action-button primary" + : "action-button secondary"; + const isResetting = resettingPreviews.has(pullRequestNumber); + const label = isResetting ? "Resetting..." : "Reset preview"; + const disabled = isResetting ? " disabled" : ""; + const resetAction = preview + ? ` + ` + : ""; + + return ` + `; +} + +function buildAuthorOptions(entries, activeAuthors) { + const counts = new Map(); + const options = []; + + for (const entry of entries) { + const value = getAuthorValue(entry.authorLogin); + counts.set(value, (counts.get(value) ?? 0) + 1); + } + + for (const [value, count] of counts) { + options.push({ + value, + label: getAuthorLabelFromValue(value), + count, + }); + } + + for (const value of activeAuthors) { + if (counts.has(value)) { + continue; + } + + options.push({ + value, + label: getAuthorLabelFromValue(value), + count: 0, + }); + } + + return options.sort((left, right) => left.label.localeCompare(right.label, undefined, { sensitivity: "base" })); +} + +function applyFilters(entries) { + return entries.filter((entry) => { + if (showPreviewableOnly && !isPreviewable(entry)) { + return false; + } + + if (selectedAuthors.size > 0 && !selectedAuthors.has(getAuthorValue(entry.authorLogin))) { + return false; + } + + return true; + }); +} + +function getAuthorValue(authorLogin) { + return normalizeAuthorValue(authorLogin) || "unknown"; +} + +function getAuthorLabel(authorLogin) { + return getAuthorLabelFromValue(getAuthorValue(authorLogin)); +} + +function getAuthorLabelFromValue(value) { + return value === "unknown" + ? "Unknown" + : `@${value}`; +} + +function buildStatusDetail(preview, entry) { + if (!preview) { + return isPreviewable(entry) + ? "Open to warm the latest successful frontend build." + : "No successful frontend build for this head yet."; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) + ? "Open to refresh this PR to the latest successful frontend build." + : "New commits are waiting on a successful frontend build."; + } + + switch (preview.state) { + case "Ready": + return "Served from the warm window."; + case "Loading": + return preview.stage ? `${preview.stage} · ${preview.percent ?? 0}%` : `${preview.percent ?? 0}%`; + case "Registered": + return "Latest build is queued to warm."; + case "Cancelled": + return "Preparation was cancelled."; + case "Failed": + return preview.error ?? preview.message ?? "Couldn't finish preparing this build."; + case "Evicted": + return "Loads again on the next visit."; + default: + return preview.message ?? "Waiting for preview activity."; + } +} + +function buildPrimaryActionLabel(preview, entry) { + if (!preview) { + return isPreviewable(entry) ? "Prepare preview" : "View status"; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) ? "Refresh preview" : "View status"; + } + + switch (preview.state) { + case "Ready": + return "Open preview"; + case "Loading": + return "View progress"; + case "Registered": + return "Open preview"; + case "Cancelled": + case "Failed": + case "Evicted": + return "Retry preview"; + default: + return "Open preview"; + } +} + +function buildChipLabel(preview, entry) { + if (!preview) { + return isPreviewable(entry) ? "On demand" : "Waiting on CI"; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) ? "Outdated" : "Waiting on CI"; + } + + switch (preview.state) { + case "Ready": + return "Ready"; + case "Loading": + return "Preparing"; + case "Registered": + return "Queued"; + case "Cancelled": + return "Cancelled"; + case "Failed": + return "Failed"; + case "Evicted": + return "Evicted"; + default: + return preview.state ?? "Unknown"; + } +} + +function getStateClass(preview, entry) { + if (!preview) { + return isPreviewable(entry) ? "missing" : "registered"; + } + + if (preview.headSha && entry.headSha && preview.headSha !== entry.headSha) { + return isPreviewable(entry) ? "outdated" : "registered"; + } + + return String(preview.state ?? "missing").toLowerCase(); +} + +function isPreviewable(entry) { + const preview = entry.preview ?? null; + if (preview && preview.headSha && entry.headSha && preview.headSha === entry.headSha) { + return true; + } + + return entry.hasSuccessfulPreviewBuild === true; +} + +function buildWindowCountText(filteredEntries) { + if (showPreviewableOnly && selectedAuthors.size > 0) { + return `Showing ${numberFormatter.format(filteredEntries.length)} previewable PRs for ${buildSelectedAuthorSummary()}`; + } + + if (showPreviewableOnly) { + return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} previewable PRs`; + } + + if (selectedAuthors.size > 0) { + return `Showing ${numberFormatter.format(filteredEntries.length)} of ${numberFormatter.format(openPullRequestCount)} open PRs for ${buildSelectedAuthorSummary()}`; + } + + return `Open PRs: ${numberFormatter.format(openPullRequestCount)}`; +} + +function buildEmptyStateMessage() { + if (showPreviewableOnly && selectedAuthors.size > 0) { + return `No previewable PRs match ${buildSelectedAuthorSummary()}.`; + } + + if (showPreviewableOnly) { + return "No open PRs have a successful frontend build right now."; + } + + if (selectedAuthors.size > 0) { + return `No open PRs match ${buildSelectedAuthorSummary()}.`; + } + + return "No open PRs right now."; +} + +function buildAuthorTriggerLabel() { + if (selectedAuthors.size === 0) { + return "All authors"; + } + + if (selectedAuthors.size === 1) { + return getAuthorLabelFromValue([...selectedAuthors][0]); + } + + return `${numberFormatter.format(selectedAuthors.size)} authors selected`; +} + +function buildAuthorTriggerMeta() { + if (selectedAuthors.size === 0) { + if (authorOptions.length === 0) { + return "No authors available"; + } + + return authorOptions.length === 1 + ? "1 author available" + : `${numberFormatter.format(authorOptions.length)} authors available`; + } + + return selectedAuthors.size === 1 + ? "1 author selected" + : `${numberFormatter.format(selectedAuthors.size)} authors selected`; +} + +function buildAuthorOptionMeta(count) { + if (count === 0) { + return "No open PRs"; + } + + return count === 1 + ? "1 open PR" + : `${numberFormatter.format(count)} open PRs`; +} + +function buildSelectedAuthorSummary() { + const labels = [...selectedAuthors] + .map(getAuthorLabelFromValue) + .sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" })); + + if (labels.length === 0) { + return "the selected authors"; + } + + if (labels.length === 1) { + return labels[0]; + } + + if (labels.length === 2) { + return `${labels[0]} and ${labels[1]}`; + } + + return `${numberFormatter.format(labels.length)} authors`; +} + +function toggleDropdown(name) { + openDropdown = openDropdown === name ? "" : name; + syncAvailabilityFilter(); + syncAuthorFilter(catalogEntries); +} + +function closeDropdowns() { + if (!openDropdown) { + return; + } + + openDropdown = ""; + syncAvailabilityFilter(); + syncAuthorFilter(catalogEntries); +} + +function readFiltersFromQuery() { + const searchParams = new URLSearchParams(window.location.search); + const previewable = searchParams.has("previewable") + ? normalizeBooleanQuery(searchParams.get("previewable")) + : true; + const authors = new Set(); + const rawAuthors = searchParams.get("author"); + + if (rawAuthors) { + for (const value of rawAuthors.split(",")) { + const normalized = normalizeAuthorValue(value); + if (normalized) { + authors.add(normalized); + } + } + } + + return { + previewable, + authors, + }; +} + +function normalizeBooleanQuery(value) { + if (!value) { + return false; + } + + return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); +} + +function normalizeAuthorValue(value) { + const normalized = String(value ?? "") + .trim() + .toLowerCase() + .replace(/^author:/, "") + .replace(/^@/, ""); + + return normalized; +} + +function syncQueryString() { + const url = new URL(window.location.href); + + url.searchParams.set("previewable", showPreviewableOnly ? "true" : "false"); + + if (selectedAuthors.size > 0) { + url.searchParams.set("author", [...selectedAuthors].sort().join(",")); + } else { + url.searchParams.delete("author"); + } + + window.history.replaceState({}, "", `${url.pathname}${url.search}${url.hash}`); +} + +async function resetPreview(pullRequestNumber) { + if (resettingPreviews.has(pullRequestNumber)) { + return; + } + + if (!window.confirm(`Reset preview for PR #${pullRequestNumber}? This clears the cached preview so the next visit has to prepare it again.`)) { + return; + } + + resettingPreviews.add(pullRequestNumber); + renderCatalog(); + + try { + const response = await fetch(`/api/previews/${pullRequestNumber}/reset`, { + method: "POST", + credentials: "same-origin", + headers: { + "Accept": "application/json", + ...getCsrfHeaders(), + }, + }); + + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + return; + } + + if (!response.ok) { + throw new Error(`Preview reset request failed with status ${response.status}.`); + } + + await loadCatalog(); + } catch (error) { + console.error(error); + window.alert(error instanceof Error + ? error.message + : "The preview host could not reset this preview."); + } finally { + resettingPreviews.delete(pullRequestNumber); + renderCatalog(); + } +} + +function formatDateOnly(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "Waiting" : dateFormatter.format(date); +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function redirectToLogin() { + const returnUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.replace(`/auth/login?returnUrl=${encodeURIComponent(returnUrl)}`); +} + +function getCsrfHeaders() { + const csrfToken = getCsrfToken(); + return csrfToken + ? { "X-Preview-Csrf": csrfToken } + : {}; +} + +function getCsrfToken() { + const cookieParts = document.cookie.split(";"); + for (const part of cookieParts) { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName !== "previewhost-csrf" && rawName !== encodeURIComponent("previewhost-csrf")) { + continue; + } + + return decodeURIComponent(rawValue.join("=")); + } + + return ""; +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg b/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg new file mode 100644 index 000000000..56f14e989 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/left-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/preview.css b/src/statichost/PreviewHost/wwwroot/_preview/preview.css new file mode 100644 index 000000000..385df9efb --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/preview.css @@ -0,0 +1,1159 @@ +:root { + color-scheme: dark; + --bg: #1f1e33; + --bg-deep: #181825; + --bg-accent: #231d41; + --panel: rgba(24, 24, 37, 0.94); + --panel-strong: rgba(35, 29, 65, 0.76); + --panel-border: rgba(160, 164, 171, 0.22); + --text: #ffffff; + --muted: #c6c8cc; + --muted-strong: #eceef1; + --line: rgba(160, 164, 171, 0.16); + --accent: #7455dd; + --accent-strong: #512bd4; + --accent-soft: #c6c2f2; + --danger: #f65163; + --warning: #e9b44c; + --shadow: rgba(10, 11, 20, 0.46); +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at top, rgba(116, 85, 221, 0.14), transparent 24%), + linear-gradient(180deg, var(--bg-deep) 0%, var(--bg) 18%, var(--bg) 100%); + color: var(--text); + font-family: "Poppins", "Segoe UI", system-ui, sans-serif; +} + +body[data-view="status"] { + display: grid; + align-content: start; + justify-items: center; + padding: 2rem 1rem 3rem; +} + +body[data-view="index"] { + padding: 2rem 1rem 3rem; +} + +.page-chrome { + width: min(42rem, 100%); + margin-bottom: 1rem; +} + +.page-chrome-actions, +.collection-actions, +.session-actions { + display: flex; + align-items: center; + gap: 0.75rem; + min-width: 0; +} + +.page-chrome-actions { + flex-wrap: wrap; + justify-content: space-between; +} + +.collection-actions, +.session-actions { + flex-wrap: nowrap; + justify-content: flex-end; +} + +.page-shell { + width: min(42rem, 100%); +} + +.page-shell-wide { + width: min(72rem, 100%); + margin: 0 auto; +} + +.header-copy { + display: grid; + gap: 0.85rem; +} + +.brand-lockup { + display: inline-flex; + align-items: center; + gap: 0.8rem; + width: fit-content; + color: var(--text); + text-decoration: none; +} + +.brand-mark { + width: 2.5rem; + height: 2.5rem; + flex: none; + filter: drop-shadow(0 0.4rem 1.1rem rgba(81, 43, 212, 0.42)); +} + +.brand-text { + display: grid; + gap: 0.05rem; +} + +.brand-title { + font-size: 1.05rem; + line-height: 1; +} + +.brand-subtitle { + color: var(--muted); + font-size: 0.82rem; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.nav-button, +.action-link, +.action-button, +.preview-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 0.75rem; + border: 1px solid var(--panel-border); + text-decoration: none; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.nav-button, +.action-link, +.action-button { + min-height: 2.55rem; + padding: 0.62rem 0.9rem; + color: var(--text); + background: rgba(24, 24, 37, 0.84); + font: inherit; + font-size: 0.95rem; + font-weight: 600; + white-space: nowrap; +} + +.action-link-github { + background: linear-gradient(180deg, rgba(53, 44, 103, 0.92), rgba(35, 29, 65, 0.92)); + box-shadow: 0 0.9rem 1.8rem rgba(10, 11, 20, 0.2); + cursor: pointer; +} + +.action-link-compact { + padding-inline: 0.9rem; +} + +.action-link-icon-only { + width: 2.55rem; + min-width: 2.55rem; + padding: 0; +} + +.action-link-quiet { + color: var(--muted-strong); + background: rgba(24, 24, 37, 0.62); +} + +.session-toolbar .action-link-github { + margin-left: auto; +} + +.action-button.primary { + border-color: rgba(198, 194, 242, 0.35); + background: linear-gradient(180deg, var(--accent), var(--accent-strong)); + box-shadow: 0 1rem 2rem rgba(81, 43, 212, 0.28); +} + +.action-icon { + width: 1rem; + height: 1rem; + flex: none; +} + +.action-icon-github { + background-color: currentColor; + -webkit-mask-image: url("/_preview/github-mark.svg"); + mask-image: url("/_preview/github-mark.svg"); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +.action-icon-left-arrow, +.action-icon-retry { + background-color: currentColor; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: contain; + mask-size: contain; +} + +.action-icon-left-arrow { + -webkit-mask-image: url("/_preview/left-arrow.svg"); + mask-image: url("/_preview/left-arrow.svg"); +} + +.action-icon-retry { + -webkit-mask-image: url("/_preview/retry-arrow.svg"); + mask-image: url("/_preview/retry-arrow.svg"); +} + +.nav-button:hover, +.action-link:hover, +.action-button:hover:not(:disabled), +.preview-link:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.55); +} + +.action-button { + cursor: pointer; +} + +.action-button.secondary { + background: var(--panel-strong); +} + +.action-button.danger { + border-color: rgba(246, 81, 99, 0.28); + background: rgba(246, 81, 99, 0.12); +} + +.action-button:disabled { + cursor: wait; + opacity: 0.7; +} + +.status-card, +.preview-card, +.empty-card { + border-radius: 0.9rem; + border: 1px solid var(--panel-border); + background: + linear-gradient(180deg, rgba(198, 194, 242, 0.05), transparent 20%), + var(--panel); + box-shadow: 0 1.5rem 3rem var(--shadow); + backdrop-filter: blur(12px); +} + +.status-card { + position: relative; + padding: 1.75rem; +} + +.card-busy-indicator { + position: absolute; + top: 1.5rem; + right: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.88); + box-shadow: 0 0.75rem 1.5rem rgba(10, 11, 20, 0.24); + pointer-events: none; +} + +.card-spinner { + width: 1rem; + height: 1rem; + border-radius: 999px; + border: 2px solid rgba(198, 194, 242, 0.2); + border-top-color: var(--accent); + border-right-color: var(--accent-soft); + animation: status-spinner 0.9s linear infinite; +} + +.card-header, +.collection-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.card-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; +} + +.eyebrow { + margin: 0; + color: var(--accent-soft); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.preview-title { + margin-top: 0.35rem; + font-size: 1.35rem; +} + +h1, +h2 { + margin: 0; + line-height: 1.08; +} + +h1 { + font-size: clamp(2rem, 4vw, 3rem); +} + +h2 { + font-size: 1.2rem; +} + +p { + line-height: 1.6; +} + +.status-message { + margin: 1rem 0 0; + color: var(--muted); + font-size: 1rem; +} + +.status-message.error { + color: var(--danger); +} + +.progress-section { + display: grid; + gap: 1rem; + margin-top: 1.5rem; +} + +.progress-card, +.terminal-section { + border-radius: 1rem; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.82); + padding: 1rem; +} + +.progress-header { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.75rem; + color: var(--muted); +} + +.progress-header strong { + color: var(--text); +} + +.progress-track { + overflow: hidden; + border-radius: 999px; + background: rgba(53, 56, 62, 0.56); + padding: 0.3rem; +} + +.progress-bar { + height: 0.8rem; + width: 0%; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent-strong), var(--accent)); + transition: width 0.2s ease; +} + +.progress-track.stage .progress-bar { + background: linear-gradient(90deg, rgba(233, 180, 76, 0.96), rgba(198, 194, 242, 0.92)); +} + +.terminal-section { + display: grid; + gap: 0.75rem; + justify-items: start; + margin-top: 1.5rem; +} + +.terminal-summary, +.hint-text, +.collection-summary { + margin: 0; + color: var(--muted); +} + +.terminal-summary.error { + color: var(--danger); +} + +.detail-grid { + margin: 1.5rem 0 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.detail-grid dt, +.preview-meta dt { + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.detail-grid dd, +.preview-meta dd { + margin: 0.35rem 0 0; +} + +.hint-text { + margin-top: 1.25rem; +} + +.status-card-actions { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 1rem; + margin-top: 1.5rem; + padding-top: 1.25rem; + border-top: 1px solid var(--line); +} + +.status-card-secondary-actions, +.status-card-primary-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.status-card-primary-actions { + margin-left: auto; + justify-content: flex-end; +} + +.status-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.86); + color: var(--text); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.status-chip.ready { + border-color: rgba(198, 194, 242, 0.3); + background: rgba(116, 85, 221, 0.2); +} + +.status-chip.loading, +.status-chip.registered { + border-color: rgba(116, 85, 221, 0.28); + background: rgba(81, 43, 212, 0.22); +} + +.status-chip.failed, +.status-chip.cancelled, +.status-chip.evicted { + border-color: rgba(246, 81, 99, 0.24); + background: rgba(246, 81, 99, 0.14); +} + +.status-chip.missing { + border-color: rgba(233, 180, 76, 0.22); + background: rgba(233, 180, 76, 0.14); +} + +.status-chip.outdated, +.status-chip.draft { + border-color: rgba(233, 180, 76, 0.22); + background: rgba(233, 180, 76, 0.14); +} + +.viewer-summary { + display: inline-flex; + align-items: center; + gap: 0.6rem; + min-height: 2.55rem; + max-width: min(100%, 16rem); + padding: 0.35rem 0.75rem 0.35rem 0.45rem; + border-radius: 0.95rem; + border: 1px solid var(--panel-border); + background: rgba(24, 24, 37, 0.84); + box-shadow: 0 0.75rem 1.4rem rgba(10, 11, 20, 0.18); + min-width: 0; + color: var(--text); + text-decoration: none; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.viewer-summary:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.4); + background: rgba(35, 29, 65, 0.82); +} + +.viewer-summary:focus-visible { + outline: 2px solid rgba(198, 194, 242, 0.45); + outline-offset: 2px; +} + +.viewer-avatar { + width: 1.75rem; + height: 1.75rem; + border-radius: 999px; + object-fit: cover; + border: 1px solid rgba(198, 194, 242, 0.24); + flex: none; +} + +.viewer-copy { + display: flex; + align-items: baseline; + gap: 0.4rem; + min-width: 0; +} + +.viewer-copy strong { + min-width: 0; + font-size: 0.92rem; + line-height: 1.1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.viewer-role { + color: var(--muted); + font-size: 0.82rem; + line-height: 1.2; + flex: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.collection-meta { + display: grid; + align-content: start; + justify-items: end; + gap: 0.5rem; + text-align: right; + min-width: 0; +} + +.collection-stats { + display: grid; + gap: 0.2rem; + justify-items: end; +} + +.session-toolbar { + justify-content: flex-end; +} + +.collection-controls { + display: flex; + justify-content: flex-start; + flex-wrap: wrap; + align-items: flex-start; + gap: 1rem; + margin-top: 1rem; +} + +.filter-stack { + display: grid; + gap: 0.55rem; + min-width: min(100%, 16rem); +} + +.filter-label { + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.filter-dropdown { + position: relative; + width: min(100%, 18rem); +} + +.filter-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.9rem; + width: 100%; + min-height: 2.85rem; + padding: 0.65rem 0.85rem; + border-radius: 0.95rem; + border: 1px solid var(--panel-border); + background: rgba(24, 24, 37, 0.88); + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.filter-trigger:hover:not(:disabled) { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.42); + background: rgba(35, 29, 65, 0.84); + box-shadow: 0 0.9rem 1.8rem rgba(10, 11, 20, 0.22); +} + +.filter-trigger:focus-visible { + outline: 2px solid rgba(198, 194, 242, 0.45); + outline-offset: 2px; +} + +.filter-trigger.is-active, +.filter-dropdown[data-open="true"] .filter-trigger { + border-color: rgba(198, 194, 242, 0.35); + background: linear-gradient(180deg, rgba(53, 44, 103, 0.92), rgba(35, 29, 65, 0.92)); +} + +.filter-trigger:disabled { + cursor: wait; +} + +.filter-trigger-copy { + display: grid; + gap: 0.2rem; + min-width: 0; +} + +.filter-trigger-value, +.filter-trigger-meta { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.filter-trigger-value { + font-weight: 600; +} + +.filter-trigger-meta { + color: var(--muted); + font-size: 0.84rem; +} + +.filter-trigger-icon { + width: 0.7rem; + height: 0.7rem; + margin-right: 0.15rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg) translateY(-1px); + opacity: 0.78; + transition: transform 0.18s ease; + flex: none; +} + +.filter-dropdown[data-open="true"] .filter-trigger-icon { + transform: rotate(225deg) translateY(-1px); +} + +.filter-trigger-loading { + position: relative; + overflow: hidden; + opacity: 0.9; +} + +.filter-menu { + position: absolute; + top: calc(100% + 0.55rem); + left: 0; + z-index: 20; + display: grid; + gap: 0.35rem; + width: min(22rem, calc(100vw - 1.8rem)); + padding: 0.55rem; + border-radius: 1rem; + border: 1px solid rgba(198, 194, 242, 0.18); + background: linear-gradient(180deg, rgba(31, 30, 51, 0.98), rgba(24, 24, 37, 0.98)); + box-shadow: 0 1.35rem 2.7rem rgba(10, 11, 20, 0.38); +} + +.filter-menu[hidden] { + display: none; +} + +.filter-menu-section { + display: grid; + gap: 0.15rem; +} + +.filter-option { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: start; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 0.85rem; + cursor: pointer; + transition: background 0.18s ease; +} + +.filter-option:hover { + background: rgba(53, 44, 103, 0.34); +} + +.filter-option-input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + +.filter-option-box { + width: 1.05rem; + height: 1.05rem; + margin-top: 0.08rem; + border-radius: 0.32rem; + border: 1px solid rgba(198, 194, 242, 0.28); + background: rgba(24, 24, 37, 0.9); + box-shadow: inset 0 0 0 1px rgba(10, 11, 20, 0.16); + transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease; +} + +.filter-option-input:focus-visible + .filter-option-box { + outline: 2px solid rgba(198, 194, 242, 0.45); + outline-offset: 2px; +} + +.filter-option-input:checked + .filter-option-box { + position: relative; + border-color: rgba(198, 194, 242, 0.35); + background: linear-gradient(180deg, var(--accent), var(--accent-strong)); + box-shadow: 0 0 0 1px rgba(116, 85, 221, 0.14); +} + +.filter-option-input:checked + .filter-option-box::after { + content: ""; + position: absolute; + top: 0.15rem; + left: 0.34rem; + width: 0.22rem; + height: 0.45rem; + border: solid rgba(255, 255, 255, 0.94); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.filter-option-copy { + display: grid; + gap: 0.18rem; + min-width: 0; +} + +.filter-option-title { + color: var(--text); + font-weight: 600; +} + +.filter-option-meta, +.filter-option-count, +.filter-menu-empty, +.filter-menu-hint { + color: var(--muted); + font-size: 0.84rem; +} + +.filter-option.is-empty .filter-option-count { + opacity: 0.7; +} + +.filter-option-count { + align-self: center; +} + +.filter-menu-empty { + padding: 0.75rem; +} + +.filter-menu-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; + padding: 0.35rem 0.75rem 0.15rem; +} + +.filter-menu-action { + border: none; + background: none; + padding: 0; + color: var(--accent); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.filter-menu-action:hover { + color: var(--accent-strong); +} + +.preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 1rem; + margin-top: 1.5rem; +} + +.preview-card, +.empty-card { + padding: 1.15rem; +} + +.preview-card { + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; +} + +.preview-card:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.36); + box-shadow: 0 1rem 2rem rgba(10, 11, 20, 0.34); +} + +.preview-card-skeleton { + pointer-events: none; +} + +.preview-card-skeleton:hover { + transform: none; + border-color: var(--panel-border); + box-shadow: 0 1.5rem 3rem var(--shadow); +} + +.preview-card-topline { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: flex-start; +} + +.preview-card-tools { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: 0.65rem; + border: 1px solid var(--panel-border); + background: rgba(24, 24, 37, 0.84); + color: var(--text); + text-decoration: none; + transition: border-color 0.18s ease, transform 0.18s ease, background 0.18s ease; +} + +.icon-button:hover { + transform: translateY(-1px); + border-color: rgba(198, 194, 242, 0.42); + background: rgba(35, 29, 65, 0.82); +} + +.preview-card-link { + display: grid; + gap: 0.55rem; + margin-top: 0.35rem; + color: inherit; + text-decoration: none; +} + +.preview-card-link:hover .preview-title { + color: var(--accent); +} + +.skeleton-line, +.skeleton-chip { + position: relative; + overflow: hidden; + border-radius: 999px; + background: rgba(198, 194, 242, 0.09); +} + +.skeleton-line::after, +.skeleton-chip::after, +.filter-trigger-loading::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent 0%, + rgba(198, 194, 242, 0.08) 30%, + rgba(198, 194, 242, 0.22) 50%, + rgba(198, 194, 242, 0.08) 70%, + transparent 100% + ); + animation: skeleton-shimmer 1.35s ease-in-out infinite; +} + +.skeleton-line-eyebrow { + width: 5.5rem; + height: 0.9rem; +} + +.skeleton-line-title { + width: 82%; + height: 1.35rem; + margin-top: 0.35rem; +} + +.skeleton-line-subtitle { + width: 70%; + height: 0.95rem; + margin-top: 0.85rem; +} + +.skeleton-line-status { + width: 52%; + height: 0.95rem; +} + +.skeleton-chip { + width: 4.75rem; + height: 2rem; + flex: none; +} + +.preview-title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + margin-top: 0.15rem; +} + +.preview-link { + color: var(--text); + background: transparent; + border: none; + padding: 0; + min-height: 0; + font-size: 1.15rem; + font-weight: 700; + justify-content: flex-start; +} + +.preview-path { + margin: 0.45rem 0 0; + color: var(--muted); + font-family: "Fira Code", "Cascadia Code", Consolas, monospace; + font-size: 0.92rem; +} + +.preview-card-subtitle { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +.preview-card-status-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem; +} + +.preview-status-detail { + color: var(--muted); + font-size: 0.95rem; +} + +.preview-card-footer { + display: grid; + gap: 0.9rem; + margin-top: 1rem; +} + +.preview-card-meta { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; +} + +.preview-meta-pill { + display: inline-flex; + align-items: center; + min-height: 2rem; + padding: 0.35rem 0.7rem; + border-radius: 0.6rem; + border: 1px solid var(--line); + background: rgba(24, 24, 37, 0.82); + color: var(--muted); + font-size: 0.88rem; +} + +.preview-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0; +} + +.preview-card-reset { + width: 100%; + min-height: 2.4rem; + padding: 0.6rem 0.85rem; + justify-content: center; + font-size: 0.92rem; +} + +.preview-meta { + margin: 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem 1rem; +} + +code { + border-radius: 0.4rem; + background: rgba(35, 29, 65, 0.72); + padding: 0.15rem 0.35rem; + font-family: Consolas, "Cascadia Code", monospace; +} + +@keyframes skeleton-shimmer { + 100% { + transform: translateX(100%); + } +} + +@keyframes status-spinner { + 100% { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + body[data-view="status"], + body[data-view="index"] { + padding: 1rem 0.9rem 2rem; + } + + .status-card { + padding: 1.25rem; + } + + .card-busy-indicator { + top: 1rem; + right: 1rem; + } + + .card-header, + .collection-header { + flex-direction: column; + } + + .card-actions, + .collection-controls, + .collection-meta { + justify-content: flex-start; + justify-items: start; + text-align: left; + } + + .page-chrome-actions, + .collection-actions, + .session-actions { + width: 100%; + flex-wrap: wrap; + justify-content: flex-start; + } + + .session-toolbar { + justify-content: flex-start; + } + + .viewer-summary { + max-width: 100%; + } + + .viewer-copy { + flex-wrap: wrap; + } + + .viewer-role { + white-space: normal; + } + + .collection-stats { + justify-items: start; + } + + .status-card-actions { + flex-direction: column; + align-items: stretch; + } + + .status-card-secondary-actions, + .status-card-primary-actions { + width: 100%; + } + + .status-card-primary-actions { + margin-left: 0; + justify-content: flex-start; + } + + .status-card-actions .action-link, + .status-card-actions .action-button { + width: 100%; + } + + .detail-grid, + .preview-meta { + grid-template-columns: 1fr; + } + + .preview-card-topline { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg b/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg new file mode 100644 index 000000000..b1f17ad24 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/retry-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.html b/src/statichost/PreviewHost/wwwroot/_preview/status.html new file mode 100644 index 000000000..f8a825d89 --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.html @@ -0,0 +1,127 @@ + + + + + + Aspire PR Preview + + + + + + +
+
+ + +
+ +
+ +

+ + + + + +
+
+
Status
+
Preparing
+
+
+
Overall
+
0%
+
+
+
Preview path
+
/prs/
+
+
+
Updated
+
Waiting
+
+ + +
+ +

+ + +
+
+ + + + diff --git a/src/statichost/PreviewHost/wwwroot/_preview/status.js b/src/statichost/PreviewHost/wwwroot/_preview/status.js new file mode 100644 index 000000000..b7f7d4d4a --- /dev/null +++ b/src/statichost/PreviewHost/wwwroot/_preview/status.js @@ -0,0 +1,724 @@ +const routeMatch = window.location.pathname.match(/^\/prs\/(\d+)(?:\/.*)?$/i); +const pullRequestNumber = routeMatch ? Number(routeMatch[1]) : null; +const refreshTarget = pullRequestNumber ? `/prs/${pullRequestNumber}/` : "/prs/"; + +const pageTitle = document.getElementById("page-title"); +const message = document.getElementById("message"); +const progressSection = document.getElementById("progress-section"); +const terminalSection = document.getElementById("terminal-section"); +const terminalChip = document.getElementById("terminal-chip"); +const terminalSummary = document.getElementById("terminal-summary"); +const statusValue = document.getElementById("status-value"); +const overallSummary = document.getElementById("overall-summary"); +const previewPath = document.getElementById("preview-path"); +const updatedAt = document.getElementById("updated-at"); +const cardBusyIndicator = document.getElementById("card-busy-indicator"); +const downloadDetailGroup = document.getElementById("download-detail-group"); +const downloadDetail = document.getElementById("download-detail"); +const extractDetailGroup = document.getElementById("extract-detail-group"); +const extractDetail = document.getElementById("extract-detail"); +const hint = document.getElementById("hint"); +const overallProgressValue = document.getElementById("overall-progress-value"); +const stageProgressLabel = document.getElementById("stage-progress-label"); +const stageProgressValue = document.getElementById("stage-progress-value"); +const overallProgressBar = document.getElementById("overall-progress-bar"); +const stageProgressBar = document.getElementById("stage-progress-bar"); +const statusCardActions = document.getElementById("status-card-actions"); +const openPrLink = document.getElementById("open-pr-link"); +const cancelButton = document.getElementById("cancel-button"); +const retryButton = document.getElementById("retry-button"); +const retryButtonLabel = retryButton?.querySelector(".button-label"); +const signoutLink = document.getElementById("signout-link"); +const viewerSummary = document.getElementById("viewer-summary"); +const viewerAvatar = document.getElementById("viewer-avatar"); +const viewerName = document.getElementById("viewer-name"); + +const numberFormatter = new Intl.NumberFormat(); +const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); +const clockFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", +}); + +let currentState = null; +let eventSource = null; +let prepareInFlight = false; +let cancelInFlight = false; +let suppressCloseCancellation = false; +let closeCancellationSent = false; +let sessionInfo = null; +const downloadRateTracker = createRateTracker(); +const extractionRateTracker = createRateTracker(); + +initializeShell(); +window.addEventListener("pagehide", cancelIfClosingDuringPreparation); +window.addEventListener("beforeunload", cancelIfClosingDuringPreparation); +document.addEventListener("click", preservePreparationWhenReturningToCatalog); + +void initialize(); + +async function initialize() { + try { + await loadSession(); + await preparePreview(); + } catch (error) { + applyState(buildFailureState(error instanceof Error ? error.message : "Couldn't load preview status.")); + } +} + +cancelButton.addEventListener("click", async () => { + if (!pullRequestNumber || cancelInFlight) { + return; + } + + cancelInFlight = true; + updateActionButtons(); + + try { + const response = await fetch(`/api/previews/${pullRequestNumber}/cancel`, { + method: "POST", + cache: "no-store", + credentials: "same-origin", + headers: getCsrfHeaders({ + "Accept": "application/json", + }), + }); + + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + return; + } + + if (!response.ok) { + throw new Error(`Cancel failed with status ${response.status}.`); + } + + closeEventSource(); + applyState(await response.json()); + } catch (error) { + hint.textContent = error instanceof Error + ? error.message + : "Couldn't cancel this prep run."; + } finally { + cancelInFlight = false; + updateActionButtons(); + } +}); + +retryButton.addEventListener("click", async () => { + try { + await preparePreview(); + } catch (error) { + hint.textContent = error instanceof Error + ? error.message + : "Couldn't restart prep."; + } +}); + +async function loadSession() { + const response = await fetch("/api/previews/session", { + cache: "no-store", + credentials: "same-origin", + }); + + if (response.status === 401) { + redirectToLogin(); + throw new Error("Sign in with GitHub to prepare this preview."); + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + throw new Error("Preview access denied."); + } + + if (!response.ok) { + throw new Error(`Session request failed with status ${response.status}.`); + } + + sessionInfo = await response.json(); + applySession(sessionInfo); +} + +function applySession(session) { + if (signoutLink && typeof session?.signOutPath === "string" && session.signOutPath) { + signoutLink.href = session.signOutPath; + signoutLink.hidden = false; + } + + if (!viewerSummary || !viewerName) { + return; + } + + const displayName = session?.viewer?.displayName || session?.viewer?.login || "Signed in"; + const login = session?.viewer?.login ? `@${session.viewer.login}` : "GitHub repo writer"; + const profileUrl = session?.viewer?.profileUrl + || (session?.viewer?.login ? `https://github.com/${session.viewer.login}` : ""); + viewerName.textContent = displayName; + viewerName.title = login; + viewerSummary.title = login; + + if (viewerSummary instanceof HTMLAnchorElement && profileUrl) { + viewerSummary.href = profileUrl; + viewerSummary.setAttribute("aria-label", `${displayName} on GitHub`); + } + + const roleElement = viewerSummary.querySelector(".viewer-role"); + if (roleElement) { + roleElement.textContent = login; + } + + if (viewerAvatar && session?.viewer?.avatarUrl) { + viewerAvatar.src = session.viewer.avatarUrl; + viewerAvatar.alt = `${displayName} avatar`; + viewerAvatar.hidden = false; + } + + viewerSummary.hidden = false; +} + +async function preparePreview() { + if (!pullRequestNumber || prepareInFlight) { + return; + } + + prepareInFlight = true; + updateActionButtons(); + suppressCloseCancellation = false; + closeEventSource(); + + try { + const response = await fetch(`/api/previews/${pullRequestNumber}/prepare`, { + method: "POST", + cache: "no-store", + credentials: "same-origin", + headers: getCsrfHeaders({ + "Accept": "application/json", + }), + }); + const payload = await readJsonSafely(response); + + if (response.status === 401) { + redirectToLogin(); + return; + } + + if (response.status === 403) { + window.location.replace("/auth/access-denied"); + return; + } + + if (response.status === 404) { + applyState(buildFailureState( + payload?.failureMessage ?? "No successful frontend build found for this PR yet.", + payload ?? {}, + )); + return; + } + + if (!response.ok) { + throw new Error(`Preview request failed with status ${response.status}.`); + } + + applyState(payload); + connectEventsIfNeeded(payload); + } finally { + prepareInFlight = false; + updateActionButtons(); + } +} + +function connectEventsIfNeeded(snapshot) { + if (!pullRequestNumber || !isActiveState(snapshot.state)) { + return; + } + + eventSource = new EventSource(`/api/previews/${pullRequestNumber}/events`); + eventSource.onmessage = (event) => { + const snapshotUpdate = JSON.parse(event.data); + applyState(snapshotUpdate); + + if (snapshotUpdate.isReady || isTerminalState(snapshotUpdate.state)) { + closeEventSource(); + } + }; + + eventSource.onerror = () => { + if (currentState && isActiveState(currentState.state)) { + hint.textContent = "Connection interrupted. The page will reconnect while the preview is still preparing."; + } + }; +} + +function applyState(snapshot) { + currentState = snapshot; + closeCancellationSent = false; + + if (snapshot.isReady) { + suppressCloseCancellation = true; + closeEventSource(); + window.location.replace(buildContentUrl(snapshot.previewPath ?? refreshTarget)); + return; + } + + const statusLabel = getStatusLabel(snapshot.state); + const titleLabel = getTitle(snapshot); + const overallPercent = snapshot.percent ?? 0; + const stagePercent = snapshot.stagePercent ?? 0; + const activeState = isActiveState(snapshot.state); + const terminalState = isTerminalState(snapshot.state) || snapshot.state === "Missing"; + const text = formatMessage(snapshot.error ?? snapshot.message ?? snapshot.failureMessage ?? "Waiting for preview status."); + const downloadRate = updateRateTracker( + downloadRateTracker, + activeState && snapshot.stage === "Downloading", + snapshot.bytesDownloaded, + ); + const extractionRate = updateRateTracker( + extractionRateTracker, + activeState && snapshot.itemsLabel === "files", + snapshot.itemsCompleted, + ); + const downloadText = getDownloadText(snapshot, downloadRate); + const extractionText = getExtractionText(snapshot, extractionRate); + + document.title = titleLabel; + pageTitle.textContent = titleLabel; + message.textContent = text; + message.hidden = terminalState; + message.classList.toggle("error", terminalState && snapshot.state !== "Cancelled"); + progressSection.hidden = !activeState; + terminalSection.hidden = !terminalState; + cardBusyIndicator.hidden = !activeState; + statusValue.textContent = statusLabel; + overallSummary.textContent = activeState ? `${overallPercent}%` : statusLabel; + previewPath.textContent = snapshot.previewPath ?? refreshTarget; + updatedAt.textContent = snapshot.updatedAtUtc ? formatUpdated(snapshot.updatedAtUtc) : "Waiting"; + downloadDetailGroup.hidden = !downloadText; + downloadDetail.textContent = downloadText ?? "Waiting"; + extractDetailGroup.hidden = !extractionText; + extractDetail.textContent = extractionText ?? "Waiting"; + hint.textContent = getHint(snapshot); + + overallProgressValue.textContent = `${overallPercent}%`; + stageProgressLabel.textContent = getStageProgressLabel(snapshot); + stageProgressValue.textContent = `${stagePercent}%`; + overallProgressBar.style.width = `${overallPercent}%`; + stageProgressBar.style.width = `${stagePercent}%`; + + terminalChip.textContent = statusLabel; + terminalChip.className = `status-chip ${getStateClassName(snapshot.state)}`; + terminalSummary.textContent = terminalState ? text : ""; + terminalSummary.classList.toggle("error", terminalState && snapshot.state !== "Cancelled"); + + updateOpenPrLink(snapshot); + updateActionButtons(); +} + +function initializeShell() { + if (!pullRequestNumber) { + return; + } + + const initialTitle = `Preparing PR #${pullRequestNumber}`; + document.title = initialTitle; + pageTitle.textContent = initialTitle; + message.textContent = "Checking GitHub for the latest successful frontend artifact you can preview."; + message.hidden = false; + cardBusyIndicator.hidden = false; + previewPath.textContent = refreshTarget; + hint.textContent = "This page prepares the latest successful build for this PR every time you open it."; +} + +function updateOpenPrLink(snapshot) { + const repositoryOwner = snapshot.repositoryOwner; + const repositoryName = snapshot.repositoryName; + if (!repositoryOwner || !repositoryName || !pullRequestNumber) { + openPrLink.hidden = true; + return; + } + + openPrLink.href = `https://github.com/${repositoryOwner}/${repositoryName}/pull/${pullRequestNumber}`; + openPrLink.hidden = false; +} + +function updateActionButtons() { + const canCancel = currentState && isActiveState(currentState.state); + const canRetry = currentState && (isTerminalState(currentState.state) || currentState.state === "Missing"); + const retryLabel = currentState?.state === "Missing" + ? "Check latest build" + : "Retry"; + + cancelButton.hidden = !canCancel && !cancelInFlight; + cancelButton.disabled = !canCancel || cancelInFlight; + cancelButton.textContent = cancelInFlight ? "Cancelling..." : "Cancel"; + + retryButton.hidden = !canRetry && !prepareInFlight; + retryButton.disabled = prepareInFlight || (!canRetry && !prepareInFlight); + if (retryButtonLabel) { + retryButtonLabel.textContent = prepareInFlight ? "Checking..." : retryLabel; + } + + if (statusCardActions) { + statusCardActions.hidden = openPrLink.hidden && cancelButton.hidden && retryButton.hidden; + } +} + +function buildFailureState(messageText, payload = {}) { + return { + pullRequestNumber: payload.pullRequestNumber ?? pullRequestNumber ?? 0, + state: payload.state ?? "Missing", + stage: payload.stage ?? "Failed", + message: payload.message ?? messageText, + error: payload.error ?? messageText, + percent: payload.percent ?? 0, + stagePercent: payload.stagePercent ?? 100, + bytesDownloaded: payload.bytesDownloaded ?? null, + bytesTotal: payload.bytesTotal ?? null, + itemsCompleted: payload.itemsCompleted ?? null, + itemsTotal: payload.itemsTotal ?? null, + itemsLabel: payload.itemsLabel ?? null, + updatedAtUtc: payload.updatedAtUtc ?? new Date().toISOString(), + previewPath: payload.previewPath ?? refreshTarget, + repositoryOwner: payload.repositoryOwner ?? null, + repositoryName: payload.repositoryName ?? null, + isReady: false, + }; +} + +async function readJsonSafely(response) { + try { + return await response.json(); + } catch { + return null; + } +} + +function closeEventSource() { + if (eventSource) { + eventSource.close(); + eventSource = null; + } +} + +function preservePreparationWhenReturningToCatalog(event) { + if (!(event.target instanceof Element) || event.defaultPrevented) { + return; + } + + const link = event.target.closest("a[data-preserve-preparation='true']"); + if (!(link instanceof HTMLAnchorElement)) { + return; + } + + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + + if (link.target && link.target !== "_self") { + return; + } + + suppressCloseCancellation = true; +} + +function cancelIfClosingDuringPreparation() { + if (!pullRequestNumber + || suppressCloseCancellation + || closeCancellationSent + || prepareInFlight + || cancelInFlight + || !currentState + || !isActiveState(currentState.state)) { + return; + } + + const csrfToken = getCsrfToken(); + if (!csrfToken) { + return; + } + + closeCancellationSent = true; + const cancelUrl = `/api/previews/${pullRequestNumber}/cancel`; + const payload = new URLSearchParams({ + "__RequestVerificationToken": csrfToken, + }); + + if (typeof navigator.sendBeacon === "function") { + navigator.sendBeacon(cancelUrl, payload); + return; + } + + fetch(cancelUrl, { + method: "POST", + cache: "no-store", + credentials: "same-origin", + keepalive: true, + headers: { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + }, + body: payload, + }).catch(() => { + }); +} + +function isActiveState(state) { + return state === "Registered" || state === "Loading"; +} + +function isTerminalState(state) { + return state === "Failed" || state === "Cancelled" || state === "Evicted"; +} + +function getStatusLabel(state) { + switch (state) { + case "Registered": + return "Queued"; + case "Loading": + return "Preparing"; + case "Ready": + return "Ready"; + case "Failed": + return "Failed"; + case "Cancelled": + return "Cancelled"; + case "Evicted": + return "Evicted"; + case "Missing": + return "Unavailable"; + default: + return state ?? "Preparing"; + } +} + +function getTitle(snapshot) { + const number = snapshot.pullRequestNumber ?? pullRequestNumber ?? "?"; + if (snapshot.state === "Failed" || snapshot.state === "Missing") { + return `PR #${number} unavailable`; + } + + if (snapshot.state === "Cancelled") { + return `PR #${number} cancelled`; + } + + if (snapshot.state === "Ready") { + return `Opening PR #${number} preview`; + } + + return `Preparing PR #${number}`; +} + +function getHint(snapshot) { + if (snapshot.state === "Missing") { + return "Wait for CI to publish a successful frontend build, then check again."; + } + + if (snapshot.state === "Failed") { + return "Retry to fetch the latest successful build again."; + } + + if (snapshot.state === "Cancelled") { + return "Retry when you're ready to prepare the latest build again."; + } + + if (snapshot.state === "Evicted") { + return "Retry to move this PR back into the warm preview window."; + } + + return "This page will open the preview automatically as soon as preparation finishes."; +} + +function getStateClassName(state) { + return String(state ?? "missing").toLowerCase(); +} + +function getStageProgressLabel(snapshot) { + if (snapshot.stage === "Downloading") { + return "Downloading build"; + } + + if (snapshot.stage === "Extracting" && snapshot.itemsLabel === "files") { + return "Extracting files"; + } + + return snapshot.stage ? `${snapshot.stage} progress` : "Stage"; +} + +function formatMessage(text) { + return String(text).replace(/\d{4,}/g, (value) => numberFormatter.format(Number(value))); +} + +function formatUpdated(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) + ? value + : `${dateFormatter.format(date)} · ${clockFormatter.format(date)}`; +} + +function getDownloadText(snapshot, bytesPerSecond) { + if (!Number.isFinite(snapshot.bytesDownloaded) && !Number.isFinite(snapshot.bytesTotal)) { + return null; + } + + const parts = []; + + if (Number.isFinite(snapshot.bytesDownloaded) && Number.isFinite(snapshot.bytesTotal) && snapshot.bytesTotal > 0) { + parts.push(`${formatBytes(snapshot.bytesDownloaded)} / ${formatBytes(snapshot.bytesTotal)}`); + } else if (Number.isFinite(snapshot.bytesDownloaded)) { + parts.push(`${formatBytes(snapshot.bytesDownloaded)} downloaded`); + } + + if (snapshot.stage === "Downloading" && Number.isFinite(bytesPerSecond) && bytesPerSecond > 0) { + parts.push(`${formatBytes(bytesPerSecond)}/s`); + } + + return parts.join(" · "); +} + +function getExtractionText(snapshot, itemsPerSecond) { + if (snapshot.itemsLabel !== "files" || !Number.isFinite(snapshot.itemsCompleted) && !Number.isFinite(snapshot.itemsTotal)) { + return null; + } + + const completed = Number.isFinite(snapshot.itemsCompleted) + ? numberFormatter.format(snapshot.itemsCompleted) + : "0"; + const total = Number.isFinite(snapshot.itemsTotal) && snapshot.itemsTotal > 0 + ? ` / ${numberFormatter.format(snapshot.itemsTotal)}` + : ""; + + const parts = [`${completed}${total} files`]; + if (Number.isFinite(itemsPerSecond) && itemsPerSecond > 0) { + parts.push(`${formatUnitRate(itemsPerSecond)} files/s`); + } + + return parts.join(" · "); +} + +function formatBytes(value) { + const bytes = Number(value); + if (!Number.isFinite(bytes) || bytes < 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB", "TB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex += 1; + } + + const digits = size >= 100 || unitIndex === 0 ? 0 : size >= 10 ? 1 : 2; + return `${size.toFixed(digits)} ${units[unitIndex]}`; +} + +function createRateTracker() { + return { + samples: [], + lastRate: null, + lastRateUpdatedAt: 0, + }; +} + +function updateRateTracker(tracker, isActive, value) { + const now = Date.now(); + + if (!isActive || !Number.isFinite(value) || Number(value) < 0) { + tracker.samples = []; + return now - tracker.lastRateUpdatedAt <= 10000 + ? tracker.lastRate + : null; + } + + const numericValue = Number(value); + const lastSample = tracker.samples[tracker.samples.length - 1]; + + if (lastSample && numericValue < lastSample.value) { + tracker.samples = []; + } + + if (!lastSample || numericValue !== lastSample.value || now - lastSample.time >= 500) { + tracker.samples.push({ time: now, value: numericValue }); + } + + while (tracker.samples.length > 0 && now - tracker.samples[0].time > 8000) { + tracker.samples.shift(); + } + + const firstSample = tracker.samples[0]; + const newestSample = tracker.samples[tracker.samples.length - 1]; + if (!firstSample || !newestSample || newestSample.time <= firstSample.time || newestSample.value <= firstSample.value) { + return now - tracker.lastRateUpdatedAt <= 10000 + ? tracker.lastRate + : null; + } + + tracker.lastRate = (newestSample.value - firstSample.value) / ((newestSample.time - firstSample.time) / 1000); + tracker.lastRateUpdatedAt = now; + return tracker.lastRate; +} + +function formatUnitRate(value) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue) || numericValue <= 0) { + return "0"; + } + + if (numericValue >= 100) { + return numberFormatter.format(Math.round(numericValue)); + } + + if (numericValue >= 10) { + return numericValue.toFixed(1); + } + + return numericValue.toFixed(2); +} + +function buildContentUrl(relativePath) { + try { + return new URL(relativePath, sessionInfo?.contentBaseUrl ?? window.location.origin).toString(); + } catch { + return relativePath; + } +} + +function redirectToLogin() { + const returnUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; + window.location.replace(`/auth/login?returnUrl=${encodeURIComponent(returnUrl)}`); +} + +function getCsrfHeaders(additionalHeaders = {}) { + const headers = { ...additionalHeaders }; + const csrfToken = getCsrfToken(); + if (csrfToken) { + headers["X-Preview-Csrf"] = csrfToken; + } + + return headers; +} + +function getCsrfToken() { + const encodedName = encodeURIComponent("previewhost-csrf"); + const parts = document.cookie.split(";"); + for (const part of parts) { + const [rawName, ...rawValue] = part.trim().split("="); + if (rawName !== encodedName && rawName !== "previewhost-csrf") { + continue; + } + + return decodeURIComponent(rawValue.join("=")); + } + + return ""; +}