$url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_CONNECTTIMEOUT => $connectTimeout === null ? min(10, max(3, $timeout)) : max(1, $connectTimeout), CURLOPT_TIMEOUT => $timeout, CURLOPT_FOLLOWLOCATION => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$respHeaders) { $pos = strpos($line, ':'); if ($pos !== false) { $name = strtolower(trim(substr($line, 0, $pos))); $respHeaders[$name] = trim(substr($line, $pos + 1)); } return strlen($line); }, ]; if ($method === 'HEAD') { $opts[CURLOPT_NOBODY] = true; // ← 必须,见上面注释 } else { $opts[CURLOPT_CUSTOMREQUEST] = $method; } curl_setopt_array($ch, $opts); $ca = self::caBundle(); if ($ca !== '') { curl_setopt($ch, CURLOPT_CAINFO, $ca); } if ($body !== null && $method !== 'HEAD') { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $t0 = microtime(true); $out = curl_exec($ch); $err = curl_error($ch); $errno = curl_errno($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); $ms = (int)round((microtime(true) - $t0) * 1000); if ($out === false) { return [ 'ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'ms' => $ms, 'error' => $errno === CURLE_OPERATION_TIMEDOUT ? "请求超时({$timeout}s)" : "网络错误({$errno}): {$err}", ]; } return [ 'ok' => $status >= 200 && $status < 300, 'status' => $status, 'headers' => $respHeaders, 'body' => $out, 'ms' => $ms, 'error' => $status >= 200 && $status < 300 ? '' : "HTTP {$status}", ]; } /** * 找系统的 CA 证书。宝塔的 open_basedir 会限制路径访问, * 所以先判断「在不在允许范围内」再去 is_file,否则会刷 Warning 污染输出。 */ public static function caBundle(): string { $candidates = [ '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/cert.pem', ini_get('curl.cainfo'), ini_get('openssl.cafile'), ]; $allowed = self::openBasedirPaths(); foreach ($candidates as $p) { $p = (string)$p; if ($p === '') { continue; } if ($allowed !== [] && !self::pathAllowed($p, $allowed)) { continue; } if (@is_file($p)) { return $p; } } return ''; } /** @return string[] */ private static function openBasedirPaths(): array { $raw = (string)ini_get('open_basedir'); if ($raw === '') { return []; } $out = []; foreach (preg_split('/[:;]/', $raw) ?: [] as $p) { if (trim($p) !== '') { $out[] = rtrim(trim($p), '/'); } } return $out; } private static function pathAllowed(string $path, array $allowed): bool { foreach ($allowed as $base) { if (strpos($path, $base) === 0 || strpos($base, $path) === 0) { return true; } } return false; } /** 对象键的 URL 编码:保留 / 分段,其余按 RFC3986 编码 */ public static function encodeKey(string $key): string { $parts = explode('/', $key); $parts = array_map('rawurlencode', $parts); return implode('/', $parts); } /** 把可能带协议的域名规整成 host[+port] */ public static function hostOf(string $domain): string { $domain = trim($domain); if (strpos($domain, '//') !== false) { $p = parse_url($domain, PHP_URL_HOST); $domain = $p ?: $domain; } return trim($domain, '/'); } /** 错误信息压缩成一行,便于塞进页面提示 */ public static function shortError(array $res, int $limit = 200): string { $msg = (string)($res['error'] ?? ''); $body = (string)($res['body'] ?? ''); if ($body !== '' && stripos($body, '') !== false) { $msg .= ' ' . trim(preg_replace('/\s+/', ' ', strip_tags($body))); } $msg = trim($msg); return mb_strlen($msg) > $limit ? mb_substr($msg, 0, $limit) . '…' : $msg; } }