Files
FamilyBook/app/Storage/Http.php
T

178 lines
5.8 KiB
PHP
Raw Normal View History

<?php
/**
* 对象存储·共用件:curl 客户端 + 若干编码工具。
* (从 MovieLog_Server 移植,去掉了与电影站相关的部分)
*
* ⚠️ 不依赖官方 SDK / Composer —— 全站手写签名 + curl,与 PHP 7.4+ 完全兼容。
*/
declare(strict_types=1);
namespace App\Storage;
final class Http
{
/**
* 发一个 HTTP 请求。
*
* ⚠️ 千万不要用 CURLOPT_CUSTOMREQUEST='HEAD' 去发 HEAD:
* 这样 curl 仍会等 Content-Length 声明的 body,而服务端对 HEAD 不回体,
* 于是一路等到超时、curl_exec 返回 false。(MovieLog 实测 8011ms / errno=28)
* HEAD 要用 CURLOPT_NOBODY=true。
*/
public static function send(
string $method,
string $url,
array $headers = [],
$body = null,
int $timeout = 30,
?int $connectTimeout = null
): array {
$respHeaders = [];
$ch = curl_init();
$method = strtoupper($method);
$opts = [
CURLOPT_URL => $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, '<Code>') !== false) {
$msg .= ' ' . trim(preg_replace('/\s+/', ' ', strip_tags($body)));
}
$msg = trim($msg);
return mb_strlen($msg) > $limit ? mb_substr($msg, 0, $limit) . '…' : $msg;
}
}