diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f786ec37ce..1fa6f156cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2069,6 +2069,9 @@ importers: '@wordpress/ui': specifier: 0.11.0 version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.44.0 + version: 4.44.0 moment: specifier: 2.30.1 version: 2.30.1 diff --git a/projects/packages/backup/changelog/wire-backup-modernization-data-layer b/projects/packages/backup/changelog/wire-backup-modernization-data-layer new file mode 100644 index 00000000000..5108082a2f2 --- /dev/null +++ b/projects/packages/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability/connection checks, drop the mock-mode dev banner. No-op when the modernization filter is off. diff --git a/projects/packages/backup/package.json b/projects/packages/backup/package.json index c6f96414c1a..defe715491e 100644 --- a/projects/packages/backup/package.json +++ b/projects/packages/backup/package.json @@ -52,6 +52,7 @@ "@wordpress/route": "0.10.0", "@wordpress/theme": "0.11.0", "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0", "moment": "2.30.1", "prop-types": "^15.8.1", "react": "18.3.1", diff --git a/projects/packages/backup/routes/dashboard/package.json b/projects/packages/backup/routes/dashboard/package.json index 017db6d29c4..18f0dc47dc4 100644 --- a/projects/packages/backup/routes/dashboard/package.json +++ b/projects/packages/backup/routes/dashboard/package.json @@ -4,8 +4,10 @@ "private": true, "dependencies": { "@automattic/jetpack-components": "workspace:*", + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", + "@wordpress/api-fetch": "7.44.0", "@wordpress/components": "32.6.0", "@wordpress/dataviews": "14.1.0", "@wordpress/date": "5.27.0", @@ -14,7 +16,8 @@ "@wordpress/icons": "12.2.0", "@wordpress/route": "0.10.0", "@wordpress/theme": "0.11.0", - "@wordpress/ui": "0.11.0" + "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0" }, "route": { "path": "/", diff --git a/projects/packages/backup/routes/download/package.json b/projects/packages/backup/routes/download/package.json index bb646ac133d..ca26cb7f733 100644 --- a/projects/packages/backup/routes/download/package.json +++ b/projects/packages/backup/routes/download/package.json @@ -4,8 +4,10 @@ "private": true, "dependencies": { "@automattic/jetpack-components": "workspace:*", + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", + "@wordpress/api-fetch": "7.44.0", "@wordpress/components": "32.6.0", "@wordpress/date": "5.27.0", "@wordpress/element": "6.44.0", @@ -13,7 +15,8 @@ "@wordpress/icons": "12.2.0", "@wordpress/route": "0.10.0", "@wordpress/theme": "0.11.0", - "@wordpress/ui": "0.11.0" + "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0" }, "route": { "path": "/download/$rewindId", diff --git a/projects/packages/backup/routes/restore/package.json b/projects/packages/backup/routes/restore/package.json index 8dbd17be5ef..efd24d4bd25 100644 --- a/projects/packages/backup/routes/restore/package.json +++ b/projects/packages/backup/routes/restore/package.json @@ -4,8 +4,10 @@ "private": true, "dependencies": { "@automattic/jetpack-components": "workspace:*", + "@tanstack/react-query": "5.90.8", "@types/react": "18.3.28", "@wordpress/admin-ui": "2.0.0", + "@wordpress/api-fetch": "7.44.0", "@wordpress/components": "32.6.0", "@wordpress/date": "5.27.0", "@wordpress/element": "6.44.0", @@ -13,7 +15,8 @@ "@wordpress/icons": "12.2.0", "@wordpress/route": "0.10.0", "@wordpress/theme": "0.11.0", - "@wordpress/ui": "0.11.0" + "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0" }, "route": { "path": "/restore/$rewindId", diff --git a/projects/packages/backup/src/class-jetpack-backup.php b/projects/packages/backup/src/class-jetpack-backup.php index e795978dd8b..33590a055be 100644 --- a/projects/packages/backup/src/class-jetpack-backup.php +++ b/projects/packages/backup/src/class-jetpack-backup.php @@ -122,6 +122,7 @@ public static function initialize() { Connection_Rest_Authentication::init(); add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) ); + add_action( 'rest_api_init', array( \Automattic\Jetpack\Backup\V0005\REST\Rest_Controller::class, 'register_routes' ) ); add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 1 ); // Akismet uses 4, so we need to use 1 to ensure both menus are added when only they exist. @@ -882,6 +883,27 @@ public static function maybe_load_wp_build() { self::load_wp_build(); add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); + add_action( 'admin_print_scripts', array( __CLASS__, 'render_connection_initial_state' ), 1 ); + } + + /** + * Emit `window.JP_CONNECTION_INITIAL_STATE` inline on the modernized + * Backup admin page. + * + * The modernized enqueue path short-circuits before the legacy + * `Connection_Initial_State::render_script()` call, so without this + * the React `` component never sees the connection state and + * sits on its loading skeleton forever. We emit the same JS payload + * the legacy path emits, just outside of a registered script handle + * (wp-build's handles aren't reliable here, and the global is + * page-scoped — any tag setting it works). + * + * @return void + */ + public static function render_connection_initial_state() { + echo ''; } /** @@ -937,7 +959,7 @@ public static function alias_screen_id_for_wp_build( $screen ) { * * @return bool */ - private static function is_modernized() { + public static function is_modernized() { return (bool) apply_filters( self::MODERNIZATION_FILTER, false ); } diff --git a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx index b262c9d3770..72500b95304 100644 --- a/projects/packages/backup/src/dashboard/components/activity-list/index.tsx +++ b/projects/packages/backup/src/dashboard/components/activity-list/index.tsx @@ -4,7 +4,7 @@ import { useCallback, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, cloud, image, post, plugins as pluginsIcon, color } from '@wordpress/icons'; import { Card, Stack, Text } from '@wordpress/ui'; -import { useMockActivityLog } from '../../hooks/use-mock-activity-log'; +import { useActivityLog } from '../../hooks/use-activity-log'; import { isBackupItem } from '../../types/activity'; import './style.scss'; import type { ActivityItem, ActivityKind } from '../../types/activity'; @@ -115,14 +115,11 @@ export default function ActivityList( { selectedId, onSelect }: Props ) { } ); // Drive the hook with DataViews' own view state so the hook is the - // single source of truth for the visible slice + counts. The mock - // hook's contract matches the real `useActivityLog` from the - // data-layer follow-up, so swapping the import line later is the - // only change required here. + // single source of truth for the visible slice + counts. const page = view.page ?? 1; const perPage = view.perPage ?? DEFAULT_PER_PAGE; const search = view.search ?? ''; - const { items, totalItems, totalPages, isLoading } = useMockActivityLog( { + const { items, totalItems, totalPages, isLoading } = useActivityLog( { page, pageSize: perPage, search, diff --git a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx index 3e43ce035df..129c9b8581d 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -2,7 +2,8 @@ import JetpackFooter from '@automattic/jetpack-components/jetpack-footer'; import JetpackLogo from '@automattic/jetpack-components/jetpack-logo'; import { Page } from '@wordpress/admin-ui'; import { __ } from '@wordpress/i18n'; -import DevModeBanner from '../dev-mode-banner'; +import QueryClientProvider from '../../providers/query-client-provider'; +import Gates from '../gates'; import './style.scss'; import type { ReactNode } from 'react'; @@ -17,9 +18,9 @@ const PRODUCT_NAME = 'VaultPress Backup'; // Product name; do not translate. * Shared shell for every screen of the modernized Backup dashboard. * * Wraps a `` from `@wordpress/admin-ui` (the standard wp-admin - * chrome) with a Jetpack logo in the `visual` slot, the dev-mode banner, - * the page body, and `` at the bottom — matching every - * other modernized Jetpack dashboard (Newsletter, VideoPress, Forms). + * chrome) with a Jetpack logo in the `visual` slot, the page body, and + * `` at the bottom — matching every other modernized + * Jetpack dashboard (Newsletter, VideoPress, Forms). * * The page is laid out so the body grows to fill the viewport and the * footer stays parked at the bottom when content is short, but scrolls @@ -44,11 +45,14 @@ export default function DashboardLayout( { children, actions }: Props ) { hasPadding={ false } actions={ actions } > - -
-
{ children }
-
- + +
+
+ { children } +
+
+ +
); } diff --git a/projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx b/projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx deleted file mode 100644 index 78de887bae3..00000000000 --- a/projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { useIsMockMode } from '../../hooks/use-is-mock-mode'; -import './style.scss'; - -/** - * Banner shown above the dashboard body when fixture/mock data is active. - * - * Renders nothing in normal mode; only appears when the URL has `?jpb-mock=1` - * so contributors can tell at a glance that the page is not making real requests. - * - * @return The rendered banner, or `null` when mock mode is off. - */ -export default function DevModeBanner() { - const isMock = useIsMockMode(); - if ( ! isMock ) { - return null; - } - return ( -
- { __( - "Dev mode: the backup list below is fixture data ('?jpb-mock=1'). No real requests are being made.", - 'jetpack-backup-pkg' - ) } -
- ); -} diff --git a/projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss b/projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss deleted file mode 100644 index cf346aab01b..00000000000 --- a/projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss +++ /dev/null @@ -1,9 +0,0 @@ -.jpb-dev-mode-banner { - background-color: #fff8e1; - color: #4a3a06; - text-align: center; - padding: 12px 16px; - font-size: 13px; - line-height: 1.4; - border-bottom: 1px solid #f0e7c2; -} diff --git a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx index 3babaa8d41e..afaec619040 100644 --- a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -9,8 +9,7 @@ import { category as folderIcon, } from '@wordpress/icons'; import { Stack } from '@wordpress/ui'; -import { MOCK_FILE_TREE } from '../../fixtures/file-tree'; -import { useMockFileTree } from '../../hooks/use-mock-file-tree'; +import { useFileTree } from '../../hooks/use-file-tree'; import { isFolder } from '../../types/file-tree'; import FileInfoCard from '../file-info-card'; import './style.scss'; @@ -301,7 +300,7 @@ function countSelectedInLoadedTree( /** * Lazy file-tree browser for the selected backup. Folders fetch their - * children on first expand via `useMockFileTree`; selecting a file opens + * children on first expand via `useFileTree`; selecting a file opens * `` to the right of the tree with a text preview when the * mime type is text-shaped. * @@ -322,8 +321,9 @@ export default function FileBrowser( { onSelectionChange, onSelectionCountChange, }: Props ) { - const [ openFilePath, setOpenFilePath ] = useState< string | null >( null ); - const roots = MOCK_FILE_TREE; + const [ openFile, setOpenFile ] = useState< FileNodeFile | null >( null ); + const { children: rootsData, isLoading: rootsLoading } = useFileTree( rewindId, null ); + const roots = useMemo< FileNode[] >( () => rootsData ?? [], [ rootsData ] ); const { selected, deselected } = selection; // Loaded folder children, hoisted here so toggle propagation and the @@ -331,9 +331,25 @@ export default function FileBrowser( { // already expanded. Top-level roots live under the empty-string key // so `parentOf('/foo') === ''` resolves consistently. const [ loadedChildren, setLoadedChildren ] = useState< Map< string, FileNode[] > >( - () => new Map< string, FileNode[] >( [ [ '', roots ] ] ) + () => new Map< string, FileNode[] >() ); + // Sync the lazily-fetched root list into the loadedChildren map once + // it resolves so toggle propagation can see the top-level siblings. + useEffect( () => { + if ( ! rootsData ) { + return; + } + setLoadedChildren( prev => { + if ( prev.get( '' ) === rootsData ) { + return prev; + } + const next = new Map( prev ); + next.set( '', rootsData ); + return next; + } ); + }, [ rootsData ] ); + const registerChildren = useCallback( ( path: string, children: FileNode[] ) => { setLoadedChildren( prev => { if ( prev.get( path ) === children ) { @@ -400,9 +416,7 @@ export default function FileBrowser( { } ); }, [ selected.size, roots, onSelectionChange ] ); - const closeInfoCard = useCallback( () => setOpenFilePath( null ), [] ); - - const openFile = findFileInTree( roots, openFilePath ); + const closeInfoCard = useCallback( () => setOpenFile( null ), [] ); return (
@@ -420,19 +434,26 @@ export default function FileBrowser( {
- { roots.map( ( node, index ) => ( - - ) ) } + { rootsLoading && ( +
+ +
+ ) } + { ! rootsLoading && + roots.map( ( node, index ) => ( + + ) ) }
{ openFile && }
@@ -445,14 +466,15 @@ type NodeRowProps = { depth: number; isAlternate: boolean; ancestorSelected: boolean; + rewindId: string; selection: FileSelection; onToggle: ( path: string, effectiveBefore: boolean ) => void; - onOpenFile: ( path: string ) => void; + onOpenFile: ( file: FileNodeFile ) => void; onRegisterChildren: ( path: string, children: FileNode[] ) => void; }; /** - * Recursive row inside the file-browser tree. Folders own their own expand state; while a folder is open, `useMockFileTree` keeps its children resolved (re-collapsing and re-opening re-issues the fetch). + * Recursive row inside the file-browser tree. Folders own their own expand state; while a folder is open, `useFileTree` keeps its children resolved (re-collapsing and re-opening re-issues the fetch). * * Two pieces of state propagate top-down: `ancestorSelected` carries the *effective* checked state of the nearest ancestor (own selected beats own deselected beats ancestor), and zebra parity (`isAlternate`) is toggled before each child so the stripe runs continuously through nested branches. * @@ -463,6 +485,7 @@ type NodeRowProps = { * @param props.depth - Indent depth (root = 0). * @param props.isAlternate - Whether this row gets the alt (gray) background. * @param props.ancestorSelected - True when this row inherits a checked state from a selected ancestor (modulo its own deselection). + * @param props.rewindId - The selected backup's rewind id, threaded down so each folder row can fetch its own children via `useFileTree`. * @param props.selection - Current selection state (selected + deselected sets). * @param props.onToggle - Called with the row's path and current effective state when the checkbox toggles. * @param props.onOpenFile - Open the info-card for a file path. @@ -474,6 +497,7 @@ function NodeRow( { depth, isAlternate, ancestorSelected, + rewindId, selection, onToggle, onOpenFile, @@ -481,7 +505,7 @@ function NodeRow( { }: NodeRowProps ) { const [ open, setOpen ] = useState( false ); const nodeIsFolder = isFolder( node ); - const { children, isLoading } = useMockFileTree( open && nodeIsFolder ? node.path : null ); + const { children, isLoading } = useFileTree( rewindId, open && nodeIsFolder ? node.path : null ); const { selected, deselected } = selection; // Effective check: own positive > own negative > inherited positive. @@ -508,7 +532,11 @@ function NodeRow( { [ onToggle, node.path, isEffectivelySelected ] ); const handleToggleOpen = useCallback( () => setOpen( v => ! v ), [] ); - const handleOpenFile = useCallback( () => onOpenFile( node.path ), [ onOpenFile, node.path ] ); + const handleOpenFile = useCallback( () => { + if ( ! nodeIsFolder ) { + onOpenFile( node as FileNodeFile ); + } + }, [ onOpenFile, node, nodeIsFolder ] ); // Register the loaded children with the FileBrowser parent once // they've actually resolved for this folder. The gate skips the @@ -578,6 +606,7 @@ function NodeRow( { // parent's parity, then alternates from there. isAlternate={ index % 2 === 0 ? ! isAlternate : isAlternate } ancestorSelected={ isEffectivelySelected } + rewindId={ rewindId } selection={ selection } onToggle={ onToggle } onOpenFile={ onOpenFile } @@ -589,28 +618,3 @@ function NodeRow( {
); } - -/** - * Recursively searches the rendered tree for a file at the given path. - * - * @param nodes - Nodes to search. - * @param path - File path to match, or null to short-circuit. - * @return The matching file node, or null. - */ -function findFileInTree( nodes: FileNode[], path: string | null ): FileNodeFile | null { - if ( ! path ) { - return null; - } - for ( const node of nodes ) { - if ( node.path === path && ! isFolder( node ) ) { - return node; - } - if ( isFolder( node ) && node.children ) { - const found = findFileInTree( node.children, path ); - if ( found ) { - return found; - } - } - } - return null; -} diff --git a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx index b821eb825b9..15d062102d5 100644 --- a/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-info-card/index.tsx @@ -1,16 +1,108 @@ +import { Spinner } from '@wordpress/components'; import { dateI18n } from '@wordpress/date'; import { __, sprintf } from '@wordpress/i18n'; import { closeSmall } from '@wordpress/icons'; import { Button, Card, Stack, Text } from '@wordpress/ui'; -import { findContents } from '../../fixtures/file-contents'; +import { useFileContents } from '../../hooks/use-file-contents'; import './style.scss'; import type { FileNodeFile } from '../../types/file-tree'; +/** + * Heuristic mime-type lookup by file extension. + * + * WPCOM's `/path-info` endpoint, which PR 48859 originally relied on, + * returns "No file found" for every variant we tried — it appears to + * query an internal index that isn't populated for every site / file. + * Deriving mime from the extension keeps the FileInfoCard's preview-or- + * not decision working without that fetch, and matches the heuristic + * legacy file managers use. + */ +const EXT_TO_MIME: Record< string, string > = { + css: 'text/css', + csv: 'text/csv', + htm: 'text/html', + html: 'text/html', + js: 'application/javascript', + json: 'application/json', + log: 'text/plain', + md: 'text/markdown', + php: 'application/x-php', + po: 'text/plain', + pot: 'text/plain', + sql: 'application/sql', + svg: 'image/svg+xml', + txt: 'text/plain', + xml: 'application/xml', + yaml: 'text/yaml', + yml: 'text/yaml', +}; + +/** + * Derive a mime type from the given filename's extension. Returns an + * empty string when the extension isn't recognized, in which case the + * card falls back to the "preview unavailable" branch. + * + * @param name - Filename, e.g. `wp-config.php`. + * @return The matched mime type, or `''`. + */ +function mimeFromName( name: string ): string { + const idx = name.lastIndexOf( '.' ); + if ( idx <= 0 || idx === name.length - 1 ) { + return ''; + } + const ext = name.slice( idx + 1 ).toLowerCase(); + return EXT_TO_MIME[ ext ] ?? ''; +} + type Props = { file: FileNodeFile; onClose: () => void; }; +/** + * Renders the preview slot's body: a spinner while loading, the file + * contents in a `
` when available, or a "preview unavailable" muted
+ * line when the mime type isn't text or the fetch returned nothing.
+ *
+ * Pulled out as a standalone component to keep `FileInfoCard`'s JSX flat
+ * (no nested ternaries) and to give the loading / error / empty branches
+ * unique render paths the linter can reason about.
+ *
+ * @param props             - Component props.
+ * @param props.showPreview - Whether the file's mime type is renderable as text.
+ * @param props.isLoading   - Whether the file-contents query is in flight.
+ * @param props.content     - The fetched body, or null when not yet resolved.
+ * @return The preview body.
+ */
+function PreviewBody( {
+	showPreview,
+	isLoading,
+	content,
+}: {
+	showPreview: boolean;
+	isLoading: boolean;
+	content: string | null;
+} ) {
+	if ( ! showPreview ) {
+		return (
+			
+				{ __( 'Preview unavailable for this file.', 'jetpack-backup-pkg' ) }
+			
+		);
+	}
+	if ( isLoading ) {
+		return ;
+	}
+	if ( content === null ) {
+		return (
+			
+				{ __( 'Preview unavailable for this file.', 'jetpack-backup-pkg' ) }
+			
+		);
+	}
+	return 
{ content }
; +} + /** * Returns true when the given mime type is renderable as plain text. * @@ -27,33 +119,29 @@ function isTextual( mime: string ): boolean { } /** - * Formats a byte count as a short human-readable string. - * - * @param bytes - Size in bytes. - * @return Formatted size (e.g. `4.7 KB`). - */ -function humanSize( bytes: number ): string { - if ( bytes < 1024 ) { - return `${ bytes } B`; - } - if ( bytes < 1024 * 1024 ) { - return `${ ( bytes / 1024 ).toFixed( 1 ) } KB`; - } - return `${ ( bytes / 1024 / 1024 ).toFixed( 1 ) } MB`; -} - -/** - * Side panel showing details for the currently-open file: size, modified - * timestamp, hash, monospace text preview for recognized text mime types, + * Side panel showing details for the currently-open file: modified + * timestamp, monospace text preview for recognized text mime types, * plus per-file Download and Restore buttons. * + * `lastModified`, `period`, and `manifestPath` all come from `/ls` and + * are carried on the FileNode itself — no extra fetch needed. The + * preview pulls content via the file's own `period` (not the parent + * backup's rewindId) because VaultPress addresses file blobs by their + * per-entry snapshot timestamp. + * * @param props - Component props. - * @param props.file - The file to render. + * @param props.file - The file node clicked in the tree. * @param props.onClose - Callback to close the card. * @return The rendered info card. */ export default function FileInfoCard( { file, onClose }: Props ) { - const contents = isTextual( file.mimeType ) ? findContents( file.path ) : null; + const mimeType = mimeFromName( file.name ); + const showPreview = mimeType ? isTextual( mimeType ) : false; + const { content, isLoading: contentsLoading } = useFileContents( + file.period, + file.manifestPath, + showPreview + ); return ( @@ -77,27 +165,25 @@ export default function FileInfoCard( { file, onClose }: Props ) {
-
-
{ __( 'Size:', 'jetpack-backup-pkg' ) }
-
{ humanSize( file.sizeBytes ) }
-
-
-
{ __( 'Modified:', 'jetpack-backup-pkg' ) }
-
{ dateI18n( 'M j, Y, g:i A', file.lastModified, undefined ) }
-
-
-
{ __( 'Hash:', 'jetpack-backup-pkg' ) }
-
{ file.hash }
-
+ { file.lastModified && ( +
+
{ __( 'Modified:', 'jetpack-backup-pkg' ) }
+
{ dateI18n( 'M j, Y, g:i A', file.lastModified, undefined ) }
+
+ ) } + { mimeType && ( +
+
{ __( 'Type:', 'jetpack-backup-pkg' ) }
+
{ mimeType }
+
+ ) }
- { contents !== null ? ( -
{ contents }
- ) : ( - - { __( 'Preview unavailable for this file.', 'jetpack-backup-pkg' ) } - - ) } +
+ + + ); + } + + if ( ! connection.isFullyConnected ) { + return ; + } + + if ( connection.isSecondaryAdminNotConnected ) { + return ; + } + + if ( ! capabilities.data?.hasBackupPlan ) { + return ; + } + + return <>{ children }; +} diff --git a/projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx b/projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx new file mode 100644 index 00000000000..4bc3d16b2fb --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx @@ -0,0 +1,32 @@ +import { Card, Notice } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; + +const UPGRADE_URL = 'https://jetpack.com/upgrade/backup/'; + +/** + * Fallback shown when the site is fully connected but has no active + * Backup plan. + * + * @return The rendered fallback. + */ +export default function NoBackupPlanScreen() { + return ( + + + }> + { __( "This site doesn't have an active Backup plan", 'jetpack-backup-pkg' ) } + + + { __( + 'Add Jetpack Backup to start protecting your site with automatic backups, granular restores, and offsite storage.', + 'jetpack-backup-pkg' + ) } + + + { __( 'See Backup plans', 'jetpack-backup-pkg' ) } + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/not-connected.tsx b/projects/packages/backup/src/dashboard/components/gates/not-connected.tsx new file mode 100644 index 00000000000..eb954ffcb9d --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/not-connected.tsx @@ -0,0 +1,35 @@ +import { Button, Card } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; + +const JETPACK_CONNECT_URL = 'admin.php?page=jetpack#/connection'; + +/** + * Fallback shown when Jetpack isn't fully connected to WPCOM. + * + * Links out to the Jetpack settings connection screen instead of + * embedding `` — the connection package pulls in SCSS + * that wp-build can't resolve cleanly today. + * + * @return The rendered fallback. + */ +export default function NotConnectedScreen() { + return ( + + + }> + { __( 'Connect Jetpack to get started', 'jetpack-backup-pkg' ) } + + + { __( + 'Backup needs an active Jetpack connection to show your backup history.', + 'jetpack-backup-pkg' + ) } + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx b/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx new file mode 100644 index 00000000000..805f65fc5bf --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx @@ -0,0 +1,36 @@ +import { Button, Card } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; + +const JETPACK_CONNECT_USER_URL = 'admin.php?page=jetpack#/connection'; + +/** + * Fallback shown when the current user is an admin but isn't personally + * linked to a WordPress.com account on this site. + * + * Links out to the Jetpack settings connection screen rather than + * embedding `` (see `not-connected.tsx` for the + * wp-build/SCSS rationale). + * + * @return The rendered fallback. + */ +export default function SecondaryAdminScreen() { + return ( + + + }> + { __( 'Link your account to view backups', 'jetpack-backup-pkg' ) } + + + { __( + "This site's Jetpack connection is already set up, but your account isn't linked to a WordPress.com user yet.", + 'jetpack-backup-pkg' + ) } + + + + + ); +} diff --git a/projects/packages/backup/src/dashboard/components/gates/style.scss b/projects/packages/backup/src/dashboard/components/gates/style.scss new file mode 100644 index 00000000000..dba6af0927b --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/style.scss @@ -0,0 +1,31 @@ +.jpb-gates { + + &__skeleton { + display: flex; + justify-content: center; + align-items: center; + min-height: 320px; + } + + &__card { + max-width: 560px; + margin-inline: auto; + margin-block: 48px; + padding: 32px; + } + + &__cta { + display: inline-block; + padding: 8px 16px; + background-color: var(--wp-admin-theme-color, #007cba); + border-radius: 2px; + text-decoration: none; + + &, + &:hover, + &:focus, + &:visited { + color: #fff; + } + } +} diff --git a/projects/packages/backup/src/dashboard/data/api/_helpers.ts b/projects/packages/backup/src/dashboard/data/api/_helpers.ts new file mode 100644 index 00000000000..458dabbbdb2 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/_helpers.ts @@ -0,0 +1,79 @@ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +const ROOT = '/jetpack/v4'; + +/** + * Prepend `/jetpack/v4` to a path and optionally append query args. + * + * @param path - Path under `/jetpack/v4`, e.g. `/site/capabilities`. + * @param args - Optional query args record. + * @return The fully-qualified API path. + */ +export function apiPath( + path: string, + args?: Record< string, string | number | boolean | undefined > +): string { + const base = `${ ROOT }${ path }`; + if ( ! args ) { + return base; + } + const filtered: Record< string, string | number | boolean > = {}; + for ( const key of Object.keys( args ) ) { + const value = args[ key ]; + if ( value !== undefined && value !== '' ) { + filtered[ key ] = value; + } + } + return Object.keys( filtered ).length ? addQueryArgs( base, filtered ) : base; +} + +/** + * Strip the decimal suffix WPCOM ships on rewind ids (e.g. + * `'1777035492.615'` → `'1777035492'`). The `/rewind/backup/$rewindId` + * URLs reject the suffixed form with a 404. + * + * @param rewindId - Raw rewind id, possibly with a decimal suffix. + * @return The integer-seconds portion only. + */ +export function toIntRewindId( rewindId: string ): string { + const idx = rewindId.indexOf( '.' ); + return idx === -1 ? rewindId : rewindId.slice( 0, idx ); +} + +/** + * Error thrown by the data-layer fetchers when WPCOM (via the bridge) + * responds with a known error code. Consumers branch on `code` to pick + * the right user-facing message. + */ +export class ApiError extends Error { + public readonly code: string; + public readonly data?: unknown; + + constructor( code: string, message: string, data?: unknown ) { + super( message ); + this.name = 'ApiError'; + this.code = code; + this.data = data; + } +} + +/** + * Thin wrapper around `@wordpress/api-fetch` that re-throws bridge errors + * as `ApiError` so React Query's onError handlers can branch on `code`. + * + * @param options - apiFetch options. + * @return The decoded JSON response. + */ +export async function apiCall< T >( options: Parameters< typeof apiFetch >[ 0 ] ): Promise< T > { + try { + return await apiFetch< T >( options ); + } catch ( raw ) { + const err = raw as { code?: string; message?: string; data?: unknown }; + throw new ApiError( + typeof err.code === 'string' ? err.code : 'unknown', + typeof err.message === 'string' ? err.message : 'Request failed', + err.data + ); + } +} diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts new file mode 100644 index 00000000000..28a71417fbb --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -0,0 +1,39 @@ +import { apiCall, apiPath } from './_helpers'; + +export type WpcomActivityEntry = { + activity_id: string; + name: string; + gridicon: string; + rewind_id?: string; + published: string; + summary: string; + actor?: { type: string; name: string }; + content?: { text?: string }; + object?: { backup_stats?: string }; + is_rewindable?: boolean; +}; + +export type WpcomActivityLogResponse = { + current?: { + orderedItems?: WpcomActivityEntry[]; + }; + total_items?: number; +}; + +type FetchArgs = { + number?: number; +}; + +/** + * Fetch the rewindable activity log entries from the bridge. + * + * @param args - Optional pagination args. + * @return The raw WPCOM-shaped response. + */ +export async function fetchActivityLog( + args: FetchArgs = {} +): Promise< WpcomActivityLogResponse > { + return apiCall< WpcomActivityLogResponse >( { + path: apiPath( '/site/rewindable-activity', { number: args.number } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/capabilities.ts b/projects/packages/backup/src/dashboard/data/api/capabilities.ts new file mode 100644 index 00000000000..5d8da74aa87 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/capabilities.ts @@ -0,0 +1,19 @@ +import { apiCall, apiPath } from './_helpers'; + +export type Capabilities = { + hasBackupPlan: boolean; + hasScan: boolean; +}; + +/** + * Fetch the site's backup capabilities. + * + * Wrapped in `apiCall` (not raw `apiFetch`) so bridge errors come back as + * `ApiError`s with a `code` field, matching the rest of the data-layer + * fetchers. + * + * @return The capabilities payload. + */ +export async function fetchCapabilities(): Promise< Capabilities > { + return apiCall< Capabilities >( { path: apiPath( '/site/capabilities' ) } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/download.ts b/projects/packages/backup/src/dashboard/data/api/download.ts new file mode 100644 index 00000000000..b8a01ddc0bd --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/download.ts @@ -0,0 +1,52 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; +import type { RestoreItems } from '../../types/restore'; + +export type InitiateDownloadResponse = { + id: number; +}; + +export type DownloadStatusResponse = { + url?: string; + valid_until?: string; + progress?: number; + status: 'in-progress' | 'completed' | 'failed' | string; +}; + +/** + * Initiate a backup download. + * + * `types` maps directly to WPCOM's `types` payload (the keys match the + * `RestoreItems` checklist). Sending `{}` means "include everything". + * + * @param rewindId - The backup's rewind id. + * @param types - Which categories to include in the download. + * @return The download id. + */ +export async function initiateDownload( + rewindId: string, + types: RestoreItems +): Promise< InitiateDownloadResponse > { + return apiCall< InitiateDownloadResponse >( { + path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + data: { types }, + } ); +} + +/** + * Poll status for an in-flight download. + * + * @param rewindId - The backup's rewind id. + * @param downloadId - The download id returned by `initiateDownload`. + * @return The current download state. + */ +export async function fetchDownloadStatus( + rewindId: string, + downloadId: number +): Promise< DownloadStatusResponse > { + return apiCall< DownloadStatusResponse >( { + path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }/status`, { + download_id: downloadId, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/file-contents.ts b/projects/packages/backup/src/dashboard/data/api/file-contents.ts new file mode 100644 index 00000000000..2e2036ae266 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/file-contents.ts @@ -0,0 +1,29 @@ +import { apiCall, apiPath } from './_helpers'; + +export type FileContentsResponse = { + content: string; +}; + +/** + * Fetch a text file's contents via the proxy (WPCOM signed URL + 64KB cap). + * + * `filePeriod` is the file's own snapshot timestamp from `/ls` — NOT + * the parent backup's rewindId. VaultPress stores file content per the + * file's own period, so passing the parent rewindId here silently + * produces a signed URL for a non-existent storage location. + * + * @param filePeriod - The file's own snapshot timestamp (Unix seconds, as returned in /ls's `period`). + * @param encodedManifestPath - Standard base64 of the full manifest path (with `f5:/`-style volume prefix). + * @return The decoded file content. + */ +export async function fetchFileContents( + filePeriod: string, + encodedManifestPath: string +): Promise< FileContentsResponse > { + return apiCall< FileContentsResponse >( { + path: apiPath( '/rewind/backup/file-content', { + file_period: filePeriod, + encoded_manifest_path: encodedManifestPath, + } ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/file-tree.ts b/projects/packages/backup/src/dashboard/data/api/file-tree.ts new file mode 100644 index 00000000000..eda3f12684b --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/file-tree.ts @@ -0,0 +1,47 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +/** + * A single entry in WPCOM's `/rewind/backup/ls` `contents` map. + * + * The endpoint returns `contents` as an object keyed by filename, so the + * entry value itself has no `name` field — the parent's key carries it. + * Type discriminators: `'dir'` (folder), `'file'` (regular file), + * `'wordpress'` (virtual core-version marker that has no children). + */ +export type WpcomFileNode = { + type: 'dir' | 'file' | 'wordpress' | string; + manifest_path?: string; + has_children?: boolean; + total_items?: number; + period?: string; + id?: string; + wordpress_version?: string; + sort?: number; +}; + +export type WpcomLsResponse = { + ok?: boolean; + error?: string; + contents?: Record< string, WpcomFileNode >; +}; + +/** + * List the children of a folder inside a backup. + * + * @param rewindId - The backup's rewind id (decimal suffix stripped here). + * @param folderPath - Folder to list, relative to backup root. + * @return The decoded WPCOM ls response. + */ +export async function fetchFileTree( + rewindId: string, + folderPath: string +): Promise< WpcomLsResponse > { + return apiCall< WpcomLsResponse >( { + path: apiPath( '/rewind/backup/ls' ), + method: 'POST', + data: { + rewind_id: toIntRewindId( rewindId ), + path: folderPath, + }, + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/restore.ts b/projects/packages/backup/src/dashboard/data/api/restore.ts new file mode 100644 index 00000000000..13debe4a796 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/restore.ts @@ -0,0 +1,48 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; +import type { RestoreItems } from '../../types/restore'; + +export type InitiateRestoreResponse = { + id: number; +}; + +export type RestoreStatusResponse = { + id: number; + status: 'queued' | 'in-progress' | 'finished' | 'failed' | string; + progress: number; + rewind_id: string; + error_code: string; + message: string; +}; + +/** + * Start a restore for the given backup point. + * + * `types` maps directly to WPCOM's `types` payload (the keys match the + * `RestoreItems` checklist). Sending `{}` means "restore everything". + * + * @param rewindId - The backup's rewind id. + * @param types - Which categories to restore (themes/plugins/roots/contents/sqls/uploads). + * @return The restore id. + */ +export async function initiateRestore( + rewindId: string, + types: RestoreItems +): Promise< InitiateRestoreResponse > { + return apiCall< InitiateRestoreResponse >( { + path: apiPath( `/rewind/to/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + data: { types }, + } ); +} + +/** + * Poll restore status. + * + * @param restoreId - The restore id returned by `initiateRestore`. + * @return The current restore state. + */ +export async function fetchRestoreStatus( restoreId: number ): Promise< RestoreStatusResponse > { + return apiCall< RestoreStatusResponse >( { + path: apiPath( `/rewind/restore/${ restoreId }/status` ), + } ); +} diff --git a/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts b/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts new file mode 100644 index 00000000000..bc71c2ef7ec --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts @@ -0,0 +1,15 @@ +import { toIntRewindId } from '../_helpers'; + +describe( 'toIntRewindId', () => { + test( 'returns the input unchanged when there is no decimal suffix', () => { + expect( toIntRewindId( '1777035492' ) ).toBe( '1777035492' ); + } ); + + test( 'strips a single decimal suffix', () => { + expect( toIntRewindId( '1777035492.615' ) ).toBe( '1777035492' ); + } ); + + test( 'strips everything from the first dot onwards', () => { + expect( toIntRewindId( '1777035492.615.123' ) ).toBe( '1777035492' ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts new file mode 100644 index 00000000000..9208a8ea023 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/activity-log.ts @@ -0,0 +1,72 @@ +import type { ActivityItem, ActivityKind } from '../../types/activity'; +import type { WpcomActivityEntry } from '../api/activity-log'; + +const GRIDICON_TO_KIND: Record< string, ActivityKind | null > = { + cloud: 'backup', + image: 'upload', + post: 'post', + plugins: 'plugin-update', + color: 'theme-update', +}; + +/** + * Convert a single WPCOM rewindable-activity entry into the UI's + * `ActivityItem` shape. Returns null when the entry's gridicon doesn't + * map to a kind we render. + * + * @param entry - WPCOM entry. + * @return The mapped item, or null when the entry is filtered out. + */ +export function normalizeEntry( entry: WpcomActivityEntry ): ActivityItem | null { + const kind = GRIDICON_TO_KIND[ entry.gridicon ]; + if ( ! kind ) { + return null; + } + + const actor = entry.actor ?? { type: 'Application', name: 'Jetpack' }; + const base = { + id: entry.activity_id, + title: entry.summary, + publishedAt: entry.published, + actor: { + type: actor.type === 'Person' ? ( 'Person' as const ) : ( 'Application' as const ), + name: actor.name, + }, + summary: entry.content?.text ?? undefined, + }; + + if ( kind === 'backup' ) { + return { + ...base, + kind: 'backup', + rewindId: entry.rewind_id ?? entry.activity_id, + // `content.text` is the human-readable summary + // ("44 plugins, 23 themes, 1562 uploads…"). WPCOM also ships + // `object.backup_stats`, but in production that field is a + // stringified JSON blob, not a friendly string — rendering + // it verbatim dumps raw JSON into the UI. + stats: entry.content?.text ?? '', + isComplete: + entry.name === 'rewind__backup_complete_full' || entry.name === 'rewind__backup_complete', + }; + } + + return { + ...base, + kind, + }; +} + +/** + * Convert an array of WPCOM entries into UI `ActivityItem`s, filtering + * out entries whose `gridicon` doesn't map to a known kind. + * + * @param entries - WPCOM entries (possibly empty). + * @return Normalized items. + */ +export function normalizeActivityLog( entries: WpcomActivityEntry[] | undefined ): ActivityItem[] { + if ( ! entries ) { + return []; + } + return entries.map( normalizeEntry ).filter( ( item ): item is ActivityItem => item !== null ); +} diff --git a/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts b/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts new file mode 100644 index 00000000000..090e2ae040e --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts @@ -0,0 +1,79 @@ +import { normalizeActivityLog, normalizeEntry } from '../activity-log'; +import type { WpcomActivityEntry } from '../../api/activity-log'; + +const backupEntry: WpcomActivityEntry = { + activity_id: 'a-1', + name: 'rewind__backup_complete_full', + gridicon: 'cloud', + rewind_id: '1777035492', + published: '2026-05-15T12:26:00.000Z', + summary: 'Backup and scan complete by Jetpack', + actor: { type: 'Application', name: 'Jetpack' }, + content: { text: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page' }, + // In production, `backup_stats` is a JSON-encoded blob — captured + // here verbatim so the test catches any regression that goes back + // to rendering this field as a string. + object: { backup_stats: '{"themes":{"count":1,"list":["twentytwentyfive"]}}' }, +}; + +const postEntry: WpcomActivityEntry = { + activity_id: 'a-2', + name: 'post__published', + gridicon: 'post', + published: '2026-05-14T01:23:00.000Z', + summary: 'Post published by Totoro', + actor: { type: 'Person', name: 'Totoro' }, + content: { text: 'The Perks of Having a Cat' }, +}; + +const unknownEntry: WpcomActivityEntry = { + activity_id: 'a-3', + name: 'mystery__event', + gridicon: 'something-new', + published: '2026-05-13T01:23:00.000Z', + summary: 'A mystery event', +}; + +describe( 'normalizeEntry', () => { + test( 'maps a backup entry to a backup ActivityItem', () => { + const item = normalizeEntry( backupEntry ); + expect( item ).not.toBeNull(); + expect( item ).toMatchObject( { + id: 'a-1', + kind: 'backup', + rewindId: '1777035492', + // `stats` reads from `content.text`, NOT the JSON-encoded + // `object.backup_stats`. Rendering backup_stats verbatim + // would dump JSON into the UI. + stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', + isComplete: true, + } ); + } ); + + test( 'maps a post entry to a post ActivityItem', () => { + const item = normalizeEntry( postEntry ); + expect( item ).toMatchObject( { id: 'a-2', kind: 'post', actor: { type: 'Person' } } ); + } ); + + test( 'returns null for unknown gridicons', () => { + expect( normalizeEntry( unknownEntry ) ).toBeNull(); + } ); + + test( 'falls back to activity_id when rewind_id is missing', () => { + const fallback = { ...backupEntry, rewind_id: undefined }; + const item = normalizeEntry( fallback ); + expect( item ).toMatchObject( { kind: 'backup', rewindId: 'a-1' } ); + } ); +} ); + +describe( 'normalizeActivityLog', () => { + test( 'returns an empty array for undefined input', () => { + expect( normalizeActivityLog( undefined ) ).toEqual( [] ); + } ); + + test( 'filters out unmapped gridicons', () => { + const items = normalizeActivityLog( [ backupEntry, postEntry, unknownEntry ] ); + expect( items ).toHaveLength( 2 ); + expect( items.map( i => i.kind ) ).toEqual( [ 'backup', 'post' ] ); + } ); +} ); diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts new file mode 100644 index 00000000000..0995c703b69 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -0,0 +1,47 @@ +import { QueryClient } from '@tanstack/react-query'; + +const STALE_TIME_DEFAULT_MS = 30_000; +const GC_TIME_DEFAULT_MS = 5 * 60_000; + +/** + * Module-scope QueryClient for the modernized Backup dashboard. + * + * Each wp-build route is its own JS bundle and ends up with its own + * copy of this module, so the cache is per-route — it does NOT survive + * Overview ↔ Restore ↔ Download navigation. The screens don't depend on + * cross-route cache reuse today (each derives its rewindId from the + * URL), so this is fine; revisit if a future screen needs to read cache + * a sibling route populated. + */ +export const queryClient = new QueryClient( { + defaultOptions: { + queries: { + staleTime: STALE_TIME_DEFAULT_MS, + gcTime: GC_TIME_DEFAULT_MS, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +} ); + +/** + * Stable cache keys for each query the dashboard issues. + * + * Centralizing keys here means any consumer can invalidate or read + * cached data without re-deriving the tuple shape ad hoc. + */ +export const keys = { + capabilities: () => [ 'backup', 'capabilities' ] as const, + // Single shared key: the hook always fetches one fixed window of + // rewindable activity and filters/paginates client-side, so every + // consumer (list, default-selection, by-id lookup) subscribes to + // the same entry. + activityLogWindow: () => [ 'backup', 'activity-log-window' ] as const, + fileTree: ( rewindId: string, folderPath: string | null ) => + [ 'backup', 'file-tree', rewindId, folderPath ] as const, + fileContents: ( rewindId: string, path: string ) => + [ 'backup', 'file-contents', rewindId, path ] as const, + downloadStatus: ( rewindId: string, downloadId: number ) => + [ 'backup', 'download-status', rewindId, downloadId ] as const, + restoreStatus: ( restoreId: number ) => [ 'backup', 'restore-status', restoreId ] as const, +}; diff --git a/projects/packages/backup/src/dashboard/fixtures/activity-log.ts b/projects/packages/backup/src/dashboard/fixtures/activity-log.ts deleted file mode 100644 index 613e998c270..00000000000 --- a/projects/packages/backup/src/dashboard/fixtures/activity-log.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { ActivityItem } from '../types/activity'; - -const BASE_DATE = new Date( '2026-05-15T12:26:00.000Z' ); -const DAY_MS = 24 * 60 * 60 * 1000; -const HOUR_MS = 60 * 60 * 1000; - -const at = ( daysAgo: number, hoursAgo = 0 ): string => - new Date( BASE_DATE.getTime() - daysAgo * DAY_MS - hoursAgo * HOUR_MS ).toISOString(); - -const jetpack = { type: 'Application' as const, name: 'Jetpack' }; -const totoro = { type: 'Person' as const, name: 'Totoro' }; - -const backup = ( offsetDays: number ): ActivityItem => ( { - id: `backup-${ offsetDays }`, - kind: 'backup', - title: 'Backup and scan complete by Jetpack', - publishedAt: at( offsetDays ), - actor: jetpack, - summary: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', - rewindId: String( Math.floor( ( BASE_DATE.getTime() - offsetDays * DAY_MS ) / 1000 ) ), - stats: '4 plugins, 1 theme, 20 uploads, 4 posts, 1 page', - isComplete: true, -} ); - -export const MOCK_ACTIVITY_LOG: ActivityItem[] = [ - backup( 0 ), - { - id: 'upload-1', - kind: 'upload', - title: '1 image uploaded by Totoro', - publishedAt: at( 0.95 ), - actor: totoro, - summary: 'cat.png', - }, - { - id: 'post-1', - kind: 'post', - title: 'Post published by Totoro', - publishedAt: at( 1 ), - actor: totoro, - summary: 'The Perks of Having a Cat', - }, - backup( 1 ), - backup( 2 ), - backup( 3 ), - { - id: 'plugin-1', - kind: 'plugin-update', - title: 'Jetpack 15.2 plugin updated by Jetpack', - publishedAt: at( 4, 10 ), - actor: jetpack, - summary: 'Jetpack 15.2', - }, - backup( 4 ), - backup( 5 ), - backup( 6 ), - backup( 7 ), - backup( 8 ), - backup( 9 ), - { - id: 'theme-1', - kind: 'theme-update', - title: 'Twenty Twenty-Five theme updated by Totoro', - publishedAt: at( 10, 6 ), - actor: totoro, - summary: 'Twenty Twenty-Five 1.2', - }, - backup( 10 ), - backup( 11 ), - backup( 12 ), - backup( 13 ), - backup( 14 ), -]; - -/** - * Look up an activity item by the id used in the URL's `?selected=` param. - * - * Backup items match on `rewindId`; non-backup items match on `id`. - * - * @param id - Selection id from the URL. - * @return The matching activity item, or `null` if no row matches. - */ -export function findActivityById( id: string ): ActivityItem | null { - return ( - MOCK_ACTIVITY_LOG.find( item => - item.kind === 'backup' ? item.rewindId === id : item.id === id - ) ?? null - ); -} diff --git a/projects/packages/backup/src/dashboard/fixtures/file-contents.ts b/projects/packages/backup/src/dashboard/fixtures/file-contents.ts deleted file mode 100644 index 91bbe567a4e..00000000000 --- a/projects/packages/backup/src/dashboard/fixtures/file-contents.ts +++ /dev/null @@ -1,47 +0,0 @@ -const WP_CONFIG = ` = { - '/wp-config.php': WP_CONFIG, - '/wp-content/themes/twentytwentyfive/style.css': STYLE_CSS, - '/sql/database.sql': DATABASE_SQL, -}; - -/** - * Returns the mocked text contents for a path, or null when no fixture is registered. - * - * @param path - Absolute path under the backup. - * @return The text contents, or null. - */ -export function findContents( path: string ): string | null { - return MOCK_FILE_CONTENTS[ path ] ?? null; -} diff --git a/projects/packages/backup/src/dashboard/fixtures/file-tree.ts b/projects/packages/backup/src/dashboard/fixtures/file-tree.ts deleted file mode 100644 index cfb13709362..00000000000 --- a/projects/packages/backup/src/dashboard/fixtures/file-tree.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { isFolder } from '../types/file-tree'; -import type { FileNode } from '../types/file-tree'; - -// Stable timestamps + hashes so snapshot tests can't flake on -// import-time clock reads. The exact values mirror the kind of -// metadata the future REST path-info call will return. -const FIXTURE_LAST_MODIFIED = '2026-02-16T20:42:00.000Z'; - -const file = ( - name: string, - path: string, - sizeBytes: number, - mimeType: string, - hash = 'ce4fc8526a348484a88bba63b08c0976' -): FileNode => ( { - type: 'file', - name, - path, - sizeBytes, - mimeType, - lastModified: FIXTURE_LAST_MODIFIED, - hash, -} ); - -const folder = ( name: string, path: string, children?: FileNode[] ): FileNode => ( { - type: 'folder', - name, - path, - children, -} ); - -export const MOCK_FILE_TREE: FileNode[] = [ - folder( 'wp-content', '/wp-content', [ - folder( 'themes', '/wp-content/themes', [ - folder( 'twentytwentyfive', '/wp-content/themes/twentytwentyfive', [ - file( 'style.css', '/wp-content/themes/twentytwentyfive/style.css', 4_812, 'text/css' ), - ] ), - ] ), - folder( 'plugins', '/wp-content/plugins', [ - folder( 'jetpack', '/wp-content/plugins/jetpack', [ - file( - 'jetpack.php', - '/wp-content/plugins/jetpack/jetpack.php', - 12_034, - 'application/x-php' - ), - ] ), - ] ), - folder( 'uploads', '/wp-content/uploads', [ - file( 'cat.png', '/wp-content/uploads/cat.png', 188_240, 'image/png' ), - ] ), - ] ), - folder( 'sql', '/sql', [ - file( 'database.sql', '/sql/database.sql', 2_485_120, 'application/sql' ), - ] ), - file( 'wp-config.php', '/wp-config.php', 3_312, 'application/x-php' ), -]; - -/** - * Recursively searches `MOCK_FILE_TREE` for a node at the given path. - * - * @param path - Absolute path under the backup (e.g. `'/wp-content/uploads'`). - * @return The matching node, or null when no node is found. - */ -export function findNodeByPath( path: string ): FileNode | null { - const walk = ( nodes: FileNode[] ): FileNode | null => { - for ( const node of nodes ) { - if ( node.path === path ) { - return node; - } - if ( isFolder( node ) && node.children ) { - const found = walk( node.children ); - if ( found ) { - return found; - } - } - } - return null; - }; - return walk( MOCK_FILE_TREE ); -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts new file mode 100644 index 00000000000..08a6d554ece --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -0,0 +1,144 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from '@wordpress/element'; +import { fetchActivityLog } from '../data/api/activity-log'; +import { normalizeActivityLog } from '../data/normalize/activity-log'; +import { keys } from '../data/query-client'; +import type { ActivityItem } from '../types/activity'; + +type Args = { + page: number; + pageSize: number; + search: string; +}; + +type Result = { + items: ActivityItem[]; + totalItems: number; + totalPages: number; + isLoading: boolean; + error: Error | null; +}; + +const MAX_FETCH_ITEMS = 100; + +/** + * Shared `useQuery` for the single rewindable-activity window the + * dashboard fetches. Centralizing the query options means every + * consumer (`useActivityLog`, `useActivityById`, + * `useDefaultBackupRewindId`) subscribes to the same cache entry, so + * TanStack dedups the fetch and any consumer mounted after the window + * resolves re-renders without a refetch. + * + * @return The cached window query. + */ +function useActivityLogWindowQuery() { + return useQuery( { + queryKey: keys.activityLogWindow(), + queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), + } ); +} + +/** + * Case-insensitive predicate: returns true when the activity item's title + * or summary contains the search query. An empty query matches every item. + * + * WPCOM's `/sites/{id}/activity/rewindable` endpoint doesn't accept a + * search parameter, so the hook fetches a single window of recent + * activity (capped at `MAX_FETCH_ITEMS`) and filters client-side. This + * matches what DataViews expects: server returns a fixed window, the + * hook hands DataViews the filtered + paginated slice plus the totals + * it needs for its pagination footer. + * + * @param item - Activity item to test. + * @param q - Search query (raw, untrimmed). + * @return True when the item matches the query. + */ +function matchesSearch( item: ActivityItem, q: string ): boolean { + if ( ! q ) { + return true; + } + const haystack = `${ item.title } ${ item.summary ?? '' }`.toLowerCase(); + return haystack.includes( q.toLowerCase() ); +} + +/** + * React Query hook returning a paginated, search-filtered slice of the + * site's rewindable activity log. + * + * @param args - Query args. + * @param args.page - 1-indexed page number. + * @param args.pageSize - Items per page. + * @param args.search - Search query (title + summary, case-insensitive). + * @return Items, total items, total pages, loading, error. + */ +export function useActivityLog( { page, pageSize, search }: Args ): Result { + const query = useActivityLogWindowQuery(); + + const allItems = useMemo( + () => normalizeActivityLog( query.data?.current?.orderedItems ), + [ query.data ] + ); + + const { items, totalItems, totalPages } = useMemo( () => { + const filtered = allItems.filter( item => matchesSearch( item, search ) ); + const total = Math.max( 1, Math.ceil( filtered.length / pageSize ) ); + const start = ( page - 1 ) * pageSize; + return { + items: filtered.slice( start, start + pageSize ), + totalItems: filtered.length, + totalPages: total, + }; + }, [ allItems, page, pageSize, search ] ); + + return { + items, + totalItems, + totalPages, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} + +/** + * Look up a single activity item from the cached rewindable-activity + * window. Reactive: subscribes via `useQuery`, so when the window + * fetch resolves the calling component re-renders with the item. + * + * @param id - Selection id: `rewindId` for backup items, `activity_id` otherwise. + * @return The matching item, or null when the cache hasn't been populated yet or the id doesn't match anything in the cached page. + */ +export function useActivityById( id: string | null ): ActivityItem | null { + const query = useActivityLogWindowQuery(); + return useMemo( () => { + if ( ! id ) { + return null; + } + const items = normalizeActivityLog( query.data?.current?.orderedItems ); + return ( + items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? + null + ); + }, [ query.data, id ] ); +} + +/** + * Returns the newest backup row in the cached rewindable-activity + * window, or null when the cache isn't populated or holds no backup + * rows. Reactive: subscribes via `useQuery`, so Overview's first-load + * default selection reconciles to the newest backup the moment the + * window fetch resolves. + * + * @return The newest backup item's rewindId, or null. + */ +export function useDefaultBackupRewindId(): string | null { + const query = useActivityLogWindowQuery(); + return useMemo( () => { + const items = normalizeActivityLog( query.data?.current?.orderedItems ); + for ( const item of items ) { + if ( item.kind === 'backup' ) { + return item.rewindId; + } + } + return null; + }, [ query.data ] ); +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts b/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts new file mode 100644 index 00000000000..0bd6766f776 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-capabilities.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchCapabilities, type Capabilities } from '../data/api/capabilities'; +import { keys } from '../data/query-client'; + +const CAPABILITIES_STALE_MS = 5 * 60_000; + +type Result = { + data: Capabilities | undefined; + isLoading: boolean; + error: Error | null; +}; + +/** + * React Query hook returning the site's backup capabilities. + * + * @return Capabilities query state. + */ +export function useCapabilities(): Result { + const query = useQuery( { + queryKey: keys.capabilities(), + queryFn: fetchCapabilities, + staleTime: CAPABILITIES_STALE_MS, + } ); + return { + data: query.data, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-connection.ts b/projects/packages/backup/src/dashboard/hooks/use-connection.ts new file mode 100644 index 00000000000..7895a2dcefc --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -0,0 +1,52 @@ +type Result = { + isLoaded: boolean; + isFullyConnected: boolean; + isSecondaryAdminNotConnected: boolean; + hasConnectionError: boolean; +}; + +type ConnectionStatus = { + isRegistered?: boolean; + isUserConnected?: boolean; + hasConnectedOwner?: boolean; +}; + +type ConnectionInitialState = { + connectionStatus?: ConnectionStatus; +}; + +declare global { + interface Window { + JP_CONNECTION_INITIAL_STATE?: ConnectionInitialState; + } +} + +/** + * Read Jetpack's connection state from the `JP_CONNECTION_INITIAL_STATE` + * global emitted by `Connection_Initial_State::render_script()` in PHP. + * + * We deliberately avoid `@automattic/jetpack-connection`'s store / hooks + * because the package's barrel pulls in SCSS that wp-build's bundler + * can't resolve. The global is set inline on `admin_print_scripts` + * priority 1, well before any React bundle runs, so reading on every + * render is a single property lookup — no need to memoize, and not + * memoizing avoids any chance of capturing a pre-emit empty snapshot + * if load order ever drifts. + * + * @return Connection state. + */ +export function useConnection(): Result { + const state = typeof window !== 'undefined' ? window.JP_CONNECTION_INITIAL_STATE : undefined; + const status: ConnectionStatus = state?.connectionStatus ?? {}; + + const isLoaded = Object.keys( status ).length > 0; + const isFullyConnected = Boolean( isLoaded && status.isRegistered && status.hasConnectedOwner ); + const isSecondaryAdminNotConnected = Boolean( isFullyConnected && ! status.isUserConnected ); + + return { + isLoaded, + isFullyConnected, + isSecondaryAdminNotConnected, + hasConnectionError: false, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts new file mode 100644 index 00000000000..84c475cb546 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -0,0 +1,91 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { fetchDownloadStatus, initiateDownload } from '../data/api/download'; +import { keys } from '../data/query-client'; +import type { RestoreItems } from '../types/restore'; + +type DownloadState = + | { phase: 'idle' } + | { phase: 'submitting' } + | { phase: 'progress'; percent: number } + | { phase: 'success'; downloadUrl: string } + | { phase: 'error'; message: string }; + +type Result = { + state: DownloadState; + submit: ( items: RestoreItems ) => void; + reset: () => void; +}; + +const POLL_INTERVAL_MS = 1500; + +/** + * Real `useDownload` driving the modernized Download screen via the + * `/jetpack/v4/backups/download/$rewindId` bridge. + * + * Submit kicks off the WPCOM `prepare-download` and stores the download + * id; a polled status query then advances the state machine + * idle → submitting → progress → success | error. + * + * @param rewindId - The backup's rewind id. + * @return state + submit + reset. + */ +export function useDownload( rewindId: string ): Result { + const [ downloadId, setDownloadId ] = useState< number | null >( null ); + const [ errorMessage, setErrorMessage ] = useState< string | null >( null ); + + const { + mutate: mutateInitiate, + reset: resetMutation, + isPending: isInitiating, + } = useMutation( { + mutationFn: ( items: RestoreItems ) => initiateDownload( rewindId, items ), + onSuccess: result => { + setDownloadId( result.id ); + setErrorMessage( null ); + }, + onError: ( err: Error ) => { + setErrorMessage( err.message ); + }, + } ); + + const effectiveDownloadId = downloadId ?? -1; + const statusQuery = useQuery( { + queryKey: keys.downloadStatus( rewindId, effectiveDownloadId ), + queryFn: () => fetchDownloadStatus( rewindId, effectiveDownloadId ), + enabled: downloadId !== null, + refetchInterval: query => + query.state.data?.status === 'in-progress' ? POLL_INTERVAL_MS : false, + } ); + + const submit = useCallback( + ( items: RestoreItems ) => mutateInitiate( items ), + [ mutateInitiate ] + ); + const reset = useCallback( () => { + setDownloadId( null ); + setErrorMessage( null ); + resetMutation(); + }, [ resetMutation ] ); + + let state: DownloadState = { phase: 'idle' }; + if ( errorMessage ) { + state = { phase: 'error', message: errorMessage }; + } else if ( isInitiating ) { + state = { phase: 'submitting' }; + } else if ( statusQuery.data?.status === 'completed' && statusQuery.data.url ) { + state = { phase: 'success', downloadUrl: statusQuery.data.url }; + } else if ( statusQuery.data?.status === 'failed' ) { + state = { phase: 'error', message: __( 'Download failed.', 'jetpack-backup-pkg' ) }; + } else if ( downloadId !== null && statusQuery.data ) { + state = { + phase: 'progress', + percent: Math.round( ( statusQuery.data.progress ?? 0 ) * 100 ), + }; + } else if ( downloadId !== null ) { + state = { phase: 'progress', percent: 0 }; + } + + return { state, submit, reset }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts new file mode 100644 index 00000000000..bf4835416f6 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts @@ -0,0 +1,64 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchFileContents } from '../data/api/file-contents'; +import { keys } from '../data/query-client'; + +type Result = { + content: string | null; + isLoading: boolean; + error: Error | null; +}; + +/** + * Base64-encode a manifest path the way WPCOM's file-content endpoint + * expects. UTF-8 safe: plain `window.btoa` rejects code points > 0xFF + * (any non-ASCII filename — uploads with accented or CJK characters, + * for instance), so the browser path encodes to bytes first via + * `TextEncoder`. The Node `Buffer` fallback handles UTF-8 natively. + * + * @param manifestPath - The raw manifest path. + * @return Base64-encoded path. + */ +function encodeManifestPath( manifestPath: string ): string { + if ( typeof window !== 'undefined' && typeof window.btoa === 'function' ) { + const bytes = new TextEncoder().encode( manifestPath ); + let binary = ''; + for ( const byte of bytes ) { + binary += String.fromCharCode( byte ); + } + return window.btoa( binary ); + } + return Buffer.from( manifestPath, 'utf-8' ).toString( 'base64' ); +} + +/** + * Hook fetching a text file's contents for the preview pane. + * + * `filePeriod` and `manifestPath` both come from the file's `/ls` + * row — NOT from the parent backup. VaultPress addresses file content + * by the per-entry snapshot timestamp and the volume-prefixed manifest + * path, so the parent backup's rewindId is irrelevant here. + * + * @param filePeriod - The file's own snapshot timestamp (from /ls `period`). + * @param manifestPath - The volume-prefixed manifest path (from /ls `manifest_path`, e.g. `f5:/wp-config.php`). Base64-encoded before sending. + * @param enabled - When false, the query is skipped (e.g. binary mime types). + * @return Content + loading state. + */ +export function useFileContents( + filePeriod: string | undefined, + manifestPath: string | undefined, + enabled: boolean +): Result { + const safePeriod = filePeriod ?? ''; + const safePath = manifestPath ?? ''; + const query = useQuery( { + queryKey: keys.fileContents( safePeriod, safePath ), + queryFn: () => fetchFileContents( safePeriod, encodeManifestPath( safePath ) ), + enabled: enabled && Boolean( safePeriod ) && Boolean( safePath ), + } ); + + return { + content: query.data?.content ?? null, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts b/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts new file mode 100644 index 00000000000..2b67b1bfa5a --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-tree.ts @@ -0,0 +1,100 @@ +import { useQuery } from '@tanstack/react-query'; +import { useMemo } from '@wordpress/element'; +import { fetchFileTree, type WpcomFileNode } from '../data/api/file-tree'; +import { keys } from '../data/query-client'; +import type { FileNode } from '../types/file-tree'; + +const BASE_FOLDER_PATH = '/'; + +type Result = { + children: FileNode[] | null; + isLoading: boolean; + error: Error | null; +}; + +/** + * Convert a single WPCOM ls entry into the dashboard's `FileNode` shape. + * + * WPCOM returns `contents` as a map keyed by filename; the entry value + * has no `name` of its own, so the key is passed in here as `name`. + * + * The `type` field on the entry is ambiguous: at the backup root WPCOM + * uses `'dir'` / `'file'` / `'wordpress'`, but inside `/wp-content` + * folders like `"languages"` and `"mu-plugins"` are reported as + * `type: 'file'` with `has_children: true`. So the real folder/leaf + * discriminator is `has_children`, not `type`. + * + * `period` is a Unix-seconds timestamp the entry was last modified — + * we surface it as `lastModified` so the FileInfoCard can render the + * date without a separate path-info call. + * + * @param name - The entry's filename (the key in the parent contents map). + * @param raw - The WPCOM ls entry. + * @param parentPath - Path of the folder the entry lives in. + * @return The mapped `FileNode`. + */ +function toFileNode( name: string, raw: WpcomFileNode, parentPath: string ): FileNode { + const fullPath = parentPath === BASE_FOLDER_PATH ? `/${ name }` : `${ parentPath }/${ name }`; + + const isFolder = raw.type === 'dir' || raw.has_children === true; + if ( isFolder ) { + return { + type: 'folder', + name, + path: fullPath, + }; + } + + const lastModified = raw.period + ? new Date( Number.parseInt( raw.period, 10 ) * 1000 ).toISOString() + : undefined; + return { + type: 'file', + name, + path: fullPath, + lastModified, + period: raw.period, + manifestPath: raw.manifest_path, + }; +} + +/** + * Hook returning the children of a folder inside a backup. + * + * Passing `null` for `folderPath` is the "ask for the root tree" idiom; + * the hook treats it as a query for `/`. + * + * @param rewindId - The backup's rewind id (decimal-suffix-safe). + * @param folderPath - Folder to load, or null for the root. + * @return Children list, loading flag, error. + */ +export function useFileTree( rewindId: string, folderPath: string | null ): Result { + const path = folderPath ?? BASE_FOLDER_PATH; + const query = useQuery( { + queryKey: keys.fileTree( rewindId, path ), + queryFn: () => fetchFileTree( rewindId, path ), + enabled: Boolean( rewindId ), + } ); + + const children = useMemo< FileNode[] | null >( () => { + if ( ! query.data ) { + return null; + } + const contents = query.data.contents; + if ( ! contents || typeof contents !== 'object' ) { + return []; + } + // Skip the virtual `wordpress` core-version markers — they have no + // children and no manifest_path, so neither tree-render nor preview + // works on them. + return Object.entries( contents ) + .filter( ( [ , raw ] ) => raw.type !== 'wordpress' ) + .map( ( [ name, raw ] ) => toFileNode( name, raw, path ) ); + }, [ query.data, path ] ); + + return { + children, + isLoading: query.isLoading, + error: query.error ?? null, + }; +} diff --git a/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts b/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts deleted file mode 100644 index 6bdf4f21a0d..00000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts +++ /dev/null @@ -1,21 +0,0 @@ -const MOCK_URL_PARAM = 'jpb-mock'; - -/** - * Hook returning whether the dashboard should use fixture data. - * - * Reads the `?jpb-mock` query parameter from the current URL on every call, - * so client-side navigation that drops the param (e.g. a `` - * back to the overview) flips the answer back to `false` as expected. - * - * @return True when mock mode is active (`?jpb-mock=1`). - */ -export function useIsMockMode(): boolean { - if ( typeof window === 'undefined' ) { - return false; - } - try { - return new URLSearchParams( window.location.search ).has( MOCK_URL_PARAM ); - } catch { - return false; - } -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts deleted file mode 100644 index a4eb35aa05a..00000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from '@wordpress/element'; -import { MOCK_ACTIVITY_LOG } from '../fixtures/activity-log'; -import type { ActivityItem } from '../types/activity'; - -const INITIAL_LOAD_MS = 600; -const PAGE_CHANGE_MS = 150; - -type Args = { - page: number; - pageSize: number; - search: string; -}; - -type Result = { - items: ActivityItem[]; - totalItems: number; - totalPages: number; - isLoading: boolean; - error: Error | null; -}; - -/** - * Predicate that returns true when the given activity item matches the search query. - * - * Matches case-insensitively against the item's title and optional summary — - * the same surface the real `useActivityLog` hook in the data-layer follow-up - * matches against, so swapping the import line preserves search behaviour. - * - * @param item - Activity item to test. - * @param q - Search query (raw, untrimmed). - * @return True when the item matches the query. - */ -function matchesSearch( item: ActivityItem, q: string ): boolean { - if ( ! q ) { - return true; - } - const haystack = `${ item.title } ${ item.summary ?? '' }`.toLowerCase(); - return haystack.includes( q.toLowerCase() ); -} - -/** - * Hook returning a paginated, search-filtered slice of the mock activity log. - * - * Mirrors the shape of the real `useActivityLog` hook (same `Args`, same - * `Result` keys) so the data-layer follow-up can swap the import line - * without touching the consumer. - * - * Adds synthetic latency so the dashboard exercises its loading states even - * with fixture data: a longer delay on first load, a shorter one on every - * page / pageSize / search change after that. - * - * @param args - Query arguments. - * @param args.page - 1-indexed page number. - * @param args.pageSize - Number of items per page. - * @param args.search - Current search query. - * @return The current page of items, total item / page counts, loading flag, and a (mock-always-null) error. - */ -export function useMockActivityLog( { page, pageSize, search }: Args ): Result { - // Tracked in a ref (not state) so settling the first load doesn't itself - // retrigger the effect — a useState here causes a visible spinner blink - // right after the initial 600ms load, with no user input. - const firstLoadDoneRef = useRef( false ); - const [ isLoading, setIsLoading ] = useState( true ); - - useEffect( () => { - const delay = firstLoadDoneRef.current ? PAGE_CHANGE_MS : INITIAL_LOAD_MS; - setIsLoading( true ); - const handle = window.setTimeout( () => { - setIsLoading( false ); - firstLoadDoneRef.current = true; - }, delay ); - return () => window.clearTimeout( handle ); - }, [ page, pageSize, search ] ); - - const { items, totalItems, totalPages } = useMemo( () => { - const filtered = MOCK_ACTIVITY_LOG.filter( item => matchesSearch( item, search ) ); - const total = Math.max( 1, Math.ceil( filtered.length / pageSize ) ); - const start = ( page - 1 ) * pageSize; - return { - items: filtered.slice( start, start + pageSize ), - totalItems: filtered.length, - totalPages: total, - }; - }, [ page, pageSize, search ] ); - - return { items, totalItems, totalPages, isLoading, error: null }; -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts deleted file mode 100644 index 2cab0fe7501..00000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-download.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -type DownloadState = - | { phase: 'idle' } - | { phase: 'submitting' } - | { phase: 'progress'; percent: number } - | { phase: 'success'; downloadUrl: string } - | { phase: 'error'; message: string }; - -const SUBMIT_DELAY_MS = 500; -const PROGRESS_TICK_MS = 250; -const PROGRESS_STEPS = 12; -const ERROR_CHANCE = 0.1; - -type Result = { - state: DownloadState; - submit: () => void; - reset: () => void; -}; - -/** - * Mocked Download state machine. Mirrors `useMockRestore` but the success - * state surfaces a synthetic download URL the UI can render as a link. - * - * Calling `submit()` advances through `submitting → progress → success | error` - * on synthetic timers; ~10% of submits land in the error branch. `reset()` - * returns to `idle` and cancels any in-flight timers. - * - * @return The current state plus `submit` / `reset` callbacks. - */ -export function useMockDownload(): Result { - const [ state, setState ] = useState< DownloadState >( { phase: 'idle' } ); - const timers = useRef< number[] >( [] ); - - const clearTimers = useCallback( () => { - timers.current.forEach( h => window.clearTimeout( h ) ); - timers.current = []; - }, [] ); - - useEffect( () => clearTimers, [ clearTimers ] ); - - const reset = useCallback( () => { - clearTimers(); - setState( { phase: 'idle' } ); - }, [ clearTimers ] ); - - const submit = useCallback( () => { - clearTimers(); - setState( { phase: 'submitting' } ); - const submittingHandle = window.setTimeout( () => { - if ( Math.random() < ERROR_CHANCE ) { - setState( { - phase: 'error', - message: __( - 'The download service returned an error. Try again in a moment.', - 'jetpack-backup-pkg' - ), - } ); - return; - } - let step = 0; - setState( { phase: 'progress', percent: 0 } ); - const tick = () => { - step += 1; - if ( step >= PROGRESS_STEPS ) { - setState( { phase: 'success', downloadUrl: '#mock-download-url' } ); - return; - } - const percent = Math.round( ( step / PROGRESS_STEPS ) * 100 ); - setState( { phase: 'progress', percent } ); - const next = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( next ); - }; - const first = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( first ); - }, SUBMIT_DELAY_MS ); - timers.current.push( submittingHandle ); - }, [ clearTimers ] ); - - return { state, submit, reset }; -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts deleted file mode 100644 index 132b3040545..00000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useEffect, useState } from '@wordpress/element'; -import { findNodeByPath } from '../fixtures/file-tree'; -import { isFolder } from '../types/file-tree'; -import type { FileNode } from '../types/file-tree'; - -const FOLDER_LOAD_MS = 300; - -type Result = { - children: FileNode[] | null; - isLoading: boolean; -}; - -/** - * Hook returning the lazily-loaded children of a folder in the mock - * file tree. Passing a folder path resolves the children after a 300ms - * synthetic delay so the tree exercises its per-folder loading state; - * passing `null` no-ops (keeps the previously-loaded children cached - * so a caller that just collapsed a folder doesn't lose them). - * - * Callers that need the top-level roots should import `MOCK_FILE_TREE` - * directly — this hook only handles non-root lazy loads so a row that - * passes `null` (e.g. a collapsed folder) doesn't accidentally see the - * root tree as its own children. - * - * @param folderPath - Folder path to load, or null to leave state alone. - * @return Loaded children + loading flag. - */ -export function useMockFileTree( folderPath: string | null ): Result { - const [ isLoading, setIsLoading ] = useState( false ); - const [ children, setChildren ] = useState< FileNode[] | null >( null ); - - useEffect( () => { - if ( folderPath === null ) { - setIsLoading( false ); - return; - } - setIsLoading( true ); - const handle = window.setTimeout( () => { - const node = findNodeByPath( folderPath ); - setChildren( node && isFolder( node ) ? node.children ?? [] : [] ); - setIsLoading( false ); - }, FOLDER_LOAD_MS ); - return () => window.clearTimeout( handle ); - }, [ folderPath ] ); - - return { children, isLoading }; -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts deleted file mode 100644 index 429d0a6ba93..00000000000 --- a/projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import type { RestoreState } from '../types/restore'; - -const SUBMIT_DELAY_MS = 500; -const PROGRESS_TICK_MS = 250; -const PROGRESS_STEPS = 12; -const ERROR_CHANCE = 0.1; - -type Result = { - state: RestoreState; - submit: () => void; - reset: () => void; -}; - -/** - * Mocked Restore state machine. - * - * Calling `submit()` advances through `submitting → progress → success | error` - * on synthetic timers; ~10% of submits land in the error branch so the - * error UI is reachable without code changes. `reset()` returns to `idle`. - * - * @return The current state plus `submit` / `reset` callbacks. - */ -export function useMockRestore(): Result { - const [ state, setState ] = useState< RestoreState >( { phase: 'idle' } ); - const timers = useRef< number[] >( [] ); - - const clearTimers = useCallback( () => { - timers.current.forEach( h => window.clearTimeout( h ) ); - timers.current = []; - }, [] ); - - useEffect( () => clearTimers, [ clearTimers ] ); - - const reset = useCallback( () => { - clearTimers(); - setState( { phase: 'idle' } ); - }, [ clearTimers ] ); - - const submit = useCallback( () => { - clearTimers(); - setState( { phase: 'submitting' } ); - const submittingHandle = window.setTimeout( () => { - if ( Math.random() < ERROR_CHANCE ) { - setState( { - phase: 'error', - message: __( - 'The backup service returned an error. Wait a moment and try again.', - 'jetpack-backup-pkg' - ), - } ); - return; - } - let step = 0; - setState( { phase: 'progress', percent: 0 } ); - const tick = () => { - step += 1; - if ( step >= PROGRESS_STEPS ) { - setState( { phase: 'success' } ); - return; - } - const percent = Math.round( ( step / PROGRESS_STEPS ) * 100 ); - setState( { phase: 'progress', percent } ); - const next = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( next ); - }; - const first = window.setTimeout( tick, PROGRESS_TICK_MS ); - timers.current.push( first ); - }, SUBMIT_DELAY_MS ); - timers.current.push( submittingHandle ); - }, [ clearTimers ] ); - - return { state, submit, reset }; -} diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts new file mode 100644 index 00000000000..c8dca8e6c78 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -0,0 +1,94 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { fetchRestoreStatus, initiateRestore } from '../data/api/restore'; +import { keys } from '../data/query-client'; +import type { RestoreItems, RestoreState } from '../types/restore'; + +type Result = { + state: RestoreState; + submit: ( items: RestoreItems ) => void; + reset: () => void; +}; + +const POLL_INTERVAL_MS = 1500; + +/** + * Real `useRestore` driving the modernized Restore screen via the + * `/jetpack/v4/rewind/to/$rewindId` bridge. + * + * Submit kicks off the WPCOM rewind initiate and stores the restore id; + * a polled status query then advances the state machine + * idle → submitting → progress → success | error. + * + * @param rewindId - The backup's rewind id. + * @return state + submit + reset. + */ +export function useRestore( rewindId: string ): Result { + const [ restoreId, setRestoreId ] = useState< number | null >( null ); + const [ errorMessage, setErrorMessage ] = useState< string | null >( null ); + + // Destructure to keep stable references for useCallback deps — + // passing the whole `mutation` object trips + // @tanstack/query/no-unstable-deps. + const { + mutate: kickOff, + reset: resetMutation, + isPending, + } = useMutation( { + mutationFn: ( items: RestoreItems ) => initiateRestore( rewindId, items ), + onSuccess: result => { + setRestoreId( result.id ); + setErrorMessage( null ); + }, + onError: ( err: Error ) => { + setErrorMessage( err.message ); + }, + } ); + + // Always-defined query key so @tanstack/query/exhaustive-deps stays + // happy; the `enabled` flag keeps the placeholder query from firing. + const effectiveRestoreId = restoreId ?? -1; + const statusQuery = useQuery( { + queryKey: keys.restoreStatus( effectiveRestoreId ), + queryFn: () => fetchRestoreStatus( effectiveRestoreId ), + enabled: restoreId !== null, + refetchInterval: query => + query.state.data?.status === 'in-progress' || query.state.data?.status === 'queued' + ? POLL_INTERVAL_MS + : false, + } ); + + const submit = useCallback( ( items: RestoreItems ) => kickOff( items ), [ kickOff ] ); + const reset = useCallback( () => { + setRestoreId( null ); + setErrorMessage( null ); + resetMutation(); + }, [ resetMutation ] ); + + let state: RestoreState = { phase: 'idle' }; + if ( errorMessage ) { + state = { phase: 'error', message: errorMessage }; + } else if ( isPending ) { + state = { phase: 'submitting' }; + } else if ( statusQuery.data?.status === 'finished' ) { + state = { phase: 'success' }; + } else if ( statusQuery.data?.status === 'failed' ) { + // `error_code` is a machine identifier (e.g. `"checksum_mismatch"`) + // — never surface it to users; fall straight through to the + // translated generic message when `message` is empty. + state = { + phase: 'error', + message: statusQuery.data.message || __( 'Restore failed.', 'jetpack-backup-pkg' ), + }; + } else if ( restoreId !== null && statusQuery.data ) { + state = { + phase: 'progress', + percent: Math.round( statusQuery.data.progress ?? 0 ), + }; + } else if ( restoreId !== null ) { + state = { phase: 'progress', percent: 0 }; + } + + return { state, submit, reset }; +} diff --git a/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx b/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx new file mode 100644 index 00000000000..5ba39f6a66d --- /dev/null +++ b/projects/packages/backup/src/dashboard/providers/query-client-provider.tsx @@ -0,0 +1,19 @@ +import { QueryClientProvider as TanStackProvider } from '@tanstack/react-query'; +import { queryClient } from '../data/query-client'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; +}; + +/** + * Wraps children in TanStack's `` with the + * dashboard's shared singleton client. + * + * @param props - Component props. + * @param props.children - Children to wrap. + * @return The provider tree. + */ +export default function QueryClientProvider( { children }: Props ) { + return { children }; +} diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index e9d992ed63c..40e2151b23e 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -1,29 +1,44 @@ import { Notice, ProgressBar, Spinner } from '@wordpress/components'; import { dateI18n } from '@wordpress/date'; -import { useState } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, cloud, download as downloadIcon, arrowLeft } from '@wordpress/icons'; import { Link, useParams } from '@wordpress/route'; import { Button, Card, Stack, Text } from '@wordpress/ui'; import DashboardLayout from '../components/dashboard-layout'; import RestoreItemsChecklist from '../components/restore-items-checklist'; -import { findActivityById } from '../fixtures/activity-log'; -import { useMockDownload } from '../hooks/use-mock-download'; +import { useDownload } from '../hooks/use-download'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; +/** + * Derive an ISO timestamp for the download-point label from the WPCOM + * rewind id (unix seconds, possibly suffixed with a decimal). + * + * @param rewindId - The rewind id from the URL. + * @return ISO timestamp or null when the id isn't numeric. + */ +function rewindIdToIso( rewindId: string ): string | null { + const seconds = Number.parseInt( rewindId, 10 ); + if ( ! Number.isFinite( seconds ) || seconds <= 0 ) { + return null; + } + return new Date( seconds * 1000 ).toISOString(); +} + /** * Download screen — same narrow layout as the Restore screen minus the - * warning notice. Submission runs through a mocked state machine; the - * success branch surfaces a synthetic download URL as a link. + * warning notice. Submission runs through a real state machine via the + * `/jetpack/v4/backups/download/$rewindId` bridge; the success branch + * surfaces the signed download URL as a link. * * @return The rendered Download screen. */ export default function DownloadScreen() { const { rewindId } = useParams( { from: '/download/$rewindId' } ); - const item = findActivityById( rewindId ); - const downloadPoint = item ? item.publishedAt : null; + const downloadPoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); - const { state, submit, reset } = useMockDownload(); + const { state, submit, reset } = useDownload( rewindId ); + const handleGenerate = useCallback( () => submit( items ), [ submit, items ] ); return ( @@ -60,7 +75,7 @@ export default function DownloadScreen() { className="jpb-download__confirm" variant="primary" disabled={ state.phase === 'submitting' } - onClick={ submit } + onClick={ handleGenerate } > { state.phase === 'submitting' ? ( @@ -82,7 +97,12 @@ export default function DownloadScreen() { { __( 'Your download is ready.', 'jetpack-backup-pkg' ) } - + { __( 'Download the file', 'jetpack-backup-pkg' ) } diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index fcf6572c1c8..9b9811b6e1d 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from '@wordpress/element'; +import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { calendar } from '@wordpress/icons'; import { useNavigate, useSearch } from '@wordpress/route'; @@ -7,28 +7,11 @@ import ActivityDetail from '../components/activity-detail'; import ActivityList from '../components/activity-list'; import BackupDetail from '../components/backup-detail'; import DashboardLayout from '../components/dashboard-layout'; -import { MOCK_ACTIVITY_LOG, findActivityById } from '../fixtures/activity-log'; +import { useActivityById, useDefaultBackupRewindId } from '../hooks/use-activity-log'; import { isBackupItem } from '../types/activity'; -import type { ActivityItem } from '../types/activity'; type OverviewSearch = Record< string, unknown > & { selected?: string }; -/** - * Returns the selection id of the newest backup row in the fixture, so the - * Overview can preselect it on first load and keep the right pane populated. - * - * @param items - Activity items to scan. - * @return The newest backup's rewindId, or null when none exists. - */ -function findDefaultSelection( items: readonly ActivityItem[] ): string | null { - for ( const item of items ) { - if ( isBackupItem( item ) ) { - return item.rewindId; - } - } - return null; -} - /** * Overview screen for the modernized Backup dashboard. * @@ -46,7 +29,12 @@ export default function OverviewScreen() { strict: false, } ) as OverviewSearch; const navigate = useNavigate(); - const defaultSelectedId = useMemo( () => findDefaultSelection( MOCK_ACTIVITY_LOG ), [] ); + // Subscribe to the activity-log window so the right pane reconciles + // to the newest backup the moment the first fetch resolves. Until + // then, `defaultSelectedId` is null and the empty-state placeholder + // renders. The hook shares its query key with ActivityList's + // `useActivityLog`, so this doesn't issue an extra fetch. + const defaultSelectedId = useDefaultBackupRewindId(); const selectedId = typeof search.selected === 'string' ? search.selected : defaultSelectedId; const setSelected = useCallback( @@ -95,6 +83,7 @@ export default function OverviewScreen() { * @return The rendered detail card or an empty-state placeholder. */ function RightPane( { selectedId }: { selectedId: string | null } ) { + const item = useActivityById( selectedId ); if ( ! selectedId ) { return (
@@ -102,7 +91,6 @@ function RightPane( { selectedId }: { selectedId: string | null } ) {
); } - const item = findActivityById( selectedId ); if ( ! item ) { return (
diff --git a/projects/packages/backup/src/dashboard/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 60e90966b3d..e73325551fd 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -1,30 +1,45 @@ import { Notice, ProgressBar, Spinner } from '@wordpress/components'; import { dateI18n } from '@wordpress/date'; -import { useState } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Icon, backup as backupIcon, arrowLeft } from '@wordpress/icons'; import { Link, useParams } from '@wordpress/route'; import { Button, Card, Stack, Text } from '@wordpress/ui'; import DashboardLayout from '../components/dashboard-layout'; import RestoreItemsChecklist from '../components/restore-items-checklist'; -import { findActivityById } from '../fixtures/activity-log'; -import { useMockRestore } from '../hooks/use-mock-restore'; +import { useRestore } from '../hooks/use-restore'; import { DEFAULT_RESTORE_ITEMS } from '../types/restore'; +/** + * Derive an ISO timestamp for the restore-point label from the WPCOM + * rewind id. WPCOM rewind ids are unix-seconds (with an optional decimal + * suffix), so we don't need to look the activity row up in cache just + * to render "Restore point: …". + * + * @param rewindId - The rewind id from the URL. + * @return ISO timestamp or null when the id isn't numeric. + */ +function rewindIdToIso( rewindId: string ): string | null { + const seconds = Number.parseInt( rewindId, 10 ); + if ( ! Number.isFinite( seconds ) || seconds <= 0 ) { + return null; + } + return new Date( seconds * 1000 ).toISOString(); +} + /** * Restore screen — narrow centered layout with the warning notice, the - * shared item checklist, and a Confirm button. Submit transitions - * through a synthetic state machine; ~10% of submits land in the error - * branch. + * shared item checklist, and a Confirm button. Submit drives a real + * state machine over the `/jetpack/v4/rewind/to/$rewindId` bridge. * * @return The rendered Restore screen. */ export default function RestoreScreen() { const { rewindId } = useParams( { from: '/restore/$rewindId' } ); - const item = findActivityById( rewindId ); - const restorePoint = item ? item.publishedAt : null; + const restorePoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); - const { state, submit, reset } = useMockRestore(); + const { state, submit, reset } = useRestore( rewindId ); + const handleConfirm = useCallback( () => submit( items ), [ submit, items ] ); return ( @@ -62,7 +77,7 @@ export default function RestoreScreen() { className="jpb-restore__confirm" variant="primary" disabled={ state.phase === 'submitting' } - onClick={ submit } + onClick={ handleConfirm } > { state.phase === 'submitting' ? ( diff --git a/projects/packages/backup/src/dashboard/types/file-tree.ts b/projects/packages/backup/src/dashboard/types/file-tree.ts index 031fecd4e9a..e55aa2a9f2e 100644 --- a/projects/packages/backup/src/dashboard/types/file-tree.ts +++ b/projects/packages/backup/src/dashboard/types/file-tree.ts @@ -10,10 +10,19 @@ export type FolderNode = FileNodeBase & { export type FileNodeFile = FileNodeBase & { type: 'file'; - sizeBytes: number; - mimeType: string; - lastModified: string; - hash: string; + // `/rewind/backup/ls` returns a `period` Unix-seconds timestamp per + // entry — the timestamp when this file itself last changed. The + // hook converts it to an ISO string for display here. + lastModified?: string; + // The same `period` value in its raw Unix-seconds form. Used by the + // file-content preview as the `{period}` URL segment when streaming + // from VaultPress — content is stored against the file's own period, + // not the parent backup's rewindId. + period?: string; + // The volume-prefixed path WPCOM uses to identify this file + // (e.g. "f5:/wp-config.php"). Required for the file-content preview + // call. The display path on `FileNodeBase` strips this prefix. + manifestPath?: string; }; export type FileNode = FolderNode | FileNodeFile; diff --git a/projects/packages/backup/src/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php new file mode 100644 index 00000000000..14c5ea7aaef --- /dev/null +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -0,0 +1,99 @@ + WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_activity_log' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'number' => array( 'type' => 'integer' ), + ), + ) + ); + } + + /** + * Proxy the WPCOM rewindable activity log. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_activity_log( WP_REST_Request $request ) { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + + $query = array_filter( + array( 'number' => $request->get_param( 'number' ) ), + static function ( $value ) { + return null !== $value && '' !== $value; + } + ); + + $endpoint = sprintf( '/sites/%d/activity/rewindable', $blog_id ); + if ( ! empty( $query ) ) { + $endpoint .= '?' . http_build_query( $query ); + } + + $response = Client::wpcom_json_api_request_as_user( + $endpoint, + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'activity_log_fetch_failed', + __( 'Could not fetch the site activity log.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-capabilities-bridge.php b/projects/packages/backup/src/rest/class-capabilities-bridge.php new file mode 100644 index 00000000000..ce527b6605d --- /dev/null +++ b/projects/packages/backup/src/rest/class-capabilities-bridge.php @@ -0,0 +1,97 @@ +` decision tree. + */ +class Capabilities_Bridge { + + /** + * Register the GET /jetpack/v4/site/capabilities route. + * + * @return void + */ + public static function register_routes() { + register_rest_route( + 'jetpack/v4', + '/site/capabilities', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_capabilities' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + ) + ); + } + + /** + * Proxy `/sites/{id}/rewind/capabilities` (v2, as_user) and project + * the response into the shape the React layer expects. + * + * This is the same endpoint the legacy `/jetpack/v4/backup-capabilities` + * route hits — it returns a flat `{ capabilities: [...] }` envelope. + * The earlier `/rewind?force=wpcom` variant returns site *state*, not + * a capabilities list, and on some plan shapes (e.g. Jetpack Complete) + * the `capabilities` key is missing entirely, which produced a false + * "no plan" gate for plans that do include Backup. + * + * @return \WP_REST_Response|WP_Error The decoded capabilities, or WP_Error on failure. + */ + public static function get_capabilities() { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind/capabilities', $blog_id ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'capabilities_fetch_failed', + __( 'Could not fetch site capabilities.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! is_array( $body ) ) { + $body = array(); + } + + $capabilities = isset( $body['capabilities'] ) && is_array( $body['capabilities'] ) + ? $body['capabilities'] + : array(); + + return rest_ensure_response( + array( + 'hasBackupPlan' => in_array( 'backup', $capabilities, true ), + 'hasScan' => in_array( 'scan', $capabilities, true ), + ) + ); + } +} diff --git a/projects/packages/backup/src/rest/class-download-bridge.php b/projects/packages/backup/src/rest/class-download-bridge.php new file mode 100644 index 00000000000..5b8495c5f17 --- /dev/null +++ b/projects/packages/backup/src/rest/class-download-bridge.php @@ -0,0 +1,173 @@ +[A-Za-z0-9.\-]+)', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'initiate_download' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'types' => array( + 'type' => 'object', + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/backups/download/(?P[A-Za-z0-9.\-]+)/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_download_status' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'download_id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ) + ); + } + + /** + * Initiate a backup download. + * + * Proxies POST wpcom/v2 /sites/{id}/rewind/backups/{rewindId}/prepare-download. + * Returns `{ id }` for the React layer (the WPCOM key is `downloadId`). + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function initiate_download( WP_REST_Request $request ) { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + $rewind_id = (string) $request->get_param( 'rewind_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backups/%s/prepare-download', + $blog_id, + rawurlencode( $rewind_id ) + ), + 'v2', + array( 'method' => 'POST' ), + array( + 'types' => $request->get_param( 'types' ) ? $request->get_param( 'types' ) : (object) array(), + ), + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'download_initiate_failed', + __( 'Could not start the backup download.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $download_id = is_array( $body ) && isset( $body['downloadId'] ) ? (int) $body['downloadId'] : 0; + if ( ! $download_id ) { + return new WP_Error( + 'download_initiate_failed', + __( 'Download response missing download id.', 'jetpack-backup-pkg' ), + array( 'status' => 500 ) + ); + } + + return rest_ensure_response( array( 'id' => $download_id ) ); + } + + /** + * Poll download status. + * + * Proxies GET wpcom/v2 /sites/{id}/rewind/backups/{rewindId}/downloads/{downloadId}. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_download_status( WP_REST_Request $request ) { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $download_id = (int) $request->get_param( 'download_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backups/%s/downloads/%d', + $blog_id, + rawurlencode( $rewind_id ), + $download_id + ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'download_status_fetch_failed', + __( 'Could not fetch download status.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-file-browser-bridge.php b/projects/packages/backup/src/rest/class-file-browser-bridge.php new file mode 100644 index 00000000000..e9e5d6d25dc --- /dev/null +++ b/projects/packages/backup/src/rest/class-file-browser-bridge.php @@ -0,0 +1,236 @@ + WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'list_directory' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'path' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/rewind/backup/file-content', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_file_content' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'file_period' => array( + 'type' => 'string', + 'required' => true, + ), + 'encoded_manifest_path' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ) + ); + } + + /** + * List folder children. Proxies POST wpcom/v2 /sites/{id}/rewind/backup/ls. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function list_directory( WP_REST_Request $request ) { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/sites/%d/rewind/backup/ls', $blog_id ), + 'v2', + array( 'method' => 'POST' ), + array( + 'backup_id' => $request->get_param( 'rewind_id' ), + 'path' => $request->get_param( 'path' ), + ), + 'wpcom' + ); + + return self::forward_response( $response, 'backup_ls_fetch_failed', __( 'Could not list backup contents.', 'jetpack-backup-pkg' ) ); + } + + /** + * Fetch a text file's content for the preview pane. + * + * Resolves the one-time signed URL from WPCOM, then fetches the + * stream server-side and caps the body at PREVIEW_MAX_BYTES. + * WPCOM's signed-URL stream endpoint doesn't send CORS headers, so + * the browser can't fetch it directly. + * + * VaultPress stores file content per the file's own snapshot + * `period` — the timestamp when the file last changed — not by the + * parent backup's rewindId. Files don't get re-snapshotted every + * backup, so the `{period}` URL segment below is the per-entry + * `period` from `/ls`, never the rewindId of the backup the user + * is browsing. Sending the rewindId instead silently produces a + * signed URL for a non-existent storage location and 400s with + * "File not found" at stream time. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_file_content( WP_REST_Request $request ) { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + $file_period = (string) $request->get_param( 'file_period' ); + $encoded_manifest_path = (string) $request->get_param( 'encoded_manifest_path' ); + + // Step 1: resolve the signed stream URL. + $url_response = Client::wpcom_json_api_request_as_user( + sprintf( + '/sites/%d/rewind/backup/%s/file/%s/url', + $blog_id, + rawurlencode( $file_period ), + rawurlencode( $encoded_manifest_path ) + ), + 'v2', + array(), + null, + 'wpcom' + ); + + if ( is_wp_error( $url_response ) ) { + return $url_response; + } + + $url_status = wp_remote_retrieve_response_code( $url_response ); + if ( 200 !== $url_status ) { + return new WP_Error( + 'backup_file_content_url_failed', + __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $url_status ) && $url_status > 0 ? $url_status : 500 ) + ); + } + + $url_body = json_decode( wp_remote_retrieve_body( $url_response ), true ); + $signed_url = is_array( $url_body ) && isset( $url_body['url'] ) ? $url_body['url'] : null; + if ( ! $signed_url || ! wp_http_validate_url( $signed_url ) ) { + // Defense-in-depth: WPCOM is supposed to hand back an HTTPS + // URL, but a regression that returned `file://…` or another + // scheme would otherwise reach `wp_remote_get` below. + return new WP_Error( + 'backup_file_content_url_missing', + __( 'Could not resolve file download URL.', 'jetpack-backup-pkg' ), + array( 'status' => 502 ) + ); + } + + // Step 2: fetch the stream body server-side. + // + // `limit_response_size` caps the body at the HTTP-transport + // layer so a multi-GB blob can't be buffered into PHP memory + // before truncation. The bridge enforces no mime check at + // all — the React layer's allowlist is advisory only — so any + // admin can address any blob the WPCOM signer is willing to + // hand a URL for. + $stream_response = wp_remote_get( + $signed_url, + array( + 'timeout' => 15, + 'limit_response_size' => self::PREVIEW_MAX_BYTES, + ) + ); + + if ( is_wp_error( $stream_response ) ) { + return $stream_response; + } + + $stream_status = wp_remote_retrieve_response_code( $stream_response ); + if ( 200 !== $stream_status ) { + return new WP_Error( + 'backup_file_content_stream_failed', + __( 'Could not fetch file content.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $stream_status ) && $stream_status > 0 ? $stream_status : 500 ) + ); + } + + return rest_ensure_response( array( 'content' => wp_remote_retrieve_body( $stream_response ) ) ); + } + + /** + * Shared response forwarder for the bridges that just pass through + * WPCOM JSON. Wraps WP_Error / non-200 responses with bridge-level + * error codes the front-end branches on. + * + * @param array|\WP_Error $response The wp_remote_* response. + * @param string $code Error code for non-200. + * @param string $message Translated error message. + * @return \WP_REST_Response|WP_Error + */ + private static function forward_response( $response, $code, $message ) { + if ( is_wp_error( $response ) ) { + return $response; + } + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + $code, + $message, + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + return rest_ensure_response( json_decode( wp_remote_retrieve_body( $response ), true ) ); + } +} diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php new file mode 100644 index 00000000000..b2f87b8a1c1 --- /dev/null +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -0,0 +1,93 @@ +is_user_connected() ) { + return new WP_Error( + 'user_not_connected', + __( 'Your WordPress.com account is not connected to this site.', 'jetpack-backup-pkg' ), + array( 'status' => 403 ) + ); + } + + return true; + } + + /** + * Returns the site's WPCOM blog id, or a `not_connected` WP_Error + * when the site hasn't been registered yet. Shared across the bridges + * so the `sprintf( '/sites/%d/…', $blog_id )` upstream path is never + * built with an empty id. + * + * @return int|WP_Error Blog id, or WP_Error when not connected. + */ + public static function get_blog_id_or_error() { + $blog_id = (int) Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return new WP_Error( + 'not_connected', + __( 'This site is not connected to Jetpack.', 'jetpack-backup-pkg' ), + array( 'status' => 412 ) + ); + } + return $blog_id; + } +} diff --git a/projects/packages/backup/src/rest/class-restore-bridge.php b/projects/packages/backup/src/rest/class-restore-bridge.php new file mode 100644 index 00000000000..6bd54a7786b --- /dev/null +++ b/projects/packages/backup/src/rest/class-restore-bridge.php @@ -0,0 +1,184 @@ +[A-Za-z0-9.\-]+)', + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( __CLASS__, 'initiate_restore' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'rewind_id' => array( + 'type' => 'string', + 'required' => true, + ), + 'types' => array( + 'type' => 'object', + ), + ), + ) + ); + + register_rest_route( + 'jetpack/v4', + '/rewind/restore/(?P\d+)/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( __CLASS__, 'get_restore_status' ), + 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), + 'args' => array( + 'restore_id' => array( + 'type' => 'integer', + 'required' => true, + ), + ), + ) + ); + } + + /** + * Initiate a restore. + * + * Proxies POST rest/v1 /activity-log/{site}/rewind/to/{rewindId}. + * + * Sign as the user: rewind is a destructive admin action and + * WPCOM's audit log identifies the actor by their wpcom user. + * (We tried as_blog v1.1 to side-step user-permission + * requirements; WPCOM rejects it with `That API call is not + * allowed for this account.` — the rewind endpoint refuses + * blog-token auth entirely.) + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function initiate_restore( WP_REST_Request $request ) { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + $rewind_id = (string) $request->get_param( 'rewind_id' ); + $types = $request->get_param( 'types' ); + + $body = array( + 'force_rewind' => true, + 'types' => is_array( $types ) || is_object( $types ) ? $types : (object) array(), + ); + + // `base_api_path` defaults to 'wpcom' on `as_user`, but the + // activity-log endpoints live under `rest/v1/...` + // (https://public-api.wordpress.com/rest/v1/activity-log/...). + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/activity-log/%d/rewind/to/%s', $blog_id, rawurlencode( $rewind_id ) ), + '1', + array( 'method' => 'POST' ), + $body, + 'rest' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'restore_initiate_failed', + __( 'Could not start the backup restore.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $restore_id_in = is_array( $body ) && isset( $body['restore_id'] ) ? (int) $body['restore_id'] : 0; + if ( ! $restore_id_in ) { + return new WP_Error( + 'restore_initiate_failed', + __( 'Could not start the backup restore.', 'jetpack-backup-pkg' ), + array( 'status' => 500 ) + ); + } + return rest_ensure_response( array( 'id' => $restore_id_in ) ); + } + + /** + * Poll restore status. + * + * Proxies GET rest/v1 /activity-log/{site}/rewind/{restore_id}/restore-status. + * + * @param WP_REST_Request $request The REST request. + * @return \WP_REST_Response|WP_Error + */ + public static function get_restore_status( WP_REST_Request $request ) { + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } + $restore_id = (int) $request->get_param( 'restore_id' ); + + $response = Client::wpcom_json_api_request_as_user( + sprintf( '/activity-log/%d/rewind/%d/restore-status', $blog_id, $restore_id ), + '1', + array(), + null, + 'rest' + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code( $response ); + if ( 200 !== $status_code ) { + return new WP_Error( + 'restore_status_fetch_failed', + __( 'Could not fetch restore progress.', 'jetpack-backup-pkg' ), + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) + ); + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + $status = is_array( $body ) && isset( $body['restore_status'] ) && is_array( $body['restore_status'] ) + ? $body['restore_status'] + : ( is_array( $body ) ? $body : array() ); + + return rest_ensure_response( + array( + 'id' => isset( $status['restore_id'] ) ? (int) $status['restore_id'] : $restore_id, + 'status' => isset( $status['status'] ) ? (string) $status['status'] : 'queued', + 'progress' => isset( $status['percent'] ) ? (float) $status['percent'] : 0, + 'rewind_id' => isset( $status['rewind_id'] ) ? (string) $status['rewind_id'] : '', + 'error_code' => isset( $status['error_code'] ) ? (string) $status['error_code'] : '', + 'message' => isset( $status['message'] ) ? (string) $status['message'] : '', + ) + ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php new file mode 100644 index 00000000000..4200bfdfea8 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php @@ -0,0 +1,93 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * Route registers when the modernization filter is on. + */ + public function test_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/site/rewindable-activity', $routes ); + } + + /** + * Route is gated on manage_options — a subscriber gets 403. + */ + public function test_route_requires_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/site/rewindable-activity' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php new file mode 100644 index 00000000000..8d52e5bc4a2 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php @@ -0,0 +1,93 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * Route registers when the modernization filter is on. + */ + public function test_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/site/capabilities', $routes ); + } + + /** + * Route is gated on manage_options — a subscriber gets 403. + */ + public function test_route_requires_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/site/capabilities' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php new file mode 100644 index 00000000000..6a205adc9a4 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php @@ -0,0 +1,105 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The initiate route registers when the modernization filter is on. + */ + public function test_initiate_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/backups/download/(?P[A-Za-z0-9.\-]+)', $routes ); + } + + /** + * The status route registers when the modernization filter is on. + */ + public function test_status_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/backups/download/(?P[A-Za-z0-9.\-]+)/status', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $initiate_request = new WP_REST_Request( 'POST', '/jetpack/v4/backups/download/123' ); + $initiate_response = $this->server->dispatch( $initiate_request ); + $this->assertSame( 403, $initiate_response->get_status() ); + + $status_request = new WP_REST_Request( 'GET', '/jetpack/v4/backups/download/123/status' ); + $status_request->set_param( 'download_id', 1 ); + $status_response = $this->server->dispatch( $status_request ); + $this->assertSame( 403, $status_response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php new file mode 100644 index 00000000000..4e4d0266e75 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php @@ -0,0 +1,103 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The ls route registers when the modernization filter is on. + */ + public function test_ls_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/ls', $routes ); + } + + /** + * The file-content route registers when the modernization filter is on. + */ + public function test_file_content_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/backup/file-content', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack/v4/rewind/backup/file-content' ); + $request->set_param( 'file_period', '123' ); + $request->set_param( 'encoded_manifest_path', 'abc' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 403, $response->get_status() ); + } +} diff --git a/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php b/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php new file mode 100644 index 00000000000..3ee534be509 --- /dev/null +++ b/projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php @@ -0,0 +1,104 @@ +server = $wp_rest_server; + + add_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + add_action( 'rest_api_init', array( Rest_Controller::class, 'register_routes' ) ); + do_action( 'rest_api_init' ); + } + + /** + * Reset state. + */ + public function tearDown(): void { + remove_filter( Jetpack_Backup::MODERNIZATION_FILTER, '__return_true' ); + wp_set_current_user( 0 ); + + WorDBless_Options::init()->clear_options(); + WorDBless_Users::init()->clear_all_users(); + + parent::tearDown(); + } + + /** + * The initiate route registers when the modernization filter is on. + */ + public function test_initiate_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/to/(?P[A-Za-z0-9.\-]+)', $routes ); + } + + /** + * The status route registers when the modernization filter is on. + */ + public function test_status_route_registers_when_modernized() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/jetpack/v4/rewind/restore/(?P\d+)/status', $routes ); + } + + /** + * Routes are gated on manage_options — a subscriber gets 403. + */ + public function test_routes_require_manage_options() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'subscriber_user', + 'user_pass' => 'dummy_pass', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + + $initiate_request = new WP_REST_Request( 'POST', '/jetpack/v4/rewind/to/123' ); + $initiate_response = $this->server->dispatch( $initiate_request ); + $this->assertSame( 403, $initiate_response->get_status() ); + + $status_request = new WP_REST_Request( 'GET', '/jetpack/v4/rewind/restore/1/status' ); + $status_response = $this->server->dispatch( $status_request ); + $this->assertSame( 403, $status_response->get_status() ); + } +} diff --git a/projects/plugins/backup/changelog/wire-backup-modernization-data-layer b/projects/plugins/backup/changelog/wire-backup-modernization-data-layer new file mode 100644 index 00000000000..5108082a2f2 --- /dev/null +++ b/projects/plugins/backup/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: added +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability/connection checks, drop the mock-mode dev banner. No-op when the modernization filter is off. diff --git a/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer new file mode 100644 index 00000000000..8373feffce9 --- /dev/null +++ b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer @@ -0,0 +1,3 @@ +Significance: patch +Type: other +Comment: Backup: wire the modernized dashboard to real REST endpoints, add Gates for capability/connection checks, drop the mock-mode dev banner. No-op when the modernization filter is off.