Site builder: security & data-loss hardening + unified asset picker + audit backlog #3
@@ -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');
|
||||
});
|
||||
});
|
||||
+179
-84
@@ -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
|
||||
let currentHtml = '';
|
||||
let css = '';
|
||||
// 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,84 +91,113 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
// and renaming the first page should not break the root URL.
|
||||
const filename = i === 0 ? 'index.html' : page.slug + '.html';
|
||||
let pageHtml = '';
|
||||
|
||||
if (isPageActive && page.id === activePageId) {
|
||||
// Active page: use the current canvas HTML (already exported above)
|
||||
pageHtml = currentHtml;
|
||||
} else if (page.craftState) {
|
||||
try {
|
||||
pageHtml = exportBodyHtml(page.craftState).html;
|
||||
} catch (e) {
|
||||
console.error(`HTML export failed for page ${page.name}:`, 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
|
||||
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,
|
||||
// and renaming the first page should not break the root URL.
|
||||
const filename = i === 0 ? 'index.html' : page.slug + '.html';
|
||||
let pageHtml = '';
|
||||
|
||||
if (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;
|
||||
} catch (e) {
|
||||
console.error(`HTML export failed for page ${page.name}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
title: page.name,
|
||||
html: pageHtml,
|
||||
};
|
||||
});
|
||||
|
||||
// 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)
|
||||
const pagesGrapesjs = pages.map((page, i) => ({
|
||||
id: page.id,
|
||||
name: page.name,
|
||||
// Pin the landing page's slug to 'index' on the wire too, so that on
|
||||
// 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),
|
||||
}));
|
||||
|
||||
const payload = {
|
||||
site_id: whpConfig.siteId,
|
||||
name: whpConfig.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,
|
||||
pages_craft_state: pagesGrapesjs,
|
||||
return {
|
||||
filename,
|
||||
title: page.name,
|
||||
html: pageHtml,
|
||||
};
|
||||
});
|
||||
|
||||
// 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,
|
||||
// Pin the landing page's slug to 'index' on the wire too, so that on
|
||||
// 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: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null),
|
||||
}));
|
||||
|
||||
return {
|
||||
site_id: siteId,
|
||||
name: siteName,
|
||||
html: currentHtml,
|
||||
css,
|
||||
pages: pagesPayload,
|
||||
header_html: headerHtml,
|
||||
footer_html: footerHtml,
|
||||
craft_state: currentCraftState,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user