Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 118 additions & 33 deletions server/handlers/cms/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
*
* GET /admin/api/cms/export
* POST /admin/api/cms/export
* POST /admin/api/cms/export/estimate → { bytes } (size only, no download)
*
* Returns a `SiteBundle` JSON that captures a full or partial site state:
* - optionally the lean site shell (breakpoints, settings, classes, files, runtime)
* - selected (or all) data tables
* - selected (or all) non-deleted data rows
* - optionally: non-deleted media assets with bytes embedded as base64
*
* The `/export/estimate` path runs the IDENTICAL selection logic but reports
* only the bundle's byte size — without reading media files off disk or
* base64-encoding them. The estimate is therefore exact (it serializes the
* real selection and adds each asset's Base64 length analytically), so the
* "Estimated size" line in the dialog can never drift from the real download.
*
* Filter options (GET → query string, POST → JSON body via ExportRequestSchema):
* tables — comma-separated table ids (GET) or string[] (POST); default all
* rowIds — comma-separated row ids (GET) or string[] (POST); default all
Expand Down Expand Up @@ -39,13 +46,61 @@ import { CMS_API_PREFIX, type CmsHandlerOptions } from './shared'
import { ExportRequestSchema, type SiteBundle } from '@core/data/bundleSchema'
import { canSeeAllDataRows } from './data/access'

const EXPORT_PATH = `${CMS_API_PREFIX}/export`
const EXPORT_ESTIMATE_PATH = `${CMS_API_PREFIX}/export/estimate`

/** A media asset row enriched with its storage path, as loaded for export. */
type ExportableAsset = Awaited<ReturnType<typeof listMediaAssetsForExport>>[number]

/** Bundle media entry without the heavy `bytesBase64` payload. */
type MediaEntryMetadata = Omit<NonNullable<SiteBundle['media']>[number], 'bytesBase64'>

/**
* The metadata fields of a bundle media entry — everything except the Base64
* payload. Shared by the real export (which appends the encoded bytes) and the
* estimate (which appends an empty string and sizes the bytes analytically).
*/
function mediaEntryMetadata(asset: ExportableAsset): MediaEntryMetadata {
return {
id: asset.id,
filename: asset.filename,
mimeType: asset.mimeType,
sizeBytes: asset.sizeBytes,
altText: asset.altText,
caption: asset.caption,
title: asset.title,
tags: asset.tags,
width: asset.width,
height: asset.height,
durationMs: asset.durationMs,
dominantColor: asset.dominantColor,
blurHash: asset.blurHash,
storagePath: asset.storagePath,
posterPath: asset.posterPath,
}
}

/** Exact length of the Base64 encoding (with padding) of `n` raw bytes. */
function base64Length(n: number): number {
return Math.ceil(n / 3) * 4
}

interface ExportSelection {
shell: NonNullable<Awaited<ReturnType<typeof getDraftSite>>>
tables: Awaited<ReturnType<typeof listDataTables>>
rows: Awaited<ReturnType<typeof listDataRows>>
includeMedia: boolean
includeSite: boolean
}

export async function handleExportRoute(
req: Request,
db: DbClient,
options: CmsHandlerOptions = {},
): Promise<Response | null> {
const url = new URL(req.url)
if (url.pathname !== `${CMS_API_PREFIX}/export`) return null
const isEstimate = url.pathname === EXPORT_ESTIMATE_PATH
if (url.pathname !== EXPORT_PATH && !isEstimate) return null
if (req.method !== 'GET' && req.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 })
}
Expand Down Expand Up @@ -111,32 +166,58 @@ export async function handleExportRoute(
tables = tables.filter((t) => referencedTableIds.has(t.id))
}

// Optionally embed media bytes
const selection: ExportSelection = { shell, tables, rows, includeMedia, includeSite }

// Media is embedded only when requested AND an uploads dir is configured —
// both the estimate and the real export gate on this so they stay in sync.
const wantMedia = includeMedia && Boolean(options.uploadsDir)
const assets = wantMedia ? await listMediaAssetsForExport(db) : []

if (isEstimate) {
return estimateResponse(selection, wantMedia, assets)
}

return downloadResponse(selection, wantMedia, assets, options.uploadsDir)
}

/**
* Compute the exact bundle byte size without reading media files. Serializes
* the real selection with empty `bytesBase64` strings, then adds each asset's
* Base64-encoded length — which is precisely what would fill those strings.
* (Base64 is ASCII, so its JSON-string length equals its byte length.)
*/
function estimateResponse(
selection: ExportSelection,
wantMedia: boolean,
assets: ExportableAsset[],
): Response {
const mediaSkeleton: SiteBundle['media'] = wantMedia
? assets.map((asset) => ({ ...mediaEntryMetadata(asset), bytesBase64: '' }))
: undefined

const skeleton = buildBundle(selection, mediaSkeleton)
const structuralBytes = Buffer.byteLength(JSON.stringify(skeleton), 'utf8')
const mediaBytes = wantMedia
? assets.reduce((sum, asset) => sum + base64Length(asset.sizeBytes), 0)
: 0

return jsonResponse({ bytes: structuralBytes + mediaBytes })
}

/** Build the full bundle (reading + base64-encoding media bytes) and stream it as a download. */
async function downloadResponse(
selection: ExportSelection,
wantMedia: boolean,
assets: ExportableAsset[],
uploadsDir: string | undefined,
): Promise<Response> {
let media: SiteBundle['media']
if (includeMedia && options.uploadsDir) {
const assets = await listMediaAssetsForExport(db)
if (wantMedia && uploadsDir) {
const mediaItems = await Promise.all(
assets.map(async (asset) => {
try {
const bytes = await readFile(join(options.uploadsDir!, asset.storagePath))
return {
id: asset.id,
filename: asset.filename,
mimeType: asset.mimeType,
sizeBytes: asset.sizeBytes,
altText: asset.altText,
caption: asset.caption,
title: asset.title,
tags: asset.tags,
width: asset.width,
height: asset.height,
durationMs: asset.durationMs,
dominantColor: asset.dominantColor,
blurHash: asset.blurHash,
storagePath: asset.storagePath,
posterPath: asset.posterPath,
bytesBase64: bytes.toString('base64'),
}
const bytes = await readFile(join(uploadsDir, asset.storagePath))
return { ...mediaEntryMetadata(asset), bytesBase64: bytes.toString('base64') }
} catch {
// If a file is missing from disk, skip the asset rather than aborting
// the entire export. The import will recreate metadata rows but without
Expand All @@ -148,16 +229,7 @@ export async function handleExportRoute(
media = mediaItems.filter((item): item is NonNullable<typeof item> => item !== null)
}

const bundle: SiteBundle = {
schemaVersion: 1,
exportedAt: new Date().toISOString(),
sourceSiteName: shell.name,
...(includeSite ? { site: shell } : {}),
tables,
rows,
...(media !== undefined ? { media } : {}),
}

const bundle = buildBundle(selection, media)
const json = JSON.stringify(bundle)
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:.]/g, '-')
return new Response(json, {
Expand All @@ -167,3 +239,16 @@ export async function handleExportRoute(
},
})
}

/** Assemble the `SiteBundle` from a selection plus an already-resolved media array. */
function buildBundle(selection: ExportSelection, media: SiteBundle['media']): SiteBundle {
return {
schemaVersion: 1,
exportedAt: new Date().toISOString(),
sourceSiteName: selection.shell.name,
...(selection.includeSite ? { site: selection.shell } : {}),
tables: selection.tables,
rows: selection.rows,
...(media !== undefined ? { media } : {}),
}
}
32 changes: 32 additions & 0 deletions src/__tests__/admin/data/exportDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,38 @@ describe('ExportDialog', () => {
}
})

it('estimate is fetched from the server and grows when media is toggled on', async () => {
// The estimate endpoint reports a size that depends on includeMedia, so we
// can assert the dialog re-requests and re-renders when the toggle flips.
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString()
if (url === '/admin/api/cms/export/estimate') {
const body = JSON.parse((init?.body as string) ?? '{}') as { includeMedia?: boolean }
return jsonResponse({ bytes: body.includeMedia ? 5_000_000 : 12_000 })
}
return jsonResponse({ error: `Unexpected request: ${url}` }, 500)
}

render(
<ExportDialog
open={true}
onClose={() => {}}
tables={[POSTS_TABLE]}
/>,
)

// Initial (media off) estimate resolves to ~12 KB.
await waitFor(() => {
expect(screen.getByText(/estimated size: ~12 KB/i)).toBeTruthy()
})

// Flip media on → the dialog re-requests and the estimate jumps to MB.
fireEvent.click(screen.getByRole('switch', { name: /include media files/i }))
await waitFor(() => {
expect(screen.getByText(/estimated size: ~4\.8 MB/i)).toBeTruthy()
})
})

it('Cancel button calls onClose', () => {
let onCloseCalled = false
render(
Expand Down
96 changes: 96 additions & 0 deletions src/__tests__/architecture/cmsTransferExport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,16 @@
*/

import { describe, test, expect, beforeAll } from 'bun:test'
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createSqliteClient } from '../../../server/db/sqlite'
import { runMigrations } from '../../../server/db/runMigrations'
import { sqliteMigrations } from '../../../server/db/migrations-sqlite'
import { saveDraftSite } from '../../../server/repositories/site'
import { createUser } from '../../../server/repositories/users'
import { createSession } from '../../../server/auth/sessions'
import { createMediaAsset } from '../../../server/repositories/media'
import {
createSessionToken,
hashSessionToken,
Expand Down Expand Up @@ -390,3 +394,95 @@ describe('handleExportRoute — auth', () => {
expect(res!.status).toBe(401)
})
})

// ---------------------------------------------------------------------------
// Estimate endpoint — must equal the real download size exactly, because both
// run the same selection logic. The estimate just skips reading media bytes
// and sizes them analytically (Base64 length) instead.
// ---------------------------------------------------------------------------

async function estimateBytes(path: string, body: unknown, cookieStr: string, opts?: { uploadsDir?: string }): Promise<number> {
const res = await handleExportRoute(makePostRequest(path, cookieStr, body), db, opts)
expect(res!.status).toBe(200)
const parsed = JSON.parse(await res!.text()) as { bytes: number }
return parsed.bytes
}

describe('handleExportRoute — POST /export/estimate', () => {
test('estimate equals the real download byte length exactly (no media)', async () => {
const dl = await handleExportRoute(makePostRequest('/admin/api/cms/export', cookie, {}), db)
const realBytes = Buffer.byteLength(await dl!.text(), 'utf8')

const bytes = await estimateBytes('/admin/api/cms/export/estimate', {}, cookie)
expect(bytes).toBe(realBytes)
})

test('estimate drops the shell cost when includeSite is false, still matching the real download', async () => {
const withSite = await estimateBytes('/admin/api/cms/export/estimate', { includeSite: true }, cookie)
const withoutSite = await estimateBytes('/admin/api/cms/export/estimate', { includeSite: false }, cookie)
expect(withoutSite).toBeLessThan(withSite)

const realNoSite = await handleExportRoute(
makePostRequest('/admin/api/cms/export', cookie, { includeSite: false }),
db,
)
expect(withoutSite).toBe(Buffer.byteLength(await realNoSite!.text(), 'utf8'))
})

test('requires a session (401 without a cookie)', async () => {
const req = new Request('http://localhost/admin/api/cms/export/estimate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}',
})
const res = await handleExportRoute(req, db)
expect(res!.status).toBe(401)
})
})

describe('handleExportRoute — POST /export/estimate with embedded media', () => {
test('estimate equals the real download byte length exactly, including Base64 media bytes', async () => {
const mediaDb = createSqliteClient(':memory:')
await runMigrations(mediaDb, sqliteMigrations)
const mediaCookie = await seedAuth(mediaDb)

const uploadsDir = await mkdtemp(join(tmpdir(), 'instatic-export-estimate-'))
try {
// Seed one media asset whose bytes live on disk. 5000 raw bytes → a
// Base64 payload that isn't a clean multiple of 3 (exercises padding).
const fileBytes = Buffer.alloc(5000, 7)
await writeFile(join(uploadsDir, 'seed.bin'), fileBytes)
await createMediaAsset(mediaDb, {
id: 'asset-1',
filename: 'seed.bin',
mimeType: 'application/octet-stream',
sizeBytes: fileBytes.length,
storagePath: 'seed.bin',
publicPath: '/uploads/seed.bin',
uploadedByUserId: null,
storageAdapterId: '',
externallyHosted: false,
})

const dl = await handleExportRoute(
makePostRequest('/admin/api/cms/export', mediaCookie, { includeMedia: true }),
mediaDb,
{ uploadsDir },
)
const realBytes = Buffer.byteLength(await dl!.text(), 'utf8')

const estRes = await handleExportRoute(
makePostRequest('/admin/api/cms/export/estimate', mediaCookie, { includeMedia: true }),
mediaDb,
{ uploadsDir },
)
const { bytes } = JSON.parse(await estRes!.text()) as { bytes: number }

expect(bytes).toBe(realBytes)
// Sanity: media actually dominated (the asset's bytes are present).
expect(bytes).toBeGreaterThan(5000)
} finally {
await rm(uploadsDir, { recursive: true, force: true })
}
})
})
Loading