diff --git a/CPANEL_INTEGRATION.md b/CPANEL_INTEGRATION.md new file mode 100644 index 0000000000..14547f436f --- /dev/null +++ b/CPANEL_INTEGRATION.md @@ -0,0 +1,300 @@ +# cPanel UAPI Integration — Implementation Plan + +This document outlines a plan to add cPanel as a second hosting provider in WordPress Studio, enabling users to pull a production WordPress site hosted on cPanel into a local Studio environment (and, later, push local changes back). It is intended for team review and approval before implementation begins. + +For background on how the existing WordPress.com sync works, see [SYNC_ARCHITECTURE.md](SYNC_ARCHITECTURE.md). + +--- + +## What cPanel UAPI Is + +cPanel's UAPI is a JSON-over-HTTP API available on every cPanel hosting account. It gives programmatic access to file management, MySQL databases, DNS, email, and more. Unlike WordPress.com's OAuth2 flow, it uses API tokens tied to a specific cPanel account. + +**Request format:** +``` +GET/POST https://:2083/execute//?param=value +Authorization: cpanel : +``` + +**Response format:** +```json +{ "status": 1, "data": { ... }, "errors": null } +``` + +API tokens are created by the user in cPanel → Security → Manage API Tokens. They are long-lived (permanent until revoked) and scoped to the account — not to a specific application. + +--- + +## Proposed Feature Scope + +### v1: Pull only (cPanel → Local Studio) + +Given the roadblocks described below, v1 should be limited to pulling a site down locally. This is the highest-value use case (local dev environment from production) and avoids the critical gaps that affect push. + +- User connects a cPanel site by entering credentials +- Studio pulls the WordPress file tree and database to create a local site +- No selective sync — full site only +- No push back to cPanel in v1 + +### v2: Push (Local Studio → cPanel) + +Deferred until the database import gap is resolved (see roadblocks). Files-only push may be feasible in v2 as an interim step. + +--- + +## Implementation Plan + +### 1. Connection Setup + +**UI changes:** +- Add a "Connect cPanel Site" entry point in the sync tab alongside the existing WordPress.com connect button (`apps/studio/src/modules/sync/index.tsx`) +- New `CpanelCredentialsModal` component prompts for: + - cPanel hostname (e.g. `mysite.com` or `server.host.com`) + - cPanel port (default `2083`, configurable for non-standard setups) + - cPanel username + - API token (link to cPanel docs on how to create one) + - WordPress root path (e.g. `public_html`, `public_html/blog`) + - MySQL database name (list fetched via API after credentials are validated) + +**Connection validation:** +- On submit, call `Fileman::list_files dir=` — success confirms credentials and path are valid +- Call `Mysql::list_databases` to populate the database name dropdown + +**Storage:** +- Add `connectedCpanelSites: { [localSiteId: string]: CpanelSyncSite[] }` to `UserData` in appdata +- New type: + ```typescript + CpanelSyncSite { + id: string // generated UUID + localSiteId: string + hostname: string + port: number // default 2083 + username: string + apiToken: string // stored in appdata (see security note) + wpPath: string // e.g. "public_html" + dbName: string + lastPullTimestamp: string | null + lastPushTimestamp: string | null + } + ``` +- All appdata writes use `lockAppdata()` / `unlockAppdata()` (existing pattern) + +**New IPC handlers** (following existing patterns in `apps/studio/src/modules/sync/lib/ipc-handlers.ts`): +- `connectCpanelSite` — validate + save connection +- `disconnectCpanelSite` — remove from appdata +- `getConnectedCpanelSites` — retrieve connections + +--- + +### 2. Pull Flow + +**Step 1 — Compress WordPress files on server** + +``` +POST /execute/Fileman/compress + files-0= + type=tar.gz + dest-dir=/../.studio-tmp/ + dest-file=studio-backup-.tar.gz +``` + +Creates a server-side archive. The `Fileman::compress` endpoint supports `tar.gz` output. + +**Step 2 — Download the archive** + +This is where the biggest gap exists (see roadblocks). The intended approach is to use the cPanel file download URL: + +``` +GET https://:/download?skipencode=1&dir=&file= +Authorization: cpanel : +``` + +This URL is part of the cPanel web interface, not a documented UAPI endpoint, and may not be stable across cPanel versions. + +**Step 3 — Export database** + +``` +GET /execute/Mysql/dump_database_schema?dbname= +``` + +Returns the full SQL dump as the response body. Save to a `.sql` file locally. + +**Step 4 — Assemble Studio-compatible archive** + +The existing `importSite()` IPC handler expects a `tar.gz` in a specific layout. Combine the downloaded WordPress files and the SQL dump into the correct format locally before importing. + +This may require a new adapter function, or the cPanel-specific pull handler can call `importSite()` with a pre-assembled path. + +**Step 5 — Import** + +Reuse the existing import pipeline: +- Stop local server (`stopServer(localSiteId)`) +- Call `importSite({ id: localSiteId, backupFile: { path, type: 'application/tar+gzip' } })` +- Start local server (`startServer(localSiteId)`) +- Update `lastPullTimestamp` in appdata + +**Progress states (proposed):** + +| State | Description | +|---|---| +| `compressing` | Creating archive on server | +| `downloading` | Downloading archive to local temp | +| `exporting-db` | Fetching SQL dump | +| `importing` | Applying to local Studio site | +| `finished` | Complete | +| `failed` | Error with message | +| `cancelled` | User cancelled | + +--- + +### 3. Redux / State Management + +New Redux slice `cpanelSyncOperations` (parallel to `syncOperations` in `apps/studio/src/stores/sync/sync-operations-slice.ts`): +- `pullStates: { [localSiteId: string]: CpanelPullState }` +- Thunk: `pullCpanelSiteThunk` — orchestrates steps 1–5 above +- AbortController registry for cancellation (same pattern as existing `SYNC_ABORT_CONTROLLERS` map) + +--- + +### 4. UI Reuse + +The following existing components can be reused with minimal changes: +- `SyncConnectedSiteSection` — display layout for a connected site +- Progress bar and status message display from `SyncConnectedSitesSectionItem` +- The `ContentTabSync` tab shell in `apps/studio/src/modules/sync/index.tsx` + +New components needed: +- `CpanelCredentialsModal` — credential entry form +- `CpanelConnectedSite` — displays a connected cPanel site (hostname, last pull) + +--- + +## Roadblocks & Missing Pieces + +These are the gaps that require design decisions before implementation. They are listed in order of severity. + +--- + +### CRITICAL — No UAPI endpoint to import a database + +**Impact:** Push to cPanel is blocked entirely. Pull is unaffected (export-only SQL). + +**Detail:** `Mysql::dump_database_schema` exports SQL, but there is no corresponding `Mysql::import_database` or `Mysql::execute_sql` UAPI endpoint. cPanel does not expose SQL execution through its standard user API. + +**Workaround options:** + +| Option | Feasibility | Notes | +|---|---|---| +| Upload a PHP script that imports SQL, trigger via HTTP, delete it | Possible | Security risk; fragile; requires PHP `exec()` or `mysqli` + large SQL chunking | +| Require SSH access and run `mysql` CLI | Possible | Separate credential flow; not all hosts allow SSH | +| WHM/reseller API | Not viable | Requires reseller-level access, not standard cPanel | +| phpMyAdmin import URL | Fragile | Relies on phpMyAdmin being installed and session-authenticated | +| WP-CLI via SSH (`wp db import`) | Best long-term | Requires SSH and WP-CLI; out of scope for UAPI-only integration | + +**Recommendation:** Defer database push to a follow-up. v1 pull-only avoids this gap entirely. + +--- + +### CRITICAL — No documented binary file download API + +**Impact:** Downloading large compressed archives (the WordPress file tree) relies on an undocumented URL. + +**Detail:** `Fileman::get_file_content` is limited to small text files. There is no documented UAPI endpoint for downloading binary files or large archives. The cPanel web UI uses: + +``` +GET /download?skipencode=1&dir=&file= +``` + +This is part of the cPanel web interface layer, not the UAPI. It accepts the same `Authorization: cpanel` header but is not documented as a stable endpoint and could change between cPanel versions. + +**Workaround options:** + +| Option | Feasibility | Notes | +|---|---|---| +| Use the `/download` URL anyway | Likely works | Undocumented; test against cPanel 110+ | +| Fall back to SFTP/FTP for transfer | Works reliably | Requires additional credentials; broader scope | +| Require user to manually export and provide a URL | Poor UX | Defeats the purpose | + +**Recommendation:** Use the `/download` URL for v1 with a documented caveat. Investigate SFTP as a v2 enhancement. + +--- + +### SIGNIFICANT — PHP upload size limits block large push archives + +**Impact:** Pushing large sites to cPanel via `Fileman::upload_files` will fail if the archive exceeds the server's PHP upload limits. + +**Detail:** `Fileman::upload_files` is a PHP multipart endpoint subject to `upload_max_filesize` and `post_max_size`, which default to 2–32 MB on most hosts. A typical WordPress site with media uploads can be several GBs. + +**Workaround options:** + +| Option | Feasibility | Notes | +|---|---|---| +| Chunked upload + server-side reassembly | Complex | No UAPI chunked upload support; would require a PHP helper script | +| SFTP upload | Reliable | Outside UAPI scope; needs separate credentials | +| Warn user and fail gracefully | MVP | Inform user their host's PHP limits are too low | + +**Recommendation:** Surface the limit as an error with a message linking to documentation on raising `upload_max_filesize`. SFTP support is the proper long-term solution. + +--- + +### SIGNIFICANT — No server-side job polling for push progress + +**Impact:** Push progress tracking is limited to upload percentage. Post-upload extraction gives no feedback. + +**Detail:** The WordPress.com sync uses a server-side job (`/studio-app/sync/import`) that reports progress through file import phases. cPanel file operations are synchronous at the API level — `Fileman::extract` blocks until done, but for large archives this could take many minutes, and there is no status endpoint to poll. + +**Workaround:** Client-side progress only: upload % during upload, then an indeterminate spinner during extraction. + +--- + +### MODERATE — No selective sync + +**Impact:** Users cannot pull only the database, or only plugins — always a full-site pull. + +**Detail:** The WordPress.com pull uses Jetpack Rewind's `/rewind/backup/ls` to show a browsable file tree and let users select specific paths. cPanel `Fileman::list_files` only lists one directory at a time; building a recursive tree for a large WordPress install would require dozens of API calls and significant latency. + +**Recommendation:** Full-site only for v1. Selective sync could be added in v2 using a background tree fetch with caching. + +--- + +### MINOR — Credential security + +**Detail:** cPanel API tokens grant full account access (file management, DNS, email, databases). Storing them in `appdata-v1.json` gives the same access level as the user's cPanel password. On macOS, `appdata-v1.json` is not encrypted (unlike Windows where DPAPI is used). + +**Recommendation:** Display a clear warning in the connection UI. Explore OS keychain storage for the token (separate from the appdata JSON) as a follow-on. + +--- + +### MINOR — Non-standard ports and reverse proxies + +**Detail:** cPanel defaults to port 2083 for HTTPS. Some hosts use custom ports, subdomain-based access (`cpanel.myhost.com`), or reverse proxies that alter the URL structure. + +**Recommendation:** Make port configurable in the credentials form. Allow full hostname/URL input so users can paste their actual cPanel access URL. + +--- + +## Files Affected + +| File | Change | +|---|---| +| `apps/studio/src/modules/sync/index.tsx` | Add cPanel connect entry point | +| `apps/studio/src/modules/sync/components/CpanelCredentialsModal.tsx` | New credential form component | +| `apps/studio/src/modules/sync/components/CpanelConnectedSite.tsx` | New connected site display | +| `apps/studio/src/modules/sync/lib/ipc-handlers.ts` | Add cPanel IPC handlers | +| `apps/studio/src/stores/sync/cpanel-sync-operations-slice.ts` | New Redux slice for pull state | +| `apps/studio/src/stores/sync/cpanel-sites.ts` | RTK Query API for cPanel connections | +| `apps/studio/src/ipc-handlers.ts` | Register new IPC handler exports | +| `apps/studio/src/preload.ts` | Expose new IPC methods via contextBridge | +| `apps/studio/src/storage/storage-types.ts` | Add `CpanelSyncSite`, extend `UserData` | +| `apps/studio/src/constants.ts` | Add cPanel-specific constants | +| `tools/common/types/` | Add shared `CpanelSyncSite` type if needed by CLI | + +--- + +## Questions for Team Review + +1. **File download approach** — Is using the undocumented `/download` cPanel URL acceptable for v1, or should we require SFTP from the start? +2. **Database push deferral** — Agreed to ship v1 as pull-only, with push requiring a separate spike on the DB import gap? +3. **Credential storage** — Should we store the cPanel API token in the OS keychain rather than appdata, given its broad account access? +4. **PHP upload limits** — For push (v2): fail with a clear error message, or invest in SFTP support upfront? +5. **Scope of "cPanel"** — Should this also target DirectAdmin or Plesk (which have similar APIs), or stay cPanel-specific for v1? diff --git a/SYNC_ARCHITECTURE.md b/SYNC_ARCHITECTURE.md new file mode 100644 index 0000000000..30a900f5a3 --- /dev/null +++ b/SYNC_ARCHITECTURE.md @@ -0,0 +1,223 @@ +# Studio Sync Architecture + +Overview of how Studio connects to external accounts, pulls production sites locally, and pushes changes back up. Intended as a reference for integrating additional hosting providers or sync backends. + +--- + +## Authentication + +Studio uses WordPress.com OAuth2 (implicit grant) to authenticate users. + +**Flow:** +1. `tools/common/lib/oauth.ts` — builds the authorization URL (`https://public-api.wordpress.com/oauth2/authorize`) with `response_type=token`, `scope=global`, and `redirect_uri=studio://auth` +2. Main process opens the URL in the system browser via `shellOpenExternalWrapper()` +3. WordPress.com redirects back to the `studio://` custom protocol with an access token in the fragment +4. Main process catches the protocol activation, extracts the token, and emits an `auth-updated` IPC event +5. Renderer's `AuthProvider` (`apps/studio/src/components/auth-provider.tsx`) receives the event, stores the token, and initializes a WPCOM client via `setWpcomClient()` + +**Token storage:** `UserData.authToken` in `appdata-v1.json` (macOS: `~/Library/Application Support/Studio/`, Windows: `%APPDATA%\Studio\`). Schema: + +```typescript +StoredToken { + accessToken: string + expiresIn: number + expirationTime: number // epoch ms + id: number // WordPress.com user ID + email: string + displayName: string +} +``` + +Token validity is checked on each use. Expired or revoked tokens trigger auto-logout; logout calls `DELETE /studio-app/token` on the WordPress.com REST API to revoke server-side. + +--- + +## Site Discovery & Eligibility + +Once authenticated, Studio fetches the user's WordPress.com sites via RTK Query: + +**Endpoint:** `GET /me/sites` — filtered to `atomic,wpcom` sites, fields: `name, ID, URL, plan, capabilities, environment_type, jetpack` + +Each site is evaluated for sync eligibility (`SyncSupport`): + +| Status | Meaning | +|---|---| +| `syncable` | Ready to connect and sync | +| `already-connected` | Connected to a local site | +| `unsupported` | Jetpack-only / non-Atomic | +| `needs-upgrade` | Plan lacks `studio-sync` feature | +| `needs-transfer` | Simple site — activation required | +| `deleted` | Site is deleted | +| `missing-permissions` | User lacks `manage_options` | + +Pressable sites (detected via `hosting_provider_guess === 'pressable'`) bypass some plan/feature requirements. + +**Relevant files:** +- `apps/studio/src/stores/sync/wpcom-sites.ts` — RTK Query endpoints, response transforms +- `apps/studio/src/modules/sync/lib/sync-support.ts` — eligibility logic + +--- + +## Connected Sites + +A "connection" links a WordPress.com remote site to a local Studio site. Connections are stored per authenticated user in `appdata-v1.json`: + +```typescript +UserData.connectedWpcomSites: { [userId: number]: SyncSite[] } + +SyncSite { + id: number // WordPress.com site ID + localSiteId: string + name: string + url: string + isStaging: boolean + isPressable: boolean + environmentType?: string | null + syncSupport: SyncSupport + lastPullTimestamp: string | null + lastPushTimestamp: string | null +} +``` + +All connection mutations use `lockAppdata()` / `unlockAppdata()` to prevent data corruption from concurrent writes. + +**IPC handlers** (`apps/studio/src/modules/sync/lib/ipc-handlers.ts`): +- `connectWpcomSites` — adds connections, marks site as `already-connected` +- `disconnectWpcomSites` — removes connections by `localSiteId` + `siteId` +- `getConnectedWpcomSites` — returns connections, optionally filtered by `localSiteId` +- `updateConnectedWpcomSites` — updates timestamps after sync + +--- + +## Pull: Remote → Local + +Pulling clones a production site (or selected parts of it) into a local Studio site. + +**Steps:** + +1. **Initiate backup** — `POST /sites/{siteId}/studio-app/sync/backup` + - Body: `{ options: SyncOption[], include_path_list?: string[] }` + - Response: `{ backup_id }` + +2. **Poll backup status** — `GET /sites/{siteId}/studio-app/sync/backup` + - Waits for `status: 'finished'`, extracts `download_url` + +3. **Download archive** — TUS resumable download to a temp path + - Temp location: `${app.getPath('temp')}/wp-studio-backups/` + - Large backups (>5 GB) show a warning dialog before proceeding + +4. **Import** — `importSite()` IPC handler + - Stops local server, extracts tar.gz backup, restarts server + +5. **Record timestamp** — `lastPullTimestamp` updated in appdata + +**Progress states:** `in-progress → downloading → importing → finished` + +--- + +## Push: Local → Remote + +Pushing uploads local changes (code and/or database) to the connected production site. + +**Steps:** + +1. **Export archive** — `exportBackup()` creates a local tar.gz + - Supports selective sync via `specificSelectionPaths` + - Hard limit: 5 GB (`SYNC_PUSH_SIZE_LIMIT_BYTES`) + - Export is cancellable via `AbortController` + +2. **Upload via TUS** — chunked upload (500 KB chunks) + - Endpoint: `https://public-api.wordpress.com/rest/v1.1/studio-file-uploads/{siteId}` + - Bearer token auth + - Supports pause (`pauseSyncUpload`) and resume (`resumeSyncUpload`) + - Progress events emitted over IPC: `sync-upload-progress`, `sync-upload-network-paused`, `sync-upload-manually-paused`, `sync-upload-resumed` + +3. **Initiate remote import** — `POST /sites/{siteId}/studio-app/sync/import/initiate` + - Body includes `import_attachment_id` and sync options + +4. **Poll import status** — `GET /sites/{siteId}/studio-app/sync/import` + - Status progression: `started → initial_backup_started → archive_import_started → finished` + +5. **Record timestamp** — `lastPushTimestamp` updated in appdata + +**Progress states:** `creatingBackup → uploading → creatingRemoteBackup → applyingChanges → finishing → finished` + +--- + +## Selective Sync + +Users can choose which parts of a site to sync. Options are defined in `apps/studio/src/constants.ts`: + +| Key | Syncs | +|---|---| +| `all` | Everything | +| `sqls` | Database only | +| `paths` | Specific paths (pull only) | +| `themes` | Themes | +| `plugins` | Plugins | +| `uploads` | Media uploads | +| `contents` | Content (posts, pages, etc.) | + +For pull, users can browse the remote file tree before pulling: +- `getLatestRewindId()` — fetches current Rewind/backup ID +- `fetchRemoteFileTree()` — `POST /sites/{siteId}/rewind/backup/ls` returns a `TreeNode[]` tree with types `file | folder | plugin | theme` + +--- + +## Sync Exclusions + +The following are always excluded from sync archives: + +``` +database, db.php, debug.log, sqlite-database-integration, +.DS_Store, Thumbs.db, .git, node_modules, cache +``` + +Defined in `apps/studio/src/modules/sync/constants.ts`. + +--- + +## State Management + +Sync state lives in the Redux store (renderer process): + +| Slice / API | Responsibility | +|---|---| +| `sync` slice | Remote file tree state and caching | +| `syncOperations` slice | Pull/push in-progress states and progress percentages | +| `connectedSites` slice | Modal state, selected sites | +| `connectedSitesApi` (RTK Query) | CRUD for connected sites via IPC | +| `wpcomSitesApi` (RTK Query) | WordPress.com site discovery and eligibility | +| `wpcomApi` (RTK Query) | Authenticated WPCOM REST endpoints | + +--- + +## Key File Map + +``` +tools/common/lib/oauth.ts OAuth URL builder +apps/studio/src/lib/oauth.ts Token storage/validation +apps/studio/src/components/auth-provider.tsx Auth state + WPCOM client init +apps/studio/src/stores/wpcom-api.ts WPCOM client + RTK Query base +apps/studio/src/stores/sync/wpcom-sites.ts Site discovery + eligibility +apps/studio/src/stores/sync/connected-sites.ts Connection CRUD +apps/studio/src/stores/sync/sync-operations-slice.ts Pull/push Redux state +apps/studio/src/stores/sync/sync-api.ts Remote file tree browsing +apps/studio/src/modules/sync/lib/ipc-handlers.ts Main-process IPC for sync +apps/studio/src/modules/sync/lib/sync-support.ts Eligibility logic +apps/studio/src/modules/sync/constants.ts Exclusions list +apps/studio/src/constants.ts Sync options enum, size limit +``` + +--- + +## Integration Notes for Additional Providers + +To add a non-WordPress.com hosting provider (e.g., a standalone Jetpack host, Pressable direct API, or a generic SSH/SFTP provider): + +1. **Auth** — Add a provider-specific OAuth or API key flow. The `StoredToken` shape and `appdata-v1.json` `UserData` type would need to accommodate multiple token namespaces. +2. **Site discovery** — Implement a `getSites()` function analogous to `wpcom-sites.ts` that returns `SyncSite[]` with appropriate `syncSupport` values. +3. **Pull** — Implement `initiatePull()`, `pollPullStatus()`, and a download step. The TUS download layer in `sync-operations-slice.ts` can be reused if the provider supports TUS or standard HTTPS downloads. +4. **Push** — Implement `uploadArchive()` and `initiateRemoteImport()`. The local export step (`exportBackup()`) is provider-agnostic and can be reused directly. +5. **IPC surface** — New providers should expose the same IPC handler names where possible (or add a provider prefix) so the renderer Redux thunks can dispatch against them uniformly. +6. **Eligibility** — Extend `SyncSupport` union type and `sync-support.ts` logic to handle new provider-specific error conditions. diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index ed48f726ab..1c07847e65 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -97,6 +97,7 @@ export const IPC_VOID_HANDLERS = < const >[ 'showItemInFolder', 'showNotification', 'authenticate', + 'cancelCpanelPull', ]; // What's New diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 9e2267e161..be6f5441b0 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -125,6 +125,15 @@ export { updateConnectedWpcomSites, } from 'src/modules/sync/lib/ipc-handlers'; +export { + cancelCpanelPull, + connectCpanelSite, + disconnectCpanelSite, + getConnectedCpanelSites, + pullCpanelSite, + updateConnectedCpanelSites, +} from 'src/modules/cpanel/lib/ipc-handlers'; + export { createSnapshot, deleteSnapshot, diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 3b30b915e9..bcdd4ab1cd 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -33,6 +33,13 @@ export interface IpcEvents { 'on-site-create-progress': [ { siteId: string; message: string } ]; 'site-context-menu-action': [ { action: string; siteId: string } ]; 'site-event': [ SiteEvent ]; + 'cpanel-pull-progress': [ + { + localSiteId: string; + cpanelSiteId: string; + status: import('src/modules/cpanel/types').CpanelPullStatusInfo; + }, + ]; 'sync-upload-network-paused': [ { error: string; selectedSiteId: string; remoteSiteId: number } ]; 'sync-upload-resumed': [ { selectedSiteId: string; remoteSiteId: number } ]; 'sync-upload-progress': [ { selectedSiteId: string; remoteSiteId: number; progress: number } ]; diff --git a/apps/studio/src/modules/cpanel/components/cpanel-connected-site.tsx b/apps/studio/src/modules/cpanel/components/cpanel-connected-site.tsx new file mode 100644 index 0000000000..e5e5bf148e --- /dev/null +++ b/apps/studio/src/modules/cpanel/components/cpanel-connected-site.tsx @@ -0,0 +1,191 @@ +import { Icon } from '@wordpress/components'; +import { sprintf } from '@wordpress/i18n'; +import { cloudDownload, close } from '@wordpress/icons'; +import { useI18n } from '@wordpress/react-i18n'; +import Button from 'src/components/button'; +import { ClearAction } from 'src/components/clear-action'; +import offlineIcon from 'src/components/offline-icon'; +import ProgressBar from 'src/components/progress-bar'; +import { Tooltip } from 'src/components/tooltip'; +import { useOffline } from 'src/hooks/use-offline'; +import { useAppDispatch, useRootSelector } from 'src/stores'; +import { useDisconnectCpanelSiteMutation } from 'src/stores/cpanel/cpanel-connected-sites'; +import { + cpanelOperationsActions, + cpanelOperationsSelectors, + cpanelOperationsThunks, +} from 'src/stores/cpanel/cpanel-operations-slice'; +import type { CpanelSyncSite } from 'src/modules/cpanel/types'; + +type Props = { + cpanelSite: CpanelSyncSite; + selectedSite: SiteDetails; +}; + +export function CpanelConnectedSite( { cpanelSite, selectedSite }: Props ) { + const { __ } = useI18n(); + const dispatch = useAppDispatch(); + const isOffline = useOffline(); + const [ disconnectSite ] = useDisconnectCpanelSiteMutation(); + + const pullState = useRootSelector( + cpanelOperationsSelectors.selectPullState( selectedSite.id, cpanelSite.id ) + ); + + const isAnySitePulling = useRootSelector( cpanelOperationsSelectors.selectIsAnySitePulling ); + + const isPulling = + pullState?.status.key === 'compressing' || + pullState?.status.key === 'downloading' || + pullState?.status.key === 'exporting-db' || + pullState?.status.key === 'building-archive' || + pullState?.status.key === 'importing'; + + const hasPullFinished = pullState?.status.key === 'finished'; + const hasPullFailed = pullState?.status.key === 'failed'; + const hasPullCancelled = pullState?.status.key === 'cancelled'; + + const handleDisconnect = async () => { + const { response } = await window.ipcApi.showMessageBox( { + message: sprintf( __( 'Disconnect %s' ), cpanelSite.hostname ), + detail: __( 'Your cPanel site will not be affected by disconnecting it from Studio.' ), + buttons: [ __( 'Disconnect' ), __( 'Cancel' ) ], + cancelId: 1, + } ); + if ( response === 0 ) { + await disconnectSite( { + cpanelSiteId: cpanelSite.id, + localSiteId: selectedSite.id, + } ); + } + }; + + const clearPull = () => { + dispatch( + cpanelOperationsActions.clearPullState( { + localSiteId: selectedSite.id, + cpanelSiteId: cpanelSite.id, + } ) + ); + }; + + const lastPullText = cpanelSite.lastPullTimestamp + ? sprintf( __( 'Last pulled %s' ), new Date( cpanelSite.lastPullTimestamp ).toLocaleString() ) + : __( 'Never pulled' ); + + return ( +
+ { /* Header row: icon + hostname + disconnect */ } +
+
+ cP +
+
{ cpanelSite.hostname }
+
+ + + +
+
+ + { /* Controls / status row */ } +
+
{ lastPullText }
+ +
+ +
+ { isPulling && ( +
+
+
{ pullState.status.message }
+ +
+ + + +
+ ) } + + { hasPullFinished && ( + { __( 'Pull complete' ) } + ) } + + { hasPullFailed && ( + + { __( 'Error pulling site' ) } + + ) } + + { hasPullCancelled && ( + { __( 'Pull cancelled' ) } + ) } + + { ! isPulling && ! hasPullFinished && ! hasPullFailed && ! hasPullCancelled && ( + + + + + + ) } +
+
+
+ ); +} diff --git a/apps/studio/src/modules/cpanel/components/cpanel-credentials-modal.tsx b/apps/studio/src/modules/cpanel/components/cpanel-credentials-modal.tsx new file mode 100644 index 0000000000..32b3474341 --- /dev/null +++ b/apps/studio/src/modules/cpanel/components/cpanel-credentials-modal.tsx @@ -0,0 +1,141 @@ +import { Modal, TextControl } from '@wordpress/components'; +import { useI18n } from '@wordpress/react-i18n'; +import { useState } from 'react'; +import Button from 'src/components/button'; +import { useConnectCpanelSiteMutation } from 'src/stores/cpanel/cpanel-connected-sites'; +import type { CpanelSyncSite } from 'src/modules/cpanel/types'; + +type Props = { + localSiteId: string; + onClose: () => void; + onConnected: ( site: CpanelSyncSite ) => void; +}; + +export function CpanelCredentialsModal( { localSiteId, onClose, onConnected }: Props ) { + const { __ } = useI18n(); + const [ hostname, setHostname ] = useState( '' ); + const [ port, setPort ] = useState( '2083' ); + const [ username, setUsername ] = useState( '' ); + const [ apiToken, setApiToken ] = useState( '' ); + const [ wpPath, setWpPath ] = useState( 'public_html' ); + const [ dbName, setDbName ] = useState( '' ); + const [ error, setError ] = useState< string | null >( null ); + + const [ connectCpanelSite, { isLoading } ] = useConnectCpanelSiteMutation(); + + const isValid = + hostname.trim() !== '' && + username.trim() !== '' && + apiToken.trim() !== '' && + wpPath.trim() !== '' && + dbName.trim() !== '' && + ! isNaN( parseInt( port ) ); + + const handleSubmit = async ( e: React.FormEvent ) => { + e.preventDefault(); + setError( null ); + + try { + const site = await connectCpanelSite( { + localSiteId, + hostname: hostname.trim(), + port: parseInt( port ), + username: username.trim(), + apiToken: apiToken.trim(), + wpPath: wpPath.trim(), + dbName: dbName.trim(), + } ).unwrap(); + + onConnected( site ); + } catch ( err ) { + const msg = + err instanceof Error + ? err.message + : __( 'Could not connect to cPanel. Check your credentials and try again.' ); + setError( msg ); + } + }; + + return ( + +
+

+ { __( + 'Enter your cPanel credentials to connect this site. Your API token is stored locally on this computer.' + ) } +

+ + + + + + + + + + + + + + { error &&

{ error }

} + +
+ + +
+ +
+ ); +} diff --git a/apps/studio/src/modules/cpanel/lib/cpanel-api.ts b/apps/studio/src/modules/cpanel/lib/cpanel-api.ts new file mode 100644 index 0000000000..52795311dd --- /dev/null +++ b/apps/studio/src/modules/cpanel/lib/cpanel-api.ts @@ -0,0 +1,229 @@ +import fs from 'fs'; +import http from 'node:http'; +import https from 'node:https'; +import type { CpanelSyncSite } from 'src/modules/cpanel/types'; + +type UapiResponse< T = unknown > = { + status: number; + data: T; + errors: string[] | null; + messages: string[] | null; +}; + +/** + * Make a cPanel UAPI call. + * GET for reads, POST for writes (compression, deletion, etc.). + */ +export async function cpanelUapi< T = unknown >( + site: Pick< CpanelSyncSite, 'hostname' | 'port' | 'username' | 'apiToken' >, + module: string, + fn: string, + params: Record< string, string > = {}, + method: 'GET' | 'POST' = 'GET' +): Promise< T > { + const baseUrl = `https://${ site.hostname }:${ site.port }/execute/${ module }/${ fn }`; + const authHeader = `cpanel ${ site.username }:${ site.apiToken }`; + + let url: string; + let postBody: string | undefined; + + if ( method === 'POST' ) { + url = baseUrl; + postBody = new URLSearchParams( params ).toString(); + } else { + const qs = new URLSearchParams( params ).toString(); + url = qs ? `${ baseUrl }?${ qs }` : baseUrl; + } + + const parsedUrl = new URL( url ); + const isHttps = parsedUrl.protocol === 'https:'; + const httpModule = isHttps ? https : http; + + const requestOptions: https.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port ? parseInt( parsedUrl.port ) : isHttps ? 443 : 80, + path: parsedUrl.pathname + parsedUrl.search, + method, + headers: { + Authorization: authHeader, + Accept: 'application/json', + ...( method === 'POST' ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {} ), + }, + // Tolerate self-signed certs on cPanel instances (common on shared hosting) + rejectUnauthorized: false, + }; + + const responseText = await new Promise< string >( ( resolve, reject ) => { + const req = httpModule.request( requestOptions, ( res ) => { + let data = ''; + res.on( 'data', ( chunk: string ) => { + data += chunk; + } ); + res.on( 'end', () => { + if ( res.statusCode && res.statusCode >= 400 ) { + reject( + new Error( `cPanel UAPI request failed: ${ res.statusCode } ${ res.statusMessage }` ) + ); + return; + } + resolve( data ); + } ); + res.on( 'error', reject ); + } ); + + req.on( 'error', reject ); + + if ( postBody ) { + req.write( postBody ); + } + + req.end(); + } ); + + const parsed: UapiResponse< T > = JSON.parse( responseText ); + + if ( ! parsed.status ) { + const errorMsg = parsed.errors?.join( '; ' ) || 'Unknown cPanel UAPI error'; + throw new Error( `cPanel UAPI ${ module }::${ fn } failed: ${ errorMsg }` ); + } + + return parsed.data as T; +} + +/** + * Retrieve the SQL dump of a database via UAPI. + * The response body is the raw SQL, not JSON-wrapped. + */ +export async function cpanelDumpDatabase( + site: Pick< CpanelSyncSite, 'hostname' | 'port' | 'username' | 'apiToken' >, + dbName: string +): Promise< string > { + const url = `https://${ site.hostname }:${ + site.port + }/execute/Mysql/dump_database_schema?dbname=${ encodeURIComponent( dbName ) }`; + const authHeader = `cpanel ${ site.username }:${ site.apiToken }`; + + const parsedUrl = new URL( url ); + + const requestOptions: https.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port ? parseInt( parsedUrl.port ) : 443, + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers: { + Authorization: authHeader, + }, + rejectUnauthorized: false, + }; + + return new Promise< string >( ( resolve, reject ) => { + const req = https.request( requestOptions, ( res ) => { + if ( res.statusCode && res.statusCode >= 400 ) { + reject( + new Error( `cPanel database dump failed: ${ res.statusCode } ${ res.statusMessage }` ) + ); + return; + } + + let data = ''; + res.on( 'data', ( chunk: string ) => { + data += chunk; + } ); + res.on( 'end', () => resolve( data ) ); + res.on( 'error', reject ); + } ); + + req.on( 'error', reject ); + req.end(); + } ); +} + +/** + * Download a file from cPanel's file manager download URL. + * This is an undocumented but widely available endpoint on cPanel servers. + * + * Note: `dir` is the directory containing the file (relative to cPanel home), + * `filename` is just the filename portion. + */ +export async function cpanelDownloadFile( + site: Pick< CpanelSyncSite, 'hostname' | 'port' | 'username' | 'apiToken' >, + dir: string, + filename: string, + destPath: string, + signal?: AbortSignal +): Promise< void > { + const url = `https://${ site.hostname }:${ + site.port + }/download?skipencode=1&dir=${ encodeURIComponent( dir ) }&file=${ encodeURIComponent( + filename + ) }`; + const authHeader = `cpanel ${ site.username }:${ site.apiToken }`; + const parsedUrl = new URL( url ); + + const requestOptions: https.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port ? parseInt( parsedUrl.port ) : 443, + path: parsedUrl.pathname + parsedUrl.search, + method: 'GET', + headers: { + Authorization: authHeader, + }, + rejectUnauthorized: false, + }; + + await new Promise< void >( ( resolve, reject ) => { + const outStream = fs.createWriteStream( destPath ); + + const req = https.request( requestOptions, ( res ) => { + if ( res.statusCode && res.statusCode >= 400 ) { + outStream.close(); + reject( + new Error( `cPanel file download failed: ${ res.statusCode } ${ res.statusMessage }` ) + ); + return; + } + + res.pipe( outStream ); + outStream.on( 'finish', () => outStream.close( () => resolve() ) ); + res.on( 'error', ( err ) => { + outStream.close(); + reject( err ); + } ); + } ); + + req.on( 'error', ( err ) => { + outStream.close(); + reject( err ); + } ); + + if ( signal ) { + signal.addEventListener( 'abort', () => { + req.destroy(); + outStream.close(); + fs.unlink( destPath, () => {} ); + reject( new Error( 'Download aborted' ) ); + } ); + } + + req.end(); + } ); +} + +/** + * Delete a file on the cPanel server (used for cleanup after pull). + */ +export async function cpanelDeleteFile( + site: Pick< CpanelSyncSite, 'hostname' | 'port' | 'username' | 'apiToken' >, + dir: string, + filename: string +): Promise< void > { + await cpanelUapi( + site, + 'Fileman', + 'delete_files', + { + 'files-0': `${ dir }/${ filename }`, + }, + 'POST' + ); +} diff --git a/apps/studio/src/modules/cpanel/lib/ipc-handlers.ts b/apps/studio/src/modules/cpanel/lib/ipc-handlers.ts new file mode 100644 index 0000000000..e469c5c69d --- /dev/null +++ b/apps/studio/src/modules/cpanel/lib/ipc-handlers.ts @@ -0,0 +1,420 @@ +import { app, IpcMainInvokeEvent } from 'electron'; +import fsPromises from 'fs/promises'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { isWordPressDirectory } from '@studio/common/lib/fs-utils'; +import { __ } from '@wordpress/i18n'; +import * as tar from 'tar'; +import { sendIpcEventToRenderer } from 'src/ipc-utils'; +import { defaultImporterOptions, importBackup } from 'src/lib/import-export/import/import-manager'; +import { + cpanelDeleteFile, + cpanelDownloadFile, + cpanelDumpDatabase, + cpanelUapi, +} from 'src/modules/cpanel/lib/cpanel-api'; +import { SiteServer } from 'src/site-server'; +import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; +import type { CpanelPullStatusInfo, CpanelSyncSite } from 'src/modules/cpanel/types'; + +/** + * Registry to track AbortControllers for ongoing cPanel pull operations. + * Key format: `${localSiteId}-${cpanelSiteId}` + */ +const CPANEL_ABORT_CONTROLLERS = new Map< string, AbortController >(); + +function operationKey( localSiteId: string, cpanelSiteId: string ): string { + return `${ localSiteId }-${ cpanelSiteId }`; +} + +async function emitPullProgress( + localSiteId: string, + cpanelSiteId: string, + status: CpanelPullStatusInfo +): Promise< void > { + await sendIpcEventToRenderer( 'cpanel-pull-progress', { + localSiteId, + cpanelSiteId, + status, + } ); +} + +// --------------------------------------------------------------------------- +// Connection management +// --------------------------------------------------------------------------- + +export async function connectCpanelSite( + event: IpcMainInvokeEvent, + site: Omit< CpanelSyncSite, 'id' | 'lastPullTimestamp' > +): Promise< CpanelSyncSite > { + // Validate credentials by listing files at wpPath. + await cpanelUapi( site, 'Fileman', 'list_files', { dir: site.wpPath } ); + + const newSite: CpanelSyncSite = { + ...site, + id: randomUUID(), + lastPullTimestamp: null, + }; + + try { + await lockAppdata(); + const userData = await loadUserData(); + userData.connectedCpanelSites = userData.connectedCpanelSites ?? []; + + const alreadyConnected = userData.connectedCpanelSites.some( + ( s ) => s.hostname === site.hostname && s.localSiteId === site.localSiteId + ); + + if ( ! alreadyConnected ) { + userData.connectedCpanelSites.push( newSite ); + await saveUserData( userData ); + } + } finally { + await unlockAppdata(); + } + + return newSite; +} + +export async function disconnectCpanelSite( + event: IpcMainInvokeEvent, + cpanelSiteId: string, + localSiteId: string +): Promise< void > { + try { + await lockAppdata(); + const userData = await loadUserData(); + userData.connectedCpanelSites = ( userData.connectedCpanelSites ?? [] ).filter( + ( s ) => ! ( s.id === cpanelSiteId && s.localSiteId === localSiteId ) + ); + await saveUserData( userData ); + } finally { + await unlockAppdata(); + } +} + +export async function getConnectedCpanelSites( + event: IpcMainInvokeEvent, + localSiteId?: string +): Promise< CpanelSyncSite[] > { + const userData = await loadUserData(); + const all = userData.connectedCpanelSites ?? []; + return localSiteId ? all.filter( ( s ) => s.localSiteId === localSiteId ) : all; +} + +export async function updateConnectedCpanelSites( + event: IpcMainInvokeEvent, + updatedSites: CpanelSyncSite[] +): Promise< void > { + try { + await lockAppdata(); + const userData = await loadUserData(); + const all = userData.connectedCpanelSites ?? []; + + updatedSites.forEach( ( updated ) => { + const idx = all.findIndex( + ( s ) => s.id === updated.id && s.localSiteId === updated.localSiteId + ); + if ( idx !== -1 ) { + all[ idx ] = updated; + } + } ); + + userData.connectedCpanelSites = all; + await saveUserData( userData ); + } finally { + await unlockAppdata(); + } +} + +// --------------------------------------------------------------------------- +// Pull: cPanel → local Studio +// --------------------------------------------------------------------------- + +export async function pullCpanelSite( + event: IpcMainInvokeEvent, + cpanelSiteId: string, + localSiteId: string +): Promise< void > { + const userData = await loadUserData(); + const cpanelSite = ( userData.connectedCpanelSites ?? [] ).find( + ( s ) => s.id === cpanelSiteId && s.localSiteId === localSiteId + ); + + if ( ! cpanelSite ) { + throw new Error( 'cPanel connection not found.' ); + } + + const siteServer = SiteServer.get( localSiteId ); + if ( ! siteServer ) { + throw new Error( 'Local site not found.' ); + } + + const abortController = new AbortController(); + const key = operationKey( localSiteId, cpanelSiteId ); + CPANEL_ABORT_CONTROLLERS.set( key, abortController ); + + const tmpDir = path.join( + app.getPath( 'temp' ), + 'com.wordpress.studio', + `cpanel-${ randomUUID() }` + ); + + const remoteArchiveName = `studio-cpanel-${ Date.now() }.tar.gz`; + // Place the archive in the WordPress parent directory on the remote server + const remoteArchiveDir = path.dirname( cpanelSite.wpPath ).replace( /\\/g, '/' ) || '.'; + const localWpContentArchive = path.join( tmpDir, 'wp-content.tar.gz' ); + const localSqlFile = path.join( tmpDir, 'site.sql' ); + const finalArchivePath = path.join( tmpDir, 'cpanel-backup.tar.gz' ); + + try { + await fsPromises.mkdir( tmpDir, { recursive: true } ); + + // Step 1: Compress wp-content directory on the remote server + await emitPullProgress( localSiteId, cpanelSiteId, { + key: 'compressing', + progress: 10, + message: __( 'Compressing files on server…' ), + } ); + + if ( abortController.signal.aborted ) { + throw new Error( 'Cancelled' ); + } + + const wpContentPath = `${ cpanelSite.wpPath }/wp-content`; + await cpanelUapi( + cpanelSite, + 'Fileman', + 'compress', + { + 'files-0': wpContentPath, + type: 'tar.gz', + 'dest-dir': remoteArchiveDir, + 'dest-file': remoteArchiveName, + }, + 'POST' + ); + + // Step 2: Download the archive + await emitPullProgress( localSiteId, cpanelSiteId, { + key: 'downloading', + progress: 30, + message: __( 'Downloading files…' ), + } ); + + if ( abortController.signal.aborted ) { + throw new Error( 'Cancelled' ); + } + + await cpanelDownloadFile( + cpanelSite, + remoteArchiveDir, + remoteArchiveName, + localWpContentArchive, + abortController.signal + ); + + // Step 3: Export database + await emitPullProgress( localSiteId, cpanelSiteId, { + key: 'exporting-db', + progress: 55, + message: __( 'Exporting database…' ), + } ); + + if ( abortController.signal.aborted ) { + throw new Error( 'Cancelled' ); + } + + const sqlDump = await cpanelDumpDatabase( cpanelSite, cpanelSite.dbName ); + await fsPromises.writeFile( localSqlFile, sqlDump, 'utf8' ); + + // Step 4: Build a Jetpack-format archive for importBackup + await emitPullProgress( localSiteId, cpanelSiteId, { + key: 'building-archive', + progress: 65, + message: __( 'Building archive…' ), + } ); + + await buildJetpackArchive( { + wpContentArchivePath: localWpContentArchive, + sqlFilePath: localSqlFile, + outputPath: finalArchivePath, + } ); + + // Step 5: Import into local site + await emitPullProgress( localSiteId, cpanelSiteId, { + key: 'importing', + progress: 75, + message: __( 'Importing into local site…' ), + } ); + + if ( abortController.signal.aborted ) { + throw new Error( 'Cancelled' ); + } + + const wasRunning = siteServer.details.running; + if ( wasRunning ) { + await siteServer.stop(); + } + + try { + if ( ! isWordPressDirectory( siteServer.details.path ) ) { + await setupWordPressFilesOnly( siteServer.details.path ); + } + + await importBackup( + { path: finalArchivePath, type: 'application/tar+gzip' }, + siteServer.details, + () => {}, + defaultImporterOptions + ); + } finally { + if ( wasRunning ) { + await siteServer.start(); + } + } + + // Step 6: Record timestamp + await updateConnectedCpanelSites( event, [ + { ...cpanelSite, lastPullTimestamp: new Date().toISOString() }, + ] ); + + await emitPullProgress( localSiteId, cpanelSiteId, { + key: 'finished', + progress: 100, + message: __( 'Pull complete' ), + } ); + + // Clean up remote temp archive (best-effort) + cpanelDeleteFile( cpanelSite, remoteArchiveDir, remoteArchiveName ).catch( () => {} ); + } catch ( error ) { + const isCancelled = error instanceof Error && error.message === 'Cancelled'; + await emitPullProgress( localSiteId, cpanelSiteId, { + key: isCancelled ? 'cancelled' : 'failed', + progress: 100, + message: isCancelled ? __( 'Pull cancelled' ) : __( 'Error pulling site' ), + } ); + if ( ! isCancelled ) { + throw error; + } + } finally { + CPANEL_ABORT_CONTROLLERS.delete( key ); + // Clean up local temp files (best-effort) + fsPromises.rm( tmpDir, { recursive: true, force: true } ).catch( () => {} ); + } +} + +export function cancelCpanelPull( + event: IpcMainInvokeEvent, + localSiteId: string, + cpanelSiteId: string +): void { + const key = operationKey( localSiteId, cpanelSiteId ); + const controller = CPANEL_ABORT_CONTROLLERS.get( key ); + if ( controller ) { + controller.abort(); + CPANEL_ABORT_CONTROLLERS.delete( key ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a Jetpack-format tar.gz that importBackup can handle: + * sql/site.sql — the database dump + * wp-content/… — all files from the downloaded wp-content archive + * + * Strategy: + * 1. Extract the downloaded wp-content archive into a staging directory. + * 2. Locate the wp-content directory within it (cPanel may include leading path segments). + * 3. Write the SQL dump as staging/sql/site.sql. + * 4. Use tar.create() to pack the staging directory. + */ +async function buildJetpackArchive( { + wpContentArchivePath, + sqlFilePath, + outputPath, +}: { + wpContentArchivePath: string; + sqlFilePath: string; + outputPath: string; +} ): Promise< void > { + const stagingDir = `${ outputPath }-staging`; + await fsPromises.mkdir( stagingDir, { recursive: true } ); + + // Extract the downloaded wp-content archive + await tar.extract( { + file: wpContentArchivePath, + cwd: stagingDir, + // Strip path components before wp-content by using a filter + filter: ( entryPath ) => { + // Accept any path that contains wp-content/ + return entryPath.includes( 'wp-content' ); + }, + strip: computeStripDepth( wpContentArchivePath ), + } ); + + // Write the SQL dump + const sqlDir = path.join( stagingDir, 'sql' ); + await fsPromises.mkdir( sqlDir, { recursive: true } ); + await fsPromises.copyFile( sqlFilePath, path.join( sqlDir, 'site.sql' ) ); + + // Pack into the Jetpack-format tar.gz + await tar.create( + { + gzip: true, + file: outputPath, + cwd: stagingDir, + }, + await fsPromises.readdir( stagingDir ) + ); + + // Clean up staging dir + await fsPromises.rm( stagingDir, { recursive: true, force: true } ); +} + +/** + * Inspect the first entry of a tar.gz archive to determine how many path + * components appear before the `wp-content` directory, so tar.extract's + * `strip` option can remove them. + */ +function computeStripDepth( archivePath: string ): number { + // Use synchronous list to keep this simple (archive is local and small-ish) + let stripDepth = 0; + try { + // tar.list is async; use an approach compatible with sync context + // We'll set the depth when we first encounter a wp-content path entry. + const entryPaths: string[] = []; + // This is synchronous because we pass `sync: true` + tar.list( { + file: archivePath, + sync: true, + onReadEntry: ( entry ) => { + entryPaths.push( entry.path ); + }, + } ); + + const sample = entryPaths.find( ( p ) => p.includes( 'wp-content/' ) ); + if ( sample ) { + const parts = sample.replace( /\\/g, '/' ).split( '/' ); + const idx = parts.indexOf( 'wp-content' ); + if ( idx > 0 ) { + stripDepth = idx; + } + } + } catch { + // Fall back to no stripping + } + return stripDepth; +} + +/** + * Set up a bare WordPress directory structure for the import to populate. + * Mirrors the pattern in the main importSite IPC handler. + */ +async function setupWordPressFilesOnly( sitePath: string ): Promise< void > { + // A minimal wp-content directory is enough for the importer to proceed. + await fsPromises.mkdir( path.join( sitePath, 'wp-content' ), { recursive: true } ); +} diff --git a/apps/studio/src/modules/cpanel/types.ts b/apps/studio/src/modules/cpanel/types.ts new file mode 100644 index 0000000000..cecb5266d1 --- /dev/null +++ b/apps/studio/src/modules/cpanel/types.ts @@ -0,0 +1,33 @@ +export type CpanelSyncSite = { + id: string; // generated UUID + localSiteId: string; + hostname: string; + port: number; // default 2083 + username: string; + apiToken: string; + wpPath: string; // relative to cPanel home, e.g. 'public_html' + dbName: string; + lastPullTimestamp: string | null; +}; + +export type CpanelPullStatusKey = + | 'compressing' + | 'downloading' + | 'exporting-db' + | 'building-archive' + | 'importing' + | 'finished' + | 'failed' + | 'cancelled'; + +export type CpanelPullStatusInfo = { + key: CpanelPullStatusKey; + progress: number; + message: string; +}; + +export type CpanelPullState = { + cpanelSiteId: string; + selectedSite: SiteDetails; + status: CpanelPullStatusInfo; +}; diff --git a/apps/studio/src/modules/sync/index.tsx b/apps/studio/src/modules/sync/index.tsx index 0891afb2a2..bf976381c3 100644 --- a/apps/studio/src/modules/sync/index.tsx +++ b/apps/studio/src/modules/sync/index.tsx @@ -8,6 +8,8 @@ import { Tooltip } from 'src/components/tooltip'; import { useAuth } from 'src/hooks/use-auth'; import { useOffline } from 'src/hooks/use-offline'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { CpanelConnectedSite } from 'src/modules/cpanel/components/cpanel-connected-site'; +import { CpanelCredentialsModal } from 'src/modules/cpanel/components/cpanel-credentials-modal'; import { ConnectButton } from 'src/modules/sync/components/connect-button'; import { SyncConnectedSites } from 'src/modules/sync/components/sync-connected-sites'; import { SyncDialog } from 'src/modules/sync/components/sync-dialog'; @@ -18,6 +20,7 @@ import { convertTreeToPushOptions, } from 'src/modules/sync/lib/convert-tree-to-sync-options'; import { useAppDispatch, useRootSelector } from 'src/stores'; +import { useGetCpanelSitesForLocalSiteQuery } from 'src/stores/cpanel/cpanel-connected-sites'; import { syncOperationsThunks } from 'src/stores/sync'; import { connectedSitesActions, @@ -139,6 +142,11 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } const [ connectSite ] = useConnectSiteMutation(); const [ disconnectSite ] = useDisconnectSiteMutation(); + const [ showCpanelModal, setShowCpanelModal ] = useState( false ); + const { data: cpanelSites = [] } = useGetCpanelSitesForLocalSiteQuery( { + localSiteId: selectedSite.id, + } ); + const connectedSiteIds = connectedSites.map( ( { id } ) => id ); const { data: syncSites = [] } = useGetWpComSitesQuery( { connectedSiteIds, @@ -193,39 +201,64 @@ export function ContentTabSync( { selectedSite }: { selectedSite: SiteDetails } } }; + const hasAnyConnections = connectedSites.length > 0 || cpanelSites.length > 0; + return (
- { connectedSites.length > 0 ? ( + { hasAnyConnections ? (
- - disconnectSite( { siteId: id, localSiteId: selectedSite.id } ) - } - /> -
+ { connectedSites.length > 0 && ( + + disconnectSite( { siteId: id, localSiteId: selectedSite.id } ) + } + /> + ) } + { cpanelSites.map( ( cpanelSite ) => ( + + ) ) } +
dispatch( connectedSitesActions.openModal( 'connect' ) ) } > - { __( 'Connect another site' ) } + { __( 'Connect WordPress.com site' ) } + + setShowCpanelModal( true ) }> + { __( 'Connect cPanel site' ) }
) : isLoadingConnectedSites ? null : ( -
+
dispatch( connectedSitesActions.openModal( 'connect' ) ) } > - { __( 'Connect site' ) } + { __( 'Connect WordPress.com site' ) } + + setShowCpanelModal( true ) }> + { __( 'Connect cPanel site' ) }
) } + { showCpanelModal && ( + setShowCpanelModal( false ) } + onConnected={ () => setShowCpanelModal( false ) } + /> + ) } + { isModalOpen && ! effectiveRemoteSite && ( ipcRendererInvoke( 'getUserTerminal' ), getUserEditor: () => ipcRendererInvoke( 'getUserEditor' ), saveUserEditor: ( editor ) => ipcRendererInvoke( 'saveUserEditor', editor ), + connectCpanelSite: ( site ) => ipcRendererInvoke( 'connectCpanelSite', site ), + disconnectCpanelSite: ( cpanelSiteId, localSiteId ) => + ipcRendererInvoke( 'disconnectCpanelSite', cpanelSiteId, localSiteId ), + getConnectedCpanelSites: ( localSiteId ) => + ipcRendererInvoke( 'getConnectedCpanelSites', localSiteId ), + updateConnectedCpanelSites: ( updatedSites ) => + ipcRendererInvoke( 'updateConnectedCpanelSites', updatedSites ), + pullCpanelSite: ( cpanelSiteId, localSiteId ) => + ipcRendererInvoke( 'pullCpanelSite', cpanelSiteId, localSiteId ), + cancelCpanelPull: ( localSiteId, cpanelSiteId ) => + ipcRendererSend( 'cancelCpanelPull', localSiteId, cpanelSiteId ), comparePaths: ( path1, path2 ) => ipcRendererInvoke( 'comparePaths', path1, path2 ), listLocalFileTree: ( siteId, path, maxDepth ) => ipcRenderer.invoke( 'listLocalFileTree', siteId, path, maxDepth ), diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index c7d53e2299..16b23c8a8f 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -2,6 +2,7 @@ import { Snapshot } from '@studio/common/types/snapshot'; import { StatsMetric } from 'src/lib/bump-stats'; import { StoredToken } from 'src/lib/oauth'; import { SupportedEditor } from 'src/modules/user-settings/lib/editor'; +import type { CpanelSyncSite } from 'src/modules/cpanel/types'; import type { SyncSite } from 'src/modules/sync/types'; import type { SupportedTerminal } from 'src/modules/user-settings/lib/terminal'; @@ -24,6 +25,7 @@ export interface UserData { lastBumpStats?: Record< string, Partial< Record< StatsMetric, number > > >; promptWindowsSpeedUpResult?: PromptWindowsSpeedUpResult; connectedWpcomSites?: { [ userId: number ]: SyncSite[] }; + connectedCpanelSites?: CpanelSyncSite[]; sentryUserId?: string; lastSeenVersion?: string; preferredTerminal?: SupportedTerminal; diff --git a/apps/studio/src/stores/cpanel/cpanel-connected-sites.ts b/apps/studio/src/stores/cpanel/cpanel-connected-sites.ts new file mode 100644 index 0000000000..e2ab25bab0 --- /dev/null +++ b/apps/studio/src/stores/cpanel/cpanel-connected-sites.ts @@ -0,0 +1,52 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import type { CpanelSyncSite } from 'src/modules/cpanel/types'; + +export const cpanelConnectedSitesApi = createApi( { + reducerPath: 'cpanelConnectedSitesApi', + baseQuery: fetchBaseQuery(), + tagTypes: [ 'CpanelConnectedSites' ], + endpoints: ( builder ) => ( { + getCpanelSitesForLocalSite: builder.query< CpanelSyncSite[], { localSiteId?: string } >( { + queryFn: async ( { localSiteId } ) => { + if ( ! localSiteId ) { + return { data: [] }; + } + const sites = await getIpcApi().getConnectedCpanelSites( localSiteId ); + return { data: sites }; + }, + providesTags: ( result, error, arg ) => [ + { type: 'CpanelConnectedSites', localSiteId: arg.localSiteId }, + ], + } ), + + connectCpanelSite: builder.mutation< + CpanelSyncSite, + Omit< CpanelSyncSite, 'id' | 'lastPullTimestamp' > + >( { + queryFn: async ( site ) => { + const newSite = await getIpcApi().connectCpanelSite( site ); + return { data: newSite }; + }, + invalidatesTags: ( result, error, site ) => [ + { type: 'CpanelConnectedSites', localSiteId: site.localSiteId }, + ], + } ), + + disconnectCpanelSite: builder.mutation< void, { cpanelSiteId: string; localSiteId: string } >( { + queryFn: async ( { cpanelSiteId, localSiteId } ) => { + await getIpcApi().disconnectCpanelSite( cpanelSiteId, localSiteId ); + return { data: undefined }; + }, + invalidatesTags: ( result, error, { localSiteId } ) => [ + { type: 'CpanelConnectedSites', localSiteId }, + ], + } ), + } ), +} ); + +export const { + useGetCpanelSitesForLocalSiteQuery, + useConnectCpanelSiteMutation, + useDisconnectCpanelSiteMutation, +} = cpanelConnectedSitesApi; diff --git a/apps/studio/src/stores/cpanel/cpanel-operations-slice.ts b/apps/studio/src/stores/cpanel/cpanel-operations-slice.ts new file mode 100644 index 0000000000..8705284818 --- /dev/null +++ b/apps/studio/src/stores/cpanel/cpanel-operations-slice.ts @@ -0,0 +1,150 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import { __ } from '@wordpress/i18n'; +import { getIpcApi } from 'src/lib/get-ipc-api'; +import { store } from 'src/stores'; +import type { CpanelPullState, CpanelPullStatusInfo } from 'src/modules/cpanel/types'; + +// Subscribe to cPanel pull progress IPC events emitted by the main process +// and mirror them into Redux state. +window.ipcListener.subscribe( 'cpanel-pull-progress', ( _event, payload ) => { + const { localSiteId, cpanelSiteId, status } = payload; + store.dispatch( + cpanelOperationsActions.updatePullState( { + localSiteId, + cpanelSiteId, + state: { status }, + } ) + ); +} ); + +export function getCpanelPullStatesProgressInfo(): Record< + CpanelPullStatusInfo[ 'key' ], + CpanelPullStatusInfo +> { + return { + compressing: { + key: 'compressing', + progress: 10, + message: __( 'Compressing files on server…' ), + }, + downloading: { key: 'downloading', progress: 30, message: __( 'Downloading files…' ) }, + 'exporting-db': { key: 'exporting-db', progress: 55, message: __( 'Exporting database…' ) }, + 'building-archive': { + key: 'building-archive', + progress: 65, + message: __( 'Building archive…' ), + }, + importing: { key: 'importing', progress: 75, message: __( 'Importing into local site…' ) }, + finished: { key: 'finished', progress: 100, message: __( 'Pull complete' ) }, + failed: { key: 'failed', progress: 100, message: __( 'Error pulling site' ) }, + cancelled: { key: 'cancelled', progress: 0, message: __( 'Pull cancelled' ) }, + }; +} + +type CpanelOperationsState = { + pullStates: Record< string, CpanelPullState >; +}; + +const initialState: CpanelOperationsState = { + pullStates: {}, +}; + +function stateKey( localSiteId: string, cpanelSiteId: string ): string { + return `${ localSiteId }-${ cpanelSiteId }`; +} + +const cpanelOperationsSlice = createSlice( { + name: 'cpanelOperations', + initialState, + reducers: { + updatePullState: ( + state, + action: PayloadAction< { + localSiteId: string; + cpanelSiteId: string; + state: Partial< CpanelPullState >; + } > + ) => { + const { localSiteId, cpanelSiteId, state: update } = action.payload; + const key = stateKey( localSiteId, cpanelSiteId ); + state.pullStates[ key ] = { + ...state.pullStates[ key ], + ...update, + cpanelSiteId, + }; + }, + + clearPullState: ( + state, + action: PayloadAction< { localSiteId: string; cpanelSiteId: string } > + ) => { + const { localSiteId, cpanelSiteId } = action.payload; + delete state.pullStates[ stateKey( localSiteId, cpanelSiteId ) ]; + }, + }, +} ); + +export const cpanelOperationsActions = cpanelOperationsSlice.actions; +export const cpanelOperationsReducer = cpanelOperationsSlice.reducer; + +export const cpanelOperationsSelectors = { + selectPullState: + ( localSiteId: string, cpanelSiteId: string ) => + ( state: { cpanelOperations: CpanelOperationsState } ) => + state.cpanelOperations.pullStates[ stateKey( localSiteId, cpanelSiteId ) ], + + selectIsAnySitePulling: ( state: { cpanelOperations: CpanelOperationsState } ) => + Object.values( state.cpanelOperations.pullStates ).some( ( s ) => + [ 'compressing', 'downloading', 'exporting-db', 'building-archive', 'importing' ].includes( + s.status.key + ) + ), +}; + +// --------------------------------------------------------------------------- +// Async thunks +// --------------------------------------------------------------------------- + +const pullCpanelSiteThunk = createAsyncThunk< + void, + { cpanelSiteId: string; localSiteId: string; selectedSite: SiteDetails }, + { rejectValue: { title: string; message: string } } +>( + 'cpanelOperations/pullSite', + async ( { cpanelSiteId, localSiteId, selectedSite }, { dispatch, rejectWithValue } ) => { + dispatch( + cpanelOperationsActions.updatePullState( { + localSiteId, + cpanelSiteId, + state: { + selectedSite, + status: getCpanelPullStatesProgressInfo().compressing, + }, + } ) + ); + + try { + await getIpcApi().pullCpanelSite( cpanelSiteId, localSiteId ); + } catch ( error ) { + return rejectWithValue( { + title: __( 'Failed to pull cPanel site' ), + message: + error instanceof Error + ? error.message + : __( 'An unexpected error occurred. Please try again.' ), + } ); + } + } +); + +const cancelCpanelPullThunk = createAsyncThunk( + 'cpanelOperations/cancelPull', + async ( { localSiteId, cpanelSiteId }: { localSiteId: string; cpanelSiteId: string } ) => { + getIpcApi().cancelCpanelPull( localSiteId, cpanelSiteId ); + } +); + +export const cpanelOperationsThunks = { + pullSite: pullCpanelSiteThunk, + cancelPull: cancelCpanelPullThunk, +}; diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index eed7e4a25b..308021259a 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -17,6 +17,11 @@ import { appVersionApi } from 'src/stores/app-version-api'; import { betaFeaturesReducer, loadBetaFeatures } from 'src/stores/beta-features-slice'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; import { reducer as chatReducer } from 'src/stores/chat-slice'; +import { cpanelConnectedSitesApi } from 'src/stores/cpanel/cpanel-connected-sites'; +import { + cpanelOperationsReducer, + cpanelOperationsThunks, +} from 'src/stores/cpanel/cpanel-operations-slice'; import i18nReducer from 'src/stores/i18n-slice'; import { installedAppsApi } from 'src/stores/installed-apps-api'; import onboardingReducer from 'src/stores/onboarding-slice'; @@ -41,6 +46,8 @@ import type { SupportedLocale } from '@studio/common/lib/locale'; export type RootState = { appVersionApi: ReturnType< typeof appVersionApi.reducer >; betaFeatures: ReturnType< typeof betaFeaturesReducer >; + cpanelConnectedSitesApi: ReturnType< typeof cpanelConnectedSitesApi.reducer >; + cpanelOperations: ReturnType< typeof cpanelOperationsReducer >; chat: ReturnType< typeof chatReducer >; installedAppsApi: ReturnType< typeof installedAppsApi.reducer >; onboarding: ReturnType< typeof onboardingReducer >; @@ -182,6 +189,13 @@ startAppListening( { }, } ); +startAppListening( { + actionCreator: cpanelOperationsThunks.pullSite.rejected, + effect( action ) { + maybeShowErrorMessageBox( action.payload, action.meta.aborted ); + }, +} ); + const PUSH_POLLING_KEYS = [ 'creatingRemoteBackup', 'applyingChanges', 'finishing' ]; const SYNC_POLLING_INTERVAL = 3000; @@ -324,6 +338,8 @@ export const rootReducer = combineReducers( { appVersionApi: appVersionApi.reducer, betaFeatures: betaFeaturesReducer, chat: chatReducer, + cpanelConnectedSitesApi: cpanelConnectedSitesApi.reducer, + cpanelOperations: cpanelOperationsReducer, installedAppsApi: installedAppsApi.reducer, connectedSitesApi: connectedSitesApi.reducer, connectedSites: connectedSitesReducer, @@ -346,6 +362,7 @@ export const store = configureStore( { getDefaultMiddleware() .prepend( listenerMiddleware.middleware ) .concat( appVersionApi.middleware ) + .concat( cpanelConnectedSitesApi.middleware ) .concat( installedAppsApi.middleware ) .concat( connectedSitesApi.middleware ) .concat( wpcomSitesApi.middleware )