Site builder: security & data-loss hardening + unified asset picker + audit backlog #3

Merged
jknapp merged 61 commits from builder-hardening-2026-07 into main 2026-07-13 01:13:28 +00:00
2 changed files with 269 additions and 84 deletions
Showing only changes of commit 4b36ce0d6a - Show all commits
+90
View File
@@ -0,0 +1,90 @@
import { describe, test, expect } from 'vitest';
import { buildSavePayload } from './useWhpApi';
import { PageData } from '../types';
const pageA: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: 'STORED_HOME', headCode: '' };
const pageB: PageData = { id: 'page_2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT', headCode: '' };
const headerPage: PageData = { id: '__header__', name: 'Header', slug: '__header__', craftState: 'STALE_HEADER', headCode: '' };
const footerPage: PageData = { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: 'STALE_FOOTER', headCode: '' };
describe('buildSavePayload', () => {
test('editing header: live serialize lands in header_craft_state, not in any page slot', () => {
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_HEADER',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: '__header__',
isEditingHeader: true,
isEditingFooter: false,
});
// The fresh live canvas must be reflected in header_craft_state, not the stale stored one.
expect(payload.header_craft_state).toBe('LIVE_HEADER');
expect(payload.header_craft_state).not.toBe('STALE_HEADER');
// Footer must remain untouched (stored state, since we're not editing it).
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
// Live header content must never leak into a page slot or the top-level page fields.
expect(payload.craft_state).not.toBe('LIVE_HEADER');
for (const p of payload.pages_craft_state) {
expect(p.craftState).not.toBe('LIVE_HEADER');
}
// Top-level page fields should fall back to the landing page's own stored state.
expect(payload.craft_state).toBe('STORED_HOME');
// Per-page slots must reflect each page's own stored state, untouched.
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('STORED_HOME');
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT');
});
test('editing footer: live serialize lands in footer_craft_state, not in any page slot', () => {
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_FOOTER',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: '__footer__',
isEditingHeader: false,
isEditingFooter: true,
});
expect(payload.footer_craft_state).toBe('LIVE_FOOTER');
expect(payload.footer_craft_state).not.toBe('STALE_FOOTER');
expect(payload.header_craft_state).toBe('STALE_HEADER');
expect(payload.craft_state).not.toBe('LIVE_FOOTER');
for (const p of payload.pages_craft_state) {
expect(p.craftState).not.toBe('LIVE_FOOTER');
}
});
test('editing a real page: behavior unchanged — live serialize goes to that page + top-level, header/footer come from stored state', () => {
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_PAGE_HOME',
pages: [pageA, pageB],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
});
// Live canvas goes to the active page and top-level slots.
expect(payload.craft_state).toBe('LIVE_PAGE_HOME');
expect(payload.pages_craft_state.find((p) => p.id === 'home')?.craftState).toBe('LIVE_PAGE_HOME');
// Other pages keep their stored state.
expect(payload.pages_craft_state.find((p) => p.id === 'page_2')?.craftState).toBe('STORED_ABOUT');
// Header/footer come from stored (stale-but-correct, since we're not editing them) state.
expect(payload.header_craft_state).toBe('STALE_HEADER');
expect(payload.footer_craft_state).toBe('STALE_FOOTER');
});
});
+138 -43
View File
@@ -3,21 +3,87 @@ import { useEditor } from '@craftjs/core';
import { useEditorConfig } from '../state/EditorConfigContext';
import { usePages } from '../state/PageContext';
import { exportBodyHtml } from '../utils/html-export';
import { PageData } from '../types';
export function useWhpApi() {
const { query, actions } = useEditor();
const { whpConfig, isWHP } = useEditorConfig();
const { pages, headerPage, footerPage, activePageId, setHeaderCraftState, setFooterCraftState, setPagesCraftState } = usePages();
export interface BuildSavePayloadInput {
siteId: number;
siteName: string;
/** query.serialize() of whatever is currently on the live canvas. */
liveCraftState: string;
pages: PageData[];
headerPage: PageData;
footerPage: PageData;
activePageId: string;
/** True when the live canvas is showing the header zone (activePageId === '__header__'). */
isEditingHeader: boolean;
/** True when the live canvas is showing the footer zone (activePageId === '__footer__'). */
isEditingFooter: boolean;
}
const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
/**
* Pure payload builder for the save() API call. Extracted so the
* header/footer-vs-page routing logic can be unit-tested without mounting
* React/Craft.js.
*
* Bug this fixes: when the user is editing the Header or Footer,
* `activePageId` is `'__header__'`/`'__footer__'` — which matches no entry
* in `pages`. The live canvas serialization must be routed into
* `header_craft_state`/`footer_craft_state` in that case, NOT into a page
* slot or the top-level `craft_state`/`html` (which must always represent
* an actual page). Conversely, header/footer must be sourced from the FRESH
* live state when that zone is being edited, not from the stale stored
* `headerPage.craftState`/`footerPage.craftState`.
*/
export function buildSavePayload(input: BuildSavePayloadInput) {
const {
siteId,
siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
} = input;
// Serialize the current canvas state (whatever page is active)
const currentCraftState = query.serialize();
const isPageActive = !isEditingHeader && !isEditingFooter;
// Export body HTML for the current page
// Fresh header/footer state: the live canvas wins when that zone is the
// one currently being edited; otherwise fall back to the last-committed
// stored state (updated on zone switch by PageContext's saveCurrentState).
const headerCraftState = isEditingHeader ? liveCraftState : (headerPage.craftState || null);
const footerCraftState = isEditingFooter ? liveCraftState : (footerPage.craftState || null);
let headerHtml = '';
try {
if (headerCraftState) {
headerHtml = exportBodyHtml(headerCraftState).html;
}
} catch (e) {
console.error('Header HTML export failed:', e);
}
let footerHtml = '';
try {
if (footerCraftState) {
footerHtml = exportBodyHtml(footerCraftState).html;
}
} catch (e) {
console.error('Footer HTML export failed:', e);
}
// The top-level `craft_state`/`html` fields (and the matching per-page
// slot below) must always represent an actual PAGE. When editing the
// header/footer, activePageId matches no page — use the landing page's
// own stored state instead of leaking the live header/footer canvas into
// a page slot or mislabeling it as page content.
let currentCraftState: string | null;
let currentHtml = '';
let css = '';
if (isPageActive) {
currentCraftState = liveCraftState;
try {
const result = exportBodyHtml(currentCraftState);
currentHtml = result.html;
@@ -25,32 +91,23 @@ export function useWhpApi() {
} catch (e) {
console.error('HTML export failed, saving state only:', e);
}
// Export header HTML from its craft state
let headerHtml = '';
} else {
const landingPage = pages[0] ?? null;
currentCraftState = landingPage?.craftState ?? null;
try {
if (headerPage.craftState) {
const hResult = exportBodyHtml(headerPage.craftState);
headerHtml = hResult.html;
if (currentCraftState) {
const result = exportBodyHtml(currentCraftState);
currentHtml = result.html;
css = result.css;
}
} catch (e) {
console.error('Header HTML export failed:', e);
console.error('HTML export failed, saving state only:', e);
}
}
// Export footer HTML from its craft state
let footerHtml = '';
try {
if (footerPage.craftState) {
const fResult = exportBodyHtml(footerPage.craftState);
footerHtml = fResult.html;
}
} catch (e) {
console.error('Footer HTML export failed:', e);
}
// Build the pages array with HTML for each page
// For the active page, use the freshly exported HTML from the canvas;
// for others, export from their stored craft state
// Build the pages array with HTML for each page. For the active page (only
// possible when a real page is active), use the freshly exported HTML from
// the canvas; for others, export from their stored craft state.
const pagesPayload = pages.map((page, i) => {
// The first page is ALWAYS the landing page → publishes to index.html
// regardless of the page name/slug. Apache serves '/' from index.html,
@@ -58,13 +115,12 @@ export function useWhpApi() {
const filename = i === 0 ? 'index.html' : page.slug + '.html';
let pageHtml = '';
if (page.id === activePageId) {
if (isPageActive && page.id === activePageId) {
// Active page: use the current canvas HTML (already exported above)
pageHtml = currentHtml;
} else if (page.craftState) {
try {
const pResult = exportBodyHtml(page.craftState);
pageHtml = pResult.html;
pageHtml = exportBodyHtml(page.craftState).html;
} catch (e) {
console.error(`HTML export failed for page ${page.name}:`, e);
}
@@ -77,9 +133,12 @@ export function useWhpApi() {
};
});
// Build pages_craft_state array: for each page, store its craft state
// For the currently active page, always use the fresh canvas state (currentCraftState)
// since page.craftState may be stale (not updated until page switch)
// Build pages_craft_state array: for each page, store its craft state.
// For the currently active page (only when a real page is active), always
// use the fresh canvas state since page.craftState may be stale (not
// updated until page switch). When editing header/footer, activePageId
// matches no page, so every page correctly falls back to its own stored
// state below.
const pagesGrapesjs = pages.map((page, i) => ({
id: page.id,
name: page.name,
@@ -87,22 +146,58 @@ export function useWhpApi() {
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
// name.html) lines up with the file we just wrote (index.html).
slug: i === 0 ? 'index' : page.slug,
craftState: page.id === activePageId ? currentCraftState : (page.craftState || null),
craftState: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null),
}));
const payload = {
site_id: whpConfig.siteId,
name: whpConfig.siteName,
return {
site_id: siteId,
name: siteName,
html: currentHtml,
css,
pages: pagesPayload,
header_html: headerHtml,
footer_html: footerHtml,
craft_state: currentCraftState,
header_craft_state: headerPage.craftState || null,
footer_craft_state: footerPage.craftState || null,
header_craft_state: headerCraftState,
footer_craft_state: footerCraftState,
pages_craft_state: pagesGrapesjs,
};
}
export function useWhpApi() {
const { query, actions } = useEditor();
const { whpConfig, isWHP } = useEditorConfig();
const {
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
setHeaderCraftState,
setFooterCraftState,
setPagesCraftState,
} = usePages();
const save = useCallback(async () => {
if (!isWHP || !whpConfig) return null;
// Serialize whatever is currently on the live canvas (a page, the
// header, or the footer — depending on activePageId/isEditingHeader/
// isEditingFooter).
const liveCraftState = query.serialize();
const payload = buildSavePayload({
siteId: whpConfig.siteId,
siteName: whpConfig.siteName,
liveCraftState,
pages,
headerPage,
footerPage,
activePageId,
isEditingHeader,
isEditingFooter,
});
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
method: 'POST',
@@ -113,7 +208,7 @@ export function useWhpApi() {
body: JSON.stringify(payload),
});
return resp.json();
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage]);
}, [isWHP, whpConfig, query, pages, activePageId, headerPage, footerPage, isEditingHeader, isEditingFooter]);
const publish = useCallback(async () => {
if (!isWHP || !whpConfig) return null;