140 lines
9.7 KiB
PHP
140 lines
9.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
namespace app\common\service\followupaudio;
|
|
|
|
/** Explicit native-OpenSSL transport. Blocking headers are isolated from the parent's lease/scope heartbeats. */
|
|
final class FollowupAudioStreamTransport
|
|
{
|
|
public static function request(array $spec, callable $heartbeat, int $limit, bool $allowLoopback): array
|
|
{
|
|
$failure = static fn (int $errno): array => ['http_code' => 0, 'errno' => $errno, 'body' => ''];
|
|
$input = $spec;
|
|
if (isset($input['json'])) { $input['json_wire'] = json_encode($input['json'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); unset($input['json']); }
|
|
$input['max_response_bytes'] = $limit; $input['allow_loopback_tunnel'] = $allowLoopback;
|
|
if (isset($input['multipart']['file'])) {
|
|
$file = $input['multipart']['file'];
|
|
if (!$file instanceof \CURLFile) { return $failure(43); }
|
|
$input['multipart']['file'] = ['path' => $file->getFilename(), 'mime' => $file->getMimeType(), 'name' => $file->getPostFilename()];
|
|
}
|
|
$wire = json_encode($input, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
|
if (strlen($wire) > 16777216 || $limit < 1024 || $limit > 8388608 || ($spec['timeout'] ?? 0) < 1 || $spec['timeout'] > 300) { return $failure(43); }
|
|
try { if ($heartbeat([]) !== true) { return $failure(42); } } catch (\Throwable $e) { return $failure(42); }
|
|
$process = @proc_open([PHP_BINARY, __FILE__, '--child'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
|
if (!is_resource($process)) { return $failure(7); }
|
|
foreach ($pipes as $pipe) { stream_set_blocking($pipe, false); }
|
|
$offset = 0; $output = ''; $deadline = microtime(true) + (int) $spec['timeout']; $lastHeartbeat = microtime(true); $exit = -1;
|
|
try {
|
|
do {
|
|
if (isset($pipes[0])) {
|
|
$written = @fwrite($pipes[0], substr($wire, $offset, 65536));
|
|
if ($written === false) { return $failure(7); }
|
|
$offset += $written;
|
|
if ($offset === strlen($wire)) { fclose($pipes[0]); unset($pipes[0]); }
|
|
}
|
|
$output .= (string) stream_get_contents($pipes[1], 65536);
|
|
stream_get_contents($pipes[2], 65536); // Raw child diagnostics may contain sensitive paths; never expose them.
|
|
if (strlen($output) > (int) ceil($limit * 4 / 3) + 65536) { return $failure(23); }
|
|
$status = proc_get_status($process);
|
|
if (!$status['running']) { $exit = (int) $status['exitcode']; break; }
|
|
if (microtime(true) >= $deadline) { return $failure(28); }
|
|
if (microtime(true) - $lastHeartbeat >= 5) {
|
|
try { $allowed = $heartbeat([]) === true; } catch (\Throwable $e) { $allowed = false; }
|
|
if (!$allowed) { return $failure(42); }
|
|
$lastHeartbeat = microtime(true);
|
|
}
|
|
usleep(10000);
|
|
} while (true);
|
|
$output .= (string) stream_get_contents($pipes[1]);
|
|
if ($exit !== 0 || strlen($output) > (int) ceil($limit * 4 / 3) + 65536) { return $failure(7); }
|
|
$result = json_decode($output, true);
|
|
if (!is_array($result) || !is_string($result['body_base64'] ?? null)) { return $failure(7); }
|
|
$body = base64_decode($result['body_base64'], true);
|
|
if ($body === false || strlen($body) > $limit) { return $failure(23); }
|
|
return ['http_code' => (int) ($result['http_code'] ?? 0), 'errno' => (int) ($result['errno'] ?? 7), 'body' => $body];
|
|
} finally {
|
|
if (is_resource($process)) { $status = proc_get_status($process); if ($status['running']) { proc_terminate($process, 9); } }
|
|
foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } }
|
|
if (is_resource($process)) { proc_close($process); }
|
|
}
|
|
}
|
|
|
|
/** CLI child reads a private specification from stdin only: never credentials in argv or logs. */
|
|
private static function child(array $spec): array
|
|
{
|
|
$result = ['http_code' => 0, 'errno' => 43, 'body_base64' => ''];
|
|
$url = $spec['url'] ?? ''; $parts = is_string($url) ? parse_url($url) : false;
|
|
$stage = $spec['stage'] ?? ''; $timeout = $spec['timeout'] ?? 0; $limit = $spec['max_response_bytes'] ?? 0;
|
|
if (!is_array($parts) || !in_array($stage, ['asr', 'extraction'], true) || !is_int($timeout) || $timeout < 1 || $timeout > 300
|
|
|| !is_int($limit) || $limit < 1024 || $limit > 8388608 || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|
|
|| isset($parts['query']) || isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f\\\\]/', $url)
|
|
|| !is_string($spec['api_key'] ?? null) || $spec['api_key'] === '' || preg_match('/[\x00-\x20\x7f]/', $spec['api_key'])) { return $result; }
|
|
$secure = ($parts['scheme'] ?? '') === 'https';
|
|
$loopback = ($spec['allow_loopback_tunnel'] ?? false) === true && ($parts['scheme'] ?? '') === 'http'
|
|
&& in_array($parts['host'], ['127.0.0.1', '[::1]'], true);
|
|
if (!$secure && !$loopback) { return $result; }
|
|
$allowed = $stage === 'asr' ? ['/audio-to-text', '/audio/transcriptions'] : ['/chat-messages', '/chat/completions'];
|
|
$matches = array_filter($allowed, static fn (string $suffix): bool => str_ends_with((string) ($parts['path'] ?? ''), $suffix));
|
|
if ($matches === []) { return $result; }
|
|
$headers = ['Accept: application/json', 'Authorization: Bearer ' . $spec['api_key'], 'Connection: close'];
|
|
if (isset($spec['json_wire']) && !isset($spec['multipart']) && $stage === 'extraction' && is_string($spec['json_wire'])
|
|
&& is_object(json_decode($spec['json_wire']))) {
|
|
$body = $spec['json_wire'];
|
|
$headers[] = 'Content-Type: application/json';
|
|
} elseif (isset($spec['multipart']) && !isset($spec['json_wire']) && $stage === 'asr' && is_array($spec['multipart'])) {
|
|
$multipart = $spec['multipart']; $file = $multipart['file'] ?? null;
|
|
if (!is_array($file) || !is_string($file['path'] ?? null) || !is_file($file['path']) || is_link($file['path'])
|
|
|| !is_readable($file['path']) || filesize($file['path']) < 44 || filesize($file['path']) > 8388608
|
|
|| ($file['mime'] ?? '') !== 'audio/wav' || ($file['name'] ?? '') !== 'chunk.wav'
|
|
|| array_diff(array_keys($multipart), ['file', 'user', 'model', 'response_format'])) { return $result; }
|
|
$audio = file_get_contents($file['path']);
|
|
if (!is_string($audio) || substr($audio, 0, 4) !== 'RIFF' || substr($audio, 8, 4) !== 'WAVE') { return $result; }
|
|
$boundary = 'fa-' . bin2hex(random_bytes(16)); $body = '';
|
|
foreach ($multipart as $name => $value) {
|
|
if ($name === 'file') { continue; }
|
|
if (!is_string($value) || strlen($value) > 256 || preg_match('/[\x00-\x20\x7f]/', $value)) { return $result; }
|
|
$body .= '--' . $boundary . "\r\nContent-Disposition: form-data; name=\"" . $name . "\"\r\n\r\n" . $value . "\r\n";
|
|
}
|
|
$body .= '--' . $boundary . "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"chunk.wav\"\r\nContent-Type: audio/wav\r\n\r\n" . $audio . "\r\n--" . $boundary . "--\r\n";
|
|
$headers[] = 'Content-Type: multipart/form-data; boundary=' . $boundary;
|
|
} else { return $result; }
|
|
if (strlen($body) > 16777216) { return $result; }
|
|
$headers[] = 'Content-Length: ' . strlen($body);
|
|
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body,
|
|
'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 0, 'max_redirects' => 0, 'protocol_version' => 1.1],
|
|
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false, 'peer_name' => trim($parts['host'], '[]'), 'SNI_enabled' => true]]);
|
|
$stream = @fopen($url, 'rb', false, $context);
|
|
if (!is_resource($stream)) { $result['errno'] = 35; return $result; }
|
|
try {
|
|
foreach ($http_response_header ?? [] as $header) {
|
|
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $match)) { $result['http_code'] = (int) $match[1]; }
|
|
}
|
|
$response = '';
|
|
while (!feof($stream)) {
|
|
$bytes = @fread($stream, min(65536, $limit - strlen($response) + 1));
|
|
if ($bytes === false) { $result['errno'] = 56; return $result; }
|
|
$response .= $bytes;
|
|
if (strlen($response) > $limit) { $result['errno'] = 23; return $result; }
|
|
if (stream_get_meta_data($stream)['timed_out']) { $result['errno'] = 28; return $result; }
|
|
if ($bytes === '' && !feof($stream)) { usleep(10000); }
|
|
}
|
|
$result['errno'] = 0; $result['body_base64'] = base64_encode($response); return $result;
|
|
} finally { fclose($stream); }
|
|
}
|
|
|
|
public static function childMain(): void
|
|
{
|
|
$result = ['http_code' => 0, 'errno' => 43, 'body_base64' => ''];
|
|
try {
|
|
$wire = stream_get_contents(STDIN, 16777217);
|
|
$spec = is_string($wire) && strlen($wire) <= 16777216 ? json_decode($wire, true) : null;
|
|
if (is_array($spec)) { $result = self::child($spec); }
|
|
} catch (\Throwable $error) { /* Do not emit request data or native TLS diagnostics. */ }
|
|
echo json_encode($result, JSON_THROW_ON_ERROR);
|
|
}
|
|
}
|
|
|
|
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === '--child' && realpath((string) ($argv[0] ?? '')) === __FILE__) {
|
|
FollowupAudioStreamTransport::childMain();
|
|
}
|