From a9993b3731058a4e475d0dd0df53940e1be48597 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:25:13 +0000 Subject: [PATCH 1/7] Initial plan From ccdfd29f5a28d3a2afcd5cb4fa560bbbd05fbc56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:36:24 +0000 Subject: [PATCH 2/7] feat: add streaming backup command to Stack2 Connector plugin Agent-Logs-Url: https://github.com/leighharro/stack2-wp/sessions/301c52fa-fd4f-40ef-b182-a43276bcdc72 Co-authored-by: leighharro <15240396+leighharro@users.noreply.github.com> --- stack2-connector/BACKUP_API.md | 366 ++++++++++++++ .../class-stack2-backup-rest-controller.php | 186 +++++++ .../includes/class-stack2-backup-service.php | 470 ++++++++++++++++++ .../class-stack2-command-executor.php | 66 ++- .../includes/class-stack2-plugin.php | 40 +- .../includes/class-stack2-rest-controller.php | 4 +- .../includes/class-stack2-settings-page.php | 43 ++ stack2-connector/stack2-connector.php | 2 + 8 files changed, 1170 insertions(+), 7 deletions(-) create mode 100644 stack2-connector/BACKUP_API.md create mode 100644 stack2-connector/includes/class-stack2-backup-rest-controller.php create mode 100644 stack2-connector/includes/class-stack2-backup-service.php diff --git a/stack2-connector/BACKUP_API.md b/stack2-connector/BACKUP_API.md new file mode 100644 index 0000000..a65fef3 --- /dev/null +++ b/stack2-connector/BACKUP_API.md @@ -0,0 +1,366 @@ +# Stack2 Backup API Integration Guide + +This document describes how to implement the backup functionality from the Stack2 app side. +All endpoints follow the same HMAC-based request signing contract already used for plugin commands. + +--- + +## Overview + +The backup flow is: + +1. **Initiate** – Stack2 sends a `backup` command. WordPress generates a compressed backup and returns a `backup_id`. +2. **Poll status** *(optional)* – Stack2 checks the backup status until it is `ready`. +3. **Fetch chunks** – Stack2 fetches chunks sequentially by index and reassembles the file locally. +4. **Cleanup** – Stack2 sends a `backup_cleanup` command so WordPress deletes the temporary backup file. + +Backups auto-expire after **1 hour** and are automatically deleted by a background cron task. + +--- + +## 1. Initiate a Backup + +**Endpoint:** `POST /wp-json/stack2/v1/command` + +**Required headers (signed with your API key):** + +| Header | Value | +|---|---| +| `X-Stack2-Site-ID` | Your Site ID | +| `X-Stack2-Timestamp` | Current Unix timestamp (seconds) | +| `X-Stack2-Signature` | HMAC-SHA256 hex signature | +| `Content-Type` | `application/json` | + +**Signing message:** +``` +POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} +``` + +**Request body:** +```json +{ + "action": "backup", + "backup_type": "full" +} +``` + +`backup_type` values: + +| Value | Description | +|---|---| +| `full` | Database SQL dump + uploads file manifest (default) | +| `database` | WordPress database SQL dump only | +| `files_manifest` | Plain-text list of files in `wp-content/uploads` with sizes and timestamps | + +**Successful response (HTTP 200):** +```json +{ + "success": true, + "error": null, + "inventory": null, + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "total_chunks": 12, + "chunk_size": 1048576, + "file_size": 12582912, + "checksum": "abc123...", + "expires_at": "2026-05-06T11:25:33Z" +} +``` + +| Field | Type | Description | +|---|---|---| +| `backup_id` | string | Unique identifier for this backup | +| `total_chunks` | integer | Number of chunks to fetch (0-indexed) | +| `chunk_size` | integer | Bytes per chunk (1 048 576 = 1 MB) | +| `file_size` | integer | Total compressed backup size in bytes | +| `checksum` | string | SHA-256 hex digest of the full compressed backup file | +| `expires_at` | string | ISO 8601 timestamp after which the backup is automatically deleted | + +**Error response example (HTTP 503):** +```json +{ + "success": false, + "error": "Stack2 credentials are not configured.", + "inventory": null +} +``` + +--- + +## 2. Check Backup Status (optional) + +**Endpoint:** `GET /wp-json/stack2/v1/backup/{backup_id}/status` + +**Required headers:** + +| Header | Value | +|---|---| +| `X-Stack2-Site-ID` | Your Site ID | +| `X-Stack2-Timestamp` | Current Unix timestamp (seconds) | +| `X-Stack2-Signature` | HMAC-SHA256 hex signature | + +**Signing message for GET requests** (body is empty): +``` +GET:/stack2/v1/backup/{backup_id}/status:{timestamp}:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +The constant `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` is the SHA-256 hash of an empty string and is always used for GET request signatures. + +**Successful response (HTTP 200):** +```json +{ + "success": true, + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "backup_type": "full", + "status": "ready", + "file_size": 12582912, + "chunk_size": 1048576, + "total_chunks": 12, + "checksum": "abc123...", + "created_at": "2026-05-06T10:25:33Z", + "expires_at": "2026-05-06T11:25:33Z" +} +``` + +`status` values: + +| Value | Meaning | +|---|---| +| `ready` | Backup file is ready to download | +| `failed` | Backup generation encountered an error | + +**Not found response (HTTP 404):** +```json +{ + "success": false, + "error": "Backup not found or expired." +} +``` + +--- + +## 3. Fetch Backup Chunks + +**Endpoint:** `GET /wp-json/stack2/v1/backup/{backup_id}/chunk/{chunk_index}` + +Chunks are **0-indexed**. Fetch all chunks from `0` to `total_chunks - 1` sequentially or in parallel. + +**Required headers:** Same as status endpoint. + +**Signing message:** +``` +GET:/stack2/v1/backup/{backup_id}/chunk/{chunk_index}:{timestamp}:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +``` + +**Successful response (HTTP 200):** +```json +{ + "success": true, + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "chunk_index": 0, + "total_chunks": 12, + "data": "", + "checksum": "", + "is_last": false +} +``` + +| Field | Type | Description | +|---|---|---| +| `data` | string | Base64-encoded bytes of this chunk | +| `checksum` | string | SHA-256 hex of the **decoded** chunk bytes (use for integrity check) | +| `is_last` | boolean | `true` when this is the final chunk | + +**Not found / out-of-range response (HTTP 404):** +```json +{ + "success": false, + "error": "Chunk not found. Backup may have expired or the chunk index is out of range." +} +``` + +### Resumption on failure + +If fetching a chunk fails (network error, non-200 response), retry the same `chunk_index`. +Because each chunk is derived by reading a fixed byte range from an immutable backup file, retrying is safe. + +--- + +## 4. Clean Up the Backup + +After successfully downloading all chunks and verifying the reassembled file against the top-level `checksum`, instruct WordPress to delete the temporary backup. + +**Endpoint:** `POST /wp-json/stack2/v1/command` + +**Request body:** +```json +{ + "action": "backup_cleanup", + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" +} +``` + +**Successful response (HTTP 200):** +```json +{ + "success": true, + "error": null, + "inventory": null +} +``` + +If the `backup_id` is not found (already expired and auto-cleaned), the response is still `success: true`. + +--- + +## End-to-End Example (pseudo-code) + +```python +import base64, hashlib, hmac, json, time, requests + +SITE_URL = "https://example.com" +SITE_ID = "site_xxx" +API_KEY = "my-secret-api-key" + +def sign(method, route, body_bytes, api_key): + ts = str(int(time.time())) + body_hash = hashlib.sha256(body_bytes).hexdigest() + message = f"{method}:{route}:{ts}:{body_hash}" + sig = hmac.new(api_key.encode(), message.encode(), hashlib.sha256).hexdigest() + return ts, sig + +# Step 1: Initiate backup +body = json.dumps({"action": "backup", "backup_type": "full"}).encode() +ts, sig = sign("POST", "/stack2/v1/command", body, API_KEY) +resp = requests.post( + f"{SITE_URL}/wp-json/stack2/v1/command", + data=body, + headers={ + "Content-Type": "application/json", + "X-Stack2-Site-ID": SITE_ID, + "X-Stack2-Timestamp": ts, + "X-Stack2-Signature": sig, + }, +) +info = resp.json() +backup_id = info["backup_id"] +total_chunks = info["total_chunks"] +full_checksum = info["checksum"] + +# Step 2: Fetch chunks and reassemble +EMPTY_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +assembled = b"" +for i in range(total_chunks): + route = f"/stack2/v1/backup/{backup_id}/chunk/{i}" + ts = str(int(time.time())) + message = f"GET:{route}:{ts}:{EMPTY_HASH}" + sig = hmac.new(API_KEY.encode(), message.encode(), hashlib.sha256).hexdigest() + + chunk_resp = requests.get( + f"{SITE_URL}/wp-json{route}", + headers={ + "X-Stack2-Site-ID": SITE_ID, + "X-Stack2-Timestamp": ts, + "X-Stack2-Signature": sig, + }, + ) + chunk_data = base64.b64decode(chunk_resp.json()["data"]) + + # Verify chunk integrity + assert hashlib.sha256(chunk_data).hexdigest() == chunk_resp.json()["checksum"] + assembled += chunk_data + +# Step 3: Verify full file integrity +assert hashlib.sha256(assembled).hexdigest() == full_checksum + +# Step 4: Save to disk +with open(f"{backup_id}.gz", "wb") as f: + f.write(assembled) + +# Step 5: Cleanup +body = json.dumps({"action": "backup_cleanup", "backup_id": backup_id}).encode() +ts, sig = sign("POST", "/stack2/v1/command", body, API_KEY) +requests.post( + f"{SITE_URL}/wp-json/stack2/v1/command", + data=body, + headers={ + "Content-Type": "application/json", + "X-Stack2-Site-ID": SITE_ID, + "X-Stack2-Timestamp": ts, + "X-Stack2-Signature": sig, + }, +) +``` + +--- + +## Backup File Format + +The backup file is a **gzip-compressed** archive with the following structure: + +``` +-- Stack2 WordPress Database Backup +-- Generated: 2026-05-06T10:25:33+00:00 +-- WordPress Version: 6.8 +-- PHP Version: 8.3.7 +-- Site URL: https://example.com/ + +SET FOREIGN_KEY_CHECKS=0; + +-- Table: wp_options +DROP TABLE IF EXISTS `wp_options`; +CREATE TABLE `wp_options` (...); +INSERT INTO `wp_options` ...; +... + +SET FOREIGN_KEY_CHECKS=1; + +-- Files Manifest (wp-content/uploads) +-- Base Directory: /var/www/html/wp-content/uploads/ +-- Generated: 2026-05-06T10:25:34+00:00 + +FILE: 2025/01/image.jpg | SIZE: 204800 | MTIME: 1735000000 +FILE: 2025/02/document.pdf | SIZE: 512000 | MTIME: 1738000000 +... +``` + +> **Note:** Backup type `files_manifest` lists file paths and metadata only. +> For full binary file transfer, use a direct SFTP/SSH connection or extend this plugin. + +--- + +## Admin Panel + +Backup status is visible in the WordPress admin panel at: + +**Settings → Stack2 Connector → Last Backup** + +Fields displayed: + +- **Status** – `never` / `ready` / `failed` +- **Last Run** – ISO 8601 timestamp of the most recent backup +- **Backup ID** – Identifier for the last completed backup +- **File Size** – Human-readable compressed file size +- **Error** – Error message if the last backup failed + +--- + +## Security Notes + +- All backup endpoints require valid HMAC signatures and a matching Site ID. +- Requests with a timestamp skew greater than 300 seconds are rejected. +- Backup files are stored in `wp-content/uploads/stack2-backups/` which is protected by `.htaccess` to deny direct HTTP access. +- Backup IDs are cryptographically random (128-bit hex). +- All backup files are automatically deleted 1 hour after creation. + +--- + +## Troubleshooting + +| Symptom | Resolution | +|---|---| +| `401 Signature verification failed` on status/chunk endpoints | Remember that GET request signing uses the SHA-256 of an empty body, not the request body. | +| `404 Backup not found or expired` | Backups expire after 1 hour. Re-initiate the backup. | +| `503 Stack2 credentials are not configured` | Configure Stack2 Base URL, Site ID and API Key in WordPress Settings → Stack2 Connector. | +| Backup generation fails on large databases | The plugin increases PHP execution time to 300 seconds. If the server enforces a lower limit, contact your hosting provider. | +| `Cannot create backup directory` | Ensure `wp-content/uploads/` is writable by the web server. | diff --git a/stack2-connector/includes/class-stack2-backup-rest-controller.php b/stack2-connector/includes/class-stack2-backup-rest-controller.php new file mode 100644 index 0000000..a4a7cfb --- /dev/null +++ b/stack2-connector/includes/class-stack2-backup-rest-controller.php @@ -0,0 +1,186 @@ +backup_service = $backup_service; + $this->signature_service = $signature_service; + $this->logger = $logger; + $this->site_id = $site_id; + $this->api_key = $api_key; + } + + public function register_routes(): void + { + register_rest_route('stack2/v1', '/backup/(?P' . Stack2_Backup_Service::BACKUP_ID_RAW_REGEX . ')/status', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array($this, 'handle_status'), + 'permission_callback' => '__return_true', + 'args' => array( + 'backup_id' => array( + 'required' => true, + 'type' => 'string', + 'pattern' => Stack2_Backup_Service::BACKUP_ID_RAW_REGEX, + ), + ), + )); + + register_rest_route('stack2/v1', '/backup/(?P' . Stack2_Backup_Service::BACKUP_ID_RAW_REGEX . ')/chunk/(?P\d+)', array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array($this, 'handle_chunk'), + 'permission_callback' => '__return_true', + 'args' => array( + 'backup_id' => array( + 'required' => true, + 'type' => 'string', + 'pattern' => Stack2_Backup_Service::BACKUP_ID_RAW_REGEX, + ), + 'chunk_index' => array( + 'required' => true, + 'type' => 'integer', + 'minimum' => 0, + ), + ), + )); + } + + /** + * GET /wp-json/stack2/v1/backup/{backup_id}/status + */ + public function handle_status(WP_REST_Request $request): WP_REST_Response + { + $auth = $this->verify_request($request, '/stack2/v1/backup/' . $request->get_param('backup_id') . '/status'); + if (is_wp_error($auth)) { + return $this->error_response($auth); + } + + $backup_id = (string) $request->get_param('backup_id'); + $status = $this->backup_service->get_status($backup_id); + + if ($status === null) { + return new WP_REST_Response(array( + 'success' => false, + 'error' => 'Backup not found or expired.', + ), 404); + } + + return new WP_REST_Response(array_merge(array('success' => true), $status), 200); + } + + /** + * GET /wp-json/stack2/v1/backup/{backup_id}/chunk/{chunk_index} + */ + public function handle_chunk(WP_REST_Request $request): WP_REST_Response + { + $backup_id = (string) $request->get_param('backup_id'); + $chunk_index = (int) $request->get_param('chunk_index'); + + $auth = $this->verify_request( + $request, + '/stack2/v1/backup/' . $backup_id . '/chunk/' . $chunk_index + ); + if (is_wp_error($auth)) { + return $this->error_response($auth); + } + + $chunk = $this->backup_service->get_chunk($backup_id, $chunk_index); + + if ($chunk === null) { + return new WP_REST_Response(array( + 'success' => false, + 'error' => 'Chunk not found. Backup may have expired or the chunk index is out of range.', + ), 404); + } + + return new WP_REST_Response(array_merge(array('success' => true), $chunk), 200); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Verify HMAC signature headers for a GET request. + * + * @param WP_REST_Request $request + * @param string $route_path e.g. "/stack2/v1/backup/bkp_.../chunk/0" + * @return true|WP_Error + */ + private function verify_request(WP_REST_Request $request, string $route_path) + { + if (empty($this->site_id) || empty($this->api_key)) { + return new WP_Error('stack2_not_configured', 'Stack2 credentials are not configured.', array('status' => 503)); + } + + $site_id = (string) $request->get_header('x-stack2-site-id'); + $timestamp = (string) $request->get_header('x-stack2-timestamp'); + $signature = strtolower((string) $request->get_header('x-stack2-signature')); + + if ($site_id === '' || $timestamp === '' || $signature === '') { + return new WP_Error('stack2_missing_headers', 'Missing required signature headers.', array('status' => 401)); + } + + if (!is_numeric($timestamp)) { + return new WP_Error('stack2_invalid_timestamp', 'Invalid timestamp header.', array('status' => 401)); + } + + if (!hash_equals($this->site_id, $site_id)) { + return new WP_Error('stack2_site_mismatch', 'Site ID mismatch.', array('status' => 401)); + } + + if (abs(time() - (int) $timestamp) > 300) { + return new WP_Error('stack2_stale_timestamp', 'Timestamp outside allowed skew window.', array('status' => 401)); + } + + // GET requests have no body; use the sha256 of an empty string. + $message = sprintf('GET:%s:%s:%s', $route_path, $timestamp, self::EMPTY_BODY_HASH); + if (!$this->signature_service->verify($message, $signature, $this->api_key)) { + $this->logger->error('Backup request signature verification failed.', array('route' => $route_path)); + return new WP_Error('stack2_bad_signature', 'Signature verification failed.', array('status' => 401)); + } + + return true; + } + + private function error_response(WP_Error $error): WP_REST_Response + { + $error_data = $error->get_error_data(); + $status = (int) (is_array($error_data) && isset($error_data['status']) ? $error_data['status'] : 401); + return new WP_REST_Response(array( + 'success' => false, + 'error' => $error->get_error_message(), + ), $status); + } +} diff --git a/stack2-connector/includes/class-stack2-backup-service.php b/stack2-connector/includes/class-stack2-backup-service.php new file mode 100644 index 0000000..a3c0699 --- /dev/null +++ b/stack2-connector/includes/class-stack2-backup-service.php @@ -0,0 +1,470 @@ +logger = $logger; + } + + /** + * Initiate a new backup. Generates a compressed backup file and returns + * metadata (backup_id, total_chunks, etc.) so the caller can fetch chunks. + * + * @param string $backup_type "full" | "database" | "files_manifest" + * @return array{success:bool,error?:string,backup_id?:string,total_chunks?:int,chunk_size?:int,file_size?:int,checksum?:string,expires_at?:string} + */ + public function initiate_backup(string $backup_type = 'full'): array + { + $allowed_types = array('full', 'database', 'files_manifest'); + if (!in_array($backup_type, $allowed_types, true)) { + $backup_type = 'full'; + } + + $backup_dir = $this->get_backup_dir(); + if ($backup_dir === null) { + return array('success' => false, 'error' => 'Cannot create backup directory.'); + } + + try { + $backup_id = 'bkp_' . bin2hex(random_bytes(16)); + } catch (Exception $e) { + return array('success' => false, 'error' => 'Cannot generate a secure backup ID.'); + } + $backup_file = $backup_dir . '/' . $backup_id . '.gz'; + $meta_file = $backup_dir . '/' . $backup_id . '.json'; + + // Allow longer execution for backup generation. + // phpcs:ignore WordPress.PHP.IniSet.Risky + @set_time_limit(300); + + $generate_result = $this->generate_backup($backup_file, $backup_type); + if (!$generate_result['success']) { + return $generate_result; + } + + $file_size = (int) filesize($backup_file); + if ($file_size === 0) { + @unlink($backup_file); + return array('success' => false, 'error' => 'Backup generation produced an empty file.'); + } + $total_chunks = (int) ceil($file_size / self::CHUNK_SIZE); + + $meta = array( + 'backup_id' => $backup_id, + 'backup_type' => $backup_type, + 'status' => 'ready', + 'file' => $backup_file, + 'file_size' => $file_size, + 'chunk_size' => self::CHUNK_SIZE, + 'total_chunks' => max(1, $total_chunks), + 'checksum' => hash_file('sha256', $backup_file), + 'created_at' => gmdate('c'), + 'expires_at' => gmdate('c', time() + self::EXPIRY_SECONDS), + ); + + file_put_contents($meta_file, wp_json_encode($meta, JSON_UNESCAPED_SLASHES)); + + update_option('stack2_last_backup_at', $meta['created_at']); + update_option('stack2_last_backup_status', 'ready'); + update_option('stack2_last_backup_id', $backup_id); + update_option('stack2_last_backup_size', $file_size); + update_option('stack2_last_backup_error', ''); + + $this->logger->info('Backup created.', array( + 'backup_id' => $backup_id, + 'type' => $backup_type, + 'size' => $file_size, + 'total_chunks' => $meta['total_chunks'], + )); + + return array( + 'success' => true, + 'backup_id' => $backup_id, + 'total_chunks' => $meta['total_chunks'], + 'chunk_size' => self::CHUNK_SIZE, + 'file_size' => $file_size, + 'checksum' => $meta['checksum'], + 'expires_at' => $meta['expires_at'], + ); + } + + /** + * Return status metadata for an existing backup. + */ + public function get_status(string $backup_id): ?array + { + $meta = $this->load_meta($backup_id); + if ($meta === null) { + return null; + } + + return array( + 'backup_id' => $meta['backup_id'], + 'backup_type' => $meta['backup_type'], + 'status' => $meta['status'], + 'file_size' => $meta['file_size'], + 'chunk_size' => $meta['chunk_size'], + 'total_chunks' => $meta['total_chunks'], + 'checksum' => $meta['checksum'], + 'created_at' => $meta['created_at'], + 'expires_at' => $meta['expires_at'], + ); + } + + /** + * Read a specific chunk from the backup file and return it as base64. + * Returns null if the backup_id or chunk_index is invalid. + */ + public function get_chunk(string $backup_id, int $chunk_index): ?array + { + $meta = $this->load_meta($backup_id); + if ($meta === null) { + return null; + } + + if ($chunk_index < 0 || $chunk_index >= $meta['total_chunks']) { + return null; + } + + $backup_file = $meta['file']; + if (!file_exists($backup_file)) { + return null; + } + + $offset = $chunk_index * $meta['chunk_size']; + $fp = fopen($backup_file, 'rb'); + if ($fp === false) { + return null; + } + + fseek($fp, $offset); + $data = fread($fp, $meta['chunk_size']); + fclose($fp); + + if ($data === false) { + return null; + } + + return array( + 'backup_id' => $backup_id, + 'chunk_index' => $chunk_index, + 'total_chunks' => $meta['total_chunks'], + 'data' => base64_encode($data), + 'checksum' => hash('sha256', $data), + 'is_last' => ($chunk_index === $meta['total_chunks'] - 1), + ); + } + + /** + * Delete backup files for the given backup_id. + */ + public function cleanup(string $backup_id): bool + { + $backup_dir = $this->get_backup_dir(); + if ($backup_dir === null) { + return false; + } + + if (!preg_match(self::BACKUP_ID_PATTERN, $backup_id)) { + return false; + } + + $backup_file = $backup_dir . '/' . $backup_id . '.gz'; + $meta_file = $backup_dir . '/' . $backup_id . '.json'; + + $cleaned = true; + if (file_exists($backup_file)) { + if (!unlink($backup_file)) { + $cleaned = false; + } + } + if (file_exists($meta_file)) { + if (!unlink($meta_file)) { + $cleaned = false; + } + } + + $this->logger->info('Backup cleaned up.', array('backup_id' => $backup_id)); + + return $cleaned; + } + + /** + * Remove all backup files whose expiry timestamp has passed. + */ + public function cleanup_expired(): void + { + $backup_dir = $this->get_backup_dir(); + if ($backup_dir === null) { + return; + } + + $meta_files = glob($backup_dir . '/bkp_*.json'); + if (!is_array($meta_files)) { + return; + } + + foreach ($meta_files as $meta_file) { + $raw = file_get_contents($meta_file); + if ($raw === false) { + continue; + } + $meta = json_decode($raw, true); + if (!is_array($meta)) { + continue; + } + + $expires_at = isset($meta['expires_at']) ? strtotime((string) $meta['expires_at']) : false; + if ($expires_at !== false && time() > $expires_at) { + $backup_id = $meta['backup_id'] ?? ''; + if ($backup_id !== '') { + $this->cleanup($backup_id); + } + } + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private function generate_backup(string $backup_file, string $backup_type): array + { + $gz = gzopen($backup_file, 'wb9'); + if ($gz === false) { + return array('success' => false, 'error' => 'Cannot create backup file.'); + } + + try { + if ($backup_type === 'database' || $backup_type === 'full') { + $db_result = $this->write_database_backup($gz); + if (!$db_result['success']) { + gzclose($gz); + @unlink($backup_file); + + update_option('stack2_last_backup_status', 'failed'); + update_option('stack2_last_backup_error', $db_result['error'] ?? 'Database backup failed.'); + + return $db_result; + } + } + + if ($backup_type === 'files_manifest' || $backup_type === 'full') { + $this->write_files_manifest($gz); + } + } catch (Throwable $e) { + gzclose($gz); + @unlink($backup_file); + + $message = 'Backup generation failed: ' . $e->getMessage(); + $this->logger->error($message, array('backup_file' => $backup_file)); + + update_option('stack2_last_backup_status', 'failed'); + update_option('stack2_last_backup_error', $message); + + return array('success' => false, 'error' => $message); + } + + gzclose($gz); + + return array('success' => true); + } + + /** + * Write a full SQL dump of all WordPress database tables to the gz handle. + * + * @param resource $gz + */ + private function write_database_backup($gz): array + { + global $wpdb; + + gzwrite($gz, "-- Stack2 WordPress Database Backup\n"); + gzwrite($gz, "-- Generated: " . gmdate('c') . "\n"); + gzwrite($gz, "-- WordPress Version: " . get_bloginfo('version') . "\n"); + gzwrite($gz, "-- PHP Version: " . PHP_VERSION . "\n"); + gzwrite($gz, "-- Site URL: " . home_url('/') . "\n\n"); + gzwrite($gz, "SET FOREIGN_KEY_CHECKS=0;\n\n"); + + $tables = $wpdb->get_col('SHOW TABLES'); + if (!is_array($tables)) { + return array('success' => false, 'error' => 'Failed to retrieve database tables.'); + } + + foreach ($tables as $table) { + // Sanitise table name – comes from SHOW TABLES, but we validate anyway. + $safe_table = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $table); + if ($safe_table === '') { + continue; + } + + // CREATE TABLE statement. + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $create = $wpdb->get_row("SHOW CREATE TABLE `{$safe_table}`", ARRAY_N); + if (!is_array($create) || empty($create[1])) { + continue; + } + + gzwrite($gz, "\n-- Table: {$safe_table}\n"); + gzwrite($gz, "DROP TABLE IF EXISTS `{$safe_table}`;\n"); + gzwrite($gz, $create[1] . ";\n\n"); + + // Row data in batches to keep memory usage low. + $offset = 0; + $batch = 200; + + do { + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( + $wpdb->prepare("SELECT * FROM `{$safe_table}` LIMIT %d OFFSET %d", $batch, $offset), + ARRAY_A + ); + + if (empty($rows)) { + break; + } + + foreach ($rows as $row) { + $columns = implode(', ', array_map(static function ($col): string { + return '`' . str_replace('`', '``', (string) $col) . '`'; + }, array_keys($row))); + + $values = implode(', ', array_map(array($this, 'escape_sql_value'), $row)); + + gzwrite($gz, "INSERT INTO `{$safe_table}` ({$columns}) VALUES ({$values});\n"); + } + + $offset += $batch; + } while (count($rows) === $batch); + } + + gzwrite($gz, "\nSET FOREIGN_KEY_CHECKS=1;\n"); + + return array('success' => true); + } + + /** + * Write a plain-text manifest of all files inside wp-content/uploads. + * + * @param resource $gz + */ + private function write_files_manifest($gz): void + { + $upload_dir = wp_upload_dir(); + $base_dir = trailingslashit((string) $upload_dir['basedir']); + + if (!is_dir($base_dir)) { + return; + } + + gzwrite($gz, "\n-- Files Manifest (wp-content/uploads)\n"); + gzwrite($gz, "-- Base Directory: {$base_dir}\n"); + gzwrite($gz, "-- Generated: " . gmdate('c') . "\n\n"); + + try { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base_dir, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if (!($file instanceof SplFileInfo) || !$file->isFile()) { + continue; + } + + $path = $file->getPathname(); + + // Skip files inside our own backup directory. + if (strpos($path, DIRECTORY_SEPARATOR . self::BACKUP_DIR_NAME . DIRECTORY_SEPARATOR) !== false) { + continue; + } + + $relative = ltrim(str_replace($base_dir, '', $path), '/\\'); + $size = $file->getSize(); + $mtime = $file->getMTime(); + + gzwrite($gz, "FILE: {$relative} | SIZE: {$size} | MTIME: {$mtime}\n"); + } + } catch (Throwable $e) { + $this->logger->error('Files manifest generation error.', array('error' => $e->getMessage())); + gzwrite($gz, "-- WARNING: Manifest incomplete due to error: " . $e->getMessage() . "\n"); + } + } + + /** + * Escape a single SQL value for use in an INSERT statement. + * Uses WordPress's esc_sql() for string escaping. + */ + private function escape_sql_value(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + + return "'" . esc_sql((string) $value) . "'"; + } + + /** + * Return the path to the backups directory, creating it if necessary. + */ + private function get_backup_dir(): ?string + { + $upload_dir = wp_upload_dir(); + $dir = trailingslashit((string) $upload_dir['basedir']) . self::BACKUP_DIR_NAME; + + if (!file_exists($dir)) { + if (!wp_mkdir_p($dir)) { + $this->logger->error('Cannot create backup directory.', array('dir' => $dir)); + return null; + } + + // Deny direct HTTP access. + file_put_contents($dir . '/.htaccess', "deny from all\nOptions -Indexes\n"); + file_put_contents($dir . '/index.php', "get_backup_dir(); + if ($backup_dir === null) { + return null; + } + + $meta_file = $backup_dir . '/' . $backup_id . '.json'; + if (!file_exists($meta_file)) { + return null; + } + + $raw = file_get_contents($meta_file); + if ($raw === false) { + return null; + } + + $meta = json_decode($raw, true); + return is_array($meta) ? $meta : null; + } +} diff --git a/stack2-connector/includes/class-stack2-command-executor.php b/stack2-connector/includes/class-stack2-command-executor.php index f36e3b1..3b481d8 100644 --- a/stack2-connector/includes/class-stack2-command-executor.php +++ b/stack2-connector/includes/class-stack2-command-executor.php @@ -9,15 +9,21 @@ class Stack2_Command_Executor private Stack2_Inventory_Collector $inventory_collector; private Stack2_Logger $logger; private string $site_id; - - public function __construct(Stack2_Inventory_Collector $inventory_collector, Stack2_Logger $logger, string $site_id) - { + private ?Stack2_Backup_Service $backup_service; + + public function __construct( + Stack2_Inventory_Collector $inventory_collector, + Stack2_Logger $logger, + string $site_id, + ?Stack2_Backup_Service $backup_service = null + ) { $this->inventory_collector = $inventory_collector; $this->logger = $logger; $this->site_id = $site_id; + $this->backup_service = $backup_service; } - public function execute(string $action, ?string $plugin_file, ?string $slug): array + public function execute(string $action, ?string $plugin_file, ?string $slug, array $payload = array()): array { try { switch ($action) { @@ -38,6 +44,12 @@ public function execute(string $action, ?string $plugin_file, ?string $slug): ar case 'delete': return $this->delete_plugin($plugin_file, $slug); + + case 'backup': + return $this->initiate_backup($payload); + + case 'backup_cleanup': + return $this->cleanup_backup($payload); } return array('success' => false, 'error' => 'Unsupported action.', 'inventory' => null); @@ -152,6 +164,52 @@ private function delete_plugin(?string $plugin_file, ?string $slug): array return array('success' => true, 'error' => null, 'inventory' => $this->inventory_collector->collect($this->site_id)); } + private function initiate_backup(array $payload): array + { + if ($this->backup_service === null) { + return array('success' => false, 'error' => 'Backup service is not available.', 'inventory' => null); + } + + $backup_type = isset($payload['backup_type']) ? sanitize_text_field((string) $payload['backup_type']) : 'full'; + + $result = $this->backup_service->initiate_backup($backup_type); + if (!$result['success']) { + return array('success' => false, 'error' => $result['error'] ?? 'Backup failed.', 'inventory' => null); + } + + return array( + 'success' => true, + 'error' => null, + 'inventory' => null, + 'backup_id' => $result['backup_id'], + 'total_chunks' => $result['total_chunks'], + 'chunk_size' => $result['chunk_size'], + 'file_size' => $result['file_size'], + 'checksum' => $result['checksum'], + 'expires_at' => $result['expires_at'], + ); + } + + private function cleanup_backup(array $payload): array + { + if ($this->backup_service === null) { + return array('success' => false, 'error' => 'Backup service is not available.', 'inventory' => null); + } + + $backup_id = isset($payload['backup_id']) ? sanitize_text_field((string) $payload['backup_id']) : ''; + if ($backup_id === '') { + return array('success' => false, 'error' => 'backup_cleanup action requires backup_id.', 'inventory' => null); + } + + $cleaned = $this->backup_service->cleanup($backup_id); + + return array( + 'success' => $cleaned, + 'error' => $cleaned ? null : 'Backup cleanup failed or backup not found.', + 'inventory' => null, + ); + } + private function resolve_plugin_file(?string $plugin_file, ?string $slug): ?string { if (!function_exists('get_plugins')) { diff --git a/stack2-connector/includes/class-stack2-plugin.php b/stack2-connector/includes/class-stack2-plugin.php index 2f724c7..0d510f1 100644 --- a/stack2-connector/includes/class-stack2-plugin.php +++ b/stack2-connector/includes/class-stack2-plugin.php @@ -14,14 +14,21 @@ class Stack2_Plugin public const OPTION_LAST_SYNC_AT = 'stack2_last_sync_at'; public const OPTION_LAST_SYNC_STATUS = 'stack2_last_sync_status'; public const OPTION_LAST_SYNC_ERROR = 'stack2_last_sync_error'; + public const OPTION_LAST_BACKUP_AT = 'stack2_last_backup_at'; + public const OPTION_LAST_BACKUP_STATUS = 'stack2_last_backup_status'; + public const OPTION_LAST_BACKUP_ID = 'stack2_last_backup_id'; + public const OPTION_LAST_BACKUP_SIZE = 'stack2_last_backup_size'; + public const OPTION_LAST_BACKUP_ERROR = 'stack2_last_backup_error'; public const CRON_HOOK_SYNC = 'stack2_sync_inventory_event'; + public const CRON_HOOK_BACKUP_CLEANUP = 'stack2_backup_cleanup_event'; private const LOCK_TRANSIENT = 'stack2_sync_lock'; private Stack2_Logger $logger; private Stack2_Signature_Service $signature_service; private Stack2_Inventory_Collector $inventory_collector; private Stack2_Http_Client $http_client; + private Stack2_Backup_Service $backup_service; public function __construct() { @@ -29,12 +36,15 @@ public function __construct() $this->signature_service = new Stack2_Signature_Service(); $this->inventory_collector = new Stack2_Inventory_Collector(); $this->http_client = new Stack2_Http_Client($this->signature_service); + $this->backup_service = new Stack2_Backup_Service($this->logger); } public function bootstrap(): void { add_action('rest_api_init', array($this, 'register_rest_controller')); + add_action('rest_api_init', array($this, 'register_backup_rest_controller')); add_action(self::CRON_HOOK_SYNC, array($this, 'handle_scheduled_sync'), 10, 2); + add_action(self::CRON_HOOK_BACKUP_CLEANUP, array($this, 'handle_backup_cleanup')); add_filter('cron_schedules', array($this, 'register_cron_schedule')); new Stack2_Settings_Page($this); @@ -51,6 +61,7 @@ public static function on_activation(): void $plugin = new self(); $plugin->reschedule_cron(); + $plugin->reschedule_backup_cleanup_cron(); if ($plugin->has_valid_credentials()) { $plugin->schedule_single_sync(10, 'activation', 0); } @@ -59,6 +70,7 @@ public static function on_activation(): void public static function on_deactivation(): void { wp_clear_scheduled_hook(self::CRON_HOOK_SYNC); + wp_clear_scheduled_hook(self::CRON_HOOK_BACKUP_CLEANUP); delete_transient(self::LOCK_TRANSIENT); } @@ -67,7 +79,8 @@ public function register_rest_controller(): void $executor = new Stack2_Command_Executor( $this->inventory_collector, $this->logger, - $this->get_site_id() + $this->get_site_id(), + $this->backup_service ); $controller = new Stack2_REST_Controller( @@ -81,6 +94,19 @@ public function register_rest_controller(): void $controller->register_routes(); } + public function register_backup_rest_controller(): void + { + $controller = new Stack2_Backup_REST_Controller( + $this->backup_service, + $this->signature_service, + $this->logger, + $this->get_site_id(), + $this->get_api_key() + ); + + $controller->register_routes(); + } + public function register_cron_schedule(array $schedules): array { $minutes = $this->get_sync_interval_minutes(); @@ -115,6 +141,18 @@ public function handle_scheduled_sync(int $attempt = 0, string $trigger = 'cron' $this->sync_inventory($trigger, $attempt); } + public function reschedule_backup_cleanup_cron(): void + { + if (!wp_next_scheduled(self::CRON_HOOK_BACKUP_CLEANUP)) { + wp_schedule_event(time() + 600, 'hourly', self::CRON_HOOK_BACKUP_CLEANUP); + } + } + + public function handle_backup_cleanup(): void + { + $this->backup_service->cleanup_expired(); + } + public function sync_inventory(string $trigger = 'manual', int $attempt = 0): array { if (!$this->has_valid_credentials()) { diff --git a/stack2-connector/includes/class-stack2-rest-controller.php b/stack2-connector/includes/class-stack2-rest-controller.php index 8b331e6..77700a6 100644 --- a/stack2-connector/includes/class-stack2-rest-controller.php +++ b/stack2-connector/includes/class-stack2-rest-controller.php @@ -76,7 +76,7 @@ public function handle_command(WP_REST_Request $request): WP_REST_Response $plugin_file = isset($payload['plugin']) ? sanitize_text_field((string) $payload['plugin']) : null; $slug = isset($payload['slug']) ? sanitize_title((string) $payload['slug']) : null; - $allowed = array('install', 'update', 'activate', 'deactivate', 'delete', 'inventory'); + $allowed = array('install', 'update', 'activate', 'deactivate', 'delete', 'inventory', 'backup', 'backup_cleanup'); if (!in_array($action, $allowed, true)) { return new WP_REST_Response(array( 'success' => false, @@ -85,7 +85,7 @@ public function handle_command(WP_REST_Request $request): WP_REST_Response ), 400); } - $result = $this->command_executor->execute($action, $plugin_file, $slug); + $result = $this->command_executor->execute($action, $plugin_file, $slug, $payload); $status = $result['success'] ? 200 : 400; if (!$result['success']) { diff --git a/stack2-connector/includes/class-stack2-settings-page.php b/stack2-connector/includes/class-stack2-settings-page.php index ec8a160..dbd114b 100644 --- a/stack2-connector/includes/class-stack2-settings-page.php +++ b/stack2-connector/includes/class-stack2-settings-page.php @@ -44,6 +44,12 @@ public function render_page(): void $last_error = (string) get_option(Stack2_Plugin::OPTION_LAST_SYNC_ERROR, ''); $debug = (bool) get_option(Stack2_Logger::OPTION_DEBUG, false); + $last_backup_at = (string) get_option(Stack2_Plugin::OPTION_LAST_BACKUP_AT, ''); + $last_backup_status = (string) get_option(Stack2_Plugin::OPTION_LAST_BACKUP_STATUS, ''); + $last_backup_id = (string) get_option(Stack2_Plugin::OPTION_LAST_BACKUP_ID, ''); + $last_backup_size = (int) get_option(Stack2_Plugin::OPTION_LAST_BACKUP_SIZE, 0); + $last_backup_error = (string) get_option(Stack2_Plugin::OPTION_LAST_BACKUP_ERROR, ''); + $masked_api_key = $this->mask_api_key($api_key); $notice = get_transient('stack2_admin_notice'); if ($notice) { @@ -111,6 +117,43 @@ public function render_page(): void

Status:

At:

Error:

+ +

Last Backup

+ + + + + + + + + + + + + + + + + + + + + + + + +

Backups are initiated remotely by the Stack2 app. See BACKUP_API.md for integration details.

Date: Wed, 6 May 2026 05:53:30 +0000 Subject: [PATCH 3/7] refactor: switch backup to push-based model with 10MB chunks and 1000-row SQL batches Agent-Logs-Url: https://github.com/leighharro/stack2-wp/sessions/12a9026f-d3f7-426b-a43f-0b7a83b69928 Co-authored-by: leighharro <15240396+leighharro@users.noreply.github.com> --- stack2-connector/BACKUP_API.md | 309 ++++++++---------- .../class-stack2-backup-rest-controller.php | 68 +--- .../includes/class-stack2-backup-service.php | 264 +++++++-------- .../class-stack2-command-executor.php | 32 +- .../includes/class-stack2-http-client.php | 56 ++++ .../includes/class-stack2-plugin.php | 8 +- .../includes/class-stack2-settings-page.php | 2 +- 7 files changed, 351 insertions(+), 388 deletions(-) diff --git a/stack2-connector/BACKUP_API.md b/stack2-connector/BACKUP_API.md index a65fef3..c7649ac 100644 --- a/stack2-connector/BACKUP_API.md +++ b/stack2-connector/BACKUP_API.md @@ -1,20 +1,42 @@ # Stack2 Backup API Integration Guide This document describes how to implement the backup functionality from the Stack2 app side. -All endpoints follow the same HMAC-based request signing contract already used for plugin commands. +All endpoints follow the same HMAC-based request signing contract used for plugin commands. --- ## Overview -The backup flow is: +The backup flow is push-based: WordPress generates the backup and pushes each chunk directly to the Stack2 API as it is read. Stack2 does not need to poll or fetch chunks. -1. **Initiate** – Stack2 sends a `backup` command. WordPress generates a compressed backup and returns a `backup_id`. -2. **Poll status** *(optional)* – Stack2 checks the backup status until it is `ready`. -3. **Fetch chunks** – Stack2 fetches chunks sequentially by index and reassembles the file locally. -4. **Cleanup** – Stack2 sends a `backup_cleanup` command so WordPress deletes the temporary backup file. +``` +Stack2 WordPress Plugin + | | + | POST /wp-json/stack2/v1/command | + | {"action":"backup","backup_type":"full"} + |------------------------------------->| + | | 1. Generate gzip backup (DB + file manifest) + | | 2. For each 10 MB chunk: + | POST {base_url}/api/websites/backup/chunk + |<-------------------------------------| push base64 chunk with checksum + | 200 OK | + |------------------------------------->| (repeat for every chunk) + | | 3. Delete temp file from WordPress server + | 200 OK {backup_id, total_chunks ...}| + |<-------------------------------------| + | | + | (optional status check) | + | GET /wp-json/stack2/v1/backup/{id}/status + |------------------------------------->| + | 200 OK {status:"pushed", ...} | + |<-------------------------------------| +``` -Backups auto-expire after **1 hour** and are automatically deleted by a background cron task. +Key points: +- Each chunk is **10 MB** (base64-encoded in transit). +- Chunks arrive sequentially with a `chunk_index` starting at `0`. +- The **last chunk** carries additional summary metadata (`total_chunks`, `file_size`, `file_checksum`, `backup_type`) so Stack2 knows when to finalise the upload. +- The temp file is **deleted from WordPress immediately** after all chunks are pushed — no disk space is held. --- @@ -22,7 +44,7 @@ Backups auto-expire after **1 hour** and are automatically deleted by a backgrou **Endpoint:** `POST /wp-json/stack2/v1/command` -**Required headers (signed with your API key):** +**Required headers:** | Header | Value | |---|---| @@ -59,136 +81,142 @@ POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} "error": null, "inventory": null, "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "total_chunks": 12, - "chunk_size": 1048576, - "file_size": 12582912, + "total_chunks": 5, + "file_size": 52428800, "checksum": "abc123...", - "expires_at": "2026-05-06T11:25:33Z" + "backup_type": "full" } ``` | Field | Type | Description | |---|---|---| -| `backup_id` | string | Unique identifier for this backup | -| `total_chunks` | integer | Number of chunks to fetch (0-indexed) | -| `chunk_size` | integer | Bytes per chunk (1 048 576 = 1 MB) | +| `backup_id` | string | Unique identifier for this backup (also sent with every chunk) | +| `total_chunks` | integer | Total number of chunks that were pushed | | `file_size` | integer | Total compressed backup size in bytes | | `checksum` | string | SHA-256 hex digest of the full compressed backup file | -| `expires_at` | string | ISO 8601 timestamp after which the backup is automatically deleted | +| `backup_type` | string | The type of backup that was generated | + +> **Note:** By the time this response is received, all chunks have already been pushed to Stack2 and the temp file has been removed from the WordPress server. -**Error response example (HTTP 503):** +**Error response example (HTTP 400):** ```json { "success": false, - "error": "Stack2 credentials are not configured.", + "error": "Failed to push chunk 2.", "inventory": null } ``` --- -## 2. Check Backup Status (optional) +## 2. Stack2 Receiving Endpoint -**Endpoint:** `GET /wp-json/stack2/v1/backup/{backup_id}/status` +WordPress pushes each chunk to: -**Required headers:** +``` +POST {stack2_base_url}/api/websites/backup/chunk +``` + +**Headers sent by WordPress:** | Header | Value | |---|---| -| `X-Stack2-Site-ID` | Your Site ID | -| `X-Stack2-Timestamp` | Current Unix timestamp (seconds) | +| `X-Stack2-Site-ID` | Site ID | +| `X-Stack2-Timestamp` | Unix timestamp | | `X-Stack2-Signature` | HMAC-SHA256 hex signature | +| `Content-Type` | `application/json` | -**Signing message for GET requests** (body is empty): +**Signing message (computed by WordPress):** ``` -GET:/stack2/v1/backup/{backup_id}/status:{timestamp}:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +POST:stack2-backup-chunk:{timestamp}:{sha256_of_raw_json_body} ``` -The constant `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` is the SHA-256 hash of an empty string and is always used for GET request signatures. - -**Successful response (HTTP 200):** +**Request body (intermediate chunks):** ```json { - "success": true, "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "backup_type": "full", - "status": "ready", - "file_size": 12582912, - "chunk_size": 1048576, - "total_chunks": 12, - "checksum": "abc123...", - "created_at": "2026-05-06T10:25:33Z", - "expires_at": "2026-05-06T11:25:33Z" + "chunk_index": 0, + "data": "", + "checksum": "", + "is_last": false } ``` -`status` values: - -| Value | Meaning | -|---|---| -| `ready` | Backup file is ready to download | -| `failed` | Backup generation encountered an error | - -**Not found response (HTTP 404):** +**Request body (final chunk — `is_last: true`):** ```json { - "success": false, - "error": "Backup not found or expired." + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "chunk_index": 4, + "data": "", + "checksum": "", + "is_last": true, + "total_chunks": 5, + "file_size": 52428800, + "file_checksum": "", + "backup_type": "full" } ``` +**Expected response from Stack2:** `200 OK` (body can be empty or `{"success":true}`). +If Stack2 returns a non-2xx status, the plugin logs the error and stops pushing. + +### Reassembly + +Stack2 should: +1. On each incoming chunk, decode `data` from base64 and verify `hash('sha256', decoded_bytes) === checksum`. +2. Write the decoded bytes to a temp file at offset `chunk_index * 10485760`. +3. When `is_last === true`, verify the full file checksum against `file_checksum` and finalise the upload. + --- -## 3. Fetch Backup Chunks +## 3. Check Backup Status (optional) -**Endpoint:** `GET /wp-json/stack2/v1/backup/{backup_id}/chunk/{chunk_index}` +After the backup command returns, you can also query status via: -Chunks are **0-indexed**. Fetch all chunks from `0` to `total_chunks - 1` sequentially or in parallel. +**Endpoint:** `GET /wp-json/stack2/v1/backup/{backup_id}/status` -**Required headers:** Same as status endpoint. +**Required headers:** -**Signing message:** +| Header | Value | +|---|---| +| `X-Stack2-Site-ID` | Your Site ID | +| `X-Stack2-Timestamp` | Current Unix timestamp (seconds) | +| `X-Stack2-Signature` | HMAC-SHA256 hex signature | + +**Signing message for GET requests** (body is empty): ``` -GET:/stack2/v1/backup/{backup_id}/chunk/{chunk_index}:{timestamp}:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +GET:/stack2/v1/backup/{backup_id}/status:{timestamp}:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ``` +The constant `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` is the SHA-256 hash of an empty string. + **Successful response (HTTP 200):** ```json { "success": true, "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "chunk_index": 0, - "total_chunks": 12, - "data": "", - "checksum": "", - "is_last": false -} -``` - -| Field | Type | Description | -|---|---|---| -| `data` | string | Base64-encoded bytes of this chunk | -| `checksum` | string | SHA-256 hex of the **decoded** chunk bytes (use for integrity check) | -| `is_last` | boolean | `true` when this is the final chunk | - -**Not found / out-of-range response (HTTP 404):** -```json -{ - "success": false, - "error": "Chunk not found. Backup may have expired or the chunk index is out of range." + "backup_type": "full", + "status": "pushed", + "file_size": 52428800, + "chunk_size": 10485760, + "total_chunks": 5, + "checksum": "abc123...", + "created_at": "2026-05-06T10:25:33Z" } ``` -### Resumption on failure +`status` values: -If fetching a chunk fails (network error, non-200 response), retry the same `chunk_index`. -Because each chunk is derived by reading a fixed byte range from an immutable backup file, retrying is safe. +| Value | Meaning | +|---|---| +| `pushed` | All chunks were pushed to Stack2 successfully | +| `failed` | The backup or push encountered an error | --- -## 4. Clean Up the Backup +## 4. Safety Cleanup (optional) -After successfully downloading all chunks and verifying the reassembled file against the top-level `checksum`, instruct WordPress to delete the temporary backup. +If a push fails mid-way, any orphaned temp file on WordPress is automatically removed by an hourly cron task. You may also send a cleanup command: **Endpoint:** `POST /wp-json/stack2/v1/command` @@ -200,16 +228,7 @@ After successfully downloading all chunks and verifying the reassembled file aga } ``` -**Successful response (HTTP 200):** -```json -{ - "success": true, - "error": null, - "inventory": null -} -``` - -If the `backup_id` is not found (already expired and auto-cleaned), the response is still `success: true`. +This is a no-op if the file no longer exists and always returns `success: true`. --- @@ -222,16 +241,17 @@ SITE_URL = "https://example.com" SITE_ID = "site_xxx" API_KEY = "my-secret-api-key" -def sign(method, route, body_bytes, api_key): +def sign_post(route, body_bytes, api_key): ts = str(int(time.time())) body_hash = hashlib.sha256(body_bytes).hexdigest() - message = f"{method}:{route}:{ts}:{body_hash}" + message = f"POST:{route}:{ts}:{body_hash}" sig = hmac.new(api_key.encode(), message.encode(), hashlib.sha256).hexdigest() return ts, sig -# Step 1: Initiate backup +# Step 1: Trigger backup – WordPress generates the file and pushes all chunks +# to POST {SITE_URL}/api/websites/backup/chunk automatically. body = json.dumps({"action": "backup", "backup_type": "full"}).encode() -ts, sig = sign("POST", "/stack2/v1/command", body, API_KEY) +ts, sig = sign_post("/stack2/v1/command", body, API_KEY) resp = requests.post( f"{SITE_URL}/wp-json/stack2/v1/command", data=body, @@ -243,115 +263,64 @@ resp = requests.post( }, ) info = resp.json() -backup_id = info["backup_id"] -total_chunks = info["total_chunks"] +backup_id = info["backup_id"] +total_chunks = info["total_chunks"] full_checksum = info["checksum"] -# Step 2: Fetch chunks and reassemble +# Step 2: Stack2 side receives chunks at POST /api/websites/backup/chunk +# (handled by Stack2 server code – see "Stack2 Receiving Endpoint" above) +# Chunks arrive with chunk_index 0..N-1, decoded and appended in order. +# The final chunk (is_last=True) triggers reassembly verification. + +# Step 3: (Optional) Verify status EMPTY_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -assembled = b"" -for i in range(total_chunks): - route = f"/stack2/v1/backup/{backup_id}/chunk/{i}" - ts = str(int(time.time())) - message = f"GET:{route}:{ts}:{EMPTY_HASH}" - sig = hmac.new(API_KEY.encode(), message.encode(), hashlib.sha256).hexdigest() - - chunk_resp = requests.get( - f"{SITE_URL}/wp-json{route}", - headers={ - "X-Stack2-Site-ID": SITE_ID, - "X-Stack2-Timestamp": ts, - "X-Stack2-Signature": sig, - }, - ) - chunk_data = base64.b64decode(chunk_resp.json()["data"]) - - # Verify chunk integrity - assert hashlib.sha256(chunk_data).hexdigest() == chunk_resp.json()["checksum"] - assembled += chunk_data - -# Step 3: Verify full file integrity -assert hashlib.sha256(assembled).hexdigest() == full_checksum - -# Step 4: Save to disk -with open(f"{backup_id}.gz", "wb") as f: - f.write(assembled) - -# Step 5: Cleanup -body = json.dumps({"action": "backup_cleanup", "backup_id": backup_id}).encode() -ts, sig = sign("POST", "/stack2/v1/command", body, API_KEY) -requests.post( - f"{SITE_URL}/wp-json/stack2/v1/command", - data=body, +ts = str(int(time.time())) +route = f"/stack2/v1/backup/{backup_id}/status" +message = f"GET:{route}:{ts}:{EMPTY_HASH}" +sig = hmac.new(API_KEY.encode(), message.encode(), hashlib.sha256).hexdigest() +status_resp = requests.get( + f"{SITE_URL}/wp-json{route}", headers={ - "Content-Type": "application/json", "X-Stack2-Site-ID": SITE_ID, "X-Stack2-Timestamp": ts, "X-Stack2-Signature": sig, }, ) +assert status_resp.json()["status"] == "pushed" ``` --- ## Backup File Format -The backup file is a **gzip-compressed** archive with the following structure: - -``` --- Stack2 WordPress Database Backup --- Generated: 2026-05-06T10:25:33+00:00 --- WordPress Version: 6.8 --- PHP Version: 8.3.7 --- Site URL: https://example.com/ - -SET FOREIGN_KEY_CHECKS=0; - --- Table: wp_options -DROP TABLE IF EXISTS `wp_options`; -CREATE TABLE `wp_options` (...); -INSERT INTO `wp_options` ...; -... +The backup file is a **gzip-compressed** text archive. -SET FOREIGN_KEY_CHECKS=1; - --- Files Manifest (wp-content/uploads) --- Base Directory: /var/www/html/wp-content/uploads/ --- Generated: 2026-05-06T10:25:34+00:00 - -FILE: 2025/01/image.jpg | SIZE: 204800 | MTIME: 1735000000 -FILE: 2025/02/document.pdf | SIZE: 512000 | MTIME: 1738000000 -... -``` - -> **Note:** Backup type `files_manifest` lists file paths and metadata only. -> For full binary file transfer, use a direct SFTP/SSH connection or extend this plugin. +- **Database backup** — Standard SQL dump with `DROP TABLE IF EXISTS` / `CREATE TABLE` / `INSERT` statements, rows exported in batches of 1000 for memory efficiency. +- **Files manifest** — Plain-text listing of every file under `wp-content/uploads/` with size and modification time. +- **Full** — Both of the above concatenated. --- ## Admin Panel -Backup status is visible in the WordPress admin panel at: - -**Settings → Stack2 Connector → Last Backup** +Backup status is visible in the WordPress admin panel at **Settings → Stack2 Connector → Last Backup**. Fields displayed: -- **Status** – `never` / `ready` / `failed` +- **Status** – `never` / `pushed` (green) / `failed` (red) - **Last Run** – ISO 8601 timestamp of the most recent backup - **Backup ID** – Identifier for the last completed backup - **File Size** – Human-readable compressed file size -- **Error** – Error message if the last backup failed --- ## Security Notes -- All backup endpoints require valid HMAC signatures and a matching Site ID. +- All backup-related endpoints require valid HMAC signatures and a matching Site ID. - Requests with a timestamp skew greater than 300 seconds are rejected. -- Backup files are stored in `wp-content/uploads/stack2-backups/` which is protected by `.htaccess` to deny direct HTTP access. +- Backup temp files are stored in `wp-content/uploads/stack2-backups/` which is protected by `.htaccess` to deny direct HTTP access. - Backup IDs are cryptographically random (128-bit hex). -- All backup files are automatically deleted 1 hour after creation. +- Orphaned temp files are automatically deleted by an hourly cron task. --- @@ -359,8 +328,8 @@ Fields displayed: | Symptom | Resolution | |---|---| -| `401 Signature verification failed` on status/chunk endpoints | Remember that GET request signing uses the SHA-256 of an empty body, not the request body. | -| `404 Backup not found or expired` | Backups expire after 1 hour. Re-initiate the backup. | -| `503 Stack2 credentials are not configured` | Configure Stack2 Base URL, Site ID and API Key in WordPress Settings → Stack2 Connector. | -| Backup generation fails on large databases | The plugin increases PHP execution time to 300 seconds. If the server enforces a lower limit, contact your hosting provider. | +| Backup command returns `success: false` with a chunk error | Check that `{stack2_base_url}/api/websites/backup/chunk` exists and returns 2xx. | +| `503 Stack2 credentials are not configured` | Configure Stack2 Base URL, Site ID and API Key in WordPress **Settings → Stack2 Connector**. | +| Backup generation fails on large databases | The plugin sets PHP execution time to 300 s. Contact your hosting provider if the server enforces a lower limit. | | `Cannot create backup directory` | Ensure `wp-content/uploads/` is writable by the web server. | +| `401 Signature verification failed` on status endpoint | Remember that GET request signing uses the SHA-256 of an empty body. | diff --git a/stack2-connector/includes/class-stack2-backup-rest-controller.php b/stack2-connector/includes/class-stack2-backup-rest-controller.php index a4a7cfb..15d06ba 100644 --- a/stack2-connector/includes/class-stack2-backup-rest-controller.php +++ b/stack2-connector/includes/class-stack2-backup-rest-controller.php @@ -5,17 +5,15 @@ } /** - * REST controller that exposes read-only backup endpoints: + * REST controller that exposes a read-only backup status endpoint: * GET /wp-json/stack2/v1/backup/{backup_id}/status - * GET /wp-json/stack2/v1/backup/{backup_id}/chunk/{chunk_index} * - * Both endpoints require the same HMAC signature headers used by the command - * endpoint. For GET requests the body is empty, so the body hash is the - * sha256 of an empty string. + * The primary data flow is push-based: the plugin pushes each backup chunk + * directly to the Stack2 API as it is generated. This endpoint lets Stack2 + * verify the outcome after the backup command completes. * - * Signing message format: + * Signing message format (GET, empty body): * GET:/stack2/v1/backup/{backup_id}/status:{timestamp}:{sha256_of_empty_body} - * GET:/stack2/v1/backup/{backup_id}/chunk/{chunk_index}:{timestamp}:{sha256_of_empty_body} */ class Stack2_Backup_REST_Controller { @@ -56,24 +54,6 @@ public function register_routes(): void ), ), )); - - register_rest_route('stack2/v1', '/backup/(?P' . Stack2_Backup_Service::BACKUP_ID_RAW_REGEX . ')/chunk/(?P\d+)', array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array($this, 'handle_chunk'), - 'permission_callback' => '__return_true', - 'args' => array( - 'backup_id' => array( - 'required' => true, - 'type' => 'string', - 'pattern' => Stack2_Backup_Service::BACKUP_ID_RAW_REGEX, - ), - 'chunk_index' => array( - 'required' => true, - 'type' => 'integer', - 'minimum' => 0, - ), - ), - )); } /** @@ -81,52 +61,24 @@ public function register_routes(): void */ public function handle_status(WP_REST_Request $request): WP_REST_Response { - $auth = $this->verify_request($request, '/stack2/v1/backup/' . $request->get_param('backup_id') . '/status'); + $backup_id = (string) $request->get_param('backup_id'); + $auth = $this->verify_request($request, '/stack2/v1/backup/' . $backup_id . '/status'); if (is_wp_error($auth)) { return $this->error_response($auth); } - $backup_id = (string) $request->get_param('backup_id'); $status = $this->backup_service->get_status($backup_id); if ($status === null) { return new WP_REST_Response(array( 'success' => false, - 'error' => 'Backup not found or expired.', + 'error' => 'Backup not found.', ), 404); } return new WP_REST_Response(array_merge(array('success' => true), $status), 200); } - /** - * GET /wp-json/stack2/v1/backup/{backup_id}/chunk/{chunk_index} - */ - public function handle_chunk(WP_REST_Request $request): WP_REST_Response - { - $backup_id = (string) $request->get_param('backup_id'); - $chunk_index = (int) $request->get_param('chunk_index'); - - $auth = $this->verify_request( - $request, - '/stack2/v1/backup/' . $backup_id . '/chunk/' . $chunk_index - ); - if (is_wp_error($auth)) { - return $this->error_response($auth); - } - - $chunk = $this->backup_service->get_chunk($backup_id, $chunk_index); - - if ($chunk === null) { - return new WP_REST_Response(array( - 'success' => false, - 'error' => 'Chunk not found. Backup may have expired or the chunk index is out of range.', - ), 404); - } - - return new WP_REST_Response(array_merge(array('success' => true), $chunk), 200); - } - // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- @@ -135,7 +87,7 @@ public function handle_chunk(WP_REST_Request $request): WP_REST_Response * Verify HMAC signature headers for a GET request. * * @param WP_REST_Request $request - * @param string $route_path e.g. "/stack2/v1/backup/bkp_.../chunk/0" + * @param string $route_path e.g. "/stack2/v1/backup/bkp_.../status" * @return true|WP_Error */ private function verify_request(WP_REST_Request $request, string $route_path) @@ -167,7 +119,7 @@ private function verify_request(WP_REST_Request $request, string $route_path) // GET requests have no body; use the sha256 of an empty string. $message = sprintf('GET:%s:%s:%s', $route_path, $timestamp, self::EMPTY_BODY_HASH); if (!$this->signature_service->verify($message, $signature, $this->api_key)) { - $this->logger->error('Backup request signature verification failed.', array('route' => $route_path)); + $this->logger->error('Backup status request signature verification failed.', array('route' => $route_path)); return new WP_Error('stack2_bad_signature', 'Signature verification failed.', array('status' => 401)); } diff --git a/stack2-connector/includes/class-stack2-backup-service.php b/stack2-connector/includes/class-stack2-backup-service.php index a3c0699..2553573 100644 --- a/stack2-connector/includes/class-stack2-backup-service.php +++ b/stack2-connector/includes/class-stack2-backup-service.php @@ -6,8 +6,9 @@ class Stack2_Backup_Service { - public const CHUNK_SIZE = 1048576; // 1 MB per chunk - public const EXPIRY_SECONDS = 3600; // backups auto-expire after 1 hour + public const CHUNK_SIZE = 10485760; // 10 MB per chunk + public const EXPIRY_SECONDS = 3600; // orphan files cleaned up after 1 hour + public const SQL_BATCH_SIZE = 1000; // rows per SELECT batch when dumping tables /** Raw regex fragment (no anchors/delimiters) for backup ID validation. */ public const BACKUP_ID_RAW_REGEX = 'bkp_[a-f0-9]{32}'; private const BACKUP_DIR_NAME = 'stack2-backups'; @@ -21,14 +22,23 @@ public function __construct(Stack2_Logger $logger) } /** - * Initiate a new backup. Generates a compressed backup file and returns - * metadata (backup_id, total_chunks, etc.) so the caller can fetch chunks. + * Generate a compressed backup and push each chunk to the Stack2 API. + * The temp file is deleted from the server as soon as all chunks are pushed. * - * @param string $backup_type "full" | "database" | "files_manifest" - * @return array{success:bool,error?:string,backup_id?:string,total_chunks?:int,chunk_size?:int,file_size?:int,checksum?:string,expires_at?:string} + * @param string $backup_type "full" | "database" | "files_manifest" + * @param string $base_url Stack2 base URL (e.g. https://app.stack2.au) + * @param string $site_id Stack2 Site ID + * @param string $api_key Stack2 API key + * @param Stack2_Http_Client $http_client HTTP client used for pushing chunks + * @return array{success:bool,error?:string,backup_id?:string,total_chunks?:int,file_size?:int,checksum?:string,backup_type?:string} */ - public function initiate_backup(string $backup_type = 'full'): array - { + public function generate_and_push( + string $backup_type, + string $base_url, + string $site_id, + string $api_key, + Stack2_Http_Client $http_client + ): array { $allowed_types = array('full', 'database', 'files_manifest'); if (!in_array($backup_type, $allowed_types, true)) { $backup_type = 'full'; @@ -44,8 +54,8 @@ public function initiate_backup(string $backup_type = 'full'): array } catch (Exception $e) { return array('success' => false, 'error' => 'Cannot generate a secure backup ID.'); } + $backup_file = $backup_dir . '/' . $backup_id . '.gz'; - $meta_file = $backup_dir . '/' . $backup_id . '.json'; // Allow longer execution for backup generation. // phpcs:ignore WordPress.PHP.IniSet.Risky @@ -61,150 +71,143 @@ public function initiate_backup(string $backup_type = 'full'): array @unlink($backup_file); return array('success' => false, 'error' => 'Backup generation produced an empty file.'); } - $total_chunks = (int) ceil($file_size / self::CHUNK_SIZE); - $meta = array( - 'backup_id' => $backup_id, - 'backup_type' => $backup_type, - 'status' => 'ready', - 'file' => $backup_file, - 'file_size' => $file_size, - 'chunk_size' => self::CHUNK_SIZE, - 'total_chunks' => max(1, $total_chunks), - 'checksum' => hash_file('sha256', $backup_file), - 'created_at' => gmdate('c'), - 'expires_at' => gmdate('c', time() + self::EXPIRY_SECONDS), - ); + $file_checksum = hash_file('sha256', $backup_file); + $total_chunks = max(1, (int) ceil($file_size / self::CHUNK_SIZE)); - file_put_contents($meta_file, wp_json_encode($meta, JSON_UNESCAPED_SLASHES)); + // Open file and push each chunk to Stack2 as it is read. + $fp = fopen($backup_file, 'rb'); + if ($fp === false) { + @unlink($backup_file); + return array('success' => false, 'error' => 'Failed to open backup file for reading. Check file permissions.'); + } - update_option('stack2_last_backup_at', $meta['created_at']); - update_option('stack2_last_backup_status', 'ready'); + $push_error = null; + for ($i = 0; $i < $total_chunks; $i++) { + $chunk_bytes = fread($fp, self::CHUNK_SIZE); + if ($chunk_bytes === false) { + $push_error = sprintf('Failed to read chunk %d from backup file.', $i); + break; + } + + $is_last = ($i === $total_chunks - 1); + $payload = array( + 'backup_id' => $backup_id, + 'chunk_index' => $i, + 'data' => base64_encode($chunk_bytes), + 'checksum' => hash('sha256', $chunk_bytes), + 'is_last' => $is_last, + ); + + // Include summary metadata on the final chunk so Stack2 can verify + // the fully reassembled file and close the upload. + if ($is_last) { + $payload['total_chunks'] = $total_chunks; + $payload['file_size'] = $file_size; + $payload['file_checksum'] = $file_checksum; + $payload['backup_type'] = $backup_type; + } + + $result = $http_client->push_backup_chunk($base_url, $site_id, $api_key, $payload); + if (!$result['success']) { + $push_error = $result['error'] ?? sprintf('Failed to push chunk %d.', $i); + break; + } + } + + fclose($fp); + @unlink($backup_file); // Always remove the temp file from this server. + + if ($push_error !== null) { + $this->logger->error('Backup push failed.', array('backup_id' => $backup_id, 'error' => $push_error)); + update_option('stack2_last_backup_status', 'failed'); + update_option('stack2_last_backup_error', $push_error); + return array('success' => false, 'error' => $push_error); + } + + update_option('stack2_last_backup_at', gmdate('c')); + update_option('stack2_last_backup_status', 'pushed'); update_option('stack2_last_backup_id', $backup_id); update_option('stack2_last_backup_size', $file_size); + update_option('stack2_last_backup_total_chunks', $total_chunks); + update_option('stack2_last_backup_checksum', $file_checksum); + update_option('stack2_last_backup_type', $backup_type); update_option('stack2_last_backup_error', ''); - $this->logger->info('Backup created.', array( + $this->logger->info('Backup pushed successfully.', array( 'backup_id' => $backup_id, 'type' => $backup_type, 'size' => $file_size, - 'total_chunks' => $meta['total_chunks'], + 'total_chunks' => $total_chunks, )); return array( 'success' => true, 'backup_id' => $backup_id, - 'total_chunks' => $meta['total_chunks'], - 'chunk_size' => self::CHUNK_SIZE, + 'total_chunks' => $total_chunks, 'file_size' => $file_size, - 'checksum' => $meta['checksum'], - 'expires_at' => $meta['expires_at'], + 'checksum' => $file_checksum, + 'backup_type' => $backup_type, ); } /** - * Return status metadata for an existing backup. + * Return status for the given backup_id by reading from wp_options. + * Returns null if the backup_id does not match the most recent backup. */ public function get_status(string $backup_id): ?array { - $meta = $this->load_meta($backup_id); - if ($meta === null) { - return null; - } - - return array( - 'backup_id' => $meta['backup_id'], - 'backup_type' => $meta['backup_type'], - 'status' => $meta['status'], - 'file_size' => $meta['file_size'], - 'chunk_size' => $meta['chunk_size'], - 'total_chunks' => $meta['total_chunks'], - 'checksum' => $meta['checksum'], - 'created_at' => $meta['created_at'], - 'expires_at' => $meta['expires_at'], - ); - } - - /** - * Read a specific chunk from the backup file and return it as base64. - * Returns null if the backup_id or chunk_index is invalid. - */ - public function get_chunk(string $backup_id, int $chunk_index): ?array - { - $meta = $this->load_meta($backup_id); - if ($meta === null) { - return null; - } - - if ($chunk_index < 0 || $chunk_index >= $meta['total_chunks']) { - return null; - } - - $backup_file = $meta['file']; - if (!file_exists($backup_file)) { - return null; - } - - $offset = $chunk_index * $meta['chunk_size']; - $fp = fopen($backup_file, 'rb'); - if ($fp === false) { + if (!preg_match(self::BACKUP_ID_PATTERN, $backup_id)) { return null; } - fseek($fp, $offset); - $data = fread($fp, $meta['chunk_size']); - fclose($fp); - - if ($data === false) { + $last_id = (string) get_option('stack2_last_backup_id', ''); + if ($last_id !== $backup_id) { return null; } return array( - 'backup_id' => $backup_id, - 'chunk_index' => $chunk_index, - 'total_chunks' => $meta['total_chunks'], - 'data' => base64_encode($data), - 'checksum' => hash('sha256', $data), - 'is_last' => ($chunk_index === $meta['total_chunks'] - 1), + 'backup_id' => $last_id, + 'backup_type' => (string) get_option('stack2_last_backup_type', ''), + 'status' => (string) get_option('stack2_last_backup_status', 'unknown'), + 'file_size' => (int) get_option('stack2_last_backup_size', 0), + 'chunk_size' => self::CHUNK_SIZE, + 'total_chunks' => (int) get_option('stack2_last_backup_total_chunks', 0), + 'checksum' => (string) get_option('stack2_last_backup_checksum', ''), + 'created_at' => (string) get_option('stack2_last_backup_at', ''), ); } /** - * Delete backup files for the given backup_id. + * Delete any orphaned backup file for the given backup_id. + * In push mode the file is deleted automatically after push, so this + * is a safety net for failed pushes. */ public function cleanup(string $backup_id): bool { - $backup_dir = $this->get_backup_dir(); - if ($backup_dir === null) { + if (!preg_match(self::BACKUP_ID_PATTERN, $backup_id)) { return false; } - if (!preg_match(self::BACKUP_ID_PATTERN, $backup_id)) { + $backup_dir = $this->get_backup_dir(); + if ($backup_dir === null) { return false; } $backup_file = $backup_dir . '/' . $backup_id . '.gz'; - $meta_file = $backup_dir . '/' . $backup_id . '.json'; - - $cleaned = true; if (file_exists($backup_file)) { - if (!unlink($backup_file)) { - $cleaned = false; - } - } - if (file_exists($meta_file)) { - if (!unlink($meta_file)) { - $cleaned = false; - } + $result = unlink($backup_file); + $this->logger->info('Backup file cleaned up.', array('backup_id' => $backup_id)); + return $result; } - $this->logger->info('Backup cleaned up.', array('backup_id' => $backup_id)); - - return $cleaned; + // File does not exist – already cleaned up, treat as success. + return true; } /** - * Remove all backup files whose expiry timestamp has passed. + * Delete any orphaned .gz files that are older than EXPIRY_SECONDS. + * These can accumulate if a push fails before the temp file is removed. */ public function cleanup_expired(): void { @@ -213,27 +216,16 @@ public function cleanup_expired(): void return; } - $meta_files = glob($backup_dir . '/bkp_*.json'); - if (!is_array($meta_files)) { + $gz_files = glob($backup_dir . '/bkp_*.gz'); + if (!is_array($gz_files)) { return; } - foreach ($meta_files as $meta_file) { - $raw = file_get_contents($meta_file); - if ($raw === false) { - continue; - } - $meta = json_decode($raw, true); - if (!is_array($meta)) { - continue; - } - - $expires_at = isset($meta['expires_at']) ? strtotime((string) $meta['expires_at']) : false; - if ($expires_at !== false && time() > $expires_at) { - $backup_id = $meta['backup_id'] ?? ''; - if ($backup_id !== '') { - $this->cleanup($backup_id); - } + foreach ($gz_files as $gz_file) { + $mtime = filemtime($gz_file); + if ($mtime !== false && (time() - $mtime) > self::EXPIRY_SECONDS) { + @unlink($gz_file); + $this->logger->info('Expired orphan backup file cleaned up.', array('file' => basename($gz_file))); } } } @@ -325,7 +317,7 @@ private function write_database_backup($gz): array // Row data in batches to keep memory usage low. $offset = 0; - $batch = 200; + $batch = self::SQL_BATCH_SIZE; do { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -439,32 +431,4 @@ private function get_backup_dir(): ?string return $dir; } - - /** - * Load and return a backup's metadata, or null if not found / invalid. - */ - private function load_meta(string $backup_id): ?array - { - if (!preg_match(self::BACKUP_ID_PATTERN, $backup_id)) { - return null; - } - - $backup_dir = $this->get_backup_dir(); - if ($backup_dir === null) { - return null; - } - - $meta_file = $backup_dir . '/' . $backup_id . '.json'; - if (!file_exists($meta_file)) { - return null; - } - - $raw = file_get_contents($meta_file); - if ($raw === false) { - return null; - } - - $meta = json_decode($raw, true); - return is_array($meta) ? $meta : null; - } } diff --git a/stack2-connector/includes/class-stack2-command-executor.php b/stack2-connector/includes/class-stack2-command-executor.php index 3b481d8..dcbc57f 100644 --- a/stack2-connector/includes/class-stack2-command-executor.php +++ b/stack2-connector/includes/class-stack2-command-executor.php @@ -10,17 +10,26 @@ class Stack2_Command_Executor private Stack2_Logger $logger; private string $site_id; private ?Stack2_Backup_Service $backup_service; + private ?Stack2_Http_Client $http_client; + private string $base_url; + private string $api_key; public function __construct( Stack2_Inventory_Collector $inventory_collector, Stack2_Logger $logger, string $site_id, - ?Stack2_Backup_Service $backup_service = null + ?Stack2_Backup_Service $backup_service = null, + ?Stack2_Http_Client $http_client = null, + string $base_url = '', + string $api_key = '' ) { $this->inventory_collector = $inventory_collector; $this->logger = $logger; $this->site_id = $site_id; $this->backup_service = $backup_service; + $this->http_client = $http_client; + $this->base_url = $base_url; + $this->api_key = $api_key; } public function execute(string $action, ?string $plugin_file, ?string $slug, array $payload = array()): array @@ -166,13 +175,20 @@ private function delete_plugin(?string $plugin_file, ?string $slug): array private function initiate_backup(array $payload): array { - if ($this->backup_service === null) { + if ($this->backup_service === null || $this->http_client === null) { return array('success' => false, 'error' => 'Backup service is not available.', 'inventory' => null); } $backup_type = isset($payload['backup_type']) ? sanitize_text_field((string) $payload['backup_type']) : 'full'; - $result = $this->backup_service->initiate_backup($backup_type); + $result = $this->backup_service->generate_and_push( + $backup_type, + $this->base_url, + $this->site_id, + $this->api_key, + $this->http_client + ); + if (!$result['success']) { return array('success' => false, 'error' => $result['error'] ?? 'Backup failed.', 'inventory' => null); } @@ -183,10 +199,9 @@ private function initiate_backup(array $payload): array 'inventory' => null, 'backup_id' => $result['backup_id'], 'total_chunks' => $result['total_chunks'], - 'chunk_size' => $result['chunk_size'], 'file_size' => $result['file_size'], 'checksum' => $result['checksum'], - 'expires_at' => $result['expires_at'], + 'backup_type' => $result['backup_type'], ); } @@ -201,11 +216,12 @@ private function cleanup_backup(array $payload): array return array('success' => false, 'error' => 'backup_cleanup action requires backup_id.', 'inventory' => null); } - $cleaned = $this->backup_service->cleanup($backup_id); + // In push mode the temp file is removed automatically. This is a safety net. + $this->backup_service->cleanup($backup_id); return array( - 'success' => $cleaned, - 'error' => $cleaned ? null : 'Backup cleanup failed or backup not found.', + 'success' => true, + 'error' => null, 'inventory' => null, ); } diff --git a/stack2-connector/includes/class-stack2-http-client.php b/stack2-connector/includes/class-stack2-http-client.php index 118050c..73ccec9 100644 --- a/stack2-connector/includes/class-stack2-http-client.php +++ b/stack2-connector/includes/class-stack2-http-client.php @@ -76,4 +76,60 @@ public function push_inventory(array $inventory, string $base_url, string $site_ 'response_body' => $response_body, ); } + + public function push_backup_chunk(string $base_url, string $site_id, string $api_key, array $chunk_data): array + { + $body = wp_json_encode($chunk_data, JSON_UNESCAPED_SLASHES); + if (!is_string($body)) { + return array( + 'success' => false, + 'status' => 0, + 'retryable' => false, + 'error' => 'Failed to encode backup chunk payload.', + ); + } + + $timestamp = (string) time(); + $body_hash = $this->signature_service->sha256_hex($body); + $message = sprintf('POST:stack2-backup-chunk:%s:%s', $timestamp, $body_hash); + $signature = $this->signature_service->sign($message, $api_key); + + $response = wp_remote_post(untrailingslashit($base_url) . '/api/websites/backup/chunk', array( + 'timeout' => 60, + 'headers' => array( + 'Content-Type' => 'application/json', + 'X-Stack2-Site-ID' => $site_id, + 'X-Stack2-Timestamp' => $timestamp, + 'X-Stack2-Signature' => $signature, + ), + 'body' => $body, + )); + + if (is_wp_error($response)) { + return array( + 'success' => false, + 'status' => 0, + 'retryable' => true, + 'error' => $response->get_error_message(), + ); + } + + $status = (int) wp_remote_retrieve_response_code($response); + + if ($status >= 200 && $status < 300) { + return array( + 'success' => true, + 'status' => $status, + 'retryable' => false, + 'error' => null, + ); + } + + return array( + 'success' => false, + 'status' => $status, + 'retryable' => $status >= 500, + 'error' => sprintf('Stack2 API returned %d for backup chunk.', $status), + ); + } } diff --git a/stack2-connector/includes/class-stack2-plugin.php b/stack2-connector/includes/class-stack2-plugin.php index 0d510f1..b1d84ad 100644 --- a/stack2-connector/includes/class-stack2-plugin.php +++ b/stack2-connector/includes/class-stack2-plugin.php @@ -19,6 +19,9 @@ class Stack2_Plugin public const OPTION_LAST_BACKUP_ID = 'stack2_last_backup_id'; public const OPTION_LAST_BACKUP_SIZE = 'stack2_last_backup_size'; public const OPTION_LAST_BACKUP_ERROR = 'stack2_last_backup_error'; + public const OPTION_LAST_BACKUP_TYPE = 'stack2_last_backup_type'; + public const OPTION_LAST_BACKUP_TOTAL_CHUNKS = 'stack2_last_backup_total_chunks'; + public const OPTION_LAST_BACKUP_CHECKSUM = 'stack2_last_backup_checksum'; public const CRON_HOOK_SYNC = 'stack2_sync_inventory_event'; public const CRON_HOOK_BACKUP_CLEANUP = 'stack2_backup_cleanup_event'; @@ -80,7 +83,10 @@ public function register_rest_controller(): void $this->inventory_collector, $this->logger, $this->get_site_id(), - $this->backup_service + $this->backup_service, + $this->http_client, + $this->get_base_url(), + $this->get_api_key() ); $controller = new Stack2_REST_Controller( diff --git a/stack2-connector/includes/class-stack2-settings-page.php b/stack2-connector/includes/class-stack2-settings-page.php index dbd114b..e5749c5 100644 --- a/stack2-connector/includes/class-stack2-settings-page.php +++ b/stack2-connector/includes/class-stack2-settings-page.php @@ -124,7 +124,7 @@ public function render_page(): void Status ' . esc_html($last_backup_status) . ''; } elseif ($last_backup_status === 'failed') { echo '' . esc_html($last_backup_status) . ''; From 305b2adcf7f5e327fe5a99daec55e23b4f29ee27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 11:19:58 +0000 Subject: [PATCH 4/7] feat: full-site ZIP backup with wp-content and wp-config.php Agent-Logs-Url: https://github.com/leighharro/stack2-wp/sessions/97bb6496-4b5e-43d6-96b4-dc82f75ff28e Co-authored-by: leighharro <15240396+leighharro@users.noreply.github.com> --- stack2-connector/BACKUP_API.md | 102 ++++++++--- .../includes/class-stack2-backup-service.php | 170 +++++++++++------- 2 files changed, 183 insertions(+), 89 deletions(-) diff --git a/stack2-connector/BACKUP_API.md b/stack2-connector/BACKUP_API.md index c7649ac..287a6ca 100644 --- a/stack2-connector/BACKUP_API.md +++ b/stack2-connector/BACKUP_API.md @@ -7,7 +7,7 @@ All endpoints follow the same HMAC-based request signing contract used for plugi ## Overview -The backup flow is push-based: WordPress generates the backup and pushes each chunk directly to the Stack2 API as it is read. Stack2 does not need to poll or fetch chunks. +The backup flow is push-based: WordPress generates a ZIP backup archive of the entire site and pushes each chunk directly to the Stack2 API as it is read. Stack2 does not need to poll or fetch chunks. ``` Stack2 WordPress Plugin @@ -15,13 +15,17 @@ Stack2 WordPress Plugin | POST /wp-json/stack2/v1/command | | {"action":"backup","backup_type":"full"} |------------------------------------->| - | | 1. Generate gzip backup (DB + file manifest) - | | 2. For each 10 MB chunk: + | | 1. Dump database to temp .sql file + | | 2. Create ZIP archive: + | | database/database.sql + | | wp-content/ (themes, plugins, uploads…) + | | wp-config.php + | | 3. For each 10 MB chunk: | POST {base_url}/api/websites/backup/chunk |<-------------------------------------| push base64 chunk with checksum | 200 OK | |------------------------------------->| (repeat for every chunk) - | | 3. Delete temp file from WordPress server + | | 4. Delete temp ZIP from WordPress server | 200 OK {backup_id, total_chunks ...}| |<-------------------------------------| | | @@ -33,10 +37,11 @@ Stack2 WordPress Plugin ``` Key points: +- The backup is a standard **ZIP archive** — easily extracted on any platform. - Each chunk is **10 MB** (base64-encoded in transit). - Chunks arrive sequentially with a `chunk_index` starting at `0`. -- The **last chunk** carries additional summary metadata (`total_chunks`, `file_size`, `file_checksum`, `backup_type`) so Stack2 knows when to finalise the upload. -- The temp file is **deleted from WordPress immediately** after all chunks are pushed — no disk space is held. +- The **last chunk** carries summary metadata (`total_chunks`, `file_size`, `file_checksum`, `backup_type`) so Stack2 knows when to finalise reassembly. +- The temp ZIP is **deleted from WordPress immediately** after all chunks are pushed — no disk space is held on the originating server. --- @@ -70,9 +75,9 @@ POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} | Value | Description | |---|---| -| `full` | Database SQL dump + uploads file manifest (default) | +| `full` | Full site backup: database SQL dump + all wp-content files + wp-config.php (default) | | `database` | WordPress database SQL dump only | -| `files_manifest` | Plain-text list of files in `wp-content/uploads` with sizes and timestamps | +| `files` | All wp-content files + wp-config.php (no database) | **Successful response (HTTP 200):** ```json @@ -92,13 +97,13 @@ POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} |---|---|---| | `backup_id` | string | Unique identifier for this backup (also sent with every chunk) | | `total_chunks` | integer | Total number of chunks that were pushed | -| `file_size` | integer | Total compressed backup size in bytes | -| `checksum` | string | SHA-256 hex digest of the full compressed backup file | +| `file_size` | integer | Total ZIP archive size in bytes | +| `checksum` | string | SHA-256 hex digest of the full ZIP archive | | `backup_type` | string | The type of backup that was generated | -> **Note:** By the time this response is received, all chunks have already been pushed to Stack2 and the temp file has been removed from the WordPress server. +> **Note:** By the time this response is received, all chunks have already been pushed to Stack2 and the temp ZIP has been removed from the WordPress server. -**Error response example (HTTP 400):** +**Error response example (HTTP 200):** ```json { "success": false, @@ -107,6 +112,8 @@ POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} } ``` +> The command endpoint always returns HTTP 200. Check the `success` field to determine outcome. + --- ## 2. Stack2 Receiving Endpoint @@ -152,7 +159,7 @@ POST:stack2-backup-chunk:{timestamp}:{sha256_of_raw_json_body} "is_last": true, "total_chunks": 5, "file_size": 52428800, - "file_checksum": "", + "file_checksum": "", "backup_type": "full" } ``` @@ -165,13 +172,26 @@ If Stack2 returns a non-2xx status, the plugin logs the error and stops pushing. Stack2 should: 1. On each incoming chunk, decode `data` from base64 and verify `hash('sha256', decoded_bytes) === checksum`. 2. Write the decoded bytes to a temp file at offset `chunk_index * 10485760`. -3. When `is_last === true`, verify the full file checksum against `file_checksum` and finalise the upload. +3. When `is_last === true`, verify the full file SHA-256 against `file_checksum`, then save the file as a standard ZIP archive for extraction/restore. + +The resulting ZIP layout is: +``` +backup.zip +├── database/ +│ └── database.sql # Full SQL dump (types: database, full) +├── wp-content/ +│ ├── themes/… # All theme files +│ ├── plugins/… # All plugin files +│ ├── uploads/… # All media files +│ └── … # Any other wp-content subdirectories +└── wp-config.php # WordPress configuration (types: files, full) +``` --- ## 3. Check Backup Status (optional) -After the backup command returns, you can also query status via: +After the backup command returns, you can verify the outcome via: **Endpoint:** `GET /wp-json/stack2/v1/backup/{backup_id}/status` @@ -216,7 +236,7 @@ The constant `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` ## 4. Safety Cleanup (optional) -If a push fails mid-way, any orphaned temp file on WordPress is automatically removed by an hourly cron task. You may also send a cleanup command: +If a push fails mid-way, any orphaned temp file on WordPress is automatically removed by an hourly cron task. You may also trigger cleanup manually: **Endpoint:** `POST /wp-json/stack2/v1/command` @@ -235,7 +255,7 @@ This is a no-op if the file no longer exists and always returns `success: true`. ## End-to-End Example (pseudo-code) ```python -import base64, hashlib, hmac, json, time, requests +import base64, hashlib, hmac, json, time, requests, zipfile, io SITE_URL = "https://example.com" SITE_ID = "site_xxx" @@ -248,7 +268,7 @@ def sign_post(route, body_bytes, api_key): sig = hmac.new(api_key.encode(), message.encode(), hashlib.sha256).hexdigest() return ts, sig -# Step 1: Trigger backup – WordPress generates the file and pushes all chunks +# Step 1: Trigger backup – WordPress generates the ZIP and pushes all chunks # to POST {SITE_URL}/api/websites/backup/chunk automatically. body = json.dumps({"action": "backup", "backup_type": "full"}).encode() ts, sig = sign_post("/stack2/v1/command", body, API_KEY) @@ -269,8 +289,8 @@ full_checksum = info["checksum"] # Step 2: Stack2 side receives chunks at POST /api/websites/backup/chunk # (handled by Stack2 server code – see "Stack2 Receiving Endpoint" above) -# Chunks arrive with chunk_index 0..N-1, decoded and appended in order. -# The final chunk (is_last=True) triggers reassembly verification. +# Chunks arrive with chunk_index 0..N-1, decoded and written in order. +# The final chunk (is_last=True) triggers reassembly and verification. # Step 3: (Optional) Verify status EMPTY_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" @@ -287,17 +307,34 @@ status_resp = requests.get( }, ) assert status_resp.json()["status"] == "pushed" + +# Step 4: Extract the ZIP (standard Python zipfile, or any unzip tool) +with zipfile.ZipFile(f"/path/to/received/{backup_id}.zip") as zf: + zf.extractall("/restore/path/") ``` --- -## Backup File Format +## Backup ZIP Contents + +The backup is a standard **ZIP archive** that can be extracted with any unzip tool. -The backup file is a **gzip-compressed** text archive. +| Path in ZIP | Description | Included in | +|---|---|---| +| `database/database.sql` | Full SQL dump of all WordPress tables | `database`, `full` | +| `wp-content/` | All theme, plugin, upload, and other wp-content files | `files`, `full` | +| `wp-config.php` | WordPress configuration file | `files`, `full` | -- **Database backup** — Standard SQL dump with `DROP TABLE IF EXISTS` / `CREATE TABLE` / `INSERT` statements, rows exported in batches of 1000 for memory efficiency. -- **Files manifest** — Plain-text listing of every file under `wp-content/uploads/` with size and modification time. -- **Full** — Both of the above concatenated. +**database.sql format:** +- Standard SQL with `SET FOREIGN_KEY_CHECKS=0/1` wrappers +- `DROP TABLE IF EXISTS` + `CREATE TABLE` + `INSERT` for each table +- Rows exported in batches of 1,000 for memory efficiency + +**Restore procedure (simplified):** +1. Extract the ZIP on the target server. +2. Create a new database and import `database/database.sql` via `mysql` CLI or phpMyAdmin. +3. Copy `wp-content/` to your WordPress installation's `wp-content/` directory. +4. Copy or adapt `wp-config.php` (update `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` as needed for the new environment). --- @@ -310,7 +347,7 @@ Fields displayed: - **Status** – `never` / `pushed` (green) / `failed` (red) - **Last Run** – ISO 8601 timestamp of the most recent backup - **Backup ID** – Identifier for the last completed backup -- **File Size** – Human-readable compressed file size +- **File Size** – Human-readable ZIP archive size --- @@ -321,6 +358,15 @@ Fields displayed: - Backup temp files are stored in `wp-content/uploads/stack2-backups/` which is protected by `.htaccess` to deny direct HTTP access. - Backup IDs are cryptographically random (128-bit hex). - Orphaned temp files are automatically deleted by an hourly cron task. +- `wp-config.php` is included in the backup for full restorability. Stack2 stores and transmits it over the same HMAC-signed channel used for all other backup data. + +--- + +## Requirements + +- PHP `zip` extension (`ZipArchive` class) must be enabled on the WordPress server. This is enabled by default on virtually all WordPress-compatible hosts. +- `wp-content/uploads/` must be writable by the web server process. +- Sufficient disk space for the temporary ZIP archive (deleted immediately after push). --- @@ -328,8 +374,10 @@ Fields displayed: | Symptom | Resolution | |---|---| +| `PHP ZipArchive extension is not available` | Enable the `php-zip` extension on your server (e.g. `apt install php-zip`). | | Backup command returns `success: false` with a chunk error | Check that `{stack2_base_url}/api/websites/backup/chunk` exists and returns 2xx. | | `503 Stack2 credentials are not configured` | Configure Stack2 Base URL, Site ID and API Key in WordPress **Settings → Stack2 Connector**. | -| Backup generation fails on large databases | The plugin sets PHP execution time to 300 s. Contact your hosting provider if the server enforces a lower limit. | +| Backup generation fails on large sites | The plugin sets PHP execution time to 600 s. Contact your hosting provider if the server enforces a lower limit. | | `Cannot create backup directory` | Ensure `wp-content/uploads/` is writable by the web server. | | `401 Signature verification failed` on status endpoint | Remember that GET request signing uses the SHA-256 of an empty body. | + diff --git a/stack2-connector/includes/class-stack2-backup-service.php b/stack2-connector/includes/class-stack2-backup-service.php index 2553573..4b02bc3 100644 --- a/stack2-connector/includes/class-stack2-backup-service.php +++ b/stack2-connector/includes/class-stack2-backup-service.php @@ -25,7 +25,7 @@ public function __construct(Stack2_Logger $logger) * Generate a compressed backup and push each chunk to the Stack2 API. * The temp file is deleted from the server as soon as all chunks are pushed. * - * @param string $backup_type "full" | "database" | "files_manifest" + * @param string $backup_type "full" | "database" | "files" * @param string $base_url Stack2 base URL (e.g. https://app.stack2.au) * @param string $site_id Stack2 Site ID * @param string $api_key Stack2 API key @@ -39,7 +39,11 @@ public function generate_and_push( string $api_key, Stack2_Http_Client $http_client ): array { - $allowed_types = array('full', 'database', 'files_manifest'); + $allowed_types = array('full', 'database', 'files'); + // Treat legacy 'files_manifest' as 'files'. + if ($backup_type === 'files_manifest') { + $backup_type = 'files'; + } if (!in_array($backup_type, $allowed_types, true)) { $backup_type = 'full'; } @@ -55,11 +59,11 @@ public function generate_and_push( return array('success' => false, 'error' => 'Cannot generate a secure backup ID.'); } - $backup_file = $backup_dir . '/' . $backup_id . '.gz'; + $backup_file = $backup_dir . '/' . $backup_id . '.zip'; - // Allow longer execution for backup generation. + // Allow longer execution for large-site backup generation. // phpcs:ignore WordPress.PHP.IniSet.Risky - @set_time_limit(300); + @set_time_limit(600); $generate_result = $this->generate_backup($backup_file, $backup_type); if (!$generate_result['success']) { @@ -194,7 +198,7 @@ public function cleanup(string $backup_id): bool return false; } - $backup_file = $backup_dir . '/' . $backup_id . '.gz'; + $backup_file = $backup_dir . '/' . $backup_id . '.zip'; if (file_exists($backup_file)) { $result = unlink($backup_file); $this->logger->info('Backup file cleaned up.', array('backup_id' => $backup_id)); @@ -206,7 +210,7 @@ public function cleanup(string $backup_id): bool } /** - * Delete any orphaned .gz files that are older than EXPIRY_SECONDS. + * Delete any orphaned .zip files that are older than EXPIRY_SECONDS. * These can accumulate if a push fails before the temp file is removed. */ public function cleanup_expired(): void @@ -216,7 +220,7 @@ public function cleanup_expired(): void return; } - $gz_files = glob($backup_dir . '/bkp_*.gz'); + $gz_files = glob($backup_dir . '/bkp_*.zip'); if (!is_array($gz_files)) { return; } @@ -234,88 +238,119 @@ public function cleanup_expired(): void // Private helpers // ------------------------------------------------------------------------- + /** + * Build a ZIP archive containing the requested backup content. + * + * Archive layout: + * database/database.sql – full SQL dump (types: database, full) + * wp-content/ – all wp-content files recursively (types: files, full) + * wp-config.php – WordPress configuration (types: files, full) + * + * @param string $backup_file Absolute path to write the zip archive to. + * @param string $backup_type "full" | "database" | "files" + */ private function generate_backup(string $backup_file, string $backup_type): array { - $gz = gzopen($backup_file, 'wb9'); - if ($gz === false) { - return array('success' => false, 'error' => 'Cannot create backup file.'); + if (!class_exists('ZipArchive')) { + return array('success' => false, 'error' => 'PHP ZipArchive extension is not available on this server.'); + } + + $zip = new ZipArchive(); + if ($zip->open($backup_file, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + return array('success' => false, 'error' => 'Cannot create backup archive.'); } + // ZipArchive::addFile() only registers paths; actual file reading happens + // at close(). Any temp files must therefore exist until after close(). + $temp_files = array(); + try { if ($backup_type === 'database' || $backup_type === 'full') { - $db_result = $this->write_database_backup($gz); + $db_result = $this->write_database_to_temp($backup_file . '.sql.tmp'); if (!$db_result['success']) { - gzclose($gz); + $zip->close(); @unlink($backup_file); - update_option('stack2_last_backup_status', 'failed'); update_option('stack2_last_backup_error', $db_result['error'] ?? 'Database backup failed.'); - return $db_result; } + $temp_files[] = $db_result['temp_file']; + $zip->addFile($db_result['temp_file'], 'database/database.sql'); } - if ($backup_type === 'files_manifest' || $backup_type === 'full') { - $this->write_files_manifest($gz); + if ($backup_type === 'files' || $backup_type === 'full') { + $this->add_wp_content_to_zip($zip); + $this->add_wp_config_to_zip($zip); } } catch (Throwable $e) { - gzclose($gz); + $zip->close(); @unlink($backup_file); + foreach ($temp_files as $tf) { + @unlink($tf); + } $message = 'Backup generation failed: ' . $e->getMessage(); $this->logger->error($message, array('backup_file' => $backup_file)); - update_option('stack2_last_backup_status', 'failed'); update_option('stack2_last_backup_error', $message); - return array('success' => false, 'error' => $message); } - gzclose($gz); + // close() is when ZipArchive reads all registered files and writes the archive. + $zip->close(); + + foreach ($temp_files as $tf) { + @unlink($tf); + } return array('success' => true); } /** - * Write a full SQL dump of all WordPress database tables to the gz handle. + * Dump all WordPress database tables to a temporary SQL file. * - * @param resource $gz + * @param string $temp_path Desired path for the temp file. + * @return array{success:bool,temp_file?:string,error?:string} */ - private function write_database_backup($gz): array + private function write_database_to_temp(string $temp_path): array { global $wpdb; - gzwrite($gz, "-- Stack2 WordPress Database Backup\n"); - gzwrite($gz, "-- Generated: " . gmdate('c') . "\n"); - gzwrite($gz, "-- WordPress Version: " . get_bloginfo('version') . "\n"); - gzwrite($gz, "-- PHP Version: " . PHP_VERSION . "\n"); - gzwrite($gz, "-- Site URL: " . home_url('/') . "\n\n"); - gzwrite($gz, "SET FOREIGN_KEY_CHECKS=0;\n\n"); + $fh = fopen($temp_path, 'wb'); + if ($fh === false) { + return array('success' => false, 'error' => 'Cannot create temporary database dump file.'); + } + + fwrite($fh, "-- Stack2 WordPress Database Backup\n"); + fwrite($fh, "-- Generated: " . gmdate('c') . "\n"); + fwrite($fh, "-- WordPress Version: " . get_bloginfo('version') . "\n"); + fwrite($fh, "-- PHP Version: " . PHP_VERSION . "\n"); + fwrite($fh, "-- Site URL: " . home_url('/') . "\n\n"); + fwrite($fh, "SET FOREIGN_KEY_CHECKS=0;\n\n"); $tables = $wpdb->get_col('SHOW TABLES'); if (!is_array($tables)) { + fclose($fh); + @unlink($temp_path); return array('success' => false, 'error' => 'Failed to retrieve database tables.'); } foreach ($tables as $table) { - // Sanitise table name – comes from SHOW TABLES, but we validate anyway. $safe_table = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $table); if ($safe_table === '') { continue; } - // CREATE TABLE statement. // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $create = $wpdb->get_row("SHOW CREATE TABLE `{$safe_table}`", ARRAY_N); if (!is_array($create) || empty($create[1])) { continue; } - gzwrite($gz, "\n-- Table: {$safe_table}\n"); - gzwrite($gz, "DROP TABLE IF EXISTS `{$safe_table}`;\n"); - gzwrite($gz, $create[1] . ";\n\n"); + fwrite($fh, "\n-- Table: {$safe_table}\n"); + fwrite($fh, "DROP TABLE IF EXISTS `{$safe_table}`;\n"); + fwrite($fh, $create[1] . ";\n\n"); - // Row data in batches to keep memory usage low. $offset = 0; $batch = self::SQL_BATCH_SIZE; @@ -337,63 +372,74 @@ private function write_database_backup($gz): array $values = implode(', ', array_map(array($this, 'escape_sql_value'), $row)); - gzwrite($gz, "INSERT INTO `{$safe_table}` ({$columns}) VALUES ({$values});\n"); + fwrite($fh, "INSERT INTO `{$safe_table}` ({$columns}) VALUES ({$values});\n"); } $offset += $batch; } while (count($rows) === $batch); } - gzwrite($gz, "\nSET FOREIGN_KEY_CHECKS=1;\n"); + fwrite($fh, "\nSET FOREIGN_KEY_CHECKS=1;\n"); + fclose($fh); - return array('success' => true); + return array('success' => true, 'temp_file' => $temp_path); } /** - * Write a plain-text manifest of all files inside wp-content/uploads. - * - * @param resource $gz + * Add all files from wp-content/ to the ZIP archive. + * Skips our own backup directory and any non-readable files. */ - private function write_files_manifest($gz): void + private function add_wp_content_to_zip(ZipArchive $zip): void { - $upload_dir = wp_upload_dir(); - $base_dir = trailingslashit((string) $upload_dir['basedir']); + $wp_content_dir = WP_CONTENT_DIR; - if (!is_dir($base_dir)) { + if (!is_dir($wp_content_dir)) { return; } - gzwrite($gz, "\n-- Files Manifest (wp-content/uploads)\n"); - gzwrite($gz, "-- Base Directory: {$base_dir}\n"); - gzwrite($gz, "-- Generated: " . gmdate('c') . "\n\n"); - try { $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($base_dir, RecursiveDirectoryIterator::SKIP_DOTS), + new RecursiveDirectoryIterator($wp_content_dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); - foreach ($iterator as $file) { - if (!($file instanceof SplFileInfo) || !$file->isFile()) { + foreach ($iterator as $item) { + if (!($item instanceof SplFileInfo)) { continue; } - $path = $file->getPathname(); + $real_path = $item->getPathname(); - // Skip files inside our own backup directory. - if (strpos($path, DIRECTORY_SEPARATOR . self::BACKUP_DIR_NAME . DIRECTORY_SEPARATOR) !== false) { + // Skip backup directory to avoid archiving backups inside backups. + if (strpos($real_path, DIRECTORY_SEPARATOR . self::BACKUP_DIR_NAME . DIRECTORY_SEPARATOR) !== false) { continue; } - $relative = ltrim(str_replace($base_dir, '', $path), '/\\'); - $size = $file->getSize(); - $mtime = $file->getMTime(); + // Build a portable relative path for the zip entry. + $relative = 'wp-content' . str_replace($wp_content_dir, '', $real_path); + // Normalise to forward slashes so the zip is cross-platform. + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + $relative = ltrim($relative, '/'); - gzwrite($gz, "FILE: {$relative} | SIZE: {$size} | MTIME: {$mtime}\n"); + if ($item->isDir()) { + $zip->addEmptyDir($relative); + } elseif ($item->isFile() && $item->isReadable()) { + $zip->addFile($real_path, $relative); + } } } catch (Throwable $e) { - $this->logger->error('Files manifest generation error.', array('error' => $e->getMessage())); - gzwrite($gz, "-- WARNING: Manifest incomplete due to error: " . $e->getMessage() . "\n"); + $this->logger->error('wp-content backup error.', array('error' => $e->getMessage())); + } + } + + /** + * Add wp-config.php to the ZIP archive if it exists and is readable. + */ + private function add_wp_config_to_zip(ZipArchive $zip): void + { + $config_file = ABSPATH . 'wp-config.php'; + if (file_exists($config_file) && is_readable($config_file)) { + $zip->addFile($config_file, 'wp-config.php'); } } From 874a9979cbb5b90c47d19cce73b3687f8995c1b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 12:20:14 +0000 Subject: [PATCH 5/7] feat: backup entire WordPress root (ABSPATH) under wordpress/ in ZIP Agent-Logs-Url: https://github.com/leighharro/stack2-wp/sessions/740dbb28-89b9-41f5-af3b-87bef2cf80cb Co-authored-by: leighharro <15240396+leighharro@users.noreply.github.com> --- stack2-connector/BACKUP_API.md | 37 ++++++----- .../includes/class-stack2-backup-service.php | 66 +++++++++++-------- 2 files changed, 60 insertions(+), 43 deletions(-) diff --git a/stack2-connector/BACKUP_API.md b/stack2-connector/BACKUP_API.md index 287a6ca..b395d5e 100644 --- a/stack2-connector/BACKUP_API.md +++ b/stack2-connector/BACKUP_API.md @@ -18,8 +18,7 @@ Stack2 WordPress Plugin | | 1. Dump database to temp .sql file | | 2. Create ZIP archive: | | database/database.sql - | | wp-content/ (themes, plugins, uploads…) - | | wp-config.php + | | wordpress/ (entire WP root directory) | | 3. For each 10 MB chunk: | POST {base_url}/api/websites/backup/chunk |<-------------------------------------| push base64 chunk with checksum @@ -75,9 +74,9 @@ POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} | Value | Description | |---|---| -| `full` | Full site backup: database SQL dump + all wp-content files + wp-config.php (default) | +| `full` | Full site backup: database SQL dump + entire WordPress root directory (default) | | `database` | WordPress database SQL dump only | -| `files` | All wp-content files + wp-config.php (no database) | +| `files` | Entire WordPress root directory (no database) | **Successful response (HTTP 200):** ```json @@ -179,12 +178,18 @@ The resulting ZIP layout is: backup.zip ├── database/ │ └── database.sql # Full SQL dump (types: database, full) -├── wp-content/ -│ ├── themes/… # All theme files -│ ├── plugins/… # All plugin files -│ ├── uploads/… # All media files -│ └── … # Any other wp-content subdirectories -└── wp-config.php # WordPress configuration (types: files, full) +└── wordpress/ + ├── wp-admin/… # WordPress admin files + ├── wp-includes/… # WordPress core library files + ├── wp-content/ + │ ├── themes/… # All theme files + │ ├── plugins/… # All plugin files + │ ├── uploads/… # All media files + │ └── … # Any other wp-content subdirectories + ├── wp-config.php # WordPress configuration + ├── index.php # WordPress front controller + ├── .htaccess # Apache rewrite rules (if present) + └── … # Any other files in the WordPress root ``` --- @@ -322,8 +327,7 @@ The backup is a standard **ZIP archive** that can be extracted with any unzip to | Path in ZIP | Description | Included in | |---|---|---| | `database/database.sql` | Full SQL dump of all WordPress tables | `database`, `full` | -| `wp-content/` | All theme, plugin, upload, and other wp-content files | `files`, `full` | -| `wp-config.php` | WordPress configuration file | `files`, `full` | +| `wordpress/` | Entire WordPress root directory (wp-admin, wp-includes, wp-content, wp-config.php, .htaccess, and all other files) | `files`, `full` | **database.sql format:** - Standard SQL with `SET FOREIGN_KEY_CHECKS=0/1` wrappers @@ -332,9 +336,10 @@ The backup is a standard **ZIP archive** that can be extracted with any unzip to **Restore procedure (simplified):** 1. Extract the ZIP on the target server. -2. Create a new database and import `database/database.sql` via `mysql` CLI or phpMyAdmin. -3. Copy `wp-content/` to your WordPress installation's `wp-content/` directory. -4. Copy or adapt `wp-config.php` (update `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` as needed for the new environment). +2. Copy the contents of `wordpress/` into your web root (or a new directory). +3. Edit `wordpress/wp-config.php` to update `DB_HOST`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` for the new environment. +4. Create a new database and import `database/database.sql` via `mysql` CLI or phpMyAdmin. +5. Update the `siteurl` and `home` options in the database if the domain has changed. --- @@ -358,7 +363,7 @@ Fields displayed: - Backup temp files are stored in `wp-content/uploads/stack2-backups/` which is protected by `.htaccess` to deny direct HTTP access. - Backup IDs are cryptographically random (128-bit hex). - Orphaned temp files are automatically deleted by an hourly cron task. -- `wp-config.php` is included in the backup for full restorability. Stack2 stores and transmits it over the same HMAC-signed channel used for all other backup data. +- `wp-config.php` is included in the backup for full restorability (as part of the `wordpress/` tree). Stack2 stores and transmits it over the same HMAC-signed channel used for all other backup data. --- diff --git a/stack2-connector/includes/class-stack2-backup-service.php b/stack2-connector/includes/class-stack2-backup-service.php index 4b02bc3..15b4c2a 100644 --- a/stack2-connector/includes/class-stack2-backup-service.php +++ b/stack2-connector/includes/class-stack2-backup-service.php @@ -243,8 +243,10 @@ public function cleanup_expired(): void * * Archive layout: * database/database.sql – full SQL dump (types: database, full) - * wp-content/ – all wp-content files recursively (types: files, full) - * wp-config.php – WordPress configuration (types: files, full) + * wordpress/ – entire WordPress root recursively (types: files, full) + * includes wp-admin/, wp-includes/, wp-content/, + * wp-config.php, .htaccess, index.php, and any + * other files placed in the WordPress root directory. * * @param string $backup_file Absolute path to write the zip archive to. * @param string $backup_type "full" | "database" | "files" @@ -279,8 +281,7 @@ private function generate_backup(string $backup_file, string $backup_type): arra } if ($backup_type === 'files' || $backup_type === 'full') { - $this->add_wp_content_to_zip($zip); - $this->add_wp_config_to_zip($zip); + $this->add_wordpress_root_to_zip($zip, $backup_file); } } catch (Throwable $e) { $zip->close(); @@ -386,20 +387,37 @@ private function write_database_to_temp(string $temp_path): array } /** - * Add all files from wp-content/ to the ZIP archive. - * Skips our own backup directory and any non-readable files. + * Add all files from the WordPress root directory (ABSPATH) to the ZIP archive. + * + * Every file found under ABSPATH is stored under wordpress/ in the archive, + * preserving the full directory tree so the site can be restored intact. + * + * Exclusions: + * - Our own backup directory (prevents recursive self-inclusion). + * - The ZIP archive file currently being written. + * + * @param ZipArchive $zip Open archive to add files into. + * @param string $backup_file Absolute path of the ZIP file being created (excluded). */ - private function add_wp_content_to_zip(ZipArchive $zip): void + private function add_wordpress_root_to_zip(ZipArchive $zip, string $backup_file): void { - $wp_content_dir = WP_CONTENT_DIR; + $wp_root = rtrim((string) ABSPATH, '/\\'); - if (!is_dir($wp_content_dir)) { + if (!is_dir($wp_root)) { return; } + // Compute the absolute backup dir path so we can reliably exclude it. + $upload_dir = wp_upload_dir(); + $backup_dir_path = rtrim((string) $upload_dir['basedir'], '/\\') + . DIRECTORY_SEPARATOR . self::BACKUP_DIR_NAME; + + // Resolve the backup ZIP path once so the comparison is symlink-safe. + $backup_file_real = realpath($backup_file) ?: $backup_file; + try { $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($wp_content_dir, RecursiveDirectoryIterator::SKIP_DOTS), + new RecursiveDirectoryIterator($wp_root, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); @@ -410,14 +428,19 @@ private function add_wp_content_to_zip(ZipArchive $zip): void $real_path = $item->getPathname(); - // Skip backup directory to avoid archiving backups inside backups. - if (strpos($real_path, DIRECTORY_SEPARATOR . self::BACKUP_DIR_NAME . DIRECTORY_SEPARATOR) !== false) { + // Skip the backup directory and everything inside it. + if (strpos($real_path, $backup_dir_path) === 0) { continue; } - // Build a portable relative path for the zip entry. - $relative = 'wp-content' . str_replace($wp_content_dir, '', $real_path); - // Normalise to forward slashes so the zip is cross-platform. + // Skip the ZIP file currently being written. + $real_path_resolved = realpath($real_path) ?: $real_path; + if ($real_path_resolved === $backup_file_real) { + continue; + } + + // Build a portable relative path under wordpress/ in the archive. + $relative = 'wordpress' . str_replace($wp_root, '', $real_path); $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); $relative = ltrim($relative, '/'); @@ -428,18 +451,7 @@ private function add_wp_content_to_zip(ZipArchive $zip): void } } } catch (Throwable $e) { - $this->logger->error('wp-content backup error.', array('error' => $e->getMessage())); - } - } - - /** - * Add wp-config.php to the ZIP archive if it exists and is readable. - */ - private function add_wp_config_to_zip(ZipArchive $zip): void - { - $config_file = ABSPATH . 'wp-config.php'; - if (file_exists($config_file) && is_readable($config_file)) { - $zip->addFile($config_file, 'wp-config.php'); + $this->logger->error('WordPress root backup error.', array('error' => $e->getMessage())); } } From 95418ec4c52ab773ebe9ecd9443208a340fb66b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 12:44:42 +0000 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20stream=20backup=20directly=20withou?= =?UTF-8?q?t=20ZIP=20=E2=80=94=20zero=20disk=20accumulation=20for=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/leighharro/stack2-wp/sessions/6acd8237-c2ab-4f07-9518-2b02a6f402b4 Co-authored-by: leighharro <15240396+leighharro@users.noreply.github.com> --- stack2-connector/BACKUP_API.md | 163 +++--- .../includes/class-stack2-backup-service.php | 473 +++++++++--------- .../class-stack2-command-executor.php | 3 +- 3 files changed, 334 insertions(+), 305 deletions(-) diff --git a/stack2-connector/BACKUP_API.md b/stack2-connector/BACKUP_API.md index b395d5e..decf8ca 100644 --- a/stack2-connector/BACKUP_API.md +++ b/stack2-connector/BACKUP_API.md @@ -7,7 +7,7 @@ All endpoints follow the same HMAC-based request signing contract used for plugi ## Overview -The backup flow is push-based: WordPress generates a ZIP backup archive of the entire site and pushes each chunk directly to the Stack2 API as it is read. Stack2 does not need to poll or fetch chunks. +The backup flow is push-based and **zero-disk**: WordPress reads each site file directly in 10 MB pieces and pushes every chunk to the Stack2 API immediately — no ZIP archive is ever written to disk. The only temp file created is a small SQL dump for the database; it is deleted right after its chunks are pushed. Stack2 does not need to poll or fetch anything. ``` Stack2 WordPress Plugin @@ -15,16 +15,19 @@ Stack2 WordPress Plugin | POST /wp-json/stack2/v1/command | | {"action":"backup","backup_type":"full"} |------------------------------------->| - | | 1. Dump database to temp .sql file - | | 2. Create ZIP archive: - | | database/database.sql - | | wordpress/ (entire WP root directory) - | | 3. For each 10 MB chunk: + | | 1. Dump database to small temp .sql file + | | Stream SQL file in 10 MB chunks: | POST {base_url}/api/websites/backup/chunk - |<-------------------------------------| push base64 chunk with checksum - | 200 OK | - |------------------------------------->| (repeat for every chunk) - | | 4. Delete temp ZIP from WordPress server + |<-------------------------------------| file_path: "database/database.sql" + | 200 OK | (repeat per chunk) + |------------------------------------->| Delete temp .sql file + | | + | | 2. For each file under ABSPATH: + | POST {base_url}/api/websites/backup/chunk + |<-------------------------------------| file_path: "wordpress/{relative_path}" + | 200 OK | (10 MB at a time, repeat per chunk) + |------------------------------------->| + | | | 200 OK {backup_id, total_chunks ...}| |<-------------------------------------| | | @@ -36,11 +39,11 @@ Stack2 WordPress Plugin ``` Key points: -- The backup is a standard **ZIP archive** — easily extracted on any platform. -- Each chunk is **10 MB** (base64-encoded in transit). -- Chunks arrive sequentially with a `chunk_index` starting at `0`. -- The **last chunk** carries summary metadata (`total_chunks`, `file_size`, `file_checksum`, `backup_type`) so Stack2 knows when to finalise reassembly. -- The temp ZIP is **deleted from WordPress immediately** after all chunks are pushed — no disk space is held on the originating server. +- **No ZIP file is ever created on disk.** Files are streamed directly from the WordPress file system. +- Each chunk is up to **10 MB** (base64-encoded in transit). +- Every chunk carries `file_path` and `file_chunk_index` so Stack2 can reconstruct the original file tree. +- Chunks arrive sequentially; `chunk_index` is a 0-based global counter across all chunks of the backup. +- The **last chunk** carries summary metadata (`total_chunks`, `total_bytes`, `backup_type`) so Stack2 knows when to finalise. --- @@ -85,9 +88,8 @@ POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} "error": null, "inventory": null, "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "total_chunks": 5, - "file_size": 52428800, - "checksum": "abc123...", + "total_chunks": 150, + "total_bytes": 2500000000, "backup_type": "full" } ``` @@ -96,11 +98,10 @@ POST:/stack2/v1/command:{timestamp}:{sha256_hex_of_raw_json_body} |---|---|---| | `backup_id` | string | Unique identifier for this backup (also sent with every chunk) | | `total_chunks` | integer | Total number of chunks that were pushed | -| `file_size` | integer | Total ZIP archive size in bytes | -| `checksum` | string | SHA-256 hex digest of the full ZIP archive | +| `total_bytes` | integer | Total raw bytes streamed across all files | | `backup_type` | string | The type of backup that was generated | -> **Note:** By the time this response is received, all chunks have already been pushed to Stack2 and the temp ZIP has been removed from the WordPress server. +> **Note:** By the time this response is received, all chunks have already been pushed to Stack2 and the SQL temp file has been removed from the WordPress server. **Error response example (HTTP 200):** ```json @@ -142,6 +143,8 @@ POST:stack2-backup-chunk:{timestamp}:{sha256_of_raw_json_body} { "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "chunk_index": 0, + "file_path": "database/database.sql", + "file_chunk_index": 0, "data": "", "checksum": "", "is_last": false @@ -152,44 +155,58 @@ POST:stack2-backup-chunk:{timestamp}:{sha256_of_raw_json_body} ```json { "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", - "chunk_index": 4, + "chunk_index": 149, + "file_path": "wordpress/wp-login.php", + "file_chunk_index": 0, "data": "", "checksum": "", "is_last": true, - "total_chunks": 5, - "file_size": 52428800, - "file_checksum": "", + "total_chunks": 150, + "total_bytes": 2500000000, "backup_type": "full" } ``` +| Field | Always present | Description | +|---|---|---| +| `backup_id` | ✓ | Identifies the backup session | +| `chunk_index` | ✓ | 0-based global sequential index across all chunks | +| `file_path` | ✓ | Virtual path of the source file (e.g. `database/database.sql`, `wordpress/wp-config.php`) | +| `file_chunk_index` | ✓ | 0-based index within the current file (use to write bytes at the right offset) | +| `data` | ✓ | base64-encoded raw bytes | +| `checksum` | ✓ | SHA-256 hex of raw bytes before base64 | +| `is_last` | ✓ | `true` on the very last chunk of the whole backup | +| `total_chunks` | only on `is_last` | Total number of chunks pushed | +| `total_bytes` | only on `is_last` | Total raw bytes across all files | +| `backup_type` | only on `is_last` | `full`, `database`, or `files` | + **Expected response from Stack2:** `200 OK` (body can be empty or `{"success":true}`). If Stack2 returns a non-2xx status, the plugin logs the error and stops pushing. ### Reassembly Stack2 should: -1. On each incoming chunk, decode `data` from base64 and verify `hash('sha256', decoded_bytes) === checksum`. -2. Write the decoded bytes to a temp file at offset `chunk_index * 10485760`. -3. When `is_last === true`, verify the full file SHA-256 against `file_checksum`, then save the file as a standard ZIP archive for extraction/restore. +1. For each incoming chunk, decode `data` from base64 and verify `hash('sha256', decoded_bytes) === checksum`. +2. Group chunks by `backup_id` + `file_path`, sorted by `file_chunk_index`. Write the decoded bytes for each chunk at offset `file_chunk_index * 10485760` within that file. +3. A file is complete when a new `file_path` begins (or `is_last === true` for the final file). +4. When `is_last === true`, verify `chunk_index + 1 === total_chunks` to confirm no chunks were dropped, then mark the backup complete. -The resulting ZIP layout is: +The virtual file tree pushed by the plugin: ``` -backup.zip -├── database/ -│ └── database.sql # Full SQL dump (types: database, full) -└── wordpress/ - ├── wp-admin/… # WordPress admin files - ├── wp-includes/… # WordPress core library files - ├── wp-content/ - │ ├── themes/… # All theme files - │ ├── plugins/… # All plugin files - │ ├── uploads/… # All media files - │ └── … # Any other wp-content subdirectories - ├── wp-config.php # WordPress configuration - ├── index.php # WordPress front controller - ├── .htaccess # Apache rewrite rules (if present) - └── … # Any other files in the WordPress root +database/ +└── database.sql # Full SQL dump (types: database, full) +wordpress/ +├── wp-admin/… # WordPress admin files +├── wp-includes/… # WordPress core library files +├── wp-content/ +│ ├── themes/… # All theme files +│ ├── plugins/… # All plugin files +│ ├── uploads/… # All media files +│ └── … # Any other wp-content subdirectories +├── wp-config.php # WordPress configuration +├── index.php # WordPress front controller +├── .htaccess # Apache rewrite rules (if present) +└── … # Any other files in the WordPress root ``` --- @@ -222,10 +239,9 @@ The constant `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "backup_type": "full", "status": "pushed", - "file_size": 52428800, + "total_bytes": 2500000000, "chunk_size": 10485760, - "total_chunks": 5, - "checksum": "abc123...", + "total_chunks": 150, "created_at": "2026-05-06T10:25:33Z" } ``` @@ -241,7 +257,7 @@ The constant `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` ## 4. Safety Cleanup (optional) -If a push fails mid-way, any orphaned temp file on WordPress is automatically removed by an hourly cron task. You may also trigger cleanup manually: +If a push fails mid-way, any orphaned SQL temp file on WordPress is automatically removed by an hourly cron task. You may also trigger cleanup manually: **Endpoint:** `POST /wp-json/stack2/v1/command` @@ -253,14 +269,14 @@ If a push fails mid-way, any orphaned temp file on WordPress is automatically re } ``` -This is a no-op if the file no longer exists and always returns `success: true`. +This is a no-op if no temp file exists and always returns `success: true`. --- ## End-to-End Example (pseudo-code) ```python -import base64, hashlib, hmac, json, time, requests, zipfile, io +import base64, hashlib, hmac, json, os, time, requests SITE_URL = "https://example.com" SITE_ID = "site_xxx" @@ -273,8 +289,7 @@ def sign_post(route, body_bytes, api_key): sig = hmac.new(api_key.encode(), message.encode(), hashlib.sha256).hexdigest() return ts, sig -# Step 1: Trigger backup – WordPress generates the ZIP and pushes all chunks -# to POST {SITE_URL}/api/websites/backup/chunk automatically. +# Step 1: Trigger backup – WordPress streams all files directly to Stack2. body = json.dumps({"action": "backup", "backup_type": "full"}).encode() ts, sig = sign_post("/stack2/v1/command", body, API_KEY) resp = requests.post( @@ -288,14 +303,13 @@ resp = requests.post( }, ) info = resp.json() -backup_id = info["backup_id"] -total_chunks = info["total_chunks"] -full_checksum = info["checksum"] +backup_id = info["backup_id"] +total_chunks = info["total_chunks"] +total_bytes = info["total_bytes"] # Step 2: Stack2 side receives chunks at POST /api/websites/backup/chunk -# (handled by Stack2 server code – see "Stack2 Receiving Endpoint" above) -# Chunks arrive with chunk_index 0..N-1, decoded and written in order. -# The final chunk (is_last=True) triggers reassembly and verification. +# Group by file_path, sorted by file_chunk_index. +# When a new file_path arrives (or is_last=True), the previous file is complete. # Step 3: (Optional) Verify status EMPTY_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" @@ -313,18 +327,20 @@ status_resp = requests.get( ) assert status_resp.json()["status"] == "pushed" -# Step 4: Extract the ZIP (standard Python zipfile, or any unzip tool) -with zipfile.ZipFile(f"/path/to/received/{backup_id}.zip") as zf: - zf.extractall("/restore/path/") +# Step 4: Reconstruct the file tree from received chunks. +# For each file received, concatenate chunks in file_chunk_index order +# and write to the appropriate path relative to your restore root. +# e.g.: restore_root/wordpress/wp-config.php +# restore_root/database/database.sql ``` --- -## Backup ZIP Contents +## Backup File Tree -The backup is a standard **ZIP archive** that can be extracted with any unzip tool. +Files are streamed directly from WordPress without creating a ZIP on disk. -| Path in ZIP | Description | Included in | +| Virtual path | Description | Included in | |---|---|---| | `database/database.sql` | Full SQL dump of all WordPress tables | `database`, `full` | | `wordpress/` | Entire WordPress root directory (wp-admin, wp-includes, wp-content, wp-config.php, .htaccess, and all other files) | `files`, `full` | @@ -335,7 +351,7 @@ The backup is a standard **ZIP archive** that can be extracted with any unzip to - Rows exported in batches of 1,000 for memory efficiency **Restore procedure (simplified):** -1. Extract the ZIP on the target server. +1. For each received file, write the concatenated chunks to the correct path in your restore directory. 2. Copy the contents of `wordpress/` into your web root (or a new directory). 3. Edit `wordpress/wp-config.php` to update `DB_HOST`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD` for the new environment. 4. Create a new database and import `database/database.sql` via `mysql` CLI or phpMyAdmin. @@ -352,7 +368,7 @@ Fields displayed: - **Status** – `never` / `pushed` (green) / `failed` (red) - **Last Run** – ISO 8601 timestamp of the most recent backup - **Backup ID** – Identifier for the last completed backup -- **File Size** – Human-readable ZIP archive size +- **File Size** – Total bytes streamed across all files --- @@ -360,18 +376,18 @@ Fields displayed: - All backup-related endpoints require valid HMAC signatures and a matching Site ID. - Requests with a timestamp skew greater than 300 seconds are rejected. -- Backup temp files are stored in `wp-content/uploads/stack2-backups/` which is protected by `.htaccess` to deny direct HTTP access. +- The SQL temp file is stored in `wp-content/uploads/stack2-backups/` which is protected by `.htaccess` to deny direct HTTP access. - Backup IDs are cryptographically random (128-bit hex). -- Orphaned temp files are automatically deleted by an hourly cron task. -- `wp-config.php` is included in the backup for full restorability (as part of the `wordpress/` tree). Stack2 stores and transmits it over the same HMAC-signed channel used for all other backup data. +- Orphaned SQL temp files are automatically deleted by an hourly cron task. +- `wp-config.php` is included in the backup (as `wordpress/wp-config.php`). Stack2 stores and transmits it over the same HMAC-signed channel used for all other backup data. --- ## Requirements -- PHP `zip` extension (`ZipArchive` class) must be enabled on the WordPress server. This is enabled by default on virtually all WordPress-compatible hosts. -- `wp-content/uploads/` must be writable by the web server process. -- Sufficient disk space for the temporary ZIP archive (deleted immediately after push). +- `wp-content/uploads/` must be writable by the web server process (used for the small SQL temp file only). +- No PHP extension beyond the standard library is required; `ZipArchive` is not needed. +- Sufficient disk space for the SQL dump only (typically much smaller than the full site). --- @@ -379,10 +395,9 @@ Fields displayed: | Symptom | Resolution | |---|---| -| `PHP ZipArchive extension is not available` | Enable the `php-zip` extension on your server (e.g. `apt install php-zip`). | | Backup command returns `success: false` with a chunk error | Check that `{stack2_base_url}/api/websites/backup/chunk` exists and returns 2xx. | | `503 Stack2 credentials are not configured` | Configure Stack2 Base URL, Site ID and API Key in WordPress **Settings → Stack2 Connector**. | -| Backup generation fails on large sites | The plugin sets PHP execution time to 600 s. Contact your hosting provider if the server enforces a lower limit. | +| Backup stops mid-stream on large sites | The plugin sets PHP execution time to 600 s. Contact your hosting provider if the server enforces a lower limit. | | `Cannot create backup directory` | Ensure `wp-content/uploads/` is writable by the web server. | | `401 Signature verification failed` on status endpoint | Remember that GET request signing uses the SHA-256 of an empty body. | diff --git a/stack2-connector/includes/class-stack2-backup-service.php b/stack2-connector/includes/class-stack2-backup-service.php index 15b4c2a..a216c42 100644 --- a/stack2-connector/includes/class-stack2-backup-service.php +++ b/stack2-connector/includes/class-stack2-backup-service.php @@ -6,31 +6,55 @@ class Stack2_Backup_Service { - public const CHUNK_SIZE = 10485760; // 10 MB per chunk - public const EXPIRY_SECONDS = 3600; // orphan files cleaned up after 1 hour - public const SQL_BATCH_SIZE = 1000; // rows per SELECT batch when dumping tables + public const CHUNK_SIZE = 10485760; // 10 MB per chunk + public const EXPIRY_SECONDS = 3600; // orphan temp files cleaned up after 1 hour + public const SQL_BATCH_SIZE = 1000; // rows per SELECT batch when dumping tables /** Raw regex fragment (no anchors/delimiters) for backup ID validation. */ public const BACKUP_ID_RAW_REGEX = 'bkp_[a-f0-9]{32}'; - private const BACKUP_DIR_NAME = 'stack2-backups'; + private const BACKUP_DIR_NAME = 'stack2-backups'; private const BACKUP_ID_PATTERN = '/^bkp_[a-f0-9]{32}$/'; private Stack2_Logger $logger; + // ── Per-backup streaming state ──────────────────────────────────────────── + // Reset at the start of each generate_and_push() call. These track the + // hold-back buffer used to flag the very last chunk with is_last=true. + private int $next_chunk_index = 0; + private int $total_bytes = 0; + private ?array $buffered_chunk = null; + private ?string $push_error = null; + public function __construct(Stack2_Logger $logger) { $this->logger = $logger; } /** - * Generate a compressed backup and push each chunk to the Stack2 API. - * The temp file is deleted from the server as soon as all chunks are pushed. + * Stream the backup directly to the Stack2 API without writing a large + * archive to disk. + * + * Each file under ABSPATH is read in CHUNK_SIZE pieces and pushed + * immediately. The only temp file created is a small SQL dump for the + * database portion, which is deleted right after its chunks are pushed. + * No ZIP archive is ever materialised on disk. + * + * Chunk payload format: + * backup_id – shared identifier for the whole backup session + * chunk_index – 0-based sequential index across ALL chunks + * file_path – virtual path of the source file (e.g. + * "database/database.sql" or "wordpress/wp-config.php") + * file_chunk_index – 0-based index within the current file + * data – base64-encoded raw bytes + * checksum – sha256 hex of the raw bytes (before base64) + * is_last – true only on the very last chunk of the backup; + * also carries total_chunks, total_bytes, backup_type * * @param string $backup_type "full" | "database" | "files" - * @param string $base_url Stack2 base URL (e.g. https://app.stack2.au) + * @param string $base_url Stack2 base URL * @param string $site_id Stack2 Site ID * @param string $api_key Stack2 API key * @param Stack2_Http_Client $http_client HTTP client used for pushing chunks - * @return array{success:bool,error?:string,backup_id?:string,total_chunks?:int,file_size?:int,checksum?:string,backup_type?:string} + * @return array */ public function generate_and_push( string $backup_type, @@ -39,12 +63,12 @@ public function generate_and_push( string $api_key, Stack2_Http_Client $http_client ): array { - $allowed_types = array('full', 'database', 'files'); - // Treat legacy 'files_manifest' as 'files'. + // Normalise backup type. + $allowed = array('full', 'database', 'files'); if ($backup_type === 'files_manifest') { $backup_type = 'files'; } - if (!in_array($backup_type, $allowed_types, true)) { + if (!in_array($backup_type, $allowed, true)) { $backup_type = 'full'; } @@ -59,99 +83,145 @@ public function generate_and_push( return array('success' => false, 'error' => 'Cannot generate a secure backup ID.'); } - $backup_file = $backup_dir . '/' . $backup_id . '.zip'; - - // Allow longer execution for large-site backup generation. + // Allow longer execution for large-site backups. // phpcs:ignore WordPress.PHP.IniSet.Risky @set_time_limit(600); - $generate_result = $this->generate_backup($backup_file, $backup_type); - if (!$generate_result['success']) { - return $generate_result; - } + // Reset streaming state for this backup run. + $this->next_chunk_index = 0; + $this->total_bytes = 0; + $this->buffered_chunk = null; + $this->push_error = null; + + // ── Database ───────────────────────────────────────────────────────── + if ($backup_type === 'database' || $backup_type === 'full') { + $sql_temp = $backup_dir . '/' . $backup_id . '.sql.tmp'; + $db_result = $this->write_database_to_temp($sql_temp); + if (!$db_result['success']) { + update_option('stack2_last_backup_status', 'failed'); + update_option('stack2_last_backup_error', $db_result['error'] ?? 'Database backup failed.'); + return $db_result; + } - $file_size = (int) filesize($backup_file); - if ($file_size === 0) { - @unlink($backup_file); - return array('success' => false, 'error' => 'Backup generation produced an empty file.'); - } + $this->stream_file_chunks( + $sql_temp, + 'database/database.sql', + $backup_id, + $http_client, + $base_url, + $site_id, + $api_key + ); - $file_checksum = hash_file('sha256', $backup_file); - $total_chunks = max(1, (int) ceil($file_size / self::CHUNK_SIZE)); + @unlink($sql_temp); - // Open file and push each chunk to Stack2 as it is read. - $fp = fopen($backup_file, 'rb'); - if ($fp === false) { - @unlink($backup_file); - return array('success' => false, 'error' => 'Failed to open backup file for reading. Check file permissions.'); + if ($this->push_error !== null) { + $this->record_failure($backup_id, $this->push_error); + return array('success' => false, 'error' => $this->push_error); + } } - $push_error = null; - for ($i = 0; $i < $total_chunks; $i++) { - $chunk_bytes = fread($fp, self::CHUNK_SIZE); - if ($chunk_bytes === false) { - $push_error = sprintf('Failed to read chunk %d from backup file.', $i); - break; - } + // ── WordPress file tree ────────────────────────────────────────────── + if (($backup_type === 'files' || $backup_type === 'full') && $this->push_error === null) { + $wp_root = rtrim((string) ABSPATH, '/\\'); - $is_last = ($i === $total_chunks - 1); - $payload = array( - 'backup_id' => $backup_id, - 'chunk_index' => $i, - 'data' => base64_encode($chunk_bytes), - 'checksum' => hash('sha256', $chunk_bytes), - 'is_last' => $is_last, - ); + // Compute the backup dir prefix once so we can exclude it reliably. + $upload_info = wp_upload_dir(); + $backup_dir_path = rtrim((string) $upload_info['basedir'], '/\\') + . DIRECTORY_SEPARATOR . self::BACKUP_DIR_NAME; - // Include summary metadata on the final chunk so Stack2 can verify - // the fully reassembled file and close the upload. - if ($is_last) { - $payload['total_chunks'] = $total_chunks; - $payload['file_size'] = $file_size; - $payload['file_checksum'] = $file_checksum; - $payload['backup_type'] = $backup_type; - } + try { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($wp_root, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); - $result = $http_client->push_backup_chunk($base_url, $site_id, $api_key, $payload); - if (!$result['success']) { - $push_error = $result['error'] ?? sprintf('Failed to push chunk %d.', $i); - break; + foreach ($iterator as $item) { + if ($this->push_error !== null) { + break; + } + + if (!($item instanceof SplFileInfo) || !$item->isFile() || !$item->isReadable()) { + continue; + } + + $abs_path = $item->getPathname(); + + // Skip our backup temp directory to prevent recursive inclusion. + if (strpos($abs_path, $backup_dir_path) === 0) { + continue; + } + + // Build a portable virtual path for Stack2 (forward slashes). + $relative = 'wordpress' . str_replace($wp_root, '', $abs_path); + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); + $relative = ltrim($relative, '/'); + + $this->stream_file_chunks( + $abs_path, + $relative, + $backup_id, + $http_client, + $base_url, + $site_id, + $api_key + ); + } + } catch (Throwable $e) { + $message = 'WordPress root backup error: ' . $e->getMessage(); + $this->logger->error($message); + $this->record_failure($backup_id, $message); + return array('success' => false, 'error' => $message); } } - fclose($fp); - @unlink($backup_file); // Always remove the temp file from this server. + if ($this->push_error !== null) { + $this->record_failure($backup_id, $this->push_error); + return array('success' => false, 'error' => $this->push_error); + } - if ($push_error !== null) { - $this->logger->error('Backup push failed.', array('backup_id' => $backup_id, 'error' => $push_error)); - update_option('stack2_last_backup_status', 'failed'); - update_option('stack2_last_backup_error', $push_error); - return array('success' => false, 'error' => $push_error); + // ── Finalise: push the held-back chunk with summary metadata ───────── + if ($this->buffered_chunk === null) { + $message = 'Backup produced no data.'; + $this->record_failure($backup_id, $message); + return array('success' => false, 'error' => $message); + } + + $total_chunks = $this->next_chunk_index; + $this->buffered_chunk['is_last'] = true; + $this->buffered_chunk['total_chunks'] = $total_chunks; + $this->buffered_chunk['total_bytes'] = $this->total_bytes; + $this->buffered_chunk['backup_type'] = $backup_type; + + $final_result = $http_client->push_backup_chunk($base_url, $site_id, $api_key, $this->buffered_chunk); + if (!$final_result['success']) { + $error = $final_result['error'] ?? sprintf('Failed to push final chunk %d.', $this->buffered_chunk['chunk_index']); + $this->record_failure($backup_id, $error); + return array('success' => false, 'error' => $error); } update_option('stack2_last_backup_at', gmdate('c')); update_option('stack2_last_backup_status', 'pushed'); update_option('stack2_last_backup_id', $backup_id); - update_option('stack2_last_backup_size', $file_size); + update_option('stack2_last_backup_size', $this->total_bytes); update_option('stack2_last_backup_total_chunks', $total_chunks); - update_option('stack2_last_backup_checksum', $file_checksum); + update_option('stack2_last_backup_checksum', ''); update_option('stack2_last_backup_type', $backup_type); update_option('stack2_last_backup_error', ''); $this->logger->info('Backup pushed successfully.', array( - 'backup_id' => $backup_id, - 'type' => $backup_type, - 'size' => $file_size, + 'backup_id' => $backup_id, + 'type' => $backup_type, + 'total_bytes' => $this->total_bytes, 'total_chunks' => $total_chunks, )); return array( - 'success' => true, - 'backup_id' => $backup_id, + 'success' => true, + 'backup_id' => $backup_id, 'total_chunks' => $total_chunks, - 'file_size' => $file_size, - 'checksum' => $file_checksum, - 'backup_type' => $backup_type, + 'total_bytes' => $this->total_bytes, + 'backup_type' => $backup_type, ); } @@ -171,21 +241,20 @@ public function get_status(string $backup_id): ?array } return array( - 'backup_id' => $last_id, - 'backup_type' => (string) get_option('stack2_last_backup_type', ''), - 'status' => (string) get_option('stack2_last_backup_status', 'unknown'), - 'file_size' => (int) get_option('stack2_last_backup_size', 0), - 'chunk_size' => self::CHUNK_SIZE, - 'total_chunks' => (int) get_option('stack2_last_backup_total_chunks', 0), - 'checksum' => (string) get_option('stack2_last_backup_checksum', ''), - 'created_at' => (string) get_option('stack2_last_backup_at', ''), + 'backup_id' => $last_id, + 'backup_type' => (string) get_option('stack2_last_backup_type', ''), + 'status' => (string) get_option('stack2_last_backup_status', 'unknown'), + 'total_bytes' => (int) get_option('stack2_last_backup_size', 0), + 'chunk_size' => self::CHUNK_SIZE, + 'total_chunks' => (int) get_option('stack2_last_backup_total_chunks', 0), + 'created_at' => (string) get_option('stack2_last_backup_at', ''), ); } /** - * Delete any orphaned backup file for the given backup_id. - * In push mode the file is deleted automatically after push, so this - * is a safety net for failed pushes. + * Delete any orphaned temp files for the given backup_id. + * In stream mode the SQL temp file is deleted automatically after its + * chunks are pushed; this is a safety net for interrupted runs. */ public function cleanup(string $backup_id): bool { @@ -198,20 +267,22 @@ public function cleanup(string $backup_id): bool return false; } - $backup_file = $backup_dir . '/' . $backup_id . '.zip'; - if (file_exists($backup_file)) { - $result = unlink($backup_file); - $this->logger->info('Backup file cleaned up.', array('backup_id' => $backup_id)); - return $result; + $cleaned = true; + $sql_tmp = $backup_dir . '/' . $backup_id . '.sql.tmp'; + if (file_exists($sql_tmp)) { + $cleaned = unlink($sql_tmp); + } + + if ($cleaned) { + $this->logger->info('Backup temp files cleaned up.', array('backup_id' => $backup_id)); } - // File does not exist – already cleaned up, treat as success. - return true; + return $cleaned; } /** - * Delete any orphaned .zip files that are older than EXPIRY_SECONDS. - * These can accumulate if a push fails before the temp file is removed. + * Delete any orphaned SQL temp files that are older than EXPIRY_SECONDS. + * These can accumulate if a push is interrupted before the temp file is removed. */ public function cleanup_expired(): void { @@ -220,91 +291,94 @@ public function cleanup_expired(): void return; } - $gz_files = glob($backup_dir . '/bkp_*.zip'); - if (!is_array($gz_files)) { - return; - } - - foreach ($gz_files as $gz_file) { - $mtime = filemtime($gz_file); + foreach (glob($backup_dir . '/bkp_*.sql.tmp') ?: array() as $file) { + $mtime = filemtime($file); if ($mtime !== false && (time() - $mtime) > self::EXPIRY_SECONDS) { - @unlink($gz_file); - $this->logger->info('Expired orphan backup file cleaned up.', array('file' => basename($gz_file))); + @unlink($file); + $this->logger->info('Expired orphan backup file cleaned up.', array('file' => basename($file))); } } } - // ------------------------------------------------------------------------- - // Private helpers - // ------------------------------------------------------------------------- + // ── Private helpers ─────────────────────────────────────────────────────── /** - * Build a ZIP archive containing the requested backup content. + * Read a single file in CHUNK_SIZE pieces and enqueue each piece as a + * backup chunk using the hold-back pattern. + * + * Hold-back pattern: we always keep the most-recently-built chunk in + * $this->buffered_chunk without pushing it. Before buffering the next + * chunk we push the held-back one (without is_last). This means the very + * last chunk across all files remains in the buffer after this method + * returns, allowing generate_and_push() to attach summary metadata and + * set is_last=true before its final push. * - * Archive layout: - * database/database.sql – full SQL dump (types: database, full) - * wordpress/ – entire WordPress root recursively (types: files, full) - * includes wp-admin/, wp-includes/, wp-content/, - * wp-config.php, .htaccess, index.php, and any - * other files placed in the WordPress root directory. + * Unreadable files are logged and skipped (non-fatal) so a single + * permission problem does not abort the whole backup. * - * @param string $backup_file Absolute path to write the zip archive to. - * @param string $backup_type "full" | "database" | "files" + * @param string $abs_path Absolute path to the file. + * @param string $virtual_path Virtual path in the backup (e.g. 'wordpress/wp-config.php'). + * @param string $backup_id Backup session identifier. + * @param Stack2_Http_Client $http_client HTTP client. + * @param string $base_url Stack2 base URL. + * @param string $site_id Stack2 Site ID. + * @param string $api_key Stack2 API key. */ - private function generate_backup(string $backup_file, string $backup_type): array - { - if (!class_exists('ZipArchive')) { - return array('success' => false, 'error' => 'PHP ZipArchive extension is not available on this server.'); + private function stream_file_chunks( + string $abs_path, + string $virtual_path, + string $backup_id, + Stack2_Http_Client $http_client, + string $base_url, + string $site_id, + string $api_key + ): void { + if ($this->push_error !== null) { + return; } - $zip = new ZipArchive(); - if ($zip->open($backup_file, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - return array('success' => false, 'error' => 'Cannot create backup archive.'); + $fh = @fopen($abs_path, 'rb'); + if ($fh === false) { + $this->logger->error('Cannot open file for backup streaming.', array('path' => $abs_path)); + return; // Skip; non-fatal. } - // ZipArchive::addFile() only registers paths; actual file reading happens - // at close(). Any temp files must therefore exist until after close(). - $temp_files = array(); + $file_chunk_index = 0; - try { - if ($backup_type === 'database' || $backup_type === 'full') { - $db_result = $this->write_database_to_temp($backup_file . '.sql.tmp'); - if (!$db_result['success']) { - $zip->close(); - @unlink($backup_file); - update_option('stack2_last_backup_status', 'failed'); - update_option('stack2_last_backup_error', $db_result['error'] ?? 'Database backup failed.'); - return $db_result; - } - $temp_files[] = $db_result['temp_file']; - $zip->addFile($db_result['temp_file'], 'database/database.sql'); + while (!feof($fh) && $this->push_error === null) { + $raw = fread($fh, self::CHUNK_SIZE); + if ($raw === false || strlen($raw) === 0) { + break; } - if ($backup_type === 'files' || $backup_type === 'full') { - $this->add_wordpress_root_to_zip($zip, $backup_file); - } - } catch (Throwable $e) { - $zip->close(); - @unlink($backup_file); - foreach ($temp_files as $tf) { - @unlink($tf); + // Push the previously held chunk as a non-last chunk. + if ($this->buffered_chunk !== null) { + $result = $http_client->push_backup_chunk($base_url, $site_id, $api_key, $this->buffered_chunk); + if (!$result['success']) { + $this->push_error = $result['error'] ?? sprintf('Failed to push chunk %d.', $this->buffered_chunk['chunk_index']); + $this->buffered_chunk = null; + fclose($fh); + return; + } } - $message = 'Backup generation failed: ' . $e->getMessage(); - $this->logger->error($message, array('backup_file' => $backup_file)); - update_option('stack2_last_backup_status', 'failed'); - update_option('stack2_last_backup_error', $message); - return array('success' => false, 'error' => $message); - } + $this->total_bytes += strlen($raw); - // close() is when ZipArchive reads all registered files and writes the archive. - $zip->close(); + $this->buffered_chunk = array( + 'backup_id' => $backup_id, + 'chunk_index' => $this->next_chunk_index, + 'file_path' => $virtual_path, + 'file_chunk_index' => $file_chunk_index, + 'data' => base64_encode($raw), + 'checksum' => hash('sha256', $raw), + 'is_last' => false, // overridden on the very last chunk by generate_and_push() + ); - foreach ($temp_files as $tf) { - @unlink($tf); + $this->next_chunk_index++; + $file_chunk_index++; } - return array('success' => true); + fclose($fh); } /** @@ -353,7 +427,7 @@ private function write_database_to_temp(string $temp_path): array fwrite($fh, $create[1] . ";\n\n"); $offset = 0; - $batch = self::SQL_BATCH_SIZE; + $batch = self::SQL_BATCH_SIZE; do { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared @@ -386,78 +460,8 @@ private function write_database_to_temp(string $temp_path): array return array('success' => true, 'temp_file' => $temp_path); } - /** - * Add all files from the WordPress root directory (ABSPATH) to the ZIP archive. - * - * Every file found under ABSPATH is stored under wordpress/ in the archive, - * preserving the full directory tree so the site can be restored intact. - * - * Exclusions: - * - Our own backup directory (prevents recursive self-inclusion). - * - The ZIP archive file currently being written. - * - * @param ZipArchive $zip Open archive to add files into. - * @param string $backup_file Absolute path of the ZIP file being created (excluded). - */ - private function add_wordpress_root_to_zip(ZipArchive $zip, string $backup_file): void - { - $wp_root = rtrim((string) ABSPATH, '/\\'); - - if (!is_dir($wp_root)) { - return; - } - - // Compute the absolute backup dir path so we can reliably exclude it. - $upload_dir = wp_upload_dir(); - $backup_dir_path = rtrim((string) $upload_dir['basedir'], '/\\') - . DIRECTORY_SEPARATOR . self::BACKUP_DIR_NAME; - - // Resolve the backup ZIP path once so the comparison is symlink-safe. - $backup_file_real = realpath($backup_file) ?: $backup_file; - - try { - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($wp_root, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::SELF_FIRST - ); - - foreach ($iterator as $item) { - if (!($item instanceof SplFileInfo)) { - continue; - } - - $real_path = $item->getPathname(); - - // Skip the backup directory and everything inside it. - if (strpos($real_path, $backup_dir_path) === 0) { - continue; - } - - // Skip the ZIP file currently being written. - $real_path_resolved = realpath($real_path) ?: $real_path; - if ($real_path_resolved === $backup_file_real) { - continue; - } - - // Build a portable relative path under wordpress/ in the archive. - $relative = 'wordpress' . str_replace($wp_root, '', $real_path); - $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); - $relative = ltrim($relative, '/'); - - if ($item->isDir()) { - $zip->addEmptyDir($relative); - } elseif ($item->isFile() && $item->isReadable()) { - $zip->addFile($real_path, $relative); - } - } - } catch (Throwable $e) { - $this->logger->error('WordPress root backup error.', array('error' => $e->getMessage())); - } - } - /** * Escape a single SQL value for use in an INSERT statement. - * Uses WordPress's esc_sql() for string escaping. */ private function escape_sql_value(mixed $value): string { @@ -469,12 +473,23 @@ private function escape_sql_value(mixed $value): string } /** - * Return the path to the backups directory, creating it if necessary. + * Record a backup push failure in wp_options and the plugin log. + */ + private function record_failure(string $backup_id, string $error): void + { + $this->logger->error('Backup push failed.', array('backup_id' => $backup_id, 'error' => $error)); + update_option('stack2_last_backup_status', 'failed'); + update_option('stack2_last_backup_error', $error); + } + + /** + * Return the path to the backups directory, creating it (with access + * controls) if it does not yet exist. */ private function get_backup_dir(): ?string { $upload_dir = wp_upload_dir(); - $dir = trailingslashit((string) $upload_dir['basedir']) . self::BACKUP_DIR_NAME; + $dir = trailingslashit((string) $upload_dir['basedir']) . self::BACKUP_DIR_NAME; if (!file_exists($dir)) { if (!wp_mkdir_p($dir)) { diff --git a/stack2-connector/includes/class-stack2-command-executor.php b/stack2-connector/includes/class-stack2-command-executor.php index dcbc57f..b2916e6 100644 --- a/stack2-connector/includes/class-stack2-command-executor.php +++ b/stack2-connector/includes/class-stack2-command-executor.php @@ -199,8 +199,7 @@ private function initiate_backup(array $payload): array 'inventory' => null, 'backup_id' => $result['backup_id'], 'total_chunks' => $result['total_chunks'], - 'file_size' => $result['file_size'], - 'checksum' => $result['checksum'], + 'total_bytes' => $result['total_bytes'], 'backup_type' => $result['backup_type'], ); } From ee25855bfda2b6d3cc218f119c904256b805ea80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 12:46:35 +0000 Subject: [PATCH 7/7] fix: use substr for safe path construction; drop empty checksum option write Agent-Logs-Url: https://github.com/leighharro/stack2-wp/sessions/6acd8237-c2ab-4f07-9518-2b02a6f402b4 Co-authored-by: leighharro <15240396+leighharro@users.noreply.github.com> --- stack2-connector/includes/class-stack2-backup-service.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/stack2-connector/includes/class-stack2-backup-service.php b/stack2-connector/includes/class-stack2-backup-service.php index a216c42..5b82779 100644 --- a/stack2-connector/includes/class-stack2-backup-service.php +++ b/stack2-connector/includes/class-stack2-backup-service.php @@ -153,9 +153,10 @@ public function generate_and_push( } // Build a portable virtual path for Stack2 (forward slashes). - $relative = 'wordpress' . str_replace($wp_root, '', $abs_path); - $relative = str_replace(DIRECTORY_SEPARATOR, '/', $relative); - $relative = ltrim($relative, '/'); + // Use substr so the prefix removal is exact and never matches a + // longer path that merely starts with the same characters. + $rel_part = ltrim(str_replace(DIRECTORY_SEPARATOR, '/', substr($abs_path, strlen($wp_root))), '/'); + $relative = 'wordpress/' . $rel_part; $this->stream_file_chunks( $abs_path, @@ -205,7 +206,6 @@ public function generate_and_push( update_option('stack2_last_backup_id', $backup_id); update_option('stack2_last_backup_size', $this->total_bytes); update_option('stack2_last_backup_total_chunks', $total_chunks); - update_option('stack2_last_backup_checksum', ''); update_option('stack2_last_backup_type', $backup_type); update_option('stack2_last_backup_error', '');