175 lines
5.7 KiB
PHP
175 lines
5.7 KiB
PHP
<?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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$out = curl_exec($ch);
|
|||
|
|
$err = curl_error($ch);
|
|||
|
|
$errno = curl_errno($ch);
|
|||
|
|
$status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|||
|
|
curl_close($ch);
|
|||
|
|
|
|||
|
|
if ($out === false) {
|
|||
|
|
return [
|
|||
|
|
'ok' => false, 'status' => 0, 'headers' => [], 'body' => '',
|
|||
|
|
'error' => $errno === CURLE_OPERATION_TIMEDOUT
|
|||
|
|
? "请求超时({$timeout}s)"
|
|||
|
|
: "网络错误({$errno}): {$err}",
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
return [
|
|||
|
|
'ok' => $status >= 200 && $status < 300,
|
|||
|
|
'status' => $status,
|
|||
|
|
'headers' => $respHeaders,
|
|||
|
|
'body' => $out,
|
|||
|
|
'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;
|
|||
|
|
}
|
|||
|
|
}
|