wpmind/includes/API/Services/AudioService.php
LinuxJoy 6c36d563e4 security: Codex 审计问题修复 + WordPress 插件开发指导文档
安全修复(Codex 审计 11 项):
- transcribe() SSRF 防护: wp_http_validate_url + 协议白名单
- transcribe() 本地文件读取限制: realpath + uploads 目录校验
- transcribe() 文件大小上限 25MB + 扩展名白名单
- ErrorHandler 响应体截断到 500 字符,防止信息泄露
- SDKAdapter getTokenUsage() 空值保护
- SDKAdapter 异常消息脱敏,仅 WP_DEBUG 记录详情
- stream() 添加 allow_url_fopen 检测
- embed() JSON 解码显式校验
- speech() file_put_contents 返回值检查

新增文档:
- WordPress 插件开发指导手册 v1.1.0 (1225 行)
- 覆盖项目准备、编码规范、架构设计、开发流程等 10 章
- 基于 Codex 审计反馈补充安全基线和 WP 生态兼容性

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-07 12:41:21 +08:00

317 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Audio Service
*
* 处理音频转录和语音合成
*
* @package WPMind
* @subpackage API\Services
* @since 3.7.0
*/
declare(strict_types=1);
namespace WPMind\API\Services;
use WP_Error;
/**
* Audio Service
*
* @since 3.7.0
*/
class AudioService extends AbstractService {
/**
* 音频转录(语音转文字)
*
* @since 2.7.0
* @param string $audio_file 音频文件路径或 URL
* @param array $options 选项
* @return array|WP_Error
*/
public function transcribe(string $audio_file, array $options = []) {
$defaults = [
'context' => 'transcription',
'language' => 'auto',
'prompt' => '',
'format' => 'text',
'provider' => 'auto',
];
$options = wp_parse_args($options, $defaults);
$context = $options['context'];
$transcribe_providers = ['openai'];
$provider = $this->resolve_provider($options['provider'], $context);
// 文件大小上限25MB与 OpenAI Whisper API 限制一致)
$max_file_size = apply_filters('wpmind_transcribe_max_file_size', 25 * MB_IN_BYTES);
// 允许的音频文件扩展名
$allowed_extensions = ['mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm', 'ogg', 'flac'];
// 准备文件内容(在 failover 循环外处理,避免重复下载/IO
$is_temp = false;
if (filter_var($audio_file, FILTER_VALIDATE_URL)) {
// URL 安全验证:拒绝内网地址,防止 SSRF
if (!wp_http_validate_url($audio_file)) {
return new WP_Error('wpmind_invalid_url', __('URL 验证失败:不允许访问内网地址', 'wpmind'));
}
// 协议白名单
$scheme = wp_parse_url($audio_file, PHP_URL_SCHEME);
if (!in_array($scheme, ['http', 'https'], true)) {
return new WP_Error('wpmind_invalid_url', __('仅支持 HTTP/HTTPS 协议', 'wpmind'));
}
$temp_file = download_url($audio_file);
if (is_wp_error($temp_file)) {
return $temp_file;
}
$file_path = $temp_file;
$is_temp = true;
} else {
// 本地文件路径安全验证:限制在 uploads 目录内
$upload_dir = wp_upload_dir();
$realpath = realpath($audio_file);
$basedir = realpath($upload_dir['basedir']);
if ($realpath === false || $basedir === false || strpos($realpath, $basedir) !== 0) {
return new WP_Error('wpmind_invalid_path', __('文件路径必须在 uploads 目录内', 'wpmind'));
}
$file_path = $realpath;
}
if (!file_exists($file_path)) {
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
unlink($temp_file);
}
return new WP_Error('wpmind_file_not_found', __('音频文件不存在', 'wpmind'));
}
// 文件扩展名验证
$extension = strtolower(pathinfo($file_path, PATHINFO_EXTENSION));
if (!in_array($extension, $allowed_extensions, true)) {
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
unlink($temp_file);
}
return new WP_Error('wpmind_invalid_filetype',
sprintf(__('不支持的音频格式: %s', 'wpmind'), $extension));
}
// 文件大小验证
$file_size = filesize($file_path);
if ($file_size === false || $file_size > $max_file_size) {
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
unlink($temp_file);
}
return new WP_Error('wpmind_file_too_large',
sprintf(__('文件大小超过限制 (%s)', 'wpmind'), size_format($max_file_size)));
}
$file_content = file_get_contents($file_path);
if ($is_temp && isset($temp_file) && file_exists($temp_file)) {
unlink($temp_file);
}
do_action('wpmind_before_request', 'transcribe', compact('audio_file', 'options'), $context);
return $this->execute_with_failover('transcribe', $provider, $context, function (string $try_provider, array $endpoint) use ($file_content, $options, $audio_file) {
$api_key = $endpoint['api_key'];
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
$api_url = trailingslashit($base_url) . 'audio/transcriptions';
$boundary = wp_generate_password(24, false);
$body = '';
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\n";
$body .= "Content-Type: audio/mpeg\r\n\r\n";
$body .= $file_content . "\r\n";
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"model\"\r\n\r\n";
$body .= "whisper-1\r\n";
if ($options['language'] !== 'auto') {
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"language\"\r\n\r\n";
$body .= "{$options['language']}\r\n";
}
if (!empty($options['prompt'])) {
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n";
$body .= "{$options['prompt']}\r\n";
}
$response_format = $options['format'] === 'text' ? 'text' : $options['format'];
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"response_format\"\r\n\r\n";
$body .= "{$response_format}\r\n";
$body .= "--{$boundary}--\r\n";
$start_time = microtime(true);
$response = wp_remote_post($api_url, [
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
],
'body' => $body,
'timeout' => 120,
]);
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
if (is_wp_error($response)) {
$this->record_result($try_provider, false, $latency_ms);
return new WP_Error('wpmind_transcribe_failed',
sprintf(__('转录请求失败: %s', 'wpmind'), $response->get_error_message()));
}
$status_code = wp_remote_retrieve_response_code($response);
$resp_body = wp_remote_retrieve_body($response);
if ($status_code !== 200) {
$this->record_result($try_provider, false, $latency_ms);
$data = json_decode($resp_body, true);
$error_message = $data['error']['message'] ?? $resp_body;
return new WP_Error('wpmind_transcribe_error',
sprintf(__('转录 API 错误 (%d): %s', 'wpmind'), $status_code, $error_message));
}
$this->record_result($try_provider, true, $latency_ms);
$result = [
'text' => $options['format'] === 'text' ? $resp_body : '',
'data' => $options['format'] !== 'text' ? json_decode($resp_body, true) : null,
'provider' => $try_provider,
'format' => $options['format'],
];
if ($options['format'] !== 'text' && is_array($result['data'])) {
$result['text'] = $result['data']['text'] ?? '';
}
do_action('wpmind_after_request', 'transcribe', $result, compact('audio_file', 'options'), []);
return $result;
}, $transcribe_providers);
}
/**
* 文本转语音
*
* @since 2.7.0
* @param string $text 要转换的文本
* @param array $options 选项
* @return array|WP_Error
*/
public function speech(string $text, array $options = []) {
$defaults = [
'context' => 'speech',
'voice' => 'alloy',
'model' => 'tts-1',
'speed' => 1.0,
'format' => 'mp3',
'save_to' => '',
'provider' => 'auto',
];
$options = wp_parse_args($options, $defaults);
$context = $options['context'];
$speech_providers = ['openai', 'deepseek'];
$provider = $this->resolve_provider($options['provider'], $context);
do_action('wpmind_before_request', 'speech', compact('text', 'options'), $context);
return $this->execute_with_failover('speech', $provider, $context, function (string $try_provider, array $endpoint) use ($text, $options) {
$api_key = $endpoint['api_key'];
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
$api_url = trailingslashit($base_url) . 'audio/speech';
$start_time = microtime(true);
$response = wp_remote_post($api_url, [
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'model' => $options['model'],
'input' => $text,
'voice' => $options['voice'],
'speed' => $options['speed'],
'response_format' => $options['format'],
]),
'timeout' => 60,
]);
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
if (is_wp_error($response)) {
$this->record_result($try_provider, false, $latency_ms);
return new WP_Error('wpmind_speech_failed',
sprintf(__('语音合成请求失败: %s', 'wpmind'), $response->get_error_message()));
}
$status_code = wp_remote_retrieve_response_code($response);
$audio_data = wp_remote_retrieve_body($response);
if ($status_code !== 200) {
$this->record_result($try_provider, false, $latency_ms);
$data = json_decode($audio_data, true);
$error_message = $data['error']['message'] ?? __('未知错误', 'wpmind');
return new WP_Error('wpmind_speech_error',
sprintf(__('语音合成 API 错误 (%d): %s', 'wpmind'), $status_code, $error_message));
}
$this->record_result($try_provider, true, $latency_ms);
$result = [
'provider' => $try_provider,
'model' => $options['model'],
'voice' => $options['voice'],
'format' => $options['format'],
'size' => strlen($audio_data),
];
if (!empty($options['save_to'])) {
$upload_dir = wp_upload_dir();
$save_dir = realpath(dirname($options['save_to']));
$base_dir = realpath($upload_dir['basedir']);
if ($save_dir === false || $base_dir === false || strpos($save_dir, $base_dir) !== 0) {
return new WP_Error('wpmind_invalid_path', __('保存路径必须在 uploads 目录内', 'wpmind'));
}
$written = file_put_contents($options['save_to'], $audio_data);
if ($written === false) {
return new WP_Error('wpmind_write_failed', __('文件写入失败', 'wpmind'));
}
$result['file'] = $options['save_to'];
} else {
$upload = wp_upload_bits(
'wpmind-speech-' . time() . '.' . $options['format'],
null,
$audio_data
);
if (!empty($upload['error'])) {
return new WP_Error('wpmind_upload_failed', $upload['error']);
}
$result['url'] = $upload['url'];
$result['file'] = $upload['file'];
}
do_action('wpmind_after_request', 'speech', $result, compact('text', 'options'), []);
return $result;
}, $speech_providers);
}
}