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} |
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);
}