522 lines
29 KiB
PHP
522 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service;
|
|
|
|
/**
|
|
* Turn an already-authorized attachment list into Dify local_file references.
|
|
*
|
|
* Trust options are SERVER configuration, never request parameters. Local origins are
|
|
* exact scheme/host/port aliases of this application's public/uploads directory;
|
|
* storage_origins are exact origins permitted for remote downloads. Request Host is
|
|
* deliberately not a source of trust. This class does not authorize patient access.
|
|
*/
|
|
final class DifyRequiredAttachmentUpload
|
|
{
|
|
private const DEFAULT_MAX_BYTES = 15 * 1024 * 1024;
|
|
private const MAX_RESPONSE_BYTES = 262144;
|
|
private const VIDEO_EXTENSIONS = ['mp4', 'mov', 'm4v', 'avi', 'mkv', 'webm', 'flv', 'wmv', 'mpeg', 'mpg', '3gp', 'ts'];
|
|
|
|
/**
|
|
* Options: local_origin/local_origins, storage_origins, public_root, temp_root,
|
|
* max_file_bytes, ca_file (optional trusted CA bundle),
|
|
* on_upload(array{index:int,total:int,status:string,reason?:string}), resolved_hosts
|
|
* (exact hostname => nonempty public IP list). Pins take precedence over the optional
|
|
* server-owned resolve_host callable, then native DNS for unpinned hosts only.
|
|
* No file ID cache survives this call, including between different Dify users.
|
|
* skip_missing defaults to true: only confirmed absent local files or completed
|
|
* HTTP 404/410 downloads are skipped. Other source/transport errors still fail.
|
|
* Successful files and uploaded_indices have matching order; skipped metadata
|
|
* contains only the original one-based input index, attachment kind and reason.
|
|
*
|
|
* @return array{ok:bool,files?:array,uploaded_indices?:array,skipped?:array,error_code?:string,error?:string}
|
|
*/
|
|
public static function prepare(
|
|
string $chatEndpoint,
|
|
string $apiKey,
|
|
string $user,
|
|
array $files,
|
|
int $timeout,
|
|
?callable $shouldAbort = null,
|
|
array $options = []
|
|
): array {
|
|
$temporary = [];
|
|
$multi = null;
|
|
$skipped = [];
|
|
$index = 0;
|
|
$total = 0;
|
|
$deadline = microtime(true) + max(0, $timeout);
|
|
try {
|
|
self::check($deadline, $shouldAbort);
|
|
$options = self::options($options);
|
|
$plan = self::plan($files);
|
|
$total = count($files);
|
|
if ($plan === []) return ['ok' => true, 'files' => [], 'uploaded_indices' => [], 'skipped' => [], 'attachment_transport' => 'uploaded_files'];
|
|
if ($timeout < 1 || $timeout > 300 || $user === '' || strlen($user) > 1024
|
|
|| trim($apiKey) === '' || strpbrk($apiKey . $user, "\r\n\0") !== false) {
|
|
self::fail('CONFIG_INVALID');
|
|
}
|
|
if (!function_exists('curl_init') || !function_exists('curl_multi_init')) self::fail('CURL_UNAVAILABLE');
|
|
$endpoint = self::uploadEndpoint($chatEndpoint);
|
|
$publicRoot = realpath((string) ($options['public_root'] ?? dirname(__DIR__, 3) . '/public'));
|
|
if ($publicRoot === false || !is_dir($publicRoot)) self::fail('CONFIG_INVALID');
|
|
$localOrigins = self::origins($options['local_origins'] ?? []);
|
|
if (!empty($options['local_origin'])) $localOrigins += self::origins([(string) $options['local_origin']]);
|
|
$storageOrigins = self::origins($options['storage_origins'] ?? []);
|
|
$maxBytes = (int) ($options['max_file_bytes'] ?? self::DEFAULT_MAX_BYTES);
|
|
if ($maxBytes < 1 || $maxBytes > 100 * 1024 * 1024) self::fail('CONFIG_INVALID');
|
|
$tempRoot = self::tempRoot((string) ($options['temp_root'] ?? dirname(__DIR__, 3) . '/runtime/dify-required-attachments'), $publicRoot);
|
|
|
|
// Validate the whole manifest before the first upload. Never truncate it.
|
|
foreach ($plan as &$item) {
|
|
self::check($deadline, $shouldAbort);
|
|
$origin = self::origin($item['parts']);
|
|
if (isset($localOrigins[$origin])) {
|
|
$item['path'] = self::localPath($publicRoot, $item['parts']['path'] ?? '/');
|
|
} elseif (!isset($storageOrigins[$origin])) {
|
|
self::fail('REQUIRED_FILES_SOURCE_UNTRUSTED');
|
|
}
|
|
}
|
|
unset($item);
|
|
// Keep libcurl's connection cache only for this preparation. Each
|
|
// transfer still owns a fresh easy handle, so source downloads never
|
|
// inherit the Dify upload headers or request body.
|
|
$multi = curl_multi_init();
|
|
if ($multi === false) self::fail('CURL_UNAVAILABLE');
|
|
$prepared = [];
|
|
$uploadedIndices = [];
|
|
$cache = [];
|
|
foreach ($plan as $item) {
|
|
$index = $item['index'];
|
|
self::check($deadline, $shouldAbort);
|
|
$cacheKey = hash('sha256', $item['type'] . "\0" . $item['url']);
|
|
if (array_key_exists('path', $item) && $item['path'] === null) $cache[$cacheKey] = null;
|
|
if (!array_key_exists($cacheKey, $cache) || $cache[$cacheKey] !== null) {
|
|
self::notify($options, $index, $total, 'uploading');
|
|
}
|
|
if (!array_key_exists($cacheKey, $cache)) {
|
|
$path = @tempnam($tempRoot, 'required-');
|
|
if ($path === false) self::fail('REQUIRED_FILES_PREPARE_FAILED');
|
|
$temporary[] = $path;
|
|
if (!self::inside($tempRoot, (string) realpath($path))) self::fail('REQUIRED_FILES_PREPARE_FAILED');
|
|
@chmod($path, 0600);
|
|
$exists = true;
|
|
if (isset($item['path'])) {
|
|
self::copyLocal($item['path'], $path, $maxBytes, $deadline, $shouldAbort);
|
|
} else {
|
|
$exists = self::download($item['url'], $item['parts'], $path, $maxBytes, $deadline, $shouldAbort, $options, $multi);
|
|
}
|
|
self::check($deadline, $shouldAbort);
|
|
if (!$exists) {
|
|
$cache[$cacheKey] = null;
|
|
} else {
|
|
$mime = @(new \finfo(FILEINFO_MIME_TYPE))->file($path) ?: 'application/octet-stream';
|
|
if (str_starts_with(strtolower($mime), 'video/')) self::fail('REQUIRED_FILES_INVALID');
|
|
$extension = strtolower(pathinfo(rawurldecode($item['parts']['path'] ?? ''), PATHINFO_EXTENSION));
|
|
if (!preg_match('/^[a-z0-9]{1,10}$/D', $extension)) {
|
|
$extension = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'application/pdf' => 'pdf',
|
|
'audio/wav' => 'wav', 'audio/mpeg' => 'mp3', 'text/plain' => 'txt'][$mime] ?? 'bin';
|
|
}
|
|
$cache[$cacheKey] = self::upload($endpoint, $apiKey, $user, $path, $mime,
|
|
'attachment-' . $index . '.' . $extension, $deadline, $shouldAbort, $options['ca_file'], $multi);
|
|
}
|
|
}
|
|
if ($cache[$cacheKey] === null) {
|
|
if (!$options['skip_missing']) self::fail(array_key_exists('path', $item)
|
|
? 'REQUIRED_FILES_SOURCE_UNAVAILABLE' : 'REQUIRED_FILES_DOWNLOAD_FAILED');
|
|
$skipped[] = ['index' => $index, 'kind' => $item['type'], 'reason' => 'not_found'];
|
|
self::notify($options, $index, $total, 'skipped', 'not_found');
|
|
self::check($deadline, $shouldAbort);
|
|
continue;
|
|
}
|
|
$prepared[] = ['type' => $item['type'], 'transfer_method' => 'local_file', 'upload_file_id' => $cache[$cacheKey]];
|
|
$uploadedIndices[] = $index;
|
|
self::notify($options, $index, $total, 'uploaded');
|
|
}
|
|
self::check($deadline, $shouldAbort);
|
|
return ['ok' => true, 'files' => $prepared, 'uploaded_indices' => $uploadedIndices,
|
|
'skipped' => $skipped, 'attachment_transport' => 'uploaded_files'];
|
|
} catch (\Throwable $error) {
|
|
// Never expose cURL response bodies, URLs, local paths, credentials or exception text.
|
|
$code = $error instanceof \RuntimeException ? $error->getMessage() : 'REQUIRED_FILES_PREPARE_FAILED';
|
|
$messages = [
|
|
'CONFIG_INVALID' => '附件上传服务配置无效',
|
|
'CURL_UNAVAILABLE' => '服务器尚未启用附件上传所需的 cURL 扩展',
|
|
'AI_ASSISTANT_CANCELLED' => '本次请求已取消',
|
|
'REQUIRED_FILES_TIMEOUT' => '必需附件上传超时,请重试',
|
|
'REQUIRED_FILES_INVALID' => '必需附件格式或来源地址无效',
|
|
'REQUIRED_FILES_SOURCE_UNTRUSTED' => '必需附件来源未在服务器可信列表中',
|
|
'REQUIRED_FILES_SOURCE_UNAVAILABLE' => '必需附件不可读取,请检查资料后重试',
|
|
'REQUIRED_FILES_TOO_LARGE' => '必需附件超过服务器允许的文件大小',
|
|
'REQUIRED_FILES_DOWNLOAD_FAILED' => '必需附件下载失败,请检查资料后重试',
|
|
'REQUIRED_FILES_UPLOAD_FAILED' => '必需附件上传失败,本次未提交分析',
|
|
'REQUIRED_FILES_PREPARE_FAILED' => '必需附件准备失败,本次未提交分析',
|
|
];
|
|
if (!isset($messages[$code])) $code = 'REQUIRED_FILES_PREPARE_FAILED';
|
|
if ($index > 0 && $total > 0 && !in_array($index, array_column($skipped, 'index'), true)) {
|
|
try { self::notify($options, $index, $total, 'failed'); } catch (\Throwable $ignored) {}
|
|
}
|
|
return ['ok' => false, 'error_code' => $code, 'error' => $messages[$code],
|
|
'failed_index' => $index, 'attachment_count' => $total, 'skipped' => $skipped];
|
|
} finally {
|
|
foreach ($temporary as $path) if (is_file($path)) @unlink($path);
|
|
if ($multi !== null && $multi !== false) curl_multi_close($multi);
|
|
}
|
|
}
|
|
|
|
private static function options(array $options): array
|
|
{
|
|
$configured = [];
|
|
if (function_exists('config')) {
|
|
try { $configured = \config('prescription_ai.required_attachment_upload', []); }
|
|
catch (\Throwable $ignored) { $configured = []; }
|
|
}
|
|
$options = array_replace(is_array($configured) ? $configured : [], $options);
|
|
if (!array_key_exists('skip_missing', $options)) $options['skip_missing'] = true;
|
|
if (!is_bool($options['skip_missing'])) self::fail('CONFIG_INVALID');
|
|
$caFile = $options['ca_file'] ?? '';
|
|
if (!is_string($caFile) || ($caFile !== '' && (!is_file($caFile) || !is_readable($caFile)))) self::fail('CONFIG_INVALID');
|
|
$options['ca_file'] = $caFile;
|
|
$options['resolved_hosts'] = self::resolvedHosts(array_key_exists('resolved_hosts', $options)
|
|
? $options['resolved_hosts'] : []);
|
|
return $options;
|
|
}
|
|
|
|
/** Server-owned exact DNS pins never grant an origin access or permit private IPs. */
|
|
private static function resolvedHosts($hosts): array
|
|
{
|
|
if (!is_array($hosts) || ($hosts !== [] && array_is_list($hosts))) self::fail('CONFIG_INVALID');
|
|
$result = [];
|
|
foreach ($hosts as $host => $addresses) {
|
|
if (!is_string($host)) self::fail('CONFIG_INVALID');
|
|
$host = strtolower($host);
|
|
// DNS names only, with bounded labels and an alphabetic final label.
|
|
// Reject URL/port/wildcard/IP spellings that cURL could interpret differently.
|
|
if (strlen($host) > 253
|
|
|| !preg_match('/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/D', $host)
|
|
|| array_key_exists($host, $result)
|
|
|| !is_array($addresses) || $addresses === [] || !array_is_list($addresses)) {
|
|
self::fail('CONFIG_INVALID');
|
|
}
|
|
foreach ($addresses as $address) {
|
|
if (!is_string($address) || !self::publicIp($address)) self::fail('CONFIG_INVALID');
|
|
}
|
|
$result[$host] = $addresses;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
private static function plan(array $files): array
|
|
{
|
|
$plan = [];
|
|
$index = 0;
|
|
foreach ($files as $file) {
|
|
++$index;
|
|
if (!is_array($file)) self::fail('REQUIRED_FILES_INVALID');
|
|
$type = strtolower((string) ($file['type'] ?? ''));
|
|
$mime = strtolower((string) ($file['mime_type'] ?? $file['mime'] ?? $file['content_type'] ?? ''));
|
|
$url = $file['url'] ?? '';
|
|
$path = is_string($url) ? (parse_url($url, PHP_URL_PATH) ?: '') : '';
|
|
$extension = strtolower(pathinfo(rawurldecode($path), PATHINFO_EXTENSION));
|
|
if ($type === 'video' || str_starts_with($mime, 'video/') || in_array($extension, self::VIDEO_EXTENSIONS, true)) continue;
|
|
if (!in_array($type, ['image', 'document', 'audio'], true)
|
|
|| ($file['transfer_method'] ?? 'remote_url') !== 'remote_url' || !is_string($url)) self::fail('REQUIRED_FILES_INVALID');
|
|
$plan[] = ['index' => $index, 'type' => $type, 'url' => $url, 'parts' => self::url($url)];
|
|
}
|
|
return $plan;
|
|
}
|
|
|
|
private static function url(string $url): array
|
|
{
|
|
if ($url === '' || strlen($url) > 8192 || preg_match('/[\x00-\x20\x7f\\\\]/', $url)) self::fail('REQUIRED_FILES_INVALID');
|
|
$parts = parse_url($url);
|
|
if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|
|
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])) self::fail('REQUIRED_FILES_INVALID');
|
|
$host = strtolower($parts['host']);
|
|
$ip = trim($host, '[]');
|
|
if (filter_var($ip, FILTER_VALIDATE_IP) === false && !preg_match('/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/D', $host)) self::fail('REQUIRED_FILES_INVALID');
|
|
$parts['host'] = $host;
|
|
$parts['scheme'] = strtolower($parts['scheme']);
|
|
return $parts;
|
|
}
|
|
|
|
private static function origin(array $parts): string
|
|
{
|
|
$port = (int) ($parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80));
|
|
if ($port < 1 || $port > 65535) self::fail('REQUIRED_FILES_INVALID');
|
|
return $parts['scheme'] . '://' . $parts['host'] . ':' . $port;
|
|
}
|
|
|
|
private static function origins($origins): array
|
|
{
|
|
if (!is_array($origins)) self::fail('CONFIG_INVALID');
|
|
$result = [];
|
|
foreach ($origins as $origin) {
|
|
if (!is_string($origin)) self::fail('CONFIG_INVALID');
|
|
$parts = self::url($origin);
|
|
if (($parts['path'] ?? '/') !== '/' || isset($parts['query'])) self::fail('CONFIG_INVALID');
|
|
$result[self::origin($parts)] = true;
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
private static function uploadEndpoint(string $endpoint): string
|
|
{
|
|
$parts = self::url($endpoint);
|
|
if (isset($parts['query']) || preg_match('~/chat/completions/?$~', $parts['path'] ?? '')) self::fail('CONFIG_INVALID');
|
|
$path = rtrim($parts['path'] ?? '', '/');
|
|
$path = preg_replace('~/chat-messages$~', '', $path);
|
|
$authority = $parts['scheme'] . '://' . $parts['host'] . (isset($parts['port']) ? ':' . $parts['port'] : '');
|
|
return $authority . $path . '/files/upload';
|
|
}
|
|
|
|
private static function inside(string $root, string $path): bool
|
|
{
|
|
$root = rtrim(str_replace('\\', '/', $root), '/') . '/';
|
|
$path = str_replace('\\', '/', $path);
|
|
if (PHP_OS_FAMILY === 'Windows') { $root = strtolower($root); $path = strtolower($path); }
|
|
return str_starts_with($path, $root);
|
|
}
|
|
|
|
private static function localPath(string $publicRoot, string $urlPath): ?string
|
|
{
|
|
if (preg_match('/%(?:2f|5c)/i', $urlPath)) self::fail('REQUIRED_FILES_INVALID');
|
|
$decoded = rawurldecode($urlPath);
|
|
if (!str_starts_with($decoded, '/uploads/') || preg_match('/[\x00-\x1f\x7f\\\\%:?#<>"|*]/', $decoded)) self::fail('REQUIRED_FILES_INVALID');
|
|
$segments = explode('/', substr($decoded, 1));
|
|
foreach ($segments as $segment) {
|
|
if ($segment === '' || $segment === '.' || $segment === '..' || trim($segment) !== $segment
|
|
|| str_ends_with($segment, '.') || preg_match('/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i', $segment)) self::fail('REQUIRED_FILES_INVALID');
|
|
}
|
|
$uploads = realpath($publicRoot . '/uploads');
|
|
if ($uploads === false || !self::inside($publicRoot, $uploads) || !is_dir($uploads)) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
$current = $uploads;
|
|
foreach (array_slice($segments, 1) as $position => $segment) {
|
|
$candidate = $current . DIRECTORY_SEPARATOR . $segment;
|
|
$real = realpath($candidate);
|
|
if ($real === false) {
|
|
// realpath(false) alone also means denied access or a broken symlink.
|
|
// A readable parent listing must confirm absence before skipping.
|
|
if (@lstat($candidate) !== false || is_link($candidate)) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
$entries = @scandir($current);
|
|
if ($entries === false) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
foreach ($entries as $entry) {
|
|
if ($entry === $segment || (PHP_OS_FAMILY === 'Windows' && strcasecmp($entry, $segment) === 0)) {
|
|
self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
if (!self::inside($uploads, $real)) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
$last = $position === count($segments) - 2;
|
|
if (!is_readable($real) || ($last ? !is_file($real) : !is_dir($real))) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
$current = $real;
|
|
}
|
|
return $current;
|
|
}
|
|
|
|
private static function tempRoot(string $root, string $publicRoot): string
|
|
{
|
|
if (!is_dir($root) && !@mkdir($root, 0700, true) && !is_dir($root)) self::fail('REQUIRED_FILES_PREPARE_FAILED');
|
|
$real = realpath($root);
|
|
if ($real === false || $real === $publicRoot || self::inside($publicRoot, $real) || !is_writable($real)) self::fail('CONFIG_INVALID');
|
|
@chmod($real, 0700);
|
|
return $real;
|
|
}
|
|
|
|
private static function copyLocal(string $source, string $destination, int $maxBytes, float $deadline, ?callable $shouldAbort): void
|
|
{
|
|
$input = @fopen($source, 'rb');
|
|
$output = @fopen($destination, 'wb');
|
|
try {
|
|
if ($input === false || $output === false) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
$bytes = 0;
|
|
while (!feof($input)) {
|
|
self::check($deadline, $shouldAbort);
|
|
$chunk = fread($input, 65536);
|
|
if ($chunk === false) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
$bytes += strlen($chunk);
|
|
if ($bytes > $maxBytes) self::fail('REQUIRED_FILES_TOO_LARGE');
|
|
if (fwrite($output, $chunk) !== strlen($chunk)) self::fail('REQUIRED_FILES_PREPARE_FAILED');
|
|
}
|
|
if ($bytes === 0) self::fail('REQUIRED_FILES_SOURCE_UNAVAILABLE');
|
|
} finally {
|
|
if (is_resource($input)) fclose($input);
|
|
if (is_resource($output)) fclose($output);
|
|
}
|
|
}
|
|
|
|
private static function download(string $url, array $parts, string $path, int $maxBytes, float $deadline, ?callable $shouldAbort, array $options, $multi): bool
|
|
{
|
|
self::check($deadline, $shouldAbort);
|
|
$host = trim($parts['host'], '[]');
|
|
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
|
|
$addresses = [$host];
|
|
} elseif (array_key_exists($host, $options['resolved_hosts'])) {
|
|
// Validated server pins win over every resolver; an invalid configured
|
|
// map has already failed before any transfer, never fallen back to DNS.
|
|
$addresses = $options['resolved_hosts'][$host];
|
|
} elseif (is_callable($options['resolve_host'] ?? null)) {
|
|
$addresses = ($options['resolve_host'])($host);
|
|
} else {
|
|
// Validate every returned address, then pin the actual transfer to one.
|
|
// Native PHP DNS is synchronous; its elapsed time still consumes this deadline.
|
|
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
|
$addresses = [];
|
|
foreach (is_array($records) ? $records : [] as $record) {
|
|
if (isset($record['ip'])) $addresses[] = $record['ip'];
|
|
if (isset($record['ipv6'])) $addresses[] = $record['ipv6'];
|
|
}
|
|
}
|
|
self::check($deadline, $shouldAbort);
|
|
if (!is_array($addresses) || $addresses === []) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED');
|
|
foreach ($addresses as $address) if (!is_string($address) || !self::publicIp($address)) self::fail('REQUIRED_FILES_SOURCE_UNTRUSTED');
|
|
$ip = $addresses[0];
|
|
$port = (int) ($parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80));
|
|
$output = @fopen($path, 'wb');
|
|
if ($output === false) self::fail('REQUIRED_FILES_PREPARE_FAILED');
|
|
$ch = curl_init();
|
|
if ($ch === false) { fclose($output); self::fail('CURL_UNAVAILABLE'); }
|
|
$bytes = 0;
|
|
$writeError = '';
|
|
try {
|
|
$curlOptions = self::curlOptions($url, $deadline, $shouldAbort, $options['ca_file']);
|
|
$curlOptions[CURLOPT_RESOLVE] = [$host . ':' . $port . ':' . (str_contains($ip, ':') ? '[' . $ip . ']' : $ip)];
|
|
$curlOptions[CURLOPT_HEADERFUNCTION] = static function ($handle, string $line) use ($maxBytes, &$writeError): int {
|
|
if (preg_match('/^Content-Length:\s*(\d+)/i', $line, $match) && (float) $match[1] > $maxBytes) {
|
|
$writeError = 'REQUIRED_FILES_TOO_LARGE'; return 0;
|
|
}
|
|
return strlen($line);
|
|
};
|
|
$curlOptions[CURLOPT_WRITEFUNCTION] = static function ($handle, string $chunk) use ($output, $maxBytes, &$bytes, &$writeError): int {
|
|
$length = strlen($chunk);
|
|
$bytes += $length;
|
|
if ($bytes > $maxBytes) { $writeError = 'REQUIRED_FILES_TOO_LARGE'; return 0; }
|
|
$written = fwrite($output, $chunk);
|
|
if ($written !== $length) { $writeError = 'REQUIRED_FILES_PREPARE_FAILED'; return 0; }
|
|
return $written;
|
|
};
|
|
if (!curl_setopt_array($ch, $curlOptions)) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED');
|
|
$result = self::execute($multi, $ch, $deadline, $shouldAbort);
|
|
if ($writeError !== '') self::fail($writeError);
|
|
if ($result['errno'] === CURLE_OPERATION_TIMEDOUT) self::fail('REQUIRED_FILES_TIMEOUT');
|
|
if ($result['errno'] !== CURLE_OK) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED');
|
|
if (in_array($result['status'], [404, 410], true)) return false;
|
|
if ($result['status'] !== 200 || $bytes === 0) self::fail('REQUIRED_FILES_DOWNLOAD_FAILED');
|
|
return true;
|
|
} finally { curl_close($ch); fclose($output); }
|
|
}
|
|
|
|
private static function publicIp(string $ip): bool
|
|
{
|
|
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) return false;
|
|
if (str_contains($ip, ':')) {
|
|
// Global unicast only; exclude transition/documentation/special-use ranges.
|
|
return self::cidr($ip, '2000::', 3) && !self::cidr($ip, '2001::', 23)
|
|
&& !self::cidr($ip, '2002::', 16) && !self::cidr($ip, '3fff::', 20);
|
|
}
|
|
foreach ([['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16],
|
|
['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16],
|
|
['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]] as [$network, $bits]) {
|
|
if (self::cidr($ip, $network, $bits)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private static function cidr(string $ip, string $network, int $bits): bool
|
|
{
|
|
$packed = @inet_pton($ip); $base = @inet_pton($network);
|
|
if ($packed === false || $base === false || strlen($packed) !== strlen($base)) return false;
|
|
$bytes = intdiv($bits, 8); $remaining = $bits % 8;
|
|
return substr($packed, 0, $bytes) === substr($base, 0, $bytes)
|
|
&& ($remaining === 0 || (ord($packed[$bytes]) & (255 << (8 - $remaining))) === (ord($base[$bytes]) & (255 << (8 - $remaining))));
|
|
}
|
|
|
|
private static function upload(string $endpoint, string $apiKey, string $user, string $path, string $mime, string $name, float $deadline, ?callable $shouldAbort, string $caFile, $multi): string
|
|
{
|
|
self::check($deadline, $shouldAbort);
|
|
$ch = curl_init();
|
|
if ($ch === false) self::fail('CURL_UNAVAILABLE');
|
|
$body = '';
|
|
$tooLarge = false;
|
|
try {
|
|
$options = self::curlOptions($endpoint, $deadline, $shouldAbort, $caFile);
|
|
$options[CURLOPT_POST] = true;
|
|
$options[CURLOPT_HTTPHEADER] = ['Authorization: Bearer ' . trim($apiKey), 'Accept: application/json', 'Expect:'];
|
|
$options[CURLOPT_POSTFIELDS] = ['user' => $user, 'file' => new \CURLFile($path, $mime, $name)];
|
|
$options[CURLOPT_WRITEFUNCTION] = static function ($handle, string $chunk) use (&$body, &$tooLarge): int {
|
|
if (strlen($body) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { $tooLarge = true; return 0; }
|
|
$body .= $chunk;
|
|
return strlen($chunk);
|
|
};
|
|
if (!curl_setopt_array($ch, $options)) self::fail('REQUIRED_FILES_UPLOAD_FAILED');
|
|
$result = self::execute($multi, $ch, $deadline, $shouldAbort);
|
|
if ($result['errno'] === CURLE_OPERATION_TIMEDOUT) self::fail('REQUIRED_FILES_TIMEOUT');
|
|
if ($result['errno'] !== CURLE_OK || $tooLarge || !in_array($result['status'], [200, 201], true)) self::fail('REQUIRED_FILES_UPLOAD_FAILED');
|
|
$decoded = json_decode($body, true);
|
|
$id = is_array($decoded) ? ($decoded['id'] ?? '') : '';
|
|
if (!is_string($id) || !preg_match('/^[a-zA-Z0-9_-]{1,128}$/D', $id)) self::fail('REQUIRED_FILES_UPLOAD_FAILED');
|
|
return $id;
|
|
} finally { curl_close($ch); }
|
|
}
|
|
|
|
private static function curlOptions(string $url, float $deadline, ?callable $shouldAbort, string $caFile = ''): array
|
|
{
|
|
$remaining = max(1, (int) ceil(($deadline - microtime(true)) * 1000));
|
|
$options = [CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => false,
|
|
CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_PROXY => '',
|
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
|
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
|
|
CURLOPT_TIMEOUT_MS => $remaining, CURLOPT_CONNECTTIMEOUT_MS => min(10000, $remaining),
|
|
CURLOPT_NOSIGNAL => true, CURLOPT_NOPROGRESS => false];
|
|
if ($caFile !== '') $options[CURLOPT_CAINFO] = $caFile;
|
|
foreach (['CURLOPT_XFERINFOFUNCTION', 'CURLOPT_PROGRESSFUNCTION'] as $name) {
|
|
if (!defined($name)) continue;
|
|
$options[constant($name)] = static function (...$unused) use ($deadline, $shouldAbort): int {
|
|
try { self::check($deadline, $shouldAbort); return 0; }
|
|
catch (\Throwable $ignored) { return 1; }
|
|
};
|
|
break;
|
|
}
|
|
return $options;
|
|
}
|
|
|
|
/** Request-local pool retains connections; polling also observes silent cancellation. */
|
|
private static function execute($multi, $ch, float $deadline, ?callable $shouldAbort): array
|
|
{
|
|
$added = false;
|
|
try {
|
|
if (curl_multi_add_handle($multi, $ch) !== CURLM_OK) self::fail('REQUIRED_FILES_PREPARE_FAILED');
|
|
$added = true;
|
|
$errno = CURLE_OK;
|
|
do {
|
|
self::check($deadline, $shouldAbort);
|
|
do { $status = curl_multi_exec($multi, $running); } while ($status === CURLM_CALL_MULTI_PERFORM);
|
|
if ($status !== CURLM_OK) self::fail('REQUIRED_FILES_PREPARE_FAILED');
|
|
while (($info = curl_multi_info_read($multi)) !== false) $errno = (int) $info['result'];
|
|
self::check($deadline, $shouldAbort);
|
|
if ($running && curl_multi_select($multi, 0.05) === -1) usleep(1000);
|
|
} while ($running);
|
|
return ['errno' => $errno, 'status' => (int) curl_getinfo($ch, CURLINFO_HTTP_CODE)];
|
|
} finally {
|
|
if ($added) curl_multi_remove_handle($multi, $ch);
|
|
}
|
|
}
|
|
|
|
private static function check(float $deadline, ?callable $shouldAbort): void
|
|
{
|
|
if ($shouldAbort !== null && $shouldAbort()) self::fail('AI_ASSISTANT_CANCELLED');
|
|
if (microtime(true) >= $deadline) self::fail('REQUIRED_FILES_TIMEOUT');
|
|
}
|
|
|
|
private static function notify(array $options, int $index, int $total, string $status, ?string $reason = null): void
|
|
{
|
|
$event = ['index' => $index, 'total' => $total, 'status' => $status];
|
|
if ($reason !== null) $event['reason'] = $reason;
|
|
if (is_callable($options['on_upload'] ?? null)) ($options['on_upload'])($event);
|
|
}
|
|
|
|
private static function fail(string $code): void { throw new \RuntimeException($code); }
|
|
}
|