From eda63f95f4d497e62cf7f7fb4ecae0c5760f3d54 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:51:46 -0300 Subject: [PATCH 01/10] Backup: add QueryClient + provider to the modernized dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the @tanstack/react-query infrastructure with no behavior change. DashboardLayout now wraps children in a QueryClientProvider so every screen in every route shares a module-scope singleton client — caches survive navigation between routes. Phase 2 setup commit. Capability gates and real REST hooks follow. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 3 + .../wire-backup-modernization-data-layer | 3 + projects/packages/backup/package.json | 1 + .../backup/routes/dashboard/package.json | 5 +- .../backup/routes/download/package.json | 5 +- .../backup/routes/restore/package.json | 5 +- .../components/dashboard-layout/index.tsx | 13 +-- .../backup/src/dashboard/data/api/_helpers.ts | 79 +++++++++++++++++++ .../dashboard/data/api/test/_helpers.test.ts | 15 ++++ .../backup/src/dashboard/data/query-client.ts | 43 ++++++++++ .../providers/query-client-provider.tsx | 19 +++++ .../wire-backup-modernization-data-layer | 3 + .../wire-backup-modernization-data-layer | 3 + 13 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 projects/packages/backup/changelog/wire-backup-modernization-data-layer create mode 100644 projects/packages/backup/src/dashboard/data/api/_helpers.ts create mode 100644 projects/packages/backup/src/dashboard/data/api/test/_helpers.test.ts create mode 100644 projects/packages/backup/src/dashboard/data/query-client.ts create mode 100644 projects/packages/backup/src/dashboard/providers/query-client-provider.tsx create mode 100644 projects/plugins/backup/changelog/wire-backup-modernization-data-layer create mode 100644 projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer 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/dashboard/components/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx index 3e43ce035df..3bc818b8773 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -2,6 +2,7 @@ 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 QueryClientProvider from '../../providers/query-client-provider'; import DevModeBanner from '../dev-mode-banner'; import './style.scss'; import type { ReactNode } from 'react'; @@ -44,11 +45,13 @@ export default function DashboardLayout( { children, actions }: Props ) { hasPadding={ false } actions={ actions } > - -
-
{ children }
-
- + + +
+
{ children }
+
+ +
); } 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/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/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts new file mode 100644 index 00000000000..43f9e7be6f7 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -0,0 +1,43 @@ +import { QueryClient } from '@tanstack/react-query'; + +const STALE_TIME_DEFAULT_MS = 30_000; +const GC_TIME_DEFAULT_MS = 5 * 60_000; + +/** + * Module-scope QueryClient shared across the modernized Backup dashboard. + * + * Each wp-build route mounts its own `` (via + * ``), but the client itself is created once so the + * cache survives the per-route bundle re-mount on navigation. + */ +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, + activityLog: ( args: { page: number; pageSize: number; search: string } ) => + [ 'backup', 'activity-log', args ] 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, + pathInfo: ( rewindId: string, path: string | null ) => + [ 'backup', 'path-info', 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/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/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..5108082a2f2 --- /dev/null +++ b/projects/plugins/jetpack/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. From 0ed41dee9424378d46b02882746e451e1383364f Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:52:17 -0300 Subject: [PATCH 02/10] Backup: add Capabilities REST bridge + Gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the per-feature REST bridges pattern with a thin Rest_Controller that registers routes only when the modernization filter is on. Capabilities_Bridge proxies /sites/{id}/rewind to expose hasBackupPlan + planSlug for the React layer. Gates mounts inside DashboardLayout between the QueryClientProvider and the body slot. Top-to-bottom: loading → not-connected → secondary-admin → no-plan → children. All three routes inherit gating for free. Connection state comes from window.JP_CONNECTION_INITIAL_STATE, emitted inline on the modernized Backup admin page from class-jetpack-backup.php (the modernized enqueue path skips the legacy render_script call, so we need to put the global up ourselves). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/class-jetpack-backup.php | 24 ++++- .../components/dashboard-layout/index.tsx | 5 +- .../src/dashboard/components/gates/index.tsx | 49 +++++++++ .../components/gates/no-backup-plan.tsx | 32 ++++++ .../components/gates/not-connected.tsx | 35 ++++++ .../components/gates/secondary-admin.tsx | 36 +++++++ .../src/dashboard/components/gates/style.scss | 31 ++++++ .../src/dashboard/data/api/capabilities.ts | 19 ++++ .../src/dashboard/hooks/use-capabilities.ts | 29 +++++ .../src/dashboard/hooks/use-connection.ts | 53 +++++++++ .../src/rest/class-capabilities-bridge.php | 102 ++++++++++++++++++ .../backup/src/rest/class-rest-controller.php | 52 +++++++++ 12 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/components/gates/index.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/no-backup-plan.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/not-connected.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/secondary-admin.tsx create mode 100644 projects/packages/backup/src/dashboard/components/gates/style.scss create mode 100644 projects/packages/backup/src/dashboard/data/api/capabilities.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-capabilities.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-connection.ts create mode 100644 projects/packages/backup/src/rest/class-capabilities-bridge.php create mode 100644 projects/packages/backup/src/rest/class-rest-controller.php 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/dashboard-layout/index.tsx b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx index 3bc818b8773..5d981f6cb4c 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -4,6 +4,7 @@ import { Page } from '@wordpress/admin-ui'; import { __ } from '@wordpress/i18n'; import QueryClientProvider from '../../providers/query-client-provider'; import DevModeBanner from '../dev-mode-banner'; +import Gates from '../gates'; import './style.scss'; import type { ReactNode } from 'react'; @@ -48,7 +49,9 @@ export default function DashboardLayout( { children, actions }: Props ) {
-
{ children }
+
+ { children } +
diff --git a/projects/packages/backup/src/dashboard/components/gates/index.tsx b/projects/packages/backup/src/dashboard/components/gates/index.tsx new file mode 100644 index 00000000000..2dcd25032aa --- /dev/null +++ b/projects/packages/backup/src/dashboard/components/gates/index.tsx @@ -0,0 +1,49 @@ +import { Spinner } from '@wordpress/components'; +import { useCapabilities } from '../../hooks/use-capabilities'; +import { useConnection } from '../../hooks/use-connection'; +import NoBackupPlanScreen from './no-backup-plan'; +import NotConnectedScreen from './not-connected'; +import SecondaryAdminScreen from './secondary-admin'; +import './style.scss'; +import type { ReactNode } from 'react'; + +type Props = { + children: ReactNode; +}; + +/** + * Capability + connection gate that wraps the modernized dashboard body. + * + * Top-to-bottom decision tree, first match wins: + * loading → not-connected → secondary-admin → no-plan → children + * + * @param props - Component props. + * @param props.children - The dashboard body to render when all gates pass. + * @return The matching fallback screen, or `children`. + */ +export default function Gates( { children }: Props ) { + const connection = useConnection(); + const capabilities = useCapabilities(); + + if ( ! connection.isLoaded || capabilities.isLoading ) { + return ( +
+ +
+ ); + } + + 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/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/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..80157a6e5bf --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -0,0 +1,53 @@ +import { useMemo } from '@wordpress/element'; + +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, and the connection state is page-load static anyway — + * a single `window` read at hook-mount time covers every consumer. + * + * @return Connection state. + */ +export function useConnection(): Result { + return useMemo( () => { + 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/rest/class-capabilities-bridge.php b/projects/packages/backup/src/rest/class-capabilities-bridge.php new file mode 100644 index 00000000000..2d73e7ebcb4 --- /dev/null +++ b/projects/packages/backup/src/rest/class-capabilities-bridge.php @@ -0,0 +1,102 @@ +` 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 = 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 ) + ); + } + + $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-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php new file mode 100644 index 00000000000..a117d1cecb3 --- /dev/null +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -0,0 +1,52 @@ + Date: Sat, 16 May 2026 16:52:52 -0300 Subject: [PATCH 03/10] Backup: wire the activity list to /jetpack/v4/activity-log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces useMockActivityLog with useActivityLog backed by an apiFetch -> bridge -> WPCOM /sites/{id}/activity/rewindable chain. A small normalize/activity-log.ts maps WPCOM's rewindable-activity shape (gridicon, content.text, object.backup_stats) onto the ActivityItem discriminated union the UI already consumes. The hook returns `totalItems` in addition to `totalPages` because the trunk ActivityList drives DataViews' `paginationInfo` from both. The WPCOM endpoint doesn't accept `page` or `search`, so the hook fetches a 100-item window and filters/paginates client-side — DataViews remains the single source of truth for the visible slice. Overview's right-pane lookup swaps from the fixture's findActivityById to getCachedActivityById, which reads the same single-window cache the list populates. The default selection (newest backup) is derived from the cache the same way. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/activity-list/index.tsx | 9 +- .../src/dashboard/data/api/activity-log.ts | 47 +++++++ .../dashboard/data/normalize/activity-log.ts | 72 ++++++++++ .../data/normalize/test/activity-log.test.ts | 79 +++++++++++ .../src/dashboard/hooks/use-activity-log.ts | 127 ++++++++++++++++++ .../backup/src/dashboard/screens/overview.tsx | 29 ++-- .../src/rest/class-activity-log-bridge.php | 99 ++++++++++++++ 7 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/activity-log.ts create mode 100644 projects/packages/backup/src/dashboard/data/normalize/activity-log.ts create mode 100644 projects/packages/backup/src/dashboard/data/normalize/test/activity-log.test.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-activity-log.ts create mode 100644 projects/packages/backup/src/rest/class-activity-log-bridge.php 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/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts new file mode 100644 index 00000000000..7e04059bb03 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -0,0 +1,47 @@ +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; + aggregate?: boolean; + after?: string; + before?: string; +}; + +/** + * Fetch the rewindable activity log entries from the bridge. + * + * @param args - Optional pagination and filter args. + * @return The raw WPCOM-shaped response. + */ +export async function fetchActivityLog( + args: FetchArgs = {} +): Promise< WpcomActivityLogResponse > { + return apiCall< WpcomActivityLogResponse >( { + path: apiPath( '/activity-log', { + number: args.number, + aggregate: args.aggregate, + after: args.after, + before: args.before, + } ), + } ); +} 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/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts new file mode 100644 index 00000000000..413f7d402a0 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -0,0 +1,127 @@ +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, queryClient } 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; + +/** + * 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 = useQuery( { + queryKey: keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ), + queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), + } ); + + 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 React Query cache. + * + * Reads the same single window the hook fetches, so the right-pane + * detail card on Overview can resolve its selected id without issuing + * an extra request. + * + * @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 getCachedActivityById( id: string ): ActivityItem | null { + const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( + keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) + ); + const items = normalizeActivityLog( data?.current?.orderedItems ); + return ( + items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? null + ); +} + +/** + * Returns the newest backup row in the cached activity log, or null when + * the cache isn't populated or holds no backup rows. + * + * Used by the Overview to preselect a default right-pane on first load. + * + * @return The newest backup item's rewindId, or null. + */ +export function getCachedDefaultBackupRewindId(): string | null { + const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( + keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) + ); + const items = normalizeActivityLog( data?.current?.orderedItems ); + for ( const item of items ) { + if ( item.kind === 'backup' ) { + return item.rewindId; + } + } + return null; +} diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index fcf6572c1c8..3a2a32c9431 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 { getCachedActivityById, getCachedDefaultBackupRewindId } 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,11 @@ export default function OverviewScreen() { strict: false, } ) as OverviewSearch; const navigate = useNavigate(); - const defaultSelectedId = useMemo( () => findDefaultSelection( MOCK_ACTIVITY_LOG ), [] ); + // The default selection comes from the activity-log cache; ActivityList + // populates that cache on mount, so the right pane will reconcile to + // the newest backup once the first fetch resolves. Until then, + // `selectedId` is null and the empty-state placeholder renders. + const defaultSelectedId = getCachedDefaultBackupRewindId(); const selectedId = typeof search.selected === 'string' ? search.selected : defaultSelectedId; const setSelected = useCallback( @@ -102,7 +89,7 @@ function RightPane( { selectedId }: { selectedId: string | null } ) { ); } - const item = findActivityById( selectedId ); + const item = getCachedActivityById( selectedId ); if ( ! item ) { return (
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..d7044ad77ed --- /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' ), + 'aggregate' => array( 'type' => 'boolean' ), + 'after' => array( 'type' => 'string' ), + 'before' => array( 'type' => 'string' ), + ), + ) + ); + } + + /** + * 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 = Jetpack_Options::get_option( 'id' ); + + $query = array_filter( + array( + 'number' => $request->get_param( 'number' ), + 'aggregate' => $request->get_param( 'aggregate' ) ? 'true' : null, + 'after' => $request->get_param( 'after' ), + 'before' => $request->get_param( 'before' ), + ), + 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 ) ); + } +} From 951f23a7b726be2fdb26c8d903957f2bad14d158 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:53:12 -0300 Subject: [PATCH 04/10] Backup: wire the file browser to /jetpack/v4/rewind/backup/$rewindId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the File_Browser_Bridge (ls + path-info + file-content), each proxying WPCOM /sites/{id}/rewind/backup/* via as_user. The file-content endpoint resolves the signed stream URL server-side and caps the returned body at 64 KB so previews can't balloon the REST response. React side: useFileTree replaces useMockFileTree, useFileContents replaces the static findContents() fixture lookup. The rewindId prop on FileBrowser is now actually consumed and threaded through every NodeRow so each folder fetches its own children. FileInfoCard no longer reads metadata from fixture nodes — usePathInfo fetches size + mime + last-modified for the open file, and useFileContents pulls the preview body only when the mime type is text-shaped. FileNodeFile's metadata fields drop to optional since WPCOM's /ls endpoint only returns name + type. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/file-browser/index.tsx | 102 ++++---- .../components/file-info-card/index.tsx | 166 +++++++++---- .../src/dashboard/data/api/file-contents.ts | 29 +++ .../src/dashboard/data/api/file-tree.ts | 47 ++++ .../backup/src/dashboard/data/query-client.ts | 2 - .../src/dashboard/hooks/use-file-contents.ts | 57 +++++ .../src/dashboard/hooks/use-file-tree.ts | 100 ++++++++ .../backup/src/dashboard/types/file-tree.ts | 17 +- .../src/rest/class-file-browser-bridge.php | 221 ++++++++++++++++++ 9 files changed, 646 insertions(+), 95 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/file-contents.ts create mode 100644 projects/packages/backup/src/dashboard/data/api/file-tree.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-file-contents.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-file-tree.ts create mode 100644 projects/packages/backup/src/rest/class-file-browser-bridge.php 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..abb81825454 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'; @@ -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,9 +466,10 @@ 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; }; @@ -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' ) } - - ) } +
{ + 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/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts index 43f9e7be6f7..72aad9119db 100644 --- a/projects/packages/backup/src/dashboard/data/query-client.ts +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -35,8 +35,6 @@ export const keys = { [ 'backup', 'file-tree', rewindId, folderPath ] as const, fileContents: ( rewindId: string, path: string ) => [ 'backup', 'file-contents', rewindId, path ] as const, - pathInfo: ( rewindId: string, path: string | null ) => - [ 'backup', 'path-info', 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/hooks/use-file-contents.ts b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts new file mode 100644 index 00000000000..05adac33865 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts @@ -0,0 +1,57 @@ +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. Uses `window.btoa` in the browser and falls back to Node's + * `Buffer` for tests/SSR. + * + * @param manifestPath - The raw manifest path. + * @return Base64-encoded path. + */ +function encodeManifestPath( manifestPath: string ): string { + if ( typeof window !== 'undefined' && typeof window.btoa === 'function' ) { + return window.btoa( manifestPath ); + } + 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/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-file-browser-bridge.php b/projects/packages/backup/src/rest/class-file-browser-bridge.php new file mode 100644 index 00000000000..a2c13f534c9 --- /dev/null +++ b/projects/packages/backup/src/rest/class-file-browser-bridge.php @@ -0,0 +1,221 @@ + 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 = Jetpack_Options::get_option( '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 = Jetpack_Options::get_option( '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 ) { + 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. + $stream_response = wp_remote_get( $signed_url, array( 'timeout' => 15 ) ); + + 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 ) + ); + } + + $content = wp_remote_retrieve_body( $stream_response ); + + if ( strlen( $content ) > self::PREVIEW_MAX_BYTES ) { + $content = substr( $content, 0, self::PREVIEW_MAX_BYTES ); + } + + return rest_ensure_response( array( 'content' => $content ) ); + } + + /** + * 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 ) ); + } +} From daae1a827cabe67a9278586769c5e9a340473e75 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:53:28 -0300 Subject: [PATCH 05/10] Backup: wire Download to /jetpack/v4/backups/download/$rewindId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useDownload replaces useMockDownload, driving the same idle → submitting → progress → success | error state machine off a TanStack useMutation (initiate) + polled useQuery (status). The bridge proxies WPCOM's /rewind/backups/{rewindId}/prepare-download and /rewind/backups/{rewindId}/downloads/{id} endpoints. The screen now derives its "download point" timestamp directly from the rewindId (it's unix-seconds) instead of resolving it through the activity-log fixture — Download / Restore can stand alone without relying on Overview having populated its cache first. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/dashboard/data/api/download.ts | 43 +++++ .../src/dashboard/hooks/use-download.ts | 87 +++++++++ .../backup/src/dashboard/screens/download.tsx | 28 ++- .../backup/src/rest/class-download-bridge.php | 168 ++++++++++++++++++ 4 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/download.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-download.ts create mode 100644 projects/packages/backup/src/rest/class-download-bridge.php 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..e494b4d30a3 --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/download.ts @@ -0,0 +1,43 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +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. + * + * @param rewindId - The backup's rewind id. + * @return The download id. + */ +export async function initiateDownload( rewindId: string ): Promise< InitiateDownloadResponse > { + return apiCall< InitiateDownloadResponse >( { + path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + } ); +} + +/** + * 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/hooks/use-download.ts b/projects/packages/backup/src/dashboard/hooks/use-download.ts new file mode 100644 index 00000000000..a959fe95e57 --- /dev/null +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -0,0 +1,87 @@ +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'; + +type DownloadState = + | { phase: 'idle' } + | { phase: 'submitting' } + | { phase: 'progress'; percent: number } + | { phase: 'success'; downloadUrl: string } + | { phase: 'error'; message: string }; + +type Result = { + state: DownloadState; + submit: () => 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: () => initiateDownload( rewindId ), + 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( () => mutateInitiate(), [ 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/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index e9d992ed63c..53b2fa33a32 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -7,23 +7,37 @@ 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 ); return ( 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..d2730e87e54 --- /dev/null +++ b/projects/packages/backup/src/rest/class-download-bridge.php @@ -0,0 +1,168 @@ +[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 = Jetpack_Options::get_option( '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 = Jetpack_Options::get_option( '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 ) ); + } +} From a6b6557d9308ce293f724f521873c48005a7a90c Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:53:43 -0300 Subject: [PATCH 06/10] Backup: wire Restore to /jetpack/v4/rewind/to/$rewindId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useRestore replaces useMockRestore, driving the same state machine off a TanStack useMutation (initiate) + polled useQuery (status). The bridge proxies WPCOM's v1 /activity-log/{site}/rewind/to/{rewindId} and /rewind/{restoreId}/restore-status endpoints, with the verbatim comment explaining why the rewind endpoint must sign as_user (blog-token auth is rejected by WPCOM with "That API call is not allowed for this account"). Same rewindId → unix-seconds derivation as the Download screen, so Restore stands alone without depending on Overview's cache. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backup/src/dashboard/data/api/restore.ts | 39 ++++ .../backup/src/dashboard/hooks/use-restore.ts | 94 +++++++++ .../backup/src/dashboard/screens/restore.tsx | 30 ++- .../backup/src/rest/class-restore-bridge.php | 183 ++++++++++++++++++ 4 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 projects/packages/backup/src/dashboard/data/api/restore.ts create mode 100644 projects/packages/backup/src/dashboard/hooks/use-restore.ts create mode 100644 projects/packages/backup/src/rest/class-restore-bridge.php 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..1b29f13e01f --- /dev/null +++ b/projects/packages/backup/src/dashboard/data/api/restore.ts @@ -0,0 +1,39 @@ +import { apiCall, apiPath, toIntRewindId } from './_helpers'; + +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. + * + * @param rewindId - The backup's rewind id. + * @return The restore id. + */ +export async function initiateRestore( rewindId: string ): Promise< InitiateRestoreResponse > { + return apiCall< InitiateRestoreResponse >( { + path: apiPath( `/rewind/to/${ toIntRewindId( rewindId ) }` ), + method: 'POST', + } ); +} + +/** + * 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/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts new file mode 100644 index 00000000000..526477241e1 --- /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 { RestoreState } from '../types/restore'; + +type Result = { + state: RestoreState; + submit: () => 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: () => initiateRestore( rewindId ), + 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( () => kickOff(), [ 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' ) { + state = { + phase: 'error', + message: + statusQuery.data.message || + statusQuery.data.error_code || + __( '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/screens/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 60e90966b3d..1f07ed97319 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -7,24 +7,38 @@ 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 ); return ( 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..36d3102194b --- /dev/null +++ b/projects/packages/backup/src/rest/class-restore-bridge.php @@ -0,0 +1,183 @@ +[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 = Jetpack_Options::get_option( '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 ) { + $upstream = json_decode( wp_remote_retrieve_body( $response ), true ); + 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, + 'upstream' => is_array( $upstream ) ? $upstream : null, + ) + ); + } + + $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', + __( 'Restore response missing restore id.', '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 = Jetpack_Options::get_option( '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'] : '', + ) + ); + } +} From 3f62dfaf109c1614d743ba62be0c4fd487a7db3c Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:54:20 -0300 Subject: [PATCH 07/10] Backup: drop the ?jpb-mock=1 dev-mode banner now that everything is real Removes the last mock-mode artifacts: useIsMockMode, the dev banner component, the four mock hooks, the three fixture files, and the mount inside DashboardLayout. The modernized Backup dashboard now only renders real data via the jetpack/v4/* bridges, with handling the not-connected and no-plan fallbacks. This completes Phase 2 of the Backup modernization. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/dashboard-layout/index.tsx | 2 - .../components/dev-mode-banner/index.tsx | 26 ------ .../components/dev-mode-banner/style.scss | 9 -- .../src/dashboard/fixtures/activity-log.ts | 89 ------------------- .../src/dashboard/fixtures/file-contents.ts | 47 ---------- .../src/dashboard/fixtures/file-tree.ts | 81 ----------------- .../src/dashboard/hooks/use-is-mock-mode.ts | 21 ----- .../dashboard/hooks/use-mock-activity-log.ts | 87 ------------------ .../src/dashboard/hooks/use-mock-download.ts | 82 ----------------- .../src/dashboard/hooks/use-mock-file-tree.ts | 47 ---------- .../src/dashboard/hooks/use-mock-restore.ts | 75 ---------------- 11 files changed, 566 deletions(-) delete mode 100644 projects/packages/backup/src/dashboard/components/dev-mode-banner/index.tsx delete mode 100644 projects/packages/backup/src/dashboard/components/dev-mode-banner/style.scss delete mode 100644 projects/packages/backup/src/dashboard/fixtures/activity-log.ts delete mode 100644 projects/packages/backup/src/dashboard/fixtures/file-contents.ts delete mode 100644 projects/packages/backup/src/dashboard/fixtures/file-tree.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-is-mock-mode.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-activity-log.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-download.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-file-tree.ts delete mode 100644 projects/packages/backup/src/dashboard/hooks/use-mock-restore.ts 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 5d981f6cb4c..0fb1189953d 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -3,7 +3,6 @@ import JetpackLogo from '@automattic/jetpack-components/jetpack-logo'; import { Page } from '@wordpress/admin-ui'; import { __ } from '@wordpress/i18n'; import QueryClientProvider from '../../providers/query-client-provider'; -import DevModeBanner from '../dev-mode-banner'; import Gates from '../gates'; import './style.scss'; import type { ReactNode } from 'react'; @@ -47,7 +46,6 @@ export default function DashboardLayout( { children, actions }: Props ) { actions={ actions } > -
{ 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/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-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 }; -} From 151d7c90d66e8e9c575a4d4eb021f4ae0ea02afb Mon Sep 17 00:00:00 2001 From: Douglas Date: Sat, 16 May 2026 16:54:33 -0300 Subject: [PATCH 08/10] Backup: phpunit smoke tests for the modernized REST bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One test class per bridge: - Rest_Capabilities_Bridge_Test (2 tests, 2 assertions) - Rest_Activity_Log_Bridge_Test (2 tests, 2 assertions) - Rest_File_Browser_Bridge_Test (4 tests, 4 assertions) - Rest_Download_Bridge_Test (3 tests, 4 assertions) - Rest_Restore_Bridge_Test (3 tests, 4 assertions) Each class confirms the routes only register when the modernization filter is on and that the manage_options permission_check rejects subscriber-level access with a 403. Upstream WPCOM calls aren't exercised here — they need network and a real connection. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../php/Rest_Activity_Log_Bridge_Test.php | 93 ++++++++++++++++ .../php/Rest_Capabilities_Bridge_Test.php | 93 ++++++++++++++++ .../tests/php/Rest_Download_Bridge_Test.php | 105 ++++++++++++++++++ .../php/Rest_File_Browser_Bridge_Test.php | 103 +++++++++++++++++ .../tests/php/Rest_Restore_Bridge_Test.php | 104 +++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_Capabilities_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_Download_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_File_Browser_Bridge_Test.php create mode 100644 projects/packages/backup/tests/php/Rest_Restore_Bridge_Test.php 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..fcfc6d3a07a --- /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/activity-log', $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/activity-log' ); + $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() ); + } +} From d4d229e66407118dbd0c32a036939e7e5a9c7e19 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 17 May 2026 01:00:06 -0300 Subject: [PATCH 09/10] Backup: address PR review feedback on the modernized data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REST bridges: - Centralize the not-connected check via Rest_Controller::get_blog_id_or_error so every bridge short-circuits with a 412 instead of building /sites/0/… - Require a user-level WPCOM connection in Rest_Controller::permission_check, matching the jetpack-activity-log package's pattern - Rename Activity_Log_Bridge's route from /jetpack/v4/activity-log → /jetpack/v4/site/rewindable-activity to avoid colliding with the jetpack-activity-log package, which owns that route inside the Jetpack plugin - Cap File_Browser_Bridge's preview proxy at the HTTP transport layer (limit_response_size) so multi-GB blobs can't be buffered into PHP memory before truncation, and validate the signed URL with wp_http_validate_url - Drop the raw upstream WPCOM body from Restore_Bridge's WP_Error envelope — the React layer only reads err.message React layer: - Convert getCachedActivityById / getCachedDefaultBackupRewindId to useQuery-backed hooks so Overview's default selection reconciles to the newest backup the moment the activity-log window resolves - Collapse the activityLog cache key to a single shared activityLogWindow key the hook always uses - Drop the raw error_code fallback in useRestore — never surface machine identifiers like "checksum_mismatch" to users - Refresh stale doc comments (DashboardLayout no longer mentions the dev-mode banner; FileBrowser references useFileTree instead of useMockFileTree) Changelog: - Align the Jetpack plugin entry with Phase 1: Type: other for the flag-gated rollout Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/dashboard-layout/index.tsx | 6 +- .../components/file-browser/index.tsx | 4 +- .../src/dashboard/data/api/activity-log.ts | 2 +- .../backup/src/dashboard/data/query-client.ts | 7 +- .../src/dashboard/hooks/use-activity-log.ts | 81 +++++++++++-------- .../backup/src/dashboard/hooks/use-restore.ts | 8 +- .../backup/src/dashboard/screens/overview.tsx | 15 ++-- .../src/rest/class-activity-log-bridge.php | 14 +++- .../src/rest/class-capabilities-bridge.php | 11 +-- .../backup/src/rest/class-download-bridge.php | 11 ++- .../src/rest/class-file-browser-bridge.php | 39 ++++++--- .../backup/src/rest/class-rest-controller.php | 45 ++++++++++- .../backup/src/rest/class-restore-bridge.php | 17 ++-- .../php/Rest_Activity_Log_Bridge_Test.php | 6 +- .../wire-backup-modernization-data-layer | 2 +- 15 files changed, 177 insertions(+), 91 deletions(-) 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 0fb1189953d..129c9b8581d 100644 --- a/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx +++ b/projects/packages/backup/src/dashboard/components/dashboard-layout/index.tsx @@ -18,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 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 abb81825454..afaec619040 100644 --- a/projects/packages/backup/src/dashboard/components/file-browser/index.tsx +++ b/projects/packages/backup/src/dashboard/components/file-browser/index.tsx @@ -300,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. * @@ -474,7 +474,7 @@ type NodeRowProps = { }; /** - * 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. * diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts index 7e04059bb03..9ce53e0b91c 100644 --- a/projects/packages/backup/src/dashboard/data/api/activity-log.ts +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -37,7 +37,7 @@ export async function fetchActivityLog( args: FetchArgs = {} ): Promise< WpcomActivityLogResponse > { return apiCall< WpcomActivityLogResponse >( { - path: apiPath( '/activity-log', { + path: apiPath( '/site/rewindable-activity', { number: args.number, aggregate: args.aggregate, after: args.after, diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts index 72aad9119db..3ccbd328bda 100644 --- a/projects/packages/backup/src/dashboard/data/query-client.ts +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -29,8 +29,11 @@ export const queryClient = new QueryClient( { */ export const keys = { capabilities: () => [ 'backup', 'capabilities' ] as const, - activityLog: ( args: { page: number; pageSize: number; search: string } ) => - [ 'backup', 'activity-log', args ] 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 ) => diff --git a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts index 413f7d402a0..08a6d554ece 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-activity-log.ts @@ -2,7 +2,7 @@ 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, queryClient } from '../data/query-client'; +import { keys } from '../data/query-client'; import type { ActivityItem } from '../types/activity'; type Args = { @@ -21,6 +21,23 @@ type Result = { 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. @@ -55,10 +72,7 @@ function matchesSearch( item: ActivityItem, q: string ): boolean { * @return Items, total items, total pages, loading, error. */ export function useActivityLog( { page, pageSize, search }: Args ): Result { - const query = useQuery( { - queryKey: keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ), - queryFn: () => fetchActivityLog( { number: MAX_FETCH_ITEMS } ), - } ); + const query = useActivityLogWindowQuery(); const allItems = useMemo( () => normalizeActivityLog( query.data?.current?.orderedItems ), @@ -86,42 +100,45 @@ export function useActivityLog( { page, pageSize, search }: Args ): Result { } /** - * Look up a single activity item from the React Query cache. - * - * Reads the same single window the hook fetches, so the right-pane - * detail card on Overview can resolve its selected id without issuing - * an extra request. + * 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 getCachedActivityById( id: string ): ActivityItem | null { - const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( - keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) - ); - const items = normalizeActivityLog( data?.current?.orderedItems ); - return ( - items.find( item => ( item.kind === 'backup' ? item.rewindId === id : item.id === id ) ) ?? null - ); +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 activity log, or null when - * the cache isn't populated or holds no backup rows. - * - * Used by the Overview to preselect a default right-pane on first load. + * 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 getCachedDefaultBackupRewindId(): string | null { - const data = queryClient.getQueryData< Awaited< ReturnType< typeof fetchActivityLog > > >( - keys.activityLog( { page: 1, pageSize: MAX_FETCH_ITEMS, search: '' } ) - ); - const items = normalizeActivityLog( data?.current?.orderedItems ); - for ( const item of items ) { - if ( item.kind === 'backup' ) { - return item.rewindId; +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; + return null; + }, [ query.data ] ); } diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts index 526477241e1..0cc30fce687 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-restore.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -74,12 +74,12 @@ export function useRestore( rewindId: string ): Result { } 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 || - statusQuery.data.error_code || - __( 'Restore failed.', 'jetpack-backup-pkg' ), + message: statusQuery.data.message || __( 'Restore failed.', 'jetpack-backup-pkg' ), }; } else if ( restoreId !== null && statusQuery.data ) { state = { diff --git a/projects/packages/backup/src/dashboard/screens/overview.tsx b/projects/packages/backup/src/dashboard/screens/overview.tsx index 3a2a32c9431..9b9811b6e1d 100644 --- a/projects/packages/backup/src/dashboard/screens/overview.tsx +++ b/projects/packages/backup/src/dashboard/screens/overview.tsx @@ -7,7 +7,7 @@ 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 { getCachedActivityById, getCachedDefaultBackupRewindId } from '../hooks/use-activity-log'; +import { useActivityById, useDefaultBackupRewindId } from '../hooks/use-activity-log'; import { isBackupItem } from '../types/activity'; type OverviewSearch = Record< string, unknown > & { selected?: string }; @@ -29,11 +29,12 @@ export default function OverviewScreen() { strict: false, } ) as OverviewSearch; const navigate = useNavigate(); - // The default selection comes from the activity-log cache; ActivityList - // populates that cache on mount, so the right pane will reconcile to - // the newest backup once the first fetch resolves. Until then, - // `selectedId` is null and the empty-state placeholder renders. - const defaultSelectedId = getCachedDefaultBackupRewindId(); + // 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( @@ -82,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 (
@@ -89,7 +91,6 @@ function RightPane( { selectedId }: { selectedId: string | null } ) {
); } - const item = getCachedActivityById( selectedId ); if ( ! item ) { return (
diff --git a/projects/packages/backup/src/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php index d7044ad77ed..2b101e4e202 100644 --- a/projects/packages/backup/src/rest/class-activity-log-bridge.php +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -30,9 +29,15 @@ class Activity_Log_Bridge { * @return void */ public static function register_routes() { + // NOTE: not `/activity-log` — that namespace is owned by the + // `automattic/jetpack-activity-log` package's REST controller, + // which is loaded inside the Jetpack plugin. Sharing the route + // would let `register_rest_route`'s endpoint-merge pick the + // wrong handler at dispatch time (the two bridges proxy + // different WPCOM endpoints with different response shapes). register_rest_route( 'jetpack/v4', - '/activity-log', + '/site/rewindable-activity', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( __CLASS__, 'get_activity_log' ), @@ -54,7 +59,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function get_activity_log( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $blog_id = Rest_Controller::get_blog_id_or_error(); + if ( is_wp_error( $blog_id ) ) { + return $blog_id; + } $query = array_filter( array( diff --git a/projects/packages/backup/src/rest/class-capabilities-bridge.php b/projects/packages/backup/src/rest/class-capabilities-bridge.php index 2d73e7ebcb4..ce527b6605d 100644 --- a/projects/packages/backup/src/rest/class-capabilities-bridge.php +++ b/projects/packages/backup/src/rest/class-capabilities-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Server; @@ -53,13 +52,9 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error The decoded capabilities, or WP_Error on failure. */ public static function get_capabilities() { - $blog_id = 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 ) - ); + $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( diff --git a/projects/packages/backup/src/rest/class-download-bridge.php b/projects/packages/backup/src/rest/class-download-bridge.php index d2730e87e54..5b8495c5f17 100644 --- a/projects/packages/backup/src/rest/class-download-bridge.php +++ b/projects/packages/backup/src/rest/class-download-bridge.php @@ -9,7 +9,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -81,7 +80,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function initiate_download( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $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( @@ -133,7 +135,10 @@ public static function initiate_download( WP_REST_Request $request ) { * @return \WP_REST_Response|WP_Error */ public static function get_download_status( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $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' ); diff --git a/projects/packages/backup/src/rest/class-file-browser-bridge.php b/projects/packages/backup/src/rest/class-file-browser-bridge.php index a2c13f534c9..e9e5d6d25dc 100644 --- a/projects/packages/backup/src/rest/class-file-browser-bridge.php +++ b/projects/packages/backup/src/rest/class-file-browser-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -91,7 +90,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function list_directory( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $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 ), @@ -128,7 +130,10 @@ public static function list_directory( WP_REST_Request $request ) { * @return \WP_REST_Response|WP_Error */ public static function get_file_content( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $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' ); @@ -161,7 +166,10 @@ public static function get_file_content( WP_REST_Request $request ) { $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 ) { + 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' ), @@ -170,7 +178,20 @@ public static function get_file_content( WP_REST_Request $request ) { } // Step 2: fetch the stream body server-side. - $stream_response = wp_remote_get( $signed_url, array( 'timeout' => 15 ) ); + // + // `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; @@ -185,13 +206,7 @@ public static function get_file_content( WP_REST_Request $request ) { ); } - $content = wp_remote_retrieve_body( $stream_response ); - - if ( strlen( $content ) > self::PREVIEW_MAX_BYTES ) { - $content = substr( $content, 0, self::PREVIEW_MAX_BYTES ); - } - - return rest_ensure_response( array( 'content' => $content ) ); + return rest_ensure_response( array( 'content' => wp_remote_retrieve_body( $stream_response ) ) ); } /** diff --git a/projects/packages/backup/src/rest/class-rest-controller.php b/projects/packages/backup/src/rest/class-rest-controller.php index a117d1cecb3..b2f87b8a1c1 100644 --- a/projects/packages/backup/src/rest/class-rest-controller.php +++ b/projects/packages/backup/src/rest/class-rest-controller.php @@ -8,6 +8,9 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Backup\V0005\Jetpack_Backup; +use Automattic\Jetpack\Connection\Manager as Connection_Manager; +use Jetpack_Options; +use WP_Error; if ( ! defined( 'ABSPATH' ) ) { exit( 0 ); @@ -44,9 +47,47 @@ public static function register_routes() { /** * Permission check shared by every modernized-dashboard route. * - * @return bool True when the current user can manage options. + * Mirrors the activity-log package's pattern: `manage_options` is + * necessary but not sufficient — every bridge eventually proxies a + * WPCOM endpoint that's user-gated, so a site admin who isn't + * personally WPCOM-linked needs a clearer error than the opaque + * "Only Administrators can query…" WPCOM returns. + * + * @return bool|WP_Error True when the current user can call the bridges, WP_Error otherwise. */ public static function permission_check() { - return current_user_can( 'manage_options' ); + if ( ! current_user_can( 'manage_options' ) ) { + return false; + } + + if ( ! ( new Connection_Manager() )->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 index 36d3102194b..0c74113990f 100644 --- a/projects/packages/backup/src/rest/class-restore-bridge.php +++ b/projects/packages/backup/src/rest/class-restore-bridge.php @@ -8,7 +8,6 @@ namespace Automattic\Jetpack\Backup\V0005\REST; use Automattic\Jetpack\Connection\Client; -use Jetpack_Options; use WP_Error; use WP_REST_Request; use WP_REST_Server; @@ -82,7 +81,10 @@ public static function register_routes() { * @return \WP_REST_Response|WP_Error */ public static function initiate_restore( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $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' ); @@ -108,14 +110,10 @@ public static function initiate_restore( WP_REST_Request $request ) { $status_code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $status_code ) { - $upstream = json_decode( wp_remote_retrieve_body( $response ), true ); 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, - 'upstream' => is_array( $upstream ) ? $upstream : null, - ) + array( 'status' => is_int( $status_code ) && $status_code > 0 ? $status_code : 500 ) ); } @@ -140,7 +138,10 @@ public static function initiate_restore( WP_REST_Request $request ) { * @return \WP_REST_Response|WP_Error */ public static function get_restore_status( WP_REST_Request $request ) { - $blog_id = Jetpack_Options::get_option( 'id' ); + $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( 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 index fcfc6d3a07a..4200bfdfea8 100644 --- a/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php +++ b/projects/packages/backup/tests/php/Rest_Activity_Log_Bridge_Test.php @@ -22,7 +22,7 @@ use function wp_set_current_user; /** - * Tests for the GET /jetpack/v4/activity-log route. + * Tests for the GET /jetpack/v4/site/rewindable-activity route. * * @covers \Automattic\Jetpack\Backup\V0005\REST\Activity_Log_Bridge */ @@ -69,7 +69,7 @@ public function tearDown(): void { */ public function test_route_registers_when_modernized() { $routes = $this->server->get_routes(); - $this->assertArrayHasKey( '/jetpack/v4/activity-log', $routes ); + $this->assertArrayHasKey( '/jetpack/v4/site/rewindable-activity', $routes ); } /** @@ -85,7 +85,7 @@ public function test_route_requires_manage_options() { ); wp_set_current_user( $subscriber_id ); - $request = new WP_REST_Request( 'GET', '/jetpack/v4/activity-log' ); + $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/plugins/jetpack/changelog/wire-backup-modernization-data-layer b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer index 5108082a2f2..8373feffce9 100644 --- a/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer +++ b/projects/plugins/jetpack/changelog/wire-backup-modernization-data-layer @@ -1,3 +1,3 @@ Significance: patch -Type: added +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. From 34ea3b975ede41c9a279fb6584f66f1822b12269 Mon Sep 17 00:00:00 2001 From: Douglas Date: Sun, 17 May 2026 03:24:09 -0300 Subject: [PATCH 10/10] Backup: wire RestoreItems checklist through to Restore/Download bridges + review cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RestoreItemsChecklist selection was collected into local state on the Restore and Download screens but never sent to the bridge, so an unchecked "Plugins" or "Themes" was silently restored anyway. Thread `RestoreItems` through `useRestore` / `useDownload` → `initiateRestore` / `initiateDownload` as the mutation variable, and surface it to the bridges as `data: { types }` (the keys already match WPCOM's `types` payload). Also clears the review suggestions from the same pass: * `useFileContents` browser path now UTF-8 encodes via TextEncoder before btoa, so non-ASCII manifest paths (accented or CJK upload filenames) no longer throw InvalidCharacterError. * `useConnection` drops its empty-deps useMemo and reads JP_CONNECTION_INITIAL_STATE on every render — single property lookup, removes any risk of capturing a pre-emit empty snapshot if load order ever shifts. * `query-client.ts` docstring corrected: each wp-build route is its own bundle so the cache is per-route, not shared across navigation. The design doesn't depend on cross-route reuse, but the comment shouldn't claim otherwise. * `Restore_Bridge::initiate_restore` returns the same user-facing "Could not start the backup restore." string in both failure branches instead of the diagnostic "Restore response missing restore id." * `Activity_Log_Bridge` drops the declared-but-unused `aggregate`, `after`, `before` args (and mirrors the cleanup in `data/api/activity-log.ts`'s FetchArgs) — the hook only ever passes `number`. * Download success link gains `download` + `rel="noreferrer"` so the signed URL triggers a download instead of navigating away when WPCOM omits Content-Disposition. PHP + JS tests pass; phan unchanged (only the pre-existing build/ artifact warning); eslint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/dashboard/data/api/activity-log.ts | 12 ++----- .../backup/src/dashboard/data/api/download.ts | 11 ++++++- .../backup/src/dashboard/data/api/restore.ts | 11 ++++++- .../backup/src/dashboard/data/query-client.ts | 11 ++++--- .../src/dashboard/hooks/use-connection.ts | 33 +++++++++---------- .../src/dashboard/hooks/use-download.ts | 10 ++++-- .../src/dashboard/hooks/use-file-contents.ts | 13 ++++++-- .../backup/src/dashboard/hooks/use-restore.ts | 8 ++--- .../backup/src/dashboard/screens/download.tsx | 12 +++++-- .../backup/src/dashboard/screens/restore.tsx | 5 +-- .../src/rest/class-activity-log-bridge.php | 12 ++----- .../backup/src/rest/class-restore-bridge.php | 2 +- 12 files changed, 81 insertions(+), 59 deletions(-) diff --git a/projects/packages/backup/src/dashboard/data/api/activity-log.ts b/projects/packages/backup/src/dashboard/data/api/activity-log.ts index 9ce53e0b91c..28a71417fbb 100644 --- a/projects/packages/backup/src/dashboard/data/api/activity-log.ts +++ b/projects/packages/backup/src/dashboard/data/api/activity-log.ts @@ -22,26 +22,18 @@ export type WpcomActivityLogResponse = { type FetchArgs = { number?: number; - aggregate?: boolean; - after?: string; - before?: string; }; /** * Fetch the rewindable activity log entries from the bridge. * - * @param args - Optional pagination and filter args. + * @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, - aggregate: args.aggregate, - after: args.after, - before: args.before, - } ), + path: apiPath( '/site/rewindable-activity', { number: args.number } ), } ); } diff --git a/projects/packages/backup/src/dashboard/data/api/download.ts b/projects/packages/backup/src/dashboard/data/api/download.ts index e494b4d30a3..b8a01ddc0bd 100644 --- a/projects/packages/backup/src/dashboard/data/api/download.ts +++ b/projects/packages/backup/src/dashboard/data/api/download.ts @@ -1,4 +1,5 @@ import { apiCall, apiPath, toIntRewindId } from './_helpers'; +import type { RestoreItems } from '../../types/restore'; export type InitiateDownloadResponse = { id: number; @@ -14,13 +15,21 @@ export type DownloadStatusResponse = { /** * 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 ): Promise< InitiateDownloadResponse > { +export async function initiateDownload( + rewindId: string, + types: RestoreItems +): Promise< InitiateDownloadResponse > { return apiCall< InitiateDownloadResponse >( { path: apiPath( `/backups/download/${ toIntRewindId( rewindId ) }` ), method: 'POST', + data: { types }, } ); } diff --git a/projects/packages/backup/src/dashboard/data/api/restore.ts b/projects/packages/backup/src/dashboard/data/api/restore.ts index 1b29f13e01f..13debe4a796 100644 --- a/projects/packages/backup/src/dashboard/data/api/restore.ts +++ b/projects/packages/backup/src/dashboard/data/api/restore.ts @@ -1,4 +1,5 @@ import { apiCall, apiPath, toIntRewindId } from './_helpers'; +import type { RestoreItems } from '../../types/restore'; export type InitiateRestoreResponse = { id: number; @@ -16,13 +17,21 @@ export type RestoreStatusResponse = { /** * 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 ): Promise< InitiateRestoreResponse > { +export async function initiateRestore( + rewindId: string, + types: RestoreItems +): Promise< InitiateRestoreResponse > { return apiCall< InitiateRestoreResponse >( { path: apiPath( `/rewind/to/${ toIntRewindId( rewindId ) }` ), method: 'POST', + data: { types }, } ); } diff --git a/projects/packages/backup/src/dashboard/data/query-client.ts b/projects/packages/backup/src/dashboard/data/query-client.ts index 3ccbd328bda..0995c703b69 100644 --- a/projects/packages/backup/src/dashboard/data/query-client.ts +++ b/projects/packages/backup/src/dashboard/data/query-client.ts @@ -4,11 +4,14 @@ const STALE_TIME_DEFAULT_MS = 30_000; const GC_TIME_DEFAULT_MS = 5 * 60_000; /** - * Module-scope QueryClient shared across the modernized Backup dashboard. + * Module-scope QueryClient for the modernized Backup dashboard. * - * Each wp-build route mounts its own `` (via - * ``), but the client itself is created once so the - * cache survives the per-route bundle re-mount on navigation. + * 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: { diff --git a/projects/packages/backup/src/dashboard/hooks/use-connection.ts b/projects/packages/backup/src/dashboard/hooks/use-connection.ts index 80157a6e5bf..7895a2dcefc 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-connection.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-connection.ts @@ -1,5 +1,3 @@ -import { useMemo } from '@wordpress/element'; - type Result = { isLoaded: boolean; isFullyConnected: boolean; @@ -29,25 +27,26 @@ declare global { * * 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, and the connection state is page-load static anyway — - * a single `window` read at hook-mount time covers every consumer. + * 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 { - return useMemo( () => { - const state = typeof window !== 'undefined' ? window.JP_CONNECTION_INITIAL_STATE : undefined; - const status: ConnectionStatus = state?.connectionStatus ?? {}; + 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 ); + 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, - }; - }, [] ); + 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 index a959fe95e57..84c475cb546 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-download.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-download.ts @@ -3,6 +3,7 @@ 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' } @@ -13,7 +14,7 @@ type DownloadState = type Result = { state: DownloadState; - submit: () => void; + submit: ( items: RestoreItems ) => void; reset: () => void; }; @@ -39,7 +40,7 @@ export function useDownload( rewindId: string ): Result { reset: resetMutation, isPending: isInitiating, } = useMutation( { - mutationFn: () => initiateDownload( rewindId ), + mutationFn: ( items: RestoreItems ) => initiateDownload( rewindId, items ), onSuccess: result => { setDownloadId( result.id ); setErrorMessage( null ); @@ -58,7 +59,10 @@ export function useDownload( rewindId: string ): Result { query.state.data?.status === 'in-progress' ? POLL_INTERVAL_MS : false, } ); - const submit = useCallback( () => mutateInitiate(), [ mutateInitiate ] ); + const submit = useCallback( + ( items: RestoreItems ) => mutateInitiate( items ), + [ mutateInitiate ] + ); const reset = useCallback( () => { setDownloadId( null ); setErrorMessage( null ); diff --git a/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts index 05adac33865..bf4835416f6 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-file-contents.ts @@ -10,15 +10,22 @@ type Result = { /** * Base64-encode a manifest path the way WPCOM's file-content endpoint - * expects. Uses `window.btoa` in the browser and falls back to Node's - * `Buffer` for tests/SSR. + * 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' ) { - return window.btoa( manifestPath ); + 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' ); } diff --git a/projects/packages/backup/src/dashboard/hooks/use-restore.ts b/projects/packages/backup/src/dashboard/hooks/use-restore.ts index 0cc30fce687..c8dca8e6c78 100644 --- a/projects/packages/backup/src/dashboard/hooks/use-restore.ts +++ b/projects/packages/backup/src/dashboard/hooks/use-restore.ts @@ -3,11 +3,11 @@ 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 { RestoreState } from '../types/restore'; +import type { RestoreItems, RestoreState } from '../types/restore'; type Result = { state: RestoreState; - submit: () => void; + submit: ( items: RestoreItems ) => void; reset: () => void; }; @@ -36,7 +36,7 @@ export function useRestore( rewindId: string ): Result { reset: resetMutation, isPending, } = useMutation( { - mutationFn: () => initiateRestore( rewindId ), + mutationFn: ( items: RestoreItems ) => initiateRestore( rewindId, items ), onSuccess: result => { setRestoreId( result.id ); setErrorMessage( null ); @@ -59,7 +59,7 @@ export function useRestore( rewindId: string ): Result { : false, } ); - const submit = useCallback( () => kickOff(), [ kickOff ] ); + const submit = useCallback( ( items: RestoreItems ) => kickOff( items ), [ kickOff ] ); const reset = useCallback( () => { setRestoreId( null ); setErrorMessage( null ); diff --git a/projects/packages/backup/src/dashboard/screens/download.tsx b/projects/packages/backup/src/dashboard/screens/download.tsx index 53b2fa33a32..40e2151b23e 100644 --- a/projects/packages/backup/src/dashboard/screens/download.tsx +++ b/projects/packages/backup/src/dashboard/screens/download.tsx @@ -1,6 +1,6 @@ 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'; @@ -38,6 +38,7 @@ export default function DownloadScreen() { const downloadPoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); const { state, submit, reset } = useDownload( rewindId ); + const handleGenerate = useCallback( () => submit( items ), [ submit, items ] ); return ( @@ -74,7 +75,7 @@ export default function DownloadScreen() { className="jpb-download__confirm" variant="primary" disabled={ state.phase === 'submitting' } - onClick={ submit } + onClick={ handleGenerate } > { state.phase === 'submitting' ? ( @@ -96,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/restore.tsx b/projects/packages/backup/src/dashboard/screens/restore.tsx index 1f07ed97319..e73325551fd 100644 --- a/projects/packages/backup/src/dashboard/screens/restore.tsx +++ b/projects/packages/backup/src/dashboard/screens/restore.tsx @@ -1,6 +1,6 @@ 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'; @@ -39,6 +39,7 @@ export default function RestoreScreen() { const restorePoint = rewindIdToIso( rewindId ); const [ items, setItems ] = useState( DEFAULT_RESTORE_ITEMS ); const { state, submit, reset } = useRestore( rewindId ); + const handleConfirm = useCallback( () => submit( items ), [ submit, items ] ); return ( @@ -76,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/rest/class-activity-log-bridge.php b/projects/packages/backup/src/rest/class-activity-log-bridge.php index 2b101e4e202..14c5ea7aaef 100644 --- a/projects/packages/backup/src/rest/class-activity-log-bridge.php +++ b/projects/packages/backup/src/rest/class-activity-log-bridge.php @@ -43,10 +43,7 @@ public static function register_routes() { 'callback' => array( __CLASS__, 'get_activity_log' ), 'permission_callback' => array( Rest_Controller::class, 'permission_check' ), 'args' => array( - 'number' => array( 'type' => 'integer' ), - 'aggregate' => array( 'type' => 'boolean' ), - 'after' => array( 'type' => 'string' ), - 'before' => array( 'type' => 'string' ), + 'number' => array( 'type' => 'integer' ), ), ) ); @@ -65,12 +62,7 @@ public static function get_activity_log( WP_REST_Request $request ) { } $query = array_filter( - array( - 'number' => $request->get_param( 'number' ), - 'aggregate' => $request->get_param( 'aggregate' ) ? 'true' : null, - 'after' => $request->get_param( 'after' ), - 'before' => $request->get_param( 'before' ), - ), + array( 'number' => $request->get_param( 'number' ) ), static function ( $value ) { return null !== $value && '' !== $value; } diff --git a/projects/packages/backup/src/rest/class-restore-bridge.php b/projects/packages/backup/src/rest/class-restore-bridge.php index 0c74113990f..6bd54a7786b 100644 --- a/projects/packages/backup/src/rest/class-restore-bridge.php +++ b/projects/packages/backup/src/rest/class-restore-bridge.php @@ -122,7 +122,7 @@ public static function initiate_restore( WP_REST_Request $request ) { if ( ! $restore_id_in ) { return new WP_Error( 'restore_initiate_failed', - __( 'Restore response missing restore id.', 'jetpack-backup-pkg' ), + __( 'Could not start the backup restore.', 'jetpack-backup-pkg' ), array( 'status' => 500 ) ); }