nonempty public IP list). Pins take precedence over the optional * server-owned resolve_host callable, then native DNS for unpinned hosts only. * File IDs remain request-local unless the server explicitly enables * reuse_uploaded_files (default false); reuse_ttl_seconds is capped at 120. * Reuse always follows a fresh authorized source read and is scoped by HMAC * to credential, endpoint, user, type, MIME, upload filename and exact bytes. * on_timing receives numeric totals only, never transport IDs or cache tokens. * 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. * Optional ocr_context shares ocr_remaining_seconds across batches. Its optional * absolute deadline can further constrain OCR, never extend the upload deadline. * * @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; $reuseTokens = []; $published = []; $reuseHits = 0; $reuseMisses = 0; $timing = ['source_ms' => 0, 'upload_ms' => 0, 'ocr_ms' => 0, 'reuse_wait_ms' => 0]; $tempRoot = null; $succeeded = false; $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 = []; $imagePaths = []; $imageSources = []; 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)) { $sourceStarted = microtime(true); $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'; } $filename = 'attachment-' . $index . '.' . $extension; $token = null; if (!empty($options['reuse_uploaded_files'])) { $digest = @hash_file('sha256', $path); if (!is_string($digest)) self::fail('REQUIRED_FILES_PREPARE_FAILED'); $token = DifyUploadedFileReuse::token($apiKey, $endpoint, $user, $item['type'], $mime, $filename, $digest); } $timing['source_ms'] += max(0, (int) round((microtime(true) - $sourceStarted) * 1000)); $upload = static function () use ($endpoint, $apiKey, $user, $path, $mime, $filename, $deadline, $shouldAbort, $options, $multi, &$timing): string { $uploadStarted = microtime(true); try { return self::upload($endpoint, $apiKey, $user, $path, $mime, $filename, $deadline, $shouldAbort, $options['ca_file'], $multi); } finally { $timing['upload_ms'] += max(0, (int) round((microtime(true) - $uploadStarted) * 1000)); } }; if ($token !== null) { $reused = DifyUploadedFileReuse::resolve($tempRoot, $token, $options['reuse_ttl_seconds'], $deadline, $shouldAbort, $upload); $reuseTokens[$token] = true; $timing['reuse_wait_ms'] += $reused['wait_ms']; if ($reused['hit']) $reuseHits++; else $reuseMisses++; if ($reused['generation'] !== null) $published[$token] = $reused['generation']; $cache[$cacheKey] = $reused['id']; } else $cache[$cacheKey] = $upload(); if ($item['type'] === 'image') $imagePaths[$cacheKey] = $path; } if (!$exists) $timing['source_ms'] += max(0, (int) round((microtime(true) - $sourceStarted) * 1000)); } 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; if (isset($imagePaths[$cacheKey])) $imageSources[] = ['index' => $index, 'path' => $imagePaths[$cacheKey]]; self::notify($options, $index, $total, 'uploaded'); } self::check($deadline, $shouldAbort); $textCheck = ['status' => 'disabled', 'images' => []]; if ($imageSources !== [] && !empty($options['ocr']['enabled'])) { // Created only after the current authorized source copies/uploads succeeded. // Never accept a caller-supplied cache scope; existing reuse opt-out disables OCR reuse too. $options['ocr']['_cache_scope'] = !empty($options['reuse_uploaded_files']) ? hash_hmac('sha256', serialize(['clinical-ocr-v1', $endpoint, $user]), $apiKey) : null; $ocrStarted = microtime(true); try { $textCheck = self::optionalImageText($imageSources, $tempRoot, $deadline, $shouldAbort, $options); } finally { $timing['ocr_ms'] += max(0, (int) round((microtime(true) - $ocrStarted) * 1000)); } } self::check($deadline, $shouldAbort); $result = ['ok' => true, 'files' => $prepared, 'uploaded_indices' => $uploadedIndices, 'skipped' => $skipped, 'attachment_transport' => 'uploaded_files', 'image_text_check' => $textCheck]; if (!empty($options['reuse_uploaded_files'])) $result += ['reuse_tokens' => array_keys($reuseTokens), 'reuse_hit_count' => $reuseHits, 'reuse_miss_count' => $reuseMisses, 'timings' => $timing]; $succeeded = true; return $result; } 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 { if (!$succeeded && $published !== [] && is_string($tempRoot)) { DifyUploadedFileReuse::drop($tempRoot, array_keys($published), $published); } foreach ($temporary as $path) if (is_file($path)) @unlink($path); if ($multi !== null && $multi !== false) curl_multi_close($multi); if (is_callable($options['on_timing'] ?? null)) { try { ($options['on_timing'])($timing); } catch (\Throwable $ignored) { /* Telemetry cannot change transport. */ } } } } /** Internal stale-file recovery. Tokens must never be included in public receipts. */ public static function invalidateReuse(array $tokens, array $options = []): void { try { $options = self::options($options); $publicRoot = realpath((string) ($options['public_root'] ?? dirname(__DIR__, 3) . '/public')); $root = realpath((string) ($options['temp_root'] ?? dirname(__DIR__, 3) . '/runtime/dify-required-attachments')); if ($publicRoot === false || $root === false || $root === $publicRoot || self::inside($publicRoot, $root)) return; DifyUploadedFileReuse::drop($root, $tokens); } catch (\Throwable $ignored) { /* A cache outage does not prevent a fresh upload. */ } } /** Spend only optional OCR time; the original image/model deadline is unchanged. */ private static function optionalImageText(array $images, string $root, float $deadline, ?callable $shouldAbort, array $options): array { $unavailable = ['status' => 'unavailable', 'images' => []]; $settings = $options['ocr']; $localLimit = (float) ($settings['timeout_seconds'] ?? 10); $context = $options['ocr_context'] ?? null; $sharedLimit = is_object($context) ? (float) ($context->ocr_remaining_seconds ?? 0) : $localLimit; if (!is_finite($localLimit) || !is_finite($sharedLimit) || $localLimit <= 0 || $sharedLimit <= 0) return $unavailable; $now = microtime(true); $globalEnd = $deadline; if (is_object($context) && isset($context->deadline) && is_numeric($context->deadline) && is_finite((float) $context->deadline)) $globalEnd = min($globalEnd, (float) $context->deadline); $sharedWork = is_object($context) ? $sharedLimit - self::OCR_PROCESS_CLEANUP_SECONDS : $sharedLimit; $budget = min(60.0, $localLimit, $sharedWork, $globalEnd - $now - self::OCR_MODEL_RESERVE_SECONDS - self::OCR_PROCESS_CLEANUP_SECONDS); if ($budget < self::OCR_MIN_SECONDS) return $unavailable; $ocrDeadline = $now + $budget; $started = microtime(true); try { return ClinicalImageTextService::read($images, $root, $ocrDeadline, $shouldAbort, $settings); } catch (\Throwable $ignored) { // OCR is optional; cancellation itself is rechecked by prepare before // it can return an upload receipt or permit the model to start. return $unavailable; } finally { if (is_object($context)) { $spent = max(0.0, microtime(true) - $started); try { $context->ocr_remaining_seconds = max(0.0, $sharedLimit - $spent); } catch (\Throwable $ignored) { /* Invalid server-owned context cannot block original images. */ } } } } 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('reuse_uploaded_files', $options)) $options['reuse_uploaded_files'] = false; if (!is_bool($options['reuse_uploaded_files'])) self::fail('CONFIG_INVALID'); $options['reuse_ttl_seconds'] = max(1, min(120, (int) ($options['reuse_ttl_seconds'] ?? 120))); 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)) self::fail('CONFIG_INVALID'); if ($caFile !== '' && (!is_file($caFile) || !is_readable($caFile))) { // CA 证书包是可选项:路径失效(如 .env 沿用了其他机器的路径)时退回系统证书库, // 仍校验对端证书与主机名,不再让所有 AI 请求因此失败。 if (class_exists(\think\facade\Log::class)) { try { \think\facade\Log::warning('dify attachment ca_file unreadable, using system CA store'); } catch (\Throwable $ignored) {} } $caFile = ''; } $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 transfer to that set. // 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'); // Keep every validated address available to libcurl. A stale first DNS // pin must not hide a reachable alternate for the same verified host. $pinnedAddresses = array_map(static fn (string $ip): string => str_contains($ip, ':') ? '[' . $ip . ']' : $ip, $addresses); $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 . ':' . implode(',', $pinnedAddresses)]; $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); } }