diff --git a/craft/src/components/media/ImageBlock.render.test.tsx b/craft/src/components/media/ImageBlock.render.test.tsx index d87dcb4..8ca4594 100644 --- a/craft/src/components/media/ImageBlock.render.test.tsx +++ b/craft/src/components/media/ImageBlock.render.test.tsx @@ -60,3 +60,40 @@ describe('ImageBlock render falls back to the placeholder for an explicit empty container.remove(); }); }); + +describe('ImageBlock drop upload honours the server limits and error text', () => { + const dropFile = (file: File) => { + const img = container.querySelector('img')!; + const ev = new Event('drop', { bubbles: true, cancelable: true }) as any; + ev.dataTransfer = { files: [file] }; + return act(async () => { img.dispatchEvent(ev); await Promise.resolve(); await Promise.resolve(); }); + }; + + test('an oversize image is refused with the readable message and never sent', async () => { + const { setSiteLimits, onUploadError } = await import('../../utils/site-limits'); + const errors: string[] = []; + const off = onUploadError((m) => errors.push(m)); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + (window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 1, csrfToken: 't' }; + setSiteLimits({ upload_max_bytes: 4 }); + render(); + await dropFile(new File(['12345'], 'huge.png', { type: 'image/png' })); + expect(fetchMock).not.toHaveBeenCalled(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('huge.png'); + off(); setSiteLimits(undefined); delete (window as any).WHP_CONFIG; vi.unstubAllGlobals(); + }); + + test('a 413 from the server surfaces its own error text', async () => { + const { onUploadError } = await import('../../utils/site-limits'); + const errors: string[] = []; + const off = onUploadError((m) => errors.push(m)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 413, json: async () => ({ success: false, error: 'That file is too large. The site builder accepts files up to 64 MB.' }) })); + (window as any).WHP_CONFIG = { apiUrl: '/api', siteId: 1, csrfToken: 't' }; + render(); + await dropFile(new File(['x'], 'a.png', { type: 'image/png' })); + expect(errors).toEqual(['That file is too large. The site builder accepts files up to 64 MB.']); + off(); delete (window as any).WHP_CONFIG; vi.unstubAllGlobals(); + }); +}); diff --git a/craft/src/components/media/ImageBlock.tsx b/craft/src/components/media/ImageBlock.tsx index 7a0115c..beff0ce 100644 --- a/craft/src/components/media/ImageBlock.tsx +++ b/craft/src/components/media/ImageBlock.tsx @@ -2,6 +2,7 @@ import React, { CSSProperties, useCallback, useRef } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeAttr, safeImageUrl } from '../../utils/escape'; +import { uploadAsset } from '../../utils/assets'; export const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E"; @@ -28,24 +29,9 @@ export function pxAttr(v: unknown): string | undefined { return m ? m[1] : undefined; } -// Helper: upload a file to the WHP API and return the proxy URL -async function uploadToWhp(file: File): Promise { - const cfg = (window as any).WHP_CONFIG; - if (!cfg) return URL.createObjectURL(file); // Standalone fallback - - const formData = new FormData(); - formData.append('file', file); - try { - const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, { - method: 'POST', - headers: { 'X-CSRF-Token': cfg.csrfToken }, - body: formData, - }); - const data = await resp.json(); - if (data.success && data.url) return data.url; - return null; - } catch { return null; } -} +// Upload goes through the shared util: it enforces limits.upload_max_bytes and +// reports the server's own error text (e.g. the 413 size message). +const uploadToWhp = uploadAsset; export const ImageBlock: UserComponent = ({ src = PLACEHOLDER_SRC, diff --git a/craft/src/editor/NoticeHost.test.tsx b/craft/src/editor/NoticeHost.test.tsx index c045685..cd40380 100644 --- a/craft/src/editor/NoticeHost.test.tsx +++ b/craft/src/editor/NoticeHost.test.tsx @@ -75,3 +75,18 @@ describe('NoticeHost', () => { act(() => r.unmount()); }); }); + +describe('NoticeHost shows upload refusals', () => { + test('an upload error reported by the upload helpers becomes an error notice with the server text', async () => { + const { reportUploadError } = await import('../utils/site-limits'); + const el = document.createElement('div'); + document.body.appendChild(el); + let r: ReturnType; + act(() => { r = createRoot(el); r.render(); }); + act(() => { reportUploadError('That file is too large. The site builder accepts files up to 64 MB.'); }); + const alert = el.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('That file is too large. The site builder accepts files up to 64 MB.'); + act(() => { r.unmount(); }); + el.remove(); + }); +}); diff --git a/craft/src/editor/NoticeHost.tsx b/craft/src/editor/NoticeHost.tsx index 52a548f..4de22dc 100644 --- a/craft/src/editor/NoticeHost.tsx +++ b/craft/src/editor/NoticeHost.tsx @@ -1,9 +1,12 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useNotices } from '../state/NoticeContext'; +import { onUploadError } from '../utils/site-limits'; /** Renders the active notice stack (bottom-left; above the bars on mobile). */ export const NoticeHost: React.FC = () => { - const { notices, dismiss } = useNotices(); + const { notices, dismiss, notify } = useNotices(); + // Upload helpers have no React context: they report refusals on a small bus. + useEffect(() => onUploadError((message) => { notify({ id: 'upload-error', kind: 'error', message }); }), [notify]); if (notices.length === 0) return null; return (
diff --git a/craft/src/hooks/useAssets.test.tsx b/craft/src/hooks/useAssets.test.tsx new file mode 100644 index 0000000..169267c --- /dev/null +++ b/craft/src/hooks/useAssets.test.tsx @@ -0,0 +1,44 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { useAssets } from './useAssets'; +import { EditorConfigProvider } from '../state/EditorConfigContext'; +import { setSiteLimits } from '../utils/site-limits'; +import type { WhpConfig } from '../types'; + +const cfg: WhpConfig = { user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, siteDomain: 'e.com', siteName: 'E', backUrl: '/', isRoot: false }; +let container: HTMLDivElement; let root: Root; let api: ReturnType; let fetchMock: ReturnType; +const Probe: React.FC = () => { api = useAssets(); return null; }; + +beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + container = document.createElement('div'); document.body.appendChild(container); + act(() => { root = createRoot(container); root.render(); }); +}); +afterEach(() => { act(() => root.unmount()); container.remove(); setSiteLimits(undefined); vi.unstubAllGlobals(); }); + +describe('useAssets.uploadAsset', () => { + test('refuses a file over limits.upload_max_bytes without sending it', async () => { + setSiteLimits({ upload_max_bytes: 5 }); + let r: unknown = 'pending'; + await act(async () => { r = await api.uploadAsset(new File(['123456'], 'big.png')); }); + expect(r).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(api.error).toMatch(/big\.png.*too large/); + }); + + test('a 413 shows the server error text, not "Upload failed: 413"', async () => { + const text = 'That file is too large. The site builder accepts files up to 64 MB.'; + fetchMock.mockResolvedValue({ ok: false, status: 413, json: async () => ({ success: false, error: text }) }); + await act(async () => { await api.uploadAsset(new File(['x'], 'a.png')); }); + expect(api.error).toBe(text); + }); + + test('a non-JSON failure still says which status', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 502, json: async () => { throw new Error('html'); } }); + await act(async () => { await api.uploadAsset(new File(['x'], 'a.png')); }); + expect(api.error).toBe('Upload failed: 502'); + }); +}); diff --git a/craft/src/hooks/useAssets.ts b/craft/src/hooks/useAssets.ts index 524c872..3063c2e 100644 --- a/craft/src/hooks/useAssets.ts +++ b/craft/src/hooks/useAssets.ts @@ -1,6 +1,7 @@ import { useState, useCallback } from 'react'; import { useEditorConfig } from '../state/EditorConfigContext'; import { AssetData } from '../types'; +import { uploadSizeError, uploadFailureText } from '../utils/site-limits'; export function useAssets() { const { whpConfig, isWHP } = useEditorConfig(); @@ -38,6 +39,8 @@ export function useAssets() { const uploadAsset = useCallback(async (file: File): Promise => { if (!isWHP || !whpConfig) return null; + const tooBig = uploadSizeError(file); + if (tooBig) { setError(tooBig); return null; } setLoading(true); setError(null); try { @@ -52,9 +55,10 @@ export function useAssets() { body: formData, } ); - if (!resp.ok) throw new Error(`Upload failed: ${resp.status}`); - const data = await resp.json(); - if (!data.success) throw new Error(data.error || 'Upload failed'); + let data: any = null; + try { data = await resp.json(); } catch { /* non-JSON body */ } + // The server's `error` is human text (the 413 size message included): show it as-is. + if (!resp.ok || !data?.success) throw new Error(uploadFailureText(data, resp.status)); const newAsset: AssetData = { name: data.name || file.name, diff --git a/craft/src/hooks/useStockPhotos.test.ts b/craft/src/hooks/useStockPhotos.test.ts index da1c2e0..d066b94 100644 --- a/craft/src/hooks/useStockPhotos.test.ts +++ b/craft/src/hooks/useStockPhotos.test.ts @@ -76,6 +76,19 @@ describe('useStockPhotos.search', () => { expect(api.availability).toBe('unknown'); }); + test('the human `error` text is shown as-is; the machine `code` is never shown', async () => { + fetchMock.mockReturnValue(json({ success: false, error: 'Stock photos are busy right now. Please try again in a minute.', code: 'rate_limited' }, 429)); + await act(async () => { await api.search('fox'); }); + expect(api.error).toBe('Stock photos are busy right now. Please try again in a minute.'); + expect(api.availability).toBe('unknown'); + }); + + test('stock_disabled in `error` still hides the tab even when a human `code`/message is present', async () => { + fetchMock.mockReturnValue(json({ success: false, error: 'stock_disabled', code: 'stock_disabled' }, 404)); + await act(async () => { await api.search('fox'); }); + expect(api.availability).toBe('disabled'); + }); + test('a new query aborts the in-flight one', async () => { const signals: AbortSignal[] = []; fetchMock.mockImplementation((_u: string, init: any) => { diff --git a/craft/src/hooks/useWhpApi.backend-contract.test.tsx b/craft/src/hooks/useWhpApi.backend-contract.test.tsx new file mode 100644 index 0000000..e99990a --- /dev/null +++ b/craft/src/hooks/useWhpApi.backend-contract.test.tsx @@ -0,0 +1,178 @@ +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { createRoot, Root } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; + +/** + * Frontend half of the backend final-fix wave: client_id on every save, + * already_saved, save warnings via notices, load() limits, project_max_bytes warning, + * and the server's `error` text on a refused save. + */ +vi.mock('@craftjs/core', () => ({ + useEditor: (collector?: (state: unknown, query: unknown) => Record) => { + const query = { serialize: () => '{}', history: { canUndo: () => false, canRedo: () => false } }; + const actions = { history: { undo: vi.fn(), redo: vi.fn(), clear: vi.fn() }, deserialize: vi.fn() }; + return { actions, query, ...(collector ? collector({}, query) : {}) }; + }, +})); + +import { LoadGate } from '../editor/LoadGate'; +import { EditorConfigProvider } from '../state/EditorConfigContext'; +import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext'; +import { NoticeProvider, useNotices } from '../state/NoticeContext'; +import { PageProvider } from '../state/PageContext'; +import { SiteDesignProvider } from '../state/SiteDesignContext'; +import { useWhpApi } from './useWhpApi'; +import { getSiteLimits, setSiteLimits } from '../utils/site-limits'; +import { WhpConfig } from '../types'; + +const whpConfig: WhpConfig = { + user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1, + siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false, +}; +const jsonResp = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body }); + +let container: HTMLDivElement; +let root: Root; +let fetchMock: ReturnType; +let api: ReturnType; +let guard: ReturnType; +let notices: ReturnType; +const Probe: React.FC = () => { api = useWhpApi(); guard = useSaveGuard(); notices = useNotices(); return null; }; +const flush = () => act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); }); +const calls = (action: string) => fetchMock.mock.calls.filter(([u]) => String(u).includes(`action=${action}`)); +const sentBody = (n = 0) => JSON.parse(calls('save')[n][1].body as string); + +async function mount() { + container = document.createElement('div'); + document.body.appendChild(container); + act(() => { + root = createRoot(container); + root.render( + + + + + + , + ); + }); + await flush(); +} +const unmount = () => { act(() => { root.unmount(); }); container.remove(); }; + +let loadBody: Record; +let saveResponses: Array<[unknown, number?]>; +beforeEach(() => { + loadBody = { success: true, project: null, rev: 'r1' }; + saveResponses = []; + fetchMock = vi.fn((url: string) => { + const u = String(url); + if (u.includes('action=load')) return Promise.resolve(jsonResp(loadBody)); + if (u.includes('action=save')) { + const [body, status] = saveResponses.shift() ?? [{ success: true, rev: 'r2' }]; + return Promise.resolve(jsonResp(body, status)); + } + return Promise.resolve(jsonResp({ success: true })); + }); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(console, 'error').mockImplementation(() => {}); + setSiteLimits(undefined); +}); +afterEach(() => { + unmount(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + setSiteLimits(undefined); +}); + +describe('client_id', () => { + test('every save carries the same valid client_id within one editor load', async () => { + await mount(); + await act(async () => { await api.save({ forceEmpty: true }); }); + await act(async () => { await api.save({ forceEmpty: true }); }); + const a = sentBody(0).client_id, b = sentBody(1).client_id; + expect(a).toMatch(/^[A-Za-z0-9_-]{8,64}$/); + expect(b).toBe(a); + }); + + test('a new editor load gets a different client_id (in memory, not shared via storage)', async () => { + const setItem = vi.spyOn(Storage.prototype, 'setItem'); + await mount(); + await act(async () => { await api.save({ forceEmpty: true }); }); + const first = sentBody(0).client_id; + unmount(); + fetchMock.mockClear(); + await mount(); + await act(async () => { await api.save({ forceEmpty: true }); }); + expect(sentBody(0).client_id).not.toBe(first); + // A duplicated tab copies sessionStorage: the id must never be written to any storage. + expect(setItem.mock.calls.filter(([, v]) => String(v).includes(first))).toEqual([]); + expect(JSON.stringify({ ...sessionStorage })).not.toContain(first); + expect(JSON.stringify({ ...localStorage })).not.toContain(first); + }); +}); + +describe('already_saved', () => { + test('is a success and its rev becomes the base of the next save', async () => { + saveResponses = [[{ success: true, draft: true, rev: 'r-recovered', already_saved: true, pages: [] }]]; + await mount(); + let res: any; + await act(async () => { res = await api.save({ forceEmpty: true }); }); + expect(res).toMatchObject({ success: true, already_saved: true }); + expect(guard.getRev()).toBe('r-recovered'); + await act(async () => { await api.save({ forceEmpty: true }); }); + expect(sentBody(1).base_rev).toBe('r-recovered'); + }); +}); + +describe('save warnings', () => { + test('warnings[] from a successful save reach the notice system', async () => { + saveResponses = [[{ success: true, rev: 'r2', warnings: ['Page "About" could not be exported; the previous copy was kept.'] }]]; + await mount(); + await act(async () => { await api.save({ forceEmpty: true }); }); + expect(notices.notices.map((n) => n.message).join('\n')).toContain('Page "About" could not be exported; the previous copy was kept.'); + }); + + test('no warnings -> no notice', async () => { + await mount(); + await act(async () => { await api.save({ forceEmpty: true }); }); + expect(notices.notices).toEqual([]); + }); +}); + +describe('save refusals show the server error as-is', () => { + test('a 413 (project too large) result carries the server error text as its message', async () => { + const text = 'This site is too large to save (over 20 MB of page data).'; + saveResponses = [[{ success: false, error: text }, 413]]; + await mount(); + let res: any; + await act(async () => { res = await api.save({ forceEmpty: true }); }); + expect(res.success).toBe(false); + expect(res.message).toBe(text); + }); +}); + +describe('limits from load', () => { + test('load() adopts limits.upload_max_bytes / project_max_bytes', async () => { + loadBody = { success: true, project: null, rev: 'r1', limits: { upload_max_bytes: 67108864, project_max_bytes: 20971520 } }; + await mount(); + expect(getSiteLimits()).toEqual({ uploadMaxBytes: 67108864, projectMaxBytes: 20971520 }); + }); + + test('a payload near project_max_bytes warns before saving (and still sends: the server decides)', async () => { + loadBody = { success: true, project: null, rev: 'r1', limits: { upload_max_bytes: 67108864, project_max_bytes: 100 } }; + await mount(); + await act(async () => { await api.save({ forceEmpty: true }); }); + const msgs = notices.notices.map((n) => n.message).join('\n'); + expect(msgs).toMatch(/too large|close to/i); + expect(calls('save')).toHaveLength(1); + }); + + test('no warning when the payload is well under project_max_bytes', async () => { + loadBody = { success: true, project: null, rev: 'r1', limits: { upload_max_bytes: 67108864, project_max_bytes: 20971520 } }; + await mount(); + await act(async () => { await api.save({ forceEmpty: true }); }); + expect(notices.notices).toEqual([]); + }); +}); diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index 4a12374..6586fec 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -7,6 +7,8 @@ import { exportBodyHtml, type ExportZone } from '../utils/html-export'; import { repairOrphanNodes } from '../utils/orphan-repair'; import { PageData } from '../types'; import { useSaveGuard } from '../state/SaveGuardContext'; +import { useNotices } from '../state/NoticeContext'; +import { setSiteLimits, getSiteLimits, formatMegabytes } from '../utils/site-limits'; import { useProjectExtras } from '../state/ProjectExtrasContext'; import { GLOBAL_SECTIONS_KEY, isGlobalZoneId } from '../utils/global-sections'; @@ -254,6 +256,16 @@ export class UnreadableSiteError extends Error { } } +/** Warn from this share of project_max_bytes (the server refuses above it). */ +const PROJECT_SIZE_WARN_RATIO = 0.9; + +function projectSizeWarning(bodyBytes: number): string | null { + const max = getSiteLimits().projectMaxBytes; + if (max === undefined || bodyBytes < max * PROJECT_SIZE_WARN_RATIO) return null; + return `This site's data (${formatMegabytes(bodyBytes)}) is close to or over the ${formatMegabytes(max)} save limit, so saving may be refused. ` + + 'Large images pasted into the page are the usual cause: upload them as images instead.'; +} + /** What save()/publish() resolve to: the server's JSON, or a synthesized failure. */ export interface ApiResult { success: boolean; @@ -357,8 +369,9 @@ export function useWhpApi() { const { loadState, forceEmptyNextSave, beginSave, endSave, getRev, setRev, setConflict, forceOverwriteNextSave, - saveInFlight, isDirty, + saveInFlight, isDirty, clientId, } = useSaveGuard(); + const { notify, dismiss } = useNotices(); // A save queued behind another runs later: it must build its payload from // the editor as it is THEN, not from the render that queued it. @@ -409,6 +422,24 @@ export function useWhpApi() { extras: zone ? { ...allExtras, [GLOBAL_SECTIONS_KEY]: zone.library } : allExtras, }); + const body = JSON.stringify({ + ...payload, + // Present on every save so the server can detect a newer copy; left + // out only when load never reported a rev (an older backend). + ...(baseRev !== undefined ? { base_rev: baseRev } : {}), + // Names this editor load, so a save whose answer was lost does not + // read as a conflict with itself on the next save. + client_id: clientId, + ...(forceEmpty ? { force_empty: true } : {}), + ...(forceOverwrite ? { force_overwrite: true } : {}), + }); + + // Heads-up before the server refuses with 413 (it measures the stored + // project, not this body, so this only warns; the server decides). + const sizeWarning = projectSizeWarning(body.length); + if (sizeWarning) notify({ id: 'project-size', kind: 'info', message: sizeWarning, timeoutMs: 0 }); + else dismiss('project-size'); + let result: ApiResult; try { result = await withTimeout('Save', async (signal) => { @@ -419,14 +450,7 @@ export function useWhpApi() { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken, }, - body: JSON.stringify({ - ...payload, - // Present on every save so the server can detect a newer copy; left - // out only when load never reported a rev (an older backend). - ...(baseRev !== undefined ? { base_rev: baseRev } : {}), - ...(forceEmpty ? { force_empty: true } : {}), - ...(forceOverwrite ? { force_overwrite: true } : {}), - }), + body, }); // 409 empty_overwrite_refused is an expected outcome the UI handles, so @@ -456,14 +480,19 @@ export function useWhpApi() { if (result.success) { if (forceEmpty) forceEmptyNextSave.current = false; if (forceOverwrite) forceOverwriteNextSave.current = false; + // Also covers already_saved: the server answers with the rev it holds. if (typeof result.rev === 'string') setRev(result.rev); + // Pages kept at their previous copy and the like: saved, but worth knowing. + const warnings = Array.isArray(result.warnings) ? result.warnings.filter((w): w is string => typeof w === 'string' && w !== '') : []; + if (warnings.length) notify({ id: 'save-warnings', kind: 'info', message: warnings.join(' '), timeoutMs: 0 }); + else dismiss('save-warnings'); succeeded = true; } return result; } finally { endSave(ticket, succeeded); } - }, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView, pendingCanvasState]); + }, [whpConfig, clientId, notify, dismiss, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView, pendingCanvasState]); /** * Save, one at a time. Every save (Save button, autosave, Keep mine, the @@ -572,6 +601,8 @@ export function useWhpApi() { // The baseline for every later save's conflict check. proj._rev (inside // the project) is ignored: buildSavePayload never echoes it back. if (typeof data.rev === 'string' || data.rev === null) setRev(data.rev); + // Size limits the server enforces, kept so uploads/saves can refuse early. + setSiteLimits(data.limits); // project: null is a success (brand-new site, nothing saved yet). if (data.project) { diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index ba66996..7f26212 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -168,6 +168,16 @@ describe('TopBar publish-warnings wiring', () => { expect(container.textContent).toContain('Warning two.'); }); + test('the new sitemap / coming-soon publish warnings are shown verbatim', async () => { + const coming = 'Coming-soon mode was turned off: this publish replaced the coming-soon home page, so the site is now live and open to search engines.'; + publishMock.mockResolvedValue({ success: true, warnings: [coming] }); + render(); + + await clickPublish(); + + expect(container.querySelector('[data-testid="publish-warnings"]')!.textContent).toContain(coming); + }); + test('a clean publish (no warnings key) renders no banner at all', async () => { publishMock.mockResolvedValue({ success: true }); render(); diff --git a/craft/src/state/SaveGuardContext.tsx b/craft/src/state/SaveGuardContext.tsx index 15ff42e..3404c89 100644 --- a/craft/src/state/SaveGuardContext.tsx +++ b/craft/src/state/SaveGuardContext.tsx @@ -52,6 +52,19 @@ interface SaveGuardContextValue { /** True while the server has no copy of this site (load reported rev null) and no save * has gone through since. Drives the "Not saved yet" pill. */ neverSaved: boolean; + /** Identifies this editor load to the server (save `client_id`). In memory only: a + * duplicated tab copies sessionStorage, and two tabs must never share an id. */ + clientId: string; +} + +/** 8-64 chars of [A-Za-z0-9_-], the shape the backend accepts. */ +export function newClientId(): string { + const c = (globalThis as { crypto?: Crypto }).crypto; + if (c?.randomUUID) return c.randomUUID(); + const bytes = new Uint8Array(16); + if (c?.getRandomValues) c.getRandomValues(bytes); + else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } /** Identifies the editor state a save was built from. */ @@ -86,6 +99,7 @@ const defaultValue: SaveGuardContextValue = { reloadLatest: () => {}, saveInFlight: { current: null }, neverSaved: false, + clientId: newClientId(), }; const SaveGuardContext = createContext(defaultValue); @@ -102,6 +116,7 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable f const revRef = useRef(undefined); const [neverSaved, setNeverSaved] = useState(false); const saveInFlight = useRef | null>(null); + const [clientId] = useState(newClientId); const getRev = useCallback(() => revRef.current, []); const setRev = useCallback((rev: string | null) => { revRef.current = rev; @@ -191,7 +206,8 @@ export const SaveGuardProvider: React.FC<{ children: ReactNode; /** Injectable f reloadLatest, saveInFlight, neverSaved, - }), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest, neverSaved]); + clientId, + }), [loadState, emptyRefusal, dirty, saving, isDirty, getVersion, markDirty, beginSave, endSave, suppressBeforeUnload, getRev, setRev, conflict, reloadLatest, neverSaved, clientId]); return {children}; }; diff --git a/craft/src/utils/assets.test.ts b/craft/src/utils/assets.test.ts index 69bcc7c..f9db78f 100644 --- a/craft/src/utils/assets.test.ts +++ b/craft/src/utils/assets.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import { uploadAsset, listAssets, Asset } from './assets'; +import { setSiteLimits, onUploadError } from './site-limits'; const CFG = { apiUrl: 'https://panel.example.com/api/site-builder', @@ -70,6 +71,52 @@ describe('uploadAsset', () => { }); }); +describe('uploadAsset: limits and server error text', () => { + let errors: string[]; + let off: () => void; + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + (window as any).WHP_CONFIG = CFG; + errors = []; + off = onUploadError((m) => errors.push(m)); + }); + afterEach(() => { + off(); + setSiteLimits(undefined); + vi.unstubAllGlobals(); + delete (window as any).WHP_CONFIG; + }); + + test('a file over limits.upload_max_bytes is refused client-side: no request, readable error', async () => { + setSiteLimits({ upload_max_bytes: 10 }); + const file = new File(['x'.repeat(11)], 'big.png', { type: 'image/png' }); + expect(await uploadAsset(file)).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('big.png'); + }); + + test('a file at the limit is sent', async () => { + setSiteLimits({ upload_max_bytes: 10 }); + (fetch as any).mockResolvedValue({ ok: true, status: 200, json: async () => ({ success: true, url: '/a.png' }) }); + expect(await uploadAsset(new File(['x'.repeat(10)], 'a.png'))).toBe('/a.png'); + expect(errors).toEqual([]); + }); + + test('a 413 shows the server error text, not "Upload failed: 413"', async () => { + const text = 'That file is too large. The site builder accepts files up to 64 MB.'; + (fetch as any).mockResolvedValue({ ok: false, status: 413, json: async () => ({ success: false, error: text }) }); + expect(await uploadAsset(new File(['x'], 'a.png'))).toBeNull(); + expect(errors).toEqual([text]); + }); + + test('a non-JSON failure falls back to the status', async () => { + (fetch as any).mockResolvedValue({ ok: false, status: 502, json: async () => { throw new Error('html'); } }); + expect(await uploadAsset(new File(['x'], 'a.png'))).toBeNull(); + expect(errors).toEqual(['Upload failed: 502']); + }); +}); + describe('listAssets', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()); diff --git a/craft/src/utils/assets.ts b/craft/src/utils/assets.ts index 4d78276..241edea 100644 --- a/craft/src/utils/assets.ts +++ b/craft/src/utils/assets.ts @@ -7,6 +7,8 @@ `Navbar.tsx` / `HeroStylePanel.tsx` / `ImageBlock.tsx` / `VideoBlock.tsx`, so callers see identical behavior once switched over to this module. */ +import { uploadSizeError, uploadFailureText, reportUploadError } from './site-limits'; + export interface Asset { name: string; url: string; @@ -18,6 +20,10 @@ export interface Asset { export async function uploadAsset(file: File): Promise { const cfg = (window as any).WHP_CONFIG; if (!cfg) return URL.createObjectURL(file); + // Over the server's advertised limit: say so now instead of sending a + // multi-MB body just to be refused. + const tooBig = uploadSizeError(file); + if (tooBig) { reportUploadError(tooBig); return null; } const formData = new FormData(); formData.append('file', file); try { @@ -26,10 +32,16 @@ export async function uploadAsset(file: File): Promise { headers: { 'X-CSRF-Token': cfg.csrfToken }, body: formData, }); - const data = await resp.json(); - if (data.success && data.url) return data.url; + let data: any = null; + try { data = await resp.json(); } catch { /* non-JSON body */ } + if (data?.success && data.url) return data.url; + // The server's `error` is human text (e.g. the 413 size message): show it as-is. + reportUploadError(uploadFailureText(data, resp.status)); return null; - } catch { return null; } + } catch (e) { + reportUploadError(e instanceof Error && e.message ? `Upload failed: ${e.message}` : 'Upload failed: could not reach the server'); + return null; + } } /** List assets uploaded for the current site, optionally filtered by diff --git a/craft/src/utils/html-export.head-parity.test.ts b/craft/src/utils/html-export.head-parity.test.ts index 54d7e90..1e2ec4a 100644 --- a/craft/src/utils/html-export.head-parity.test.ts +++ b/craft/src/utils/html-export.head-parity.test.ts @@ -145,3 +145,52 @@ describe('composed preview: animation head gate covers header+body+footer', () = expect(html).not.toContain("querySelectorAll('[data-animation]')"); }); }); + + +/** + * Backend final-fix wave: generateCompiledHTML now (a) scans headCode for preset + * fonts and (b) gates the animation CSS on the composed header+body+footer. + * These two heads were printed by the BACKEND's generateCompiledHTML() (read-only + * sb-backend worktree) for the inputs below and are pinned verbatim; the Preview + * (exportToHtml / exportComposedPreviewHtml) must emit the same bytes. + * E: title 'Notes', body '
', headCode '', + * default design, canonical https://example.com/notes + * F: title 'Home', composed '
H
' (animation + * only in the header), default design, canonical https://example.com/ + */ +const BACKEND_PINNED_HEAD: Record = { + E_headcode_font: "\n\n\n \n \n Notes\n \n \n \n \n \n \n \n \n", + F_header_animation: "\n\n\n \n \n Home\n \n \n \n \n \n \n \n \n", +}; + +describe('Preview head === backend head for the head-code font scan and header/footer animation gate', () => { + test('E: a preset font named only in head code is in the Preview fonts link', () => { + const html = exportToHtml(root({}), { + title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes', + headCode: '', + }).html; + expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.E_headcode_font); + }); + + test('E (composed path, as TopBar Preview uses it) gives the same head', async () => { + const { exportComposedPreviewHtml } = await import('./html-export'); + const html = exportComposedPreviewHtml(root({}), '
', { + title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes', + headCode: '', + }); + expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.E_headcode_font); + }); + + test('without head code naming it, Merriweather is NOT linked (the scan is what adds it)', () => { + const html = exportToHtml(root({}), { title: 'Notes', includeFonts: true, canonicalUrl: U + 'notes' }).html; + expect(headOf(html)).not.toContain('family=Merriweather'); + }); + + test('F: animation only in the header -> identical head (keyframes + noscript)', async () => { + const { exportComposedPreviewHtml } = await import('./html-export'); + const html = exportComposedPreviewHtml(root({}), '
H
', { + title: 'Home', includeFonts: true, canonicalUrl: U, + }); + expect(headOf(html)).toBe(BACKEND_PINNED_HEAD.F_header_animation); + }); +}); diff --git a/craft/src/utils/html-export.ts b/craft/src/utils/html-export.ts index 25d46a5..156cb7e 100644 --- a/craft/src/utils/html-export.ts +++ b/craft/src/utils/html-export.ts @@ -464,7 +464,9 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string { // every project already has design defaults, so DEFAULT_SITE_DESIGN is the // correct stand-in rather than skipping the block entirely. const design = options.design || DEFAULT_SITE_DESIGN; - const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, options.fontScanHtml ?? bodyHtml)}` : ''; + // Head code is scanned too (a font named only in an analytics/style snippet + // still gets its link), like generateCompiledHTML's $html . $css . $headCode. + const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, (options.fontScanHtml ?? bodyHtml) + (options.headCode ?? ''))}` : ''; const headCode = options.headCode ? `\n ${options.headCode}` : ''; const seoMeta = buildSeoMeta(options, title); const tokenCss = buildTokenCss(design); diff --git a/craft/src/utils/site-limits.test.ts b/craft/src/utils/site-limits.test.ts new file mode 100644 index 0000000..82a9d8b --- /dev/null +++ b/craft/src/utils/site-limits.test.ts @@ -0,0 +1,36 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { setSiteLimits, getSiteLimits, uploadSizeError, uploadFailureText, formatMegabytes } from './site-limits'; + +describe('site limits (server-advertised, in memory)', () => { + beforeEach(() => setSiteLimits(undefined)); + + test('unknown limits (older backend) never refuse a file', () => { + expect(getSiteLimits()).toEqual({ uploadMaxBytes: undefined, projectMaxBytes: undefined }); + expect(uploadSizeError({ size: 10 ** 12, name: 'a.mp4' })).toBeNull(); + }); + + test('adopts limits.upload_max_bytes / project_max_bytes from load', () => { + setSiteLimits({ upload_max_bytes: 67108864, project_max_bytes: 20971520 }); + expect(getSiteLimits()).toEqual({ uploadMaxBytes: 67108864, projectMaxBytes: 20971520 }); + }); + + test('ignores malformed values', () => { + setSiteLimits({ upload_max_bytes: '64', project_max_bytes: -1 }); + expect(getSiteLimits()).toEqual({ uploadMaxBytes: undefined, projectMaxBytes: undefined }); + }); + + test('a file over the limit is refused with a readable message; at the limit is allowed', () => { + setSiteLimits({ upload_max_bytes: 64 * 1048576 }); + expect(uploadSizeError({ size: 64 * 1048576, name: 'ok.png' })).toBeNull(); + const msg = uploadSizeError({ size: 64 * 1048576 + 1, name: 'big.mp4' }); + expect(msg).toContain('big.mp4'); + expect(msg).toContain('64 MB'); + }); + + test('uploadFailureText prefers the server error text over the status', () => { + expect(uploadFailureText({ success: false, error: 'That file is too large. The site builder accepts files up to 64 MB.' }, 413)) + .toBe('That file is too large. The site builder accepts files up to 64 MB.'); + expect(uploadFailureText(null, 413)).toBe('Upload failed: 413'); + expect(formatMegabytes(1572864)).toBe('1.5 MB'); + }); +}); diff --git a/craft/src/utils/site-limits.ts b/craft/src/utils/site-limits.ts new file mode 100644 index 0000000..22ac8d0 --- /dev/null +++ b/craft/src/utils/site-limits.ts @@ -0,0 +1,60 @@ +/* ---------- Server-advertised size limits (in memory only) ---------- + `load` returns `limits: { upload_max_bytes, project_max_bytes }`. They live + in a module variable so non-hook code (utils/assets, ImageBlock) can read + them; nothing is persisted. Unknown (older backend) = no client-side check. */ + +export interface SiteLimits { + uploadMaxBytes?: number; + projectMaxBytes?: number; +} + +let current: SiteLimits = {}; + +const positive = (v: unknown): number | undefined => + typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined; + +/** Adopts the `limits` object from a load response; anything malformed is ignored. */ +export function setSiteLimits(raw: unknown): void { + const o = raw && typeof raw === 'object' ? (raw as Record) : {}; + current = { uploadMaxBytes: positive(o.upload_max_bytes), projectMaxBytes: positive(o.project_max_bytes) }; +} + +export function getSiteLimits(): SiteLimits { + return current; +} + +export function formatMegabytes(bytes: number): string { + const mb = bytes / 1048576; + return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB`; +} + +/** A user-facing refusal when `file` is over the upload limit, else null. */ +export function uploadSizeError(file: { size: number; name?: string }): string | null { + const max = current.uploadMaxBytes; + if (max === undefined || file.size <= max) return null; + return `${file.name ? `"${file.name}" is` : 'That file is'} too large (${formatMegabytes(file.size)}). The site builder accepts files up to ${formatMegabytes(max)}.`; +} + +/** The message to show for a failed upload response: the server's own `error` text first. */ +export function uploadFailureText(data: unknown, status: number): string { + const err = data && typeof data === 'object' ? (data as { error?: unknown; message?: unknown }) : null; + const text = (typeof err?.error === 'string' && err.error.trim()) || (typeof err?.message === 'string' && err.message.trim()); + return text || `Upload failed: ${status}`; +} + +/* ---------- Upload error bus ---------- + Upload helpers (utils/assets, used by ImageBlock and every StylePanel) return + `string | null` and have no React context. They report WHY an upload failed + here; NoticeHost bridges it to the notice system. */ +type UploadErrorListener = (message: string) => void; +const uploadErrorListeners = new Set(); + +export function onUploadError(listener: UploadErrorListener): () => void { + uploadErrorListeners.add(listener); + return () => { uploadErrorListeners.delete(listener); }; +} + +export function reportUploadError(message: string): void { + if (uploadErrorListeners.size === 0) { console.error('[upload]', message); return; } + uploadErrorListeners.forEach((l) => l(message)); +}