diff --git a/src/apphost/Aspire.Dev.AppHost/AppHost.cs b/src/apphost/Aspire.Dev.AppHost/AppHost.cs index bc378be07..fca623280 100644 --- a/src/apphost/Aspire.Dev.AppHost/AppHost.cs +++ b/src/apphost/Aspire.Dev.AppHost/AppHost.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; + var builder = DistributedApplication.CreateBuilder(args); var staticHostWebsite = builder.AddProject("aspiredev") @@ -5,6 +8,114 @@ if (builder.ExecutionContext.IsRunMode) { + var liveDevCommandSecret = builder.AddParameter("live-dev-command-secret", LiveDevCommands.NewSecret, secret: true); + var liveDevTwitchWebhookSecret = builder.AddParameter("live-dev-twitch-webhook-secret", LiveDevCommands.NewSecret, secret: true); + var liveDevYouTubeWebhookSecret = builder.AddParameter("live-dev-youtube-webhook-secret", LiveDevCommands.NewSecret, secret: true); + + staticHostWebsite + // Local AppHost runs are the explicit live-status dev mode: external providers stay idle + // when no API credentials are configured, but dashboard commands can still exercise the + // UI and webhook paths with per-run local-only signing secrets. + .WithEnvironment("Live__EnableDevEndpoint", "true") + .WithEnvironment("Live__DevCommandSecret", liveDevCommandSecret) + .WithEnvironment("Live__Twitch__WebhookSecret", liveDevTwitchWebhookSecret) + .WithEnvironment("Live__YouTube__WebhookSecret", liveDevYouTubeWebhookSecret) + .WithUrlForEndpoint("http", static url => url.DisplayText = "aspire.dev (StaticHost)") + .WithUrls(ctx => + { + if (ctx.Resource is not IResourceWithEndpoints withEndpoints) + { + return; + } + + var endpoint = withEndpoints.GetEndpoint("http"); + if (endpoint is null) + { + return; + } + + ctx.Urls.Add(new() { Url = "/api/live", DisplayText = "Live status (JSON)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/stream", DisplayText = "Live status (SSE stream)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/twitch/webhook", DisplayText = "Twitch EventSub webhook (POST)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/youtube/webhook", DisplayText = "YouTube WebSub webhook (GET/POST)", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/api/live/_dev/set", DisplayText = "Live dev override", Endpoint = endpoint }); + ctx.Urls.Add(new() { Url = "/scalar/v1", DisplayText = "API reference (Scalar)", Endpoint = endpoint }); + }) + .WithHttpCommand( + path: "/api/live/_dev/set", + displayName: "Live: all offline", + endpointName: "http", + commandName: "live-dev-all-offline", + commandOptions: LiveDevCommands.SetStatus( + liveDevCommandSecret, + "Turns off both local live-status sources.", + """ + { + "twitch": { "live": false, "channel": null, "title": null }, + "youtube": { "live": false, "videoId": null } + } + """, + iconName: "LiveOff")) + .WithHttpCommand( + path: "/api/live/twitch/webhook", + displayName: "Simulate Twitch online webhook", + endpointName: "http", + commandName: "live-dev-twitch-online-webhook", + commandOptions: LiveDevCommands.TwitchWebhook( + liveDevCommandSecret, + liveDevTwitchWebhookSecret, + "Sends a signed stream.online notification to the local Twitch EventSub endpoint.", + "stream.online")) + .WithHttpCommand( + path: "/api/live/twitch/webhook", + displayName: "Simulate Twitch offline webhook", + endpointName: "http", + commandName: "live-dev-twitch-offline-webhook", + commandOptions: LiveDevCommands.TwitchWebhook( + liveDevCommandSecret, + liveDevTwitchWebhookSecret, + "Sends a signed stream.offline notification to the local Twitch EventSub endpoint.", + "stream.offline")) + .WithHttpCommand( + path: "/api/live/youtube/webhook", + displayName: "Simulate YouTube WebSub webhook", + endpointName: "http", + commandName: "live-dev-youtube-websub-webhook", + commandOptions: LiveDevCommands.YouTubeWebhook( + liveDevCommandSecret, + liveDevYouTubeWebhookSecret, + "Sends a signed Atom notification to the local YouTube WebSub endpoint. In dev mode without a YouTube API key, the payload directly sets YouTube live.", + videoId: "dev-live-video")) + .WithHttpCommand( + path: "/api/live/_dev/set", + displayName: "Live: YouTube offline", + endpointName: "http", + commandName: "live-dev-youtube-offline", + commandOptions: LiveDevCommands.SetStatus( + liveDevCommandSecret, + "Turns off only the local YouTube live-status source.", + """ + { + "youtube": { "live": false, "videoId": null } + } + """, + iconName: "VideoOff")) + .WithHttpCommand( + path: "/api/live/_dev/set", + displayName: "Live: both online", + endpointName: "http", + commandName: "live-dev-both-online", + commandOptions: LiveDevCommands.SetStatus( + liveDevCommandSecret, + "Turns on both local live-status sources without going through provider webhook validation.", + """ + { + "twitch": { "live": true, "channel": "aspiredotdev", "title": "Local dashboard test" }, + "youtube": { "live": true, "videoId": "dev-live-video" } + } + """, + iconName: "Live")); + // For local development: Use ViteApp for hot reload and development experience builder.AddViteApp("frontend", "../../frontend") .WithPnpm() @@ -20,3 +131,141 @@ } builder.Build().Run(); + +internal static class LiveDevCommands +{ + public const string CommandSecretHeaderName = "X-Aspire-Live-Dev-Command-Key"; + + public static string NewSecret() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); + + public static HttpCommandOptions SetStatus( + IResourceBuilder commandSecret, + string description, + string body, + string iconName, + bool isHighlighted = false) => + new() + { + Method = HttpMethod.Post, + Description = description, + IconName = iconName, + IconVariant = IconVariant.Regular, + IsHighlighted = isHighlighted, + PrepareRequest = async context => + { + await AddCommandSecretAsync(context, commandSecret).ConfigureAwait(false); + context.Request.Content = Json(body, "application/json"); + }, + }; + + public static HttpCommandOptions TwitchWebhook( + IResourceBuilder commandSecret, + IResourceBuilder webhookSecret, + string description, + string subscriptionType) => + new() + { + Method = HttpMethod.Post, + Description = description, + IconName = subscriptionType == "stream.online" ? "PlugConnected" : "PlugDisconnected", + IconVariant = IconVariant.Regular, + PrepareRequest = async context => + { + var body = $$""" + { + "subscription": { "type": "{{subscriptionType}}" }, + "event": { + "broadcaster_user_id": "dev-aspire", + "broadcaster_user_login": "aspiredotdev", + "broadcaster_user_name": "Aspire", + "started_at": "{{DateTimeOffset.UtcNow:O}}" + } + } + """; + + var messageId = Guid.NewGuid().ToString("N"); + var timestamp = DateTimeOffset.UtcNow.ToString("O"); + var bodyBytes = Encoding.UTF8.GetBytes(body); + var secret = await GetRequiredSecretAsync(webhookSecret, context.CancellationToken).ConfigureAwait(false); + var signature = SignTwitch(secret, messageId, timestamp, bodyBytes); + + await AddCommandSecretAsync(context, commandSecret).ConfigureAwait(false); + context.Request.Headers.Add("Twitch-Eventsub-Message-Id", messageId); + context.Request.Headers.Add("Twitch-Eventsub-Message-Timestamp", timestamp); + context.Request.Headers.Add("Twitch-Eventsub-Message-Type", "notification"); + context.Request.Headers.Add("Twitch-Eventsub-Message-Signature", $"sha256={signature}"); + context.Request.Content = Json(body, "application/json"); + }, + }; + + public static HttpCommandOptions YouTubeWebhook( + IResourceBuilder commandSecret, + IResourceBuilder webhookSecret, + string description, + string videoId) => + new() + { + Method = HttpMethod.Post, + Description = description, + IconName = "ArrowSync", + IconVariant = IconVariant.Regular, + PrepareRequest = async context => + { + var body = $$""" + + + + {{videoId}} + Local Aspire live-status test + + + + """; + + var bodyBytes = Encoding.UTF8.GetBytes(body); + var secret = await GetRequiredSecretAsync(webhookSecret, context.CancellationToken).ConfigureAwait(false); + var signature = SignYouTube(secret, bodyBytes); + await AddCommandSecretAsync(context, commandSecret).ConfigureAwait(false); + context.Request.Headers.Add("X-Hub-Signature", $"sha1={signature}"); + context.Request.Content = Json(body, "application/atom+xml"); + }, + }; + + private static async Task AddCommandSecretAsync( + HttpCommandRequestContext context, + IResourceBuilder commandSecret) + { + var secret = await GetRequiredSecretAsync(commandSecret, context.CancellationToken).ConfigureAwait(false); + context.Request.Headers.Add(CommandSecretHeaderName, $"Key: {secret}"); + } + + private static async ValueTask GetRequiredSecretAsync( + IResourceBuilder parameter, + CancellationToken cancellationToken) + { + var value = await parameter.Resource.GetValueAsync(cancellationToken).ConfigureAwait(false); + return string.IsNullOrEmpty(value) + ? throw new InvalidOperationException($"Required parameter '{parameter.Resource.Name}' did not produce a value.") + : value; + } + + private static StringContent Json(string body, string mediaType) => + new(body, Encoding.UTF8, mediaType); + + private static string SignTwitch(string secret, string messageId, string timestamp, byte[] body) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); + var messageBytes = Encoding.UTF8.GetBytes(messageId); + hmac.TransformBlock(messageBytes, 0, messageBytes.Length, null, 0); + var timestampBytes = Encoding.UTF8.GetBytes(timestamp); + hmac.TransformBlock(timestampBytes, 0, timestampBytes.Length, null, 0); + hmac.TransformFinalBlock(body, 0, body.Length); + return Convert.ToHexStringLower(hmac.Hash!); + } + + private static string SignYouTube(string secret, byte[] body) + { + using var hmac = new HMACSHA1(Encoding.UTF8.GetBytes(secret)); + return Convert.ToHexStringLower(hmac.ComputeHash(body)); + } +} diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index cb6aa8081..82734c919 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -23,9 +23,11 @@ import jopSoftwarecookieconsent from '@jop-software/astro-cookieconsent'; const modeArgIndex = process.argv.indexOf('--mode'); const isSkipSearchBuild = modeArgIndex >= 0 && process.argv[modeArgIndex + 1] === 'skip-search'; +const outDir = process.env.ASTRO_OUT_DIR; // https://astro.build/config export default defineConfig({ + ...(outDir ? { outDir } : {}), prefetch: true, site: 'https://aspire.dev', trailingSlash: 'always', diff --git a/src/frontend/config/sidebar/community.topics.ts b/src/frontend/config/sidebar/community.topics.ts index 98d57d11c..7666cb6fe 100644 --- a/src/frontend/config/sidebar/community.topics.ts +++ b/src/frontend/config/sidebar/community.topics.ts @@ -128,23 +128,23 @@ export const communityTopics: StarlightSidebarTopicsUserConfig = { ], }, { - label: 'Videos', + label: 'Live Streams', translations: { - da: 'Videoer', - de: 'Videos', - en: 'Videos', - es: 'Videos', - fr: 'Vidéos', - hi: 'वीडियो', - id: 'Video', - it: 'Video', - ja: '動画', - ko: '비디오', - 'pt-BR': 'Vídeos', - ru: 'Видео', - tr: 'Videolar', - uk: 'Відео', - 'zh-CN': '视频', + da: 'Livestreams', + de: 'Livestreams', + en: 'Live Streams', + es: 'Transmisiones en directo', + fr: 'Diffusions en direct', + hi: 'लाइव स्ट्रीम', + id: 'Siaran langsung', + it: 'Dirette streaming', + ja: 'ライブ配信', + ko: '라이브 스트림', + 'pt-BR': 'Transmissões ao vivo', + ru: 'Прямые эфиры', + tr: 'Canlı Yayınlar', + uk: 'Прямі трансляції', + 'zh-CN': '直播', }, slug: 'community/videos', }, diff --git a/src/frontend/package.json b/src/frontend/package.json index 6eaceca2b..e9868b34f 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -24,6 +24,8 @@ "build": "pnpm git-env && astro build", "build:skip-search": "pnpm git-env && astro build --mode skip-search", "build:production": "pnpm git-env && astro build --mode production", + "build:statichost": "node ./scripts/build-static-host.mjs", + "build:statichost:skip-search": "node ./scripts/build-static-host.mjs --skip-search", "preview": "astro preview", "preview:host": "astro preview --host", "astro": "pnpm git-env && astro", diff --git a/src/frontend/scripts/build-static-host.mjs b/src/frontend/scripts/build-static-host.mjs new file mode 100644 index 000000000..ebd6f9460 --- /dev/null +++ b/src/frontend/scripts/build-static-host.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { execSync } from 'node:child_process'; +import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const frontendDir = resolve(scriptDir, '..'); +const staticHostWwwroot = resolve(frontendDir, '..', 'statichost', 'StaticHost', 'wwwroot'); +const tempDir = mkdtempSync(join(tmpdir(), 'aspire-statichost-wwwroot-')); +const skipSearch = process.argv.includes('--skip-search'); + +const gitignoreContents = `# Ignore local copies of src/frontend/dist used for StaticHost smoke testing. +* +!.gitignore +!index.html +!scalar/ +!scalar/** +`; + +function preserve(path, name) { + if (!existsSync(path)) return; + cpSync(path, join(tempDir, name), { recursive: true }); +} + +function restore(path, name) { + const preserved = join(tempDir, name); + if (!existsSync(preserved)) return; + cpSync(preserved, path, { recursive: true }); +} + +function run(command, env = process.env) { + execSync(command, { + cwd: frontendDir, + env, + stdio: 'inherit', + }); +} + +try { + preserve(join(staticHostWwwroot, 'scalar'), 'scalar'); + preserve(join(staticHostWwwroot, '.gitignore'), '.gitignore'); + + run('pnpm git-env'); + run( + `pnpm exec astro build${skipSearch ? ' --mode skip-search' : ''}`, + { + ...process.env, + ASTRO_OUT_DIR: staticHostWwwroot, + }, + ); + + restore(join(staticHostWwwroot, 'scalar'), 'scalar'); + const gitignorePath = join(staticHostWwwroot, '.gitignore'); + restore(gitignorePath, '.gitignore'); + if (!existsSync(gitignorePath)) { + writeFileSync(gitignorePath, gitignoreContents); + } +} finally { + rmSync(tempDir, { recursive: true, force: true }); +} diff --git a/src/frontend/src/assets/icons/live.svg b/src/frontend/src/assets/icons/live.svg new file mode 100644 index 000000000..8bed4e72e --- /dev/null +++ b/src/frontend/src/assets/icons/live.svg @@ -0,0 +1 @@ + diff --git a/src/frontend/src/components/LivePip.astro b/src/frontend/src/components/LivePip.astro new file mode 100644 index 000000000..00df09e8b --- /dev/null +++ b/src/frontend/src/components/LivePip.astro @@ -0,0 +1,817 @@ +--- +import { Icon } from '@astrojs/starlight/components'; +import LiveSvg from '@assets/icons/live.svg'; + +/** + * Site-global native Document Picture-in-Picture controller. + * + * The header live icon remains a normal link when we're offline. When we're + * live, clicking the icon opens a compact action dialog so visitors can choose + * native PiP, the aspire.dev embeds, a provider site, or silence the strobe. + * Closing the native PiP window never redirects. + */ +--- + + + + + + diff --git a/src/frontend/src/components/LiveVideosTabs.astro b/src/frontend/src/components/LiveVideosTabs.astro new file mode 100644 index 000000000..13c7b5d6a --- /dev/null +++ b/src/frontend/src/components/LiveVideosTabs.astro @@ -0,0 +1,110 @@ +--- +import { Tabs, TabItem } from '@astrojs/starlight/components'; +import YouTubeEmbed from '@components/YouTubeEmbed.astro'; +import TwitchEmbed from '@components/TwitchEmbed.astro'; + +export interface Props { + youtubeChannelId: string; + twitchChannel: string; +} + +const { youtubeChannelId, twitchChannel } = Astro.props; +--- + +
+ + +
+ +
+

When we’re not live, this shows the channel placeholder. Browse all past streams on youtube.com/@aspiredotdev.

+
+ +
+ +
+

When we’re not live, the Twitch player shows the offline screen. Follow twitch.tv/{twitchChannel} to get notified.

+
+
+
+ + + + diff --git a/src/frontend/src/components/TwitchEmbed.astro b/src/frontend/src/components/TwitchEmbed.astro index efcfbdb75..d8c0d394e 100644 --- a/src/frontend/src/components/TwitchEmbed.astro +++ b/src/frontend/src/components/TwitchEmbed.astro @@ -4,8 +4,8 @@ export interface Props { channel?: string; /** Twitch video ID for a past broadcast (e.g. "1234567890") */ video?: string; - /** Accessible title for the iframe */ - title?: string; + /** Accessible label for the iframe */ + title?: string | null; /** Aspect ratio — default 16/9 */ aspectRatio?: string; /** Parent domain(s) required by Twitch embed — defaults to current site origin */ @@ -44,7 +44,7 @@ if (video) {