false, 'error' => 'PHP fatal: ' . $e['message'], 'file' => basename($e['file']), 'line' => $e['line'], 'php_version' => PHP_VERSION, )); }); // === Identity ============================================================ // {{WEBANDY_TOKEN_PLACEHOLDER}} — replaced when downloaded from dashboard. define('WEBANDY_TOKEN', 'wbnd_bridge_HraGcHiKZCMVTwT15lyw3o3GvzGQVeTAxTymzPx5'); // Split-string sentinel so the dashboard's placeholder substitution only hits // the `define` line above. Do not edit. define('WEBANDY_UNCONFIGURED', '__PASTE_TOKEN' . '_HERE__'); define('WEBANDY_BRIDGE_VERSION', '0.7.0'); // === PHP 5.4 compat polyfills (some shared hosts still run 5.4) ============ // hash_equals: timing-safe string compare. Added in PHP 5.6. if (!function_exists('hash_equals')) { function hash_equals($known, $user) { $known = (string)$known; $user = (string)$user; if (strlen($known) !== strlen($user)) return false; $r = 0; $len = strlen($known); for ($i = 0; $i < $len; $i++) { $r |= ord($known[$i]) ^ ord($user[$i]); } return $r === 0; } } // json_decode 在 5.4 已有,但 JSON_UNESCAPED_UNICODE / SLASHES 5.4+ 才有 → 兼容 if (!defined('JSON_UNESCAPED_UNICODE')) define('JSON_UNESCAPED_UNICODE', 0); if (!defined('JSON_UNESCAPED_SLASHES')) define('JSON_UNESCAPED_SLASHES', 0); // http_response_code 5.4+ 有,无需 polyfill // === Optional manual config ============================================= // Filesystem root for file_* actions. Defaults to where this script lives. // FS root for file_* actions. v0.6.4: auto-detect WordPress root by walking up // from __DIR__ to find wp-config.php (or wp-load.php). 这样 bridge.php 放任何深 // 子目录都能管整个 WP 站,无需手动配 FS_ROOT。找不到则 fallback 到 __DIR__。 if (!defined('WEBANDY_FS_ROOT')) { $_wbnd_d = __DIR__; $_wbnd_found = false; for ($_wbnd_i = 0; $_wbnd_i < 16; $_wbnd_i++) { if (@file_exists($_wbnd_d . '/wp-config.php') || @file_exists($_wbnd_d . '/wp-load.php')) { define('WEBANDY_FS_ROOT', $_wbnd_d); $_wbnd_found = true; break; } $_wbnd_parent = dirname($_wbnd_d); if ($_wbnd_parent === $_wbnd_d) break; $_wbnd_d = $_wbnd_parent; } if (!$_wbnd_found) define('WEBANDY_FS_ROOT', __DIR__); unset($_wbnd_d, $_wbnd_found, $_wbnd_i, $_wbnd_parent); } // DB credentials — leave blank to auto-detect from a nearby wp-config.php. if (!defined('WEBANDY_DB_HOST')) define('WEBANDY_DB_HOST', ''); if (!defined('WEBANDY_DB_NAME')) define('WEBANDY_DB_NAME', ''); if (!defined('WEBANDY_DB_USER')) define('WEBANDY_DB_USER', ''); if (!defined('WEBANDY_DB_PASS')) define('WEBANDY_DB_PASS', ''); if (!defined('WEBANDY_DB_PREFIX')) define('WEBANDY_DB_PREFIX', ''); // === Helpers ============================================================= function wbnd_json($data, $code = 200) { http_response_code($code); // v0.7.0: ?fmt=text 输出 text/plain(救 ModSecurity 拦 application/json 的 host) $fmt = isset($_GET['fmt']) ? $_GET['fmt'] : ''; if ($fmt === 'text') { header('Content-Type: text/plain; charset=utf-8'); } else { header('Content-Type: application/json; charset=utf-8'); } header('Cache-Control: no-store'); // v0.7.0: gzip 压缩输出(仅当客户端 Accept-Encoding 带 gzip + zlib 可用) $accept = isset($_SERVER['HTTP_ACCEPT_ENCODING']) ? $_SERVER['HTTP_ACCEPT_ENCODING'] : ''; if (extension_loaded('zlib') && strpos($accept, 'gzip') !== false && !headers_sent()) { @ob_start('ob_gzhandler'); } echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); exit; } function wbnd_err($msg, $code = 400) { wbnd_json(array('ok' => false, 'error' => $msg), $code); } /** * v0.3.0+: Multi-channel token. Some WAFs (Cloudflare bot mode, Imunify360) * block requests carrying custom X-* headers. We fall through in order: * 1. X-Webandy-Token header * 2. Cookie wbt=... * 3. Query ?wbt=... * 4. POST body field wbt * Returns the candidate token string ('' if no channel sent one). */ function wbnd_extract_token() { $h = isset($_SERVER['HTTP_X_WEBANDY_TOKEN']) ? $_SERVER['HTTP_X_WEBANDY_TOKEN'] : ''; if (is_string($h) && $h !== '') return $h; $c = isset($_COOKIE['wbt']) ? $_COOKIE['wbt'] : ''; if (is_string($c) && $c !== '') return $c; $q = isset($_GET['wbt']) ? $_GET['wbt'] : ''; if (is_string($q) && $q !== '') return $q; $method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET'; if ($method === 'POST') { $b = isset($_POST['wbt']) ? $_POST['wbt'] : ''; if (is_string($b) && $b !== '') return $b; // Also peek at JSON body — POST { "wbt": "..." }. $raw = file_get_contents('php://input'); if (is_string($raw) && $raw !== '') { $d = json_decode($raw, true); if (is_array($d) && isset($d['wbt']) && is_string($d['wbt']) && $d['wbt'] !== '') return $d['wbt']; } } return ''; } function wbnd_authn() { if (WEBANDY_TOKEN === '' || WEBANDY_TOKEN === WEBANDY_UNCONFIGURED) { wbnd_err('bridge not configured; replace WEBANDY_TOKEN before use', 500); } $got = wbnd_extract_token(); if ($got === '' || !hash_equals(WEBANDY_TOKEN, $got)) { wbnd_err('unauthorized', 401); } } function wbnd_body() { $raw = file_get_contents('php://input'); if ($raw === false || $raw === '') return array(); $d = json_decode($raw, true); return is_array($d) ? $d : array(); } // === wp-config detection (regex parse only — never `require`) ============ function wbnd_resolve_db_config() { $host = WEBANDY_DB_HOST; $name = WEBANDY_DB_NAME; $user = WEBANDY_DB_USER; $pass = WEBANDY_DB_PASS; $prefix = WEBANDY_DB_PREFIX; $source = 'manual'; if ($host !== '' && $name !== '') { if ($prefix === '') $prefix = 'wp_'; return compact('host', 'name', 'user', 'pass', 'prefix', 'source'); } $check = __DIR__; $cfg = null; for ($i = 0; $i < 4; $i++) { $p = $check . '/wp-config.php'; if (is_file($p) && is_readable($p)) { $cfg = $p; break; } $parent = dirname($check); if ($parent === $check) break; $check = $parent; } if ($cfg !== null) { $source = $cfg; $contents = @file_get_contents($cfg, false, null, 0, 32768); if ($contents !== false) { $def = function ($key) use ($contents) { if (preg_match("/define\s*\(\s*['\"]" . preg_quote($key, '/') . "['\"]\s*,\s*['\"]([^'\"]*)['\"]/", $contents, $m)) { return $m[1]; } return null; }; if ($host === '') { $v = $def('DB_HOST'); $host = $v !== null ? $v : ''; } if ($name === '') { $v = $def('DB_NAME'); $name = $v !== null ? $v : ''; } if ($user === '') { $v = $def('DB_USER'); $user = $v !== null ? $v : ''; } if ($pass === '') { $v = $def('DB_PASSWORD'); $pass = $v !== null ? $v : ''; } if ($prefix === '' && preg_match("/\\\$table_prefix\s*=\s*['\"]([^'\"]*)['\"]/", $contents, $m)) { $prefix = $m[1]; } } } if ($prefix === '') $prefix = 'wp_'; return compact('host', 'name', 'user', 'pass', 'prefix', 'source'); } /** Returns [PDO|null, prefix]. If $required and DB is unreachable, emits error. */ function wbnd_db($required = true) { static $cache = null; if ($cache !== null) return $cache; $cfg = wbnd_resolve_db_config(); if ($cfg['host'] === '' || $cfg['name'] === '') { if ($required) wbnd_err('db credentials not found; set WEBANDY_DB_* constants or place bridge near wp-config.php', 500); $cache = array(null, $cfg['prefix']); return $cache; } try { $pdo = new PDO( "mysql:host={$cfg['host']};dbname={$cfg['name']};charset=utf8mb4", $cfg['user'], $cfg['pass'], array( PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ) ); } catch (Exception $e) { if ($required) wbnd_err('db connect failed: ' . $e->getMessage(), 500); $cache = array(null, $cfg['prefix']); return $cache; } $cache = array($pdo, $cfg['prefix']); return $cache; } /** Best-effort WP version probe by reading wp-includes/version.php (no eval / require). */ function wbnd_wp_version() { $candidates = array( WEBANDY_FS_ROOT . '/wp-includes/version.php', dirname(WEBANDY_FS_ROOT) . '/wp-includes/version.php', ); foreach ($candidates as $p) { if (is_readable($p)) { $c = @file_get_contents($p, false, null, 0, 4096); if ($c !== false && preg_match("/\\\$wp_version\s*=\s*['\"]([^'\"]+)['\"]/", $c, $m)) { return $m[1]; } } } return null; } // === Filesystem helpers ================================================== function wbnd_resolve_path($p) { $root = realpath(WEBANDY_FS_ROOT); if ($root === false) wbnd_err('FS root cannot be resolved', 500); $rel = ltrim($p, '/'); $rel = str_replace(array('../', '..\\', "\0"), '', $rel); $abs = $root . DIRECTORY_SEPARATOR . $rel; $real = realpath($abs); if ($real !== false) { // Target exists; ensure it's inside root. if (strpos($real, $root) !== 0) wbnd_err('path outside FS root', 422); return $real; } // Target doesn't exist yet (common for new uploads with random sub-dirs). // Walk up parents until we find one that *does* exist on disk, then // verify that anchor is inside root. The actual mkdir is handled by // wbnd_safe_write() later (mkdir -p with 0755). $check = dirname($abs); $maxUp = 16; while ($maxUp-- > 0) { $real = realpath($check); if ($real !== false) { if (strpos($real, $root) !== 0) wbnd_err('ancestor path outside FS root', 422); return $abs; } $parent = dirname($check); if ($parent === $check) break; $check = $parent; } wbnd_err('parent path outside FS root (no existing ancestor under root)', 422); } function wbnd_rrmdir($dir) { $entries = scandir($dir); if ($entries === false) $entries = array(); foreach ($entries as $e) { if ($e === '.' || $e === '..') continue; $p = $dir . DIRECTORY_SEPARATOR . $e; if (is_dir($p) && !is_link($p)) wbnd_rrmdir($p); else @unlink($p); } @rmdir($dir); } /** * Build a structured diagnostic dump explaining why a write failed. * Includes file/dir perms, owners, PHP process identity, last errno. */ function wbnd_diag_perms($abs) { $diag = array('abs' => $abs); $dir = dirname($abs); $exists = file_exists($abs); $diag['exists'] = $exists; if ($exists) { $st = @stat($abs); if ($st !== false) { $diag['file_perms'] = sprintf('0%o', $st['mode'] & 0777); $diag['file_uid'] = (int)$st['uid']; $diag['file_gid'] = (int)$st['gid']; if (function_exists('posix_getpwuid')) { $u = @posix_getpwuid($st['uid']); if (is_array($u)) $diag['file_owner'] = $u['name']; } } $diag['file_writable'] = is_writable($abs); } $diag['dir'] = $dir; $dst = @stat($dir); if ($dst !== false) { $diag['dir_perms'] = sprintf('0%o', $dst['mode'] & 0777); $diag['dir_uid'] = (int)$dst['uid']; $diag['dir_gid'] = (int)$dst['gid']; if (function_exists('posix_getpwuid')) { $u = @posix_getpwuid($dst['uid']); if (is_array($u)) $diag['dir_owner'] = $u['name']; } } $diag['dir_writable'] = is_writable($dir); if (function_exists('posix_geteuid')) { $euid = posix_geteuid(); $diag['php_uid'] = $euid; if (function_exists('posix_getpwuid')) { $u = @posix_getpwuid($euid); if (is_array($u)) $diag['php_user'] = $u['name']; } } elseif (function_exists('get_current_user')) { $diag['php_user'] = @get_current_user(); } $le = error_get_last(); if (is_array($le) && isset($le['message'])) { $diag['errno'] = preg_replace('/^.+?: /', '', $le['message']); } return $diag; } /** * Robust write that survives a read-only target file: * 1. direct file_put_contents * 2. if file exists & unwritable: @chmod 0644 + retry * 3. atomic write via tempfile + rename — only needs dir writable, * preserves the original perms on success * 4. all paths fail → emit detailed diag block * * Returns ['ok'=>true,'bytes'=>N,'mode'=>'direct|chmod|atomic'] on success. */ function wbnd_safe_write($abs, $data) { $dir = dirname($abs); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { return array('ok' => false, 'mode' => 'mkdir', 'diag' => wbnd_diag_perms($abs)); } $orig_perms = null; if (file_exists($abs)) { $st = @stat($abs); if ($st !== false) $orig_perms = $st['mode'] & 0777; } // Path 1: direct write. $bytes = @file_put_contents($abs, $data); if ($bytes !== false) { return array('ok' => true, 'bytes' => $bytes, 'mode' => 'direct'); } // Path 2: chmod retry (works iff PHP process owns the file). $chmod_ok = false; if (file_exists($abs)) { $chmod_ok = @chmod($abs, 0644); if ($chmod_ok) { $bytes = @file_put_contents($abs, $data); if ($bytes !== false) { if ($orig_perms !== null && $orig_perms !== 0644) @chmod($abs, $orig_perms); return array('ok' => true, 'bytes' => $bytes, 'mode' => 'chmod'); } } } // Path 3: atomic rename via tempfile in the same dir. // POSIX rename replaces the target even when the target is read-only, // as long as the *directory* is writable to the PHP process. $tmp = $abs . '.wbnd.tmp.' . getmypid() . '.' . mt_rand(100000, 999999); $bytes = @file_put_contents($tmp, $data); if ($bytes !== false) { if (@rename($tmp, $abs)) { if ($orig_perms !== null) @chmod($abs, $orig_perms); else @chmod($abs, 0644); return array('ok' => true, 'bytes' => $bytes, 'mode' => 'atomic'); } @unlink($tmp); } return array('ok' => false, 'mode' => 'failed', 'chmod_attempted' => $chmod_ok, 'diag' => wbnd_diag_perms($abs)); } // === Routing ============================================================= $action = isset($_GET['action']) ? $_GET['action'] : ''; $method = $_SERVER['REQUEST_METHOD']; // ----- Public endpoints (no token required) ----------------------------- $configured = (WEBANDY_TOKEN !== '' && WEBANDY_TOKEN !== WEBANDY_UNCONFIGURED); $tokenFp = $configured ? substr(hash('sha256', WEBANDY_TOKEN), 0, 12) : null; // ?action=ping → cheap unauthenticated probe. // Used by the dashboard to check "is the file installed and reachable", // without requiring the token match. Returns version + token fingerprint // so the dashboard can detect "wrong token" before retrying with auth. if ($action === 'ping') { wbnd_json(array( 'ok' => true, 'bridge_version' => WEBANDY_BRIDGE_VERSION, 'configured' => $configured, 'token_fp' => $tokenFp, 'time' => gmdate('c'), 'php_version' => PHP_VERSION, )); } // GET / with no action and no token → human-friendly install confirmation page. // Anyone hitting the URL in a browser after upload should see a clear // "✓ installed" status instead of an obscure JSON 401 error. if ($action === '' && $method === 'GET' && wbnd_extract_token() === '') { $php = htmlspecialchars(PHP_VERSION, ENT_QUOTES); $sapi = htmlspecialchars(PHP_SAPI, ENT_QUOTES); $ver = htmlspecialchars(WEBANDY_BRIDGE_VERSION, ENT_QUOTES); $fp = htmlspecialchars($tokenFp !== null ? $tokenFp : '—', ENT_QUOTES); $status = $configured ? '✓ installed' : '⚠ token not configured'; $statusColor = $configured ? '#16a34a' : '#dc2626'; header('Content-Type: text/html; charset=utf-8'); echo <<Webandy Bridge

Webandy Bridge {$status}

The bridge file is reachable and PHP is executing. Connect this site from your Webandy dashboard to start managing it.
Bridge version{$ver}
PHP version{$php} ({$sapi})
Token fingerprint{$fp}
If status is "token not configured", re-download bridge.php from the Webandy dashboard — the placeholder must be replaced before the file works.
HTML; exit; } // ----- Authenticated endpoints below ------------------------------------ wbnd_authn(); try { switch ($action) { case 'health': { $info = array( 'ok' => true, 'bridge_version' => WEBANDY_BRIDGE_VERSION, 'php_version' => PHP_VERSION, 'php_sapi' => PHP_SAPI, 'time' => gmdate('c'), 'fs_root' => WEBANDY_FS_ROOT, 'disk_free_mb' => @disk_free_space(WEBANDY_FS_ROOT) === false ? null : (int)round(disk_free_space(WEBANDY_FS_ROOT) / 1024 / 1024), 'disk_total_mb' => @disk_total_space(WEBANDY_FS_ROOT) === false ? null : (int)round(disk_total_space(WEBANDY_FS_ROOT) / 1024 / 1024), 'memory_limit' => ini_get('memory_limit'), 'max_upload' => ini_get('upload_max_filesize'), 'extensions' => array( 'pdo_mysql' => extension_loaded('pdo_mysql'), 'curl' => extension_loaded('curl'), 'gd' => extension_loaded('gd'), 'mbstring' => extension_loaded('mbstring'), 'json' => extension_loaded('json'), ), ); $wp_ver = wbnd_wp_version(); if ($wp_ver) $info['wp_version'] = $wp_ver; list($db, $prefix) = wbnd_db(false); $info['db_connected'] = $db !== null; $info['db_prefix'] = $prefix; if ($db) { try { foreach (array('siteurl' => 'site_url', 'blogname' => 'site_name', 'db_version' => 'wp_db_version') as $optName => $key) { $s = $db->prepare("SELECT option_value FROM `{$prefix}options` WHERE option_name = ? LIMIT 1"); $s->execute(array($optName)); $r = $s->fetch(); if ($r) $info[$key] = $r['option_value']; } $r = $db->query("SELECT COUNT(*) AS c FROM `{$prefix}posts` WHERE post_status='publish' AND post_type='post'")->fetch(); $info['published_posts'] = (int)$r['c']; } catch (Exception $e) { $info['db_connected'] = false; $info['db_error'] = $e->getMessage(); } } wbnd_json($info); break; } case 'posts': { list($db, $prefix) = wbnd_db(); $per_page = min(100, max(1, intval(isset($_GET['per_page']) ? $_GET['per_page'] : 20))); $page = max(1, intval(isset($_GET['page']) ? $_GET['page'] : 1)); $post_type_raw = isset($_GET['post_type']) ? (string)$_GET['post_type'] : 'post'; $post_type = preg_replace('/[^a-z0-9_-]/i', '', $post_type_raw); if ($post_type === '') $post_type = 'post'; $status = isset($_GET['status']) ? $_GET['status'] : 'any'; $where = '`post_type` = :pt'; $params = array(':pt' => $post_type); if ($status === 'any') { $where .= " AND `post_status` IN ('publish','draft','pending','private','future')"; } else { $where .= ' AND `post_status` = :ps'; $params[':ps'] = $status; } $tot = $db->prepare("SELECT COUNT(*) AS c FROM `{$prefix}posts` WHERE $where"); $tot->execute($params); $totRow = $tot->fetch(); $total = (int)$totRow['c']; $offset = ($page - 1) * $per_page; $sql = "SELECT ID, post_title, post_content, post_status, post_modified_gmt, guid FROM `{$prefix}posts` WHERE $where ORDER BY post_modified DESC LIMIT $per_page OFFSET $offset"; $stmt = $db->prepare($sql); $stmt->execute($params); $posts = array(); foreach ($stmt->fetchAll() as $r) { $posts[] = array( 'id' => (int)$r['ID'], 'title' => $r['post_title'], 'content' => $r['post_content'], 'status' => $r['post_status'], 'modified' => $r['post_modified_gmt'] . 'Z', 'link' => $r['guid'], ); } wbnd_json(array( 'ok' => true, 'page' => $page, 'per_page' => $per_page, 'total' => $total, 'total_pages' => max(1, (int)ceil($total / $per_page)), 'posts' => $posts, )); break; } case 'update_post': { if ($method !== 'POST') wbnd_err('POST required', 405); $id = intval(isset($_GET['id']) ? $_GET['id'] : 0); if (!$id) wbnd_err('missing id'); $body = wbnd_body(); list($db, $prefix) = wbnd_db(); $fields = array(); $params = array(':id' => $id, ':now' => gmdate('Y-m-d H:i:s')); if (isset($body['title'])) { $fields[] = 'post_title = :title'; $params[':title'] = $body['title']; } if (isset($body['content'])) { $fields[] = 'post_content = :content'; $params[':content'] = $body['content']; } if (isset($body['status'])) { $fields[] = 'post_status = :status'; $params[':status'] = $body['status']; } if (!$fields) wbnd_err('nothing to update'); $fields[] = 'post_modified = :now'; $fields[] = 'post_modified_gmt = :now'; $sql = "UPDATE `{$prefix}posts` SET " . implode(', ', $fields) . " WHERE ID = :id"; $stmt = $db->prepare($sql); $stmt->execute($params); // rowCount may be 0 if values unchanged; verify existence to distinguish. $exists = $db->prepare("SELECT 1 FROM `{$prefix}posts` WHERE ID = ? LIMIT 1"); $exists->execute(array($id)); if (!$exists->fetch()) wbnd_err('post not found', 404); wbnd_json(array('ok' => true, 'id' => $id, 'modified' => $params[':now'] . 'Z')); break; } case 'delete_post': { if ($method !== 'POST') wbnd_err('POST required', 405); $id = intval(isset($_GET['id']) ? $_GET['id'] : 0); $force = !empty($_GET['force']); list($db, $prefix) = wbnd_db(); if ($force) { $db->prepare("DELETE FROM `{$prefix}postmeta` WHERE post_id = ?")->execute(array($id)); $st = $db->prepare("DELETE FROM `{$prefix}posts` WHERE ID = ?"); $st->execute(array($id)); wbnd_json(array('ok' => true, 'deleted' => $st->rowCount() > 0, 'force' => true)); } else { $st = $db->prepare("UPDATE `{$prefix}posts` SET post_status = 'trash' WHERE ID = ?"); $st->execute(array($id)); wbnd_json(array('ok' => true, 'trashed' => $st->rowCount() > 0)); } break; } case 'media': { list($db, $prefix) = wbnd_db(); $per_page = min(100, max(1, intval(isset($_GET['per_page']) ? $_GET['per_page'] : 20))); $page = max(1, intval(isset($_GET['page']) ? $_GET['page'] : 1)); $offset = ($page - 1) * $per_page; $total = (int)$db->query("SELECT COUNT(*) FROM `{$prefix}posts` WHERE post_type='attachment'")->fetchColumn(); $stmt = $db->prepare("SELECT p.ID, p.post_title, p.post_mime_type, p.post_modified_gmt, p.guid, m.meta_value AS attached_file FROM `{$prefix}posts` p LEFT JOIN `{$prefix}postmeta` m ON m.post_id = p.ID AND m.meta_key = '_wp_attached_file' WHERE p.post_type = 'attachment' ORDER BY p.post_date DESC LIMIT $per_page OFFSET $offset"); $stmt->execute(); $items = array(); foreach ($stmt->fetchAll() as $r) { $size = 0; if (!empty($r['attached_file'])) { $upload_path = WEBANDY_FS_ROOT . '/wp-content/uploads/' . ltrim($r['attached_file'], '/'); if (is_file($upload_path)) $size = (int)filesize($upload_path); } $items[] = array( 'id' => (int)$r['ID'], 'title' => $r['post_title'], 'url' => $r['guid'], 'mime' => $r['post_mime_type'], 'modified' => $r['post_modified_gmt'] . 'Z', 'size' => $size, ); } wbnd_json(array('ok' => true, 'page' => $page, 'per_page' => $per_page, 'total' => $total, 'media' => $items)); break; } case 'file_list': { $path = isset($_GET['path']) ? $_GET['path'] : '/'; $abs = wbnd_resolve_path($path); if (!file_exists($abs)) wbnd_err("not found: $path", 404); if (is_file($abs)) { wbnd_json(array('ok' => true, 'cwd' => $path, 'entries' => array(array( 'name' => basename($abs), 'kind' => 'file', 'size' => filesize($abs), 'mtime' => filemtime($abs), )))); } $entries = array(); $scan = scandir($abs); if ($scan === false) $scan = array(); foreach ($scan as $e) { if ($e === '.' || $e === '..') continue; $p = $abs . DIRECTORY_SEPARATOR . $e; $entries[] = array( 'name' => $e, 'kind' => is_dir($p) ? 'dir' : 'file', 'size' => is_file($p) ? (int)filesize($p) : 0, 'mtime' => (int)filemtime($p), ); } usort($entries, function ($a, $b) { $ka = $a['kind'] === 'dir' ? 0 : 1; $kb = $b['kind'] === 'dir' ? 0 : 1; if ($ka !== $kb) return $ka - $kb; return strcasecmp($a['name'], $b['name']); }); wbnd_json(array('ok' => true, 'cwd' => $path, 'entries' => $entries)); break; } case 'file_read': { $abs = wbnd_resolve_path(isset($_GET['path']) ? $_GET['path'] : ''); if (!is_file($abs)) wbnd_err('not a file', 404); $max = intval(isset($_GET['maxBytes']) ? $_GET['maxBytes'] : 5 * 1024 * 1024); $size = filesize($abs); $data = file_get_contents($abs, false, null, 0, $max); wbnd_json(array( 'ok' => true, 'size' => $size, 'truncated' => $size > $max, 'contentB64' => base64_encode($data), )); break; } case 'file_write': { if ($method !== 'POST') wbnd_err('POST required', 405); $body = wbnd_body(); if (!isset($body['path']) || !isset($body['contentB64'])) wbnd_err('missing path / contentB64'); $abs = wbnd_resolve_path($body['path']); $data = base64_decode((string)$body['contentB64']); $res = wbnd_safe_write($abs, $data); if (!$res['ok']) { $diag = isset($res['diag']) ? $res['diag'] : wbnd_diag_perms($abs); // Build a single-line, self-explanatory error string. This is what // bubbles up to the dashboard error panel — we squeeze the key // facts into it so the user doesn't need to dig into a JSON blob. $base = basename($abs); $fileUid = isset($diag['file_uid']) ? $diag['file_uid'] : '?'; $owner = isset($diag['file_owner']) ? $diag['file_owner'] : ('uid ' . $fileUid); $phpUid = isset($diag['php_uid']) ? $diag['php_uid'] : '?'; $phpUser = isset($diag['php_user']) ? $diag['php_user'] : ('uid ' . $phpUid); $perms = isset($diag['file_perms']) ? $diag['file_perms'] : '?'; $dirWritable = !empty($diag['dir_writable']); $dirPath = isset($diag['dir']) ? $diag['dir'] : dirname($abs); $parts = array("目标文件 {$base} 写入失败"); $parts[] = "perms={$perms}, owner={$owner}, php={$phpUser}, dir_writable=" . ($dirWritable ? 'yes' : 'no'); $hint = $dirWritable ? "宿主机执行 `chmod 0644 {$base}` 或 `chown {$phpUser} {$base}` 后重试" : "目录也不可写 — 需 `chmod 0755 {$dirPath}` 或将整个目录 chown 给 {$phpUser}"; $parts[] = $hint; if (!empty($diag['errno'])) $parts[] = "errno: " . $diag['errno']; $msg = implode(' · ', $parts); // Use 422 instead of 403 — 403 triggers the dashboard's WAF-retry // logic which would waste 2-3 round-trips on a permission error. wbnd_json(array( 'ok' => false, 'error' => $msg, 'code' => 'write_denied', 'mode' => $res['mode'], 'chmod_attempted' => isset($res['chmod_attempted']) ? $res['chmod_attempted'] : false, 'diag' => $diag, ), 422); } // v0.7.0: 写 PHP 文件后立刻 invalidate opcache,改动马上生效(不用等 FPM 刷) if (function_exists('opcache_invalidate') && preg_match('/\.php$/i', $abs)) { @opcache_invalidate($abs, true); } wbnd_json(array('ok' => true, 'bytes' => $res['bytes'], 'mode' => $res['mode'])); break; } case 'file_stat': { $abs = wbnd_resolve_path(isset($_GET['path']) ? $_GET['path'] : ''); wbnd_json(array('ok' => true, 'diag' => wbnd_diag_perms($abs))); break; } // v0.7.0: 批量 file_stat — body { paths: [...] } // 返回 { results: { path: { exists: bool, size?: int, mtime?: int } } } // fleet cleanup 单次扫一个 site 1000 条文件可从 150 秒降到 15 秒 case 'file_stat_batch': { if ($method !== 'POST') wbnd_err('POST required', 405); $body = wbnd_body(); if (!isset($body['paths']) || !is_array($body['paths'])) wbnd_err('missing paths array'); $results = array(); foreach ($body['paths'] as $p) { if (!is_string($p)) continue; $info = array('exists' => false); try { $abs = wbnd_resolve_path($p); if (@file_exists($abs)) { $info['exists'] = true; $st = @stat($abs); if ($st !== false) { $info['size'] = (int)$st['size']; $info['mtime'] = (int)$st['mtime']; $info['is_dir'] = is_dir($abs); } } } catch (Exception $e) { $info['error'] = $e->getMessage(); } $results[$p] = $info; } wbnd_json(array('ok' => true, 'results' => $results)); break; } // v0.7.0: 批量 file_read — body { paths: [...], maxBytes?: int } // 返回 { results: { path: { ok: bool, size, truncated, contentB64 } } } case 'file_read_batch': { if ($method !== 'POST') wbnd_err('POST required', 405); $body = wbnd_body(); if (!isset($body['paths']) || !is_array($body['paths'])) wbnd_err('missing paths array'); $max = isset($body['maxBytes']) ? intval($body['maxBytes']) : 256 * 1024; // batch 默认每个 256KB 上限 if ($max > 1024 * 1024) $max = 1024 * 1024; // batch 单个不超过 1MB $results = array(); foreach ($body['paths'] as $p) { if (!is_string($p)) continue; $r = array('ok' => false); try { $abs = wbnd_resolve_path($p); if (!is_file($abs)) { $r['error'] = 'not a file'; } else { $size = filesize($abs); $data = file_get_contents($abs, false, null, 0, $max); $r = array( 'ok' => true, 'size' => $size, 'truncated' => $size > $max, 'contentB64' => base64_encode($data), ); } } catch (Exception $e) { $r['error'] = $e->getMessage(); } $results[$p] = $r; } wbnd_json(array('ok' => true, 'results' => $results)); break; } // v0.7.0: 综合 system_info — 一次返回 PHP / WP / disk / memory / extensions // 替代 health 的部分用途,减少多次 round-trip case 'system_info': { $exts = array(); foreach (array('pdo_mysql', 'curl', 'gd', 'mbstring', 'json', 'zlib', 'openssl', 'zip', 'opcache') as $e) { $exts[$e] = extension_loaded($e); } $opc = function_exists('opcache_get_status') ? @opcache_get_status(false) : null; wbnd_json(array( 'ok' => true, 'bridge_version' => WEBANDY_BRIDGE_VERSION, 'php_version' => PHP_VERSION, 'php_sapi' => PHP_SAPI, 'time' => gmdate('c'), 'fs_root' => WEBANDY_FS_ROOT, 'wp_version' => function_exists('wbnd_wp_version') ? wbnd_wp_version() : null, 'disk' => array( 'free_mb' => @disk_free_space(WEBANDY_FS_ROOT) === false ? null : (int)round(disk_free_space(WEBANDY_FS_ROOT) / 1048576), 'total_mb' => @disk_total_space(WEBANDY_FS_ROOT) === false ? null : (int)round(disk_total_space(WEBANDY_FS_ROOT) / 1048576), ), 'memory' => array( 'limit' => ini_get('memory_limit'), 'usage_mb' => function_exists('memory_get_usage') ? round(memory_get_usage(true) / 1048576, 1) : null, 'peak_mb' => function_exists('memory_get_peak_usage') ? round(memory_get_peak_usage(true) / 1048576, 1) : null, ), 'limits' => array( 'upload_max_filesize' => ini_get('upload_max_filesize'), 'post_max_size' => ini_get('post_max_size'), 'max_execution_time' => ini_get('max_execution_time'), ), 'extensions' => $exts, 'opcache' => $opc ? array( 'enabled' => isset($opc['opcache_enabled']) ? !!$opc['opcache_enabled'] : false, 'cached_scripts' => isset($opc['opcache_statistics']['num_cached_scripts']) ? $opc['opcache_statistics']['num_cached_scripts'] : null, 'memory_used_mb' => isset($opc['memory_usage']['used_memory']) ? round($opc['memory_usage']['used_memory'] / 1048576, 1) : null, ) : null, )); break; } // v0.7.0: 手动 opcache_reset — 适合大批量改 PHP 后一次性刷 case 'opcache_reset': { if (!function_exists('opcache_reset')) { wbnd_json(array('ok' => false, 'error' => 'opcache not available'), 200); } $r = @opcache_reset(); wbnd_json(array('ok' => !!$r)); break; } case 'file_chmod': { if ($method !== 'POST') wbnd_err('POST required', 405); $body = wbnd_body(); if (!isset($body['path']) || !isset($body['mode'])) wbnd_err('missing path / mode'); $abs = wbnd_resolve_path($body['path']); // accept either int (0644) or string ("0644") $modeRaw = $body['mode']; $mode = is_int($modeRaw) ? $modeRaw : intval((string)$modeRaw, 8); if ($mode < 0 || $mode > 0777) wbnd_err('mode out of range'); if (!file_exists($abs)) wbnd_err('not found', 404); $ok = @chmod($abs, $mode); if (!$ok) { wbnd_json(array( 'ok' => false, 'error' => 'chmod failed(PHP 进程不是文件所有者)', 'code' => 'chmod_denied', 'diag' => wbnd_diag_perms($abs), ), 422); } wbnd_json(array('ok' => true, 'mode' => sprintf('0%o', $mode))); break; } case 'file_delete': { if ($method !== 'POST') wbnd_err('POST required', 405); $body = wbnd_body(); if (!isset($body['path'])) wbnd_err('missing path'); $abs = wbnd_resolve_path($body['path']); if (!file_exists($abs)) wbnd_json(array('ok' => true, 'existed' => false)); if (is_dir($abs)) { if (!empty($body['recursive'])) wbnd_rrmdir($abs); elseif (!@rmdir($abs)) wbnd_err('rmdir failed'); } else { if (!@unlink($abs)) wbnd_err('unlink failed'); } wbnd_json(array('ok' => true, 'existed' => true)); break; } // --------------------------------------------------------------- // proxy_get — fetch a same-origin URL from this PHP process and // return the response body. Critical for probing endpoints that // do UA/IP cloaking against external requests: when we curl from // localhost the cloaking rule typically sees a trusted internal // request and returns the real content. // // path: required, must start with "/", appended to the current // scheme + HTTP_HOST so the operator can't pivot to a // different domain. // ua: optional, default Googlebot. // --------------------------------------------------------------- case 'proxy_get': { $path = (string)(isset($_GET['path']) ? $_GET['path'] : '/'); if ($path === '' || $path[0] !== '/') wbnd_err('path must start with /'); // Strip any attempt to escape (//host pattern). if (substr($path, 0, 2) === '//') $path = '/' . ltrim($path, '/'); $ua = (string)(isset($_GET['ua']) ? $_GET['ua'] : 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'); $maxBytes = max(1, min(64 * 1024, intval(isset($_GET['maxBytes']) ? $_GET['maxBytes'] : 8192))); $timeout = max(1, min(30, intval(isset($_GET['timeout']) ? $_GET['timeout'] : 10))); $scheme = (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') ? 'https' : 'http'; // Use HTTP_HOST (request host) so the request hits the same vhost // that's serving us; fall back to SERVER_NAME / localhost. $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : (isset($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'localhost'); $url = $scheme . '://' . $host . $path; $body = false; $status = null; $err = null; $fetchMethod = 'curl'; if (function_exists('curl_init')) { $ch = curl_init(); curl_setopt_array($ch, array( CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 4, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => $timeout, CURLOPT_USERAGENT => $ua, // localhost certs are commonly self-signed CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => 0, CURLOPT_HTTPHEADER => array( 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language: en-US,en;q=0.5', ), // Do NOT pin to 127.0.0.1: on shared hosting (Hostinger // LiteSpeed, GoDaddy, etc.) 127.0.0.1 serves a different // vhost (a placeholder/default site) instead of the real // one. Use normal DNS resolution — the request still // originates from the site's own server IP, which is what // most cloaking rules trust. )); $resp = curl_exec($ch); $status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($resp === false) { $err = curl_error($ch); } else { $body = $resp; } curl_close($ch); } else { $fetchMethod = 'fgc'; $ctx = stream_context_create(array( 'http' => array( 'method' => 'GET', 'user_agent' => $ua, 'header' => "Accept: text/html\r\nAccept-Language: en-US\r\n", 'timeout' => $timeout, 'follow_location' => 1, 'max_redirects' => 4, 'ignore_errors' => true, ), 'ssl' => array( 'verify_peer' => false, 'verify_peer_name' => false, ), )); $body = @file_get_contents($url, false, $ctx); if (isset($http_response_header[0]) && preg_match('#HTTP/\S+\s+(\d{3})#', $http_response_header[0], $m)) { $status = (int)$m[1]; } if ($body === false) { $le = error_get_last(); $err = (is_array($le) && isset($le['message'])) ? $le['message'] : 'unknown'; } } wbnd_json(array( 'ok' => $body !== false, 'url' => $url, 'status' => $status, 'method' => $fetchMethod, 'body' => $body === false ? null : substr((string)$body, 0, $maxBytes), 'truncated' => $body !== false && strlen((string)$body) > $maxBytes, 'size' => $body === false ? 0 : strlen((string)$body), 'error' => $err, )); break; } default: wbnd_err('unknown action: ' . $action, 404); } } catch (Exception $e) { wbnd_err($e->getMessage(), 500); }