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
3 changed files with 81 additions and 3 deletions
Showing only changes of commit 25e674badd - Show all commits
+29
View File
@@ -87,4 +87,33 @@ describe('buildSavePayload', () => {
expect(payload.header_craft_state).toBe('STALE_HEADER'); expect(payload.header_craft_state).toBe('STALE_HEADER');
expect(payload.footer_craft_state).toBe('STALE_FOOTER'); expect(payload.footer_craft_state).toBe('STALE_FOOTER');
}); });
test('I-1: activePageId matches no page (dangling, e.g. original Home deleted then reload reset it to "home") -- live serialize still lands in pages_craft_state[0] / index.html, not lost', () => {
// pages[0] has id 'page_x' (the replacement landing page after the
// original 'home' was deleted); activePageId is stuck at the stale
// default 'home', which matches no entry in `pages`.
const pageX: PageData = { id: 'page_x', name: 'Home', slug: 'index', craftState: 'STORED_X' };
const p2: PageData = { id: 'p2', name: 'About', slug: 'about', craftState: 'STORED_ABOUT_2' };
const payload = buildSavePayload({
siteId: 1,
siteName: 'Test Site',
liveCraftState: 'LIVE_EDIT',
pages: [pageX, p2],
headerPage,
footerPage,
activePageId: 'home',
isEditingHeader: false,
isEditingFooter: false,
});
// The live edit must land in pages_craft_state[0] (index.html slot),
// not be silently dropped to only the legacy top-level fields.
expect(payload.pages_craft_state[0].craftState).toBe('LIVE_EDIT');
expect(payload.pages[0].filename).toBe('index.html');
// Top-level fields (legacy) should also reflect the live edit.
expect(payload.craft_state).toBe('LIVE_EDIT');
// The other page is untouched.
expect(payload.pages_craft_state.find((p) => p.id === 'p2')?.craftState).toBe('STORED_ABOUT_2');
});
}); });
+29 -3
View File
@@ -49,6 +49,18 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
const isPageActive = !isEditingHeader && !isEditingFooter; const isPageActive = !isEditingHeader && !isEditingFooter;
// I-1 (data-loss): `activePageId` can go dangling -- e.g. the original
// Home page (id 'home') is deleted, its replacement gets a fresh id
// (`page_<ts>`), and a reload re-initializes `activePageId` back to the
// hardcoded default `'home'` (see PageContext's `useState('home')`) before
// `load()` has a chance to point it at the actually-restored page. If a
// real page IS active but matches no entry in `pages`, treat `pages[0]`
// (the landing page) as the active one so the live canvas serialization
// still reaches the index.html page slot / `pages_craft_state[0]` instead
// of only the legacy top-level `craft_state`/`html` fields.
const activePageIndex = isPageActive ? pages.findIndex((p) => p.id === activePageId) : -1;
const effectiveActivePageId = activePageIndex !== -1 ? activePageId : pages[0]?.id;
// Fresh header/footer state: the live canvas wins when that zone is the // Fresh header/footer state: the live canvas wins when that zone is the
// one currently being edited; otherwise fall back to the last-committed // one currently being edited; otherwise fall back to the last-committed
// stored state (updated on zone switch by PageContext's saveCurrentState). // stored state (updated on zone switch by PageContext's saveCurrentState).
@@ -115,7 +127,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
const filename = i === 0 ? 'index.html' : page.slug + '.html'; const filename = i === 0 ? 'index.html' : page.slug + '.html';
let pageHtml = ''; let pageHtml = '';
if (isPageActive && page.id === activePageId) { if (isPageActive && page.id === effectiveActivePageId) {
// Active page: use the current canvas HTML (already exported above) // Active page: use the current canvas HTML (already exported above)
pageHtml = currentHtml; pageHtml = currentHtml;
} else if (page.craftState) { } else if (page.craftState) {
@@ -146,7 +158,7 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
// reload the editor's clean-URL routing (.htaccess rewrite of /name → // reload the editor's clean-URL routing (.htaccess rewrite of /name →
// name.html) lines up with the file we just wrote (index.html). // name.html) lines up with the file we just wrote (index.html).
slug: i === 0 ? 'index' : page.slug, slug: i === 0 ? 'index' : page.slug,
craftState: (isPageActive && page.id === activePageId) ? liveCraftState : (page.craftState || null), craftState: (isPageActive && page.id === effectiveActivePageId) ? liveCraftState : (page.craftState || null),
})); }));
return { return {
@@ -177,6 +189,7 @@ export function useWhpApi() {
setHeaderCraftState, setHeaderCraftState,
setFooterCraftState, setFooterCraftState,
setPagesCraftState, setPagesCraftState,
setActivePageIdDirect,
} = usePages(); } = usePages();
const save = useCallback(async () => { const save = useCallback(async () => {
@@ -271,10 +284,23 @@ export function useWhpApi() {
console.warn('Failed to load page state:', e); console.warn('Failed to load page state:', e);
} }
} }
// I-1 (data-loss): point activePageId at the page we just loaded
// into the canvas. Without this, activePageId stays at whatever it
// was initialized to (the hardcoded default 'home'), which goes
// dangling the moment the original Home page has been deleted and
// replaced (its replacement gets a fresh `page_<ts>` id) -- the next
// edit+save would then only reach the legacy top-level fields
// instead of the actual page slot. Only do this when a real page is
// being loaded, i.e. we're not currently mid-edit of the header/
// footer zone (switching zones is handled separately by switchPage).
if (!isEditingHeader && !isEditingFooter) {
setActivePageIdDirect(firstPage.id);
}
} }
} }
return data; return data;
}, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState]); }, [isWHP, whpConfig, actions, setHeaderCraftState, setFooterCraftState, setPagesCraftState, setActivePageIdDirect, isEditingHeader, isEditingFooter]);
const uploadAsset = useCallback( const uploadAsset = useCallback(
async (file: File) => { async (file: File) => {
+23
View File
@@ -21,6 +21,17 @@ interface PageContextValue {
setHeaderCraftState: (craftState: string) => void; setHeaderCraftState: (craftState: string) => void;
setFooterCraftState: (craftState: string) => void; setFooterCraftState: (craftState: string) => void;
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void; setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
/**
* Bookkeeping-only: point `activePageId` at an already-loaded page without
* re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
* `load()` -- has already put the right state on the canvas itself). Used
* to fix I-1: `activePageId` defaults to the hardcoded `'home'` and
* `load()` never updated it, so after the original Home page was deleted
* (its replacement gets a fresh `page_<ts>` id) and the app reloaded,
* `activePageId` pointed at nothing in `pages`, and a subsequent edit+save
* only reached the legacy top-level fields.
*/
setActivePageIdDirect: (pageId: string) => void;
/** AI helpers — replace entire site or page with a new tree */ /** AI helpers — replace entire site or page with a new tree */
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void; replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void; replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void;
@@ -149,6 +160,7 @@ const PageContext = createContext<PageContextValue>({
setHeaderCraftState: () => {}, setHeaderCraftState: () => {},
setFooterCraftState: () => {}, setFooterCraftState: () => {},
setPagesCraftState: () => {}, setPagesCraftState: () => {},
setActivePageIdDirect: () => {},
replaceAllPages: () => {}, replaceAllPages: () => {},
replaceCurrentPage: () => {}, replaceCurrentPage: () => {},
setHeader: () => {}, setHeader: () => {},
@@ -385,6 +397,16 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setFooterPage((prev) => ({ ...prev, craftState })); setFooterPage((prev) => ({ ...prev, craftState }));
}, []); }, []);
/**
* Bookkeeping-only setter for `activePageId` -- see the doc comment on
* `PageContextValue.setActivePageIdDirect`. Does NOT serialize/deserialize
* the canvas; callers that need that should use `switchPage` instead.
*/
const setActivePageIdDirect = useCallback((pageId: string) => {
setActivePageId(pageId);
activePageIdRef.current = pageId;
}, []);
/** Allow external code (e.g., load from API) to restore pages with craft states */ /** Allow external code (e.g., load from API) to restore pages with craft states */
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => { const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
setPages(pagesData.map((p, i) => ({ setPages(pagesData.map((p, i) => ({
@@ -492,6 +514,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setHeaderCraftState, setHeaderCraftState,
setFooterCraftState, setFooterCraftState,
setPagesCraftState, setPagesCraftState,
setActivePageIdDirect,
replaceAllPages, replaceAllPages,
replaceCurrentPage, replaceCurrentPage,
setHeader, setHeader,