diff --git a/stack2-connector/BACKUP_API.md b/stack2-connector/BACKUP_API.md new file mode 100644 index 0000000..decf8ca --- /dev/null +++ b/stack2-connector/BACKUP_API.md @@ -0,0 +1,403 @@ +# 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 used for plugin commands. + +--- + +## Overview + +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 + | | + | POST /wp-json/stack2/v1/command | + | {"action":"backup","backup_type":"full"} + |------------------------------------->| + | | 1. Dump database to small temp .sql file + | | Stream SQL file in 10 MB chunks: + | POST {base_url}/api/websites/backup/chunk + |<-------------------------------------| 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 ...}| + |<-------------------------------------| + | | + | (optional status check) | + | GET /wp-json/stack2/v1/backup/{id}/status + |------------------------------------->| + | 200 OK {status:"pushed", ...} | + |<-------------------------------------| +``` + +Key points: +- **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. + +--- + +## 1. Initiate a Backup + +**Endpoint:** `POST /wp-json/stack2/v1/command` + +**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 | +| `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` | Full site backup: database SQL dump + entire WordPress root directory (default) | +| `database` | WordPress database SQL dump only | +| `files` | Entire WordPress root directory (no database) | + +**Successful response (HTTP 200):** +```json +{ + "success": true, + "error": null, + "inventory": null, + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "total_chunks": 150, + "total_bytes": 2500000000, + "backup_type": "full" +} +``` + +| Field | Type | Description | +|---|---|---| +| `backup_id` | string | Unique identifier for this backup (also sent with every chunk) | +| `total_chunks` | integer | Total number of chunks that were pushed | +| `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 SQL temp file has been removed from the WordPress server. + +**Error response example (HTTP 200):** +```json +{ + "success": false, + "error": "Failed to push chunk 2.", + "inventory": null +} +``` + +> The command endpoint always returns HTTP 200. Check the `success` field to determine outcome. + +--- + +## 2. Stack2 Receiving Endpoint + +WordPress pushes each chunk to: + +``` +POST {stack2_base_url}/api/websites/backup/chunk +``` + +**Headers sent by WordPress:** + +| Header | Value | +|---|---| +| `X-Stack2-Site-ID` | Site ID | +| `X-Stack2-Timestamp` | Unix timestamp | +| `X-Stack2-Signature` | HMAC-SHA256 hex signature | +| `Content-Type` | `application/json` | + +**Signing message (computed by WordPress):** +``` +POST:stack2-backup-chunk:{timestamp}:{sha256_of_raw_json_body} +``` + +**Request body (intermediate chunks):** +```json +{ + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "chunk_index": 0, + "file_path": "database/database.sql", + "file_chunk_index": 0, + "data": "", + "checksum": "", + "is_last": false +} +``` + +**Request body (final chunk — `is_last: true`):** +```json +{ + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "chunk_index": 149, + "file_path": "wordpress/wp-login.php", + "file_chunk_index": 0, + "data": "", + "checksum": "", + "is_last": true, + "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. 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 virtual file tree pushed by the plugin: +``` +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 +``` + +--- + +## 3. Check Backup Status (optional) + +After the backup command returns, you can verify the outcome via: + +**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. + +**Successful response (HTTP 200):** +```json +{ + "success": true, + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "backup_type": "full", + "status": "pushed", + "total_bytes": 2500000000, + "chunk_size": 10485760, + "total_chunks": 150, + "created_at": "2026-05-06T10:25:33Z" +} +``` + +`status` values: + +| Value | Meaning | +|---|---| +| `pushed` | All chunks were pushed to Stack2 successfully | +| `failed` | The backup or push encountered an error | + +--- + +## 4. Safety Cleanup (optional) + +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` + +**Request body:** +```json +{ + "action": "backup_cleanup", + "backup_id": "bkp_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" +} +``` + +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, os, time, requests + +SITE_URL = "https://example.com" +SITE_ID = "site_xxx" +API_KEY = "my-secret-api-key" + +def sign_post(route, body_bytes, api_key): + ts = str(int(time.time())) + body_hash = hashlib.sha256(body_bytes).hexdigest() + message = f"POST:{route}:{ts}:{body_hash}" + sig = hmac.new(api_key.encode(), message.encode(), hashlib.sha256).hexdigest() + return ts, sig + +# 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( + 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"] +total_bytes = info["total_bytes"] + +# Step 2: Stack2 side receives chunks at POST /api/websites/backup/chunk +# 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" +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={ + "X-Stack2-Site-ID": SITE_ID, + "X-Stack2-Timestamp": ts, + "X-Stack2-Signature": sig, + }, +) +assert status_resp.json()["status"] == "pushed" + +# 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 File Tree + +Files are streamed directly from WordPress without creating a ZIP on disk. + +| 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` | + +**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. 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. +5. Update the `siteurl` and `home` options in the database if the domain has changed. + +--- + +## Admin Panel + +Backup status is visible in the WordPress admin panel at **Settings → Stack2 Connector → Last Backup**. + +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** – Total bytes streamed across all files + +--- + +## Security Notes + +- All backup-related endpoints require valid HMAC signatures and a matching Site ID. +- Requests with a timestamp skew greater than 300 seconds are rejected. +- 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 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 + +- `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). + +--- + +## Troubleshooting + +| Symptom | Resolution | +|---|---| +| 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 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-rest-controller.php b/stack2-connector/includes/class-stack2-backup-rest-controller.php new file mode 100644 index 0000000..15d06ba --- /dev/null +++ b/stack2-connector/includes/class-stack2-backup-rest-controller.php @@ -0,0 +1,138 @@ +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, + ), + ), + )); + } + + /** + * GET /wp-json/stack2/v1/backup/{backup_id}/status + */ + public function handle_status(WP_REST_Request $request): WP_REST_Response + { + $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); + } + + $status = $this->backup_service->get_status($backup_id); + + if ($status === null) { + return new WP_REST_Response(array( + 'success' => false, + 'error' => 'Backup not found.', + ), 404); + } + + return new WP_REST_Response(array_merge(array('success' => true), $status), 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_.../status" + * @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 status 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..5b82779 --- /dev/null +++ b/stack2-connector/includes/class-stack2-backup-service.php @@ -0,0 +1,507 @@ +logger = $logger; + } + + /** + * 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 + * @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 + */ + public function generate_and_push( + string $backup_type, + string $base_url, + string $site_id, + string $api_key, + Stack2_Http_Client $http_client + ): array { + // Normalise backup type. + $allowed = array('full', 'database', 'files'); + if ($backup_type === 'files_manifest') { + $backup_type = 'files'; + } + if (!in_array($backup_type, $allowed, 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.'); + } + + // Allow longer execution for large-site backups. + // phpcs:ignore WordPress.PHP.IniSet.Risky + @set_time_limit(600); + + // 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; + } + + $this->stream_file_chunks( + $sql_temp, + 'database/database.sql', + $backup_id, + $http_client, + $base_url, + $site_id, + $api_key + ); + + @unlink($sql_temp); + + if ($this->push_error !== null) { + $this->record_failure($backup_id, $this->push_error); + return array('success' => false, 'error' => $this->push_error); + } + } + + // ── WordPress file tree ────────────────────────────────────────────── + if (($backup_type === 'files' || $backup_type === 'full') && $this->push_error === null) { + $wp_root = rtrim((string) ABSPATH, '/\\'); + + // 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; + + try { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($wp_root, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + 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). + // 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, + $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); + } + } + + if ($this->push_error !== null) { + $this->record_failure($backup_id, $this->push_error); + return array('success' => false, 'error' => $this->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', $this->total_bytes); + update_option('stack2_last_backup_total_chunks', $total_chunks); + 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, + 'total_bytes' => $this->total_bytes, + 'total_chunks' => $total_chunks, + )); + + return array( + 'success' => true, + 'backup_id' => $backup_id, + 'total_chunks' => $total_chunks, + 'total_bytes' => $this->total_bytes, + 'backup_type' => $backup_type, + ); + } + + /** + * 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 + { + if (!preg_match(self::BACKUP_ID_PATTERN, $backup_id)) { + return null; + } + + $last_id = (string) get_option('stack2_last_backup_id', ''); + if ($last_id !== $backup_id) { + return null; + } + + return array( + '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 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 + { + if (!preg_match(self::BACKUP_ID_PATTERN, $backup_id)) { + return false; + } + + $backup_dir = $this->get_backup_dir(); + if ($backup_dir === null) { + return false; + } + + $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)); + } + + return $cleaned; + } + + /** + * 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 + { + $backup_dir = $this->get_backup_dir(); + if ($backup_dir === null) { + return; + } + + foreach (glob($backup_dir . '/bkp_*.sql.tmp') ?: array() as $file) { + $mtime = filemtime($file); + if ($mtime !== false && (time() - $mtime) > self::EXPIRY_SECONDS) { + @unlink($file); + $this->logger->info('Expired orphan backup file cleaned up.', array('file' => basename($file))); + } + } + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * 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. + * + * Unreadable files are logged and skipped (non-fatal) so a single + * permission problem does not abort the whole backup. + * + * @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 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; + } + + $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. + } + + $file_chunk_index = 0; + + while (!feof($fh) && $this->push_error === null) { + $raw = fread($fh, self::CHUNK_SIZE); + if ($raw === false || strlen($raw) === 0) { + break; + } + + // 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; + } + } + + $this->total_bytes += strlen($raw); + + $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() + ); + + $this->next_chunk_index++; + $file_chunk_index++; + } + + fclose($fh); + } + + /** + * Dump all WordPress database tables to a temporary SQL file. + * + * @param string $temp_path Desired path for the temp file. + * @return array{success:bool,temp_file?:string,error?:string} + */ + private function write_database_to_temp(string $temp_path): array + { + global $wpdb; + + $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) { + $safe_table = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $table); + if ($safe_table === '') { + continue; + } + + // 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; + } + + fwrite($fh, "\n-- Table: {$safe_table}\n"); + fwrite($fh, "DROP TABLE IF EXISTS `{$safe_table}`;\n"); + fwrite($fh, $create[1] . ";\n\n"); + + $offset = 0; + $batch = self::SQL_BATCH_SIZE; + + 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)); + + fwrite($fh, "INSERT INTO `{$safe_table}` ({$columns}) VALUES ({$values});\n"); + } + + $offset += $batch; + } while (count($rows) === $batch); + } + + fwrite($fh, "\nSET FOREIGN_KEY_CHECKS=1;\n"); + fclose($fh); + + return array('success' => true, 'temp_file' => $temp_path); + } + + /** + * Escape a single SQL value for use in an INSERT statement. + */ + private function escape_sql_value(mixed $value): string + { + if ($value === null) { + return 'NULL'; + } + + return "'" . esc_sql((string) $value) . "'"; + } + + /** + * 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; + + 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', "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 + public function execute(string $action, ?string $plugin_file, ?string $slug, array $payload = array()): array { try { switch ($action) { @@ -38,6 +53,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 +173,58 @@ 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 || $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->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); + } + + return array( + 'success' => true, + 'error' => null, + 'inventory' => null, + 'backup_id' => $result['backup_id'], + 'total_chunks' => $result['total_chunks'], + 'total_bytes' => $result['total_bytes'], + 'backup_type' => $result['backup_type'], + ); + } + + 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); + } + + // In push mode the temp file is removed automatically. This is a safety net. + $this->backup_service->cleanup($backup_id); + + return array( + 'success' => true, + 'error' => null, + '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-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 2f724c7..b1d84ad 100644 --- a/stack2-connector/includes/class-stack2-plugin.php +++ b/stack2-connector/includes/class-stack2-plugin.php @@ -14,14 +14,24 @@ 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 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'; 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 +39,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 +64,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 +73,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 +82,11 @@ 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, + $this->http_client, + $this->get_base_url(), + $this->get_api_key() ); $controller = new Stack2_REST_Controller( @@ -81,6 +100,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 +147,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..e5749c5 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.