fix(asr): allow HTTP endpoints with risk warning
This commit is contained in:
1 parent
e4acd1d9b4
commit
2e5ba91b95
7 files changed
+61
-53
No files matched your search
@@ -1150,11 +1150,6 @@ async fn validate_asr_transcription(config: &ProviderConfig, model: &str) -> Res
|
||||
|
||||
fn asr_transcriptions_url(base_url: &str) -> Result<String, String> {
|
||||
let parsed = reqwest::Url::parse(base_url.trim()).map_err(|_| "endpointInvalid".to_string())?;
|
||||
let host = parsed.host_str().unwrap_or_default();
|
||||
let localhost = host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1";
|
||||
if parsed.scheme() != "https" && !localhost {
|
||||
return Err("endpointMustUseHttps".to_string());
|
||||
}
|
||||
|
||||
// Work on the URL path only so we don't corrupt query parameters.
|
||||
let mut url = parsed.clone();
|
||||
@@ -3756,6 +3751,10 @@ mod tests {
|
||||
asr_transcriptions_url("https://api.openai.com/v1?api-version=2024-12-01").unwrap(),
|
||||
"https://api.openai.com/v1/audio/transcriptions?api-version=2024-12-01"
|
||||
);
|
||||
assert_eq!(
|
||||
asr_transcriptions_url("http://192.168.1.10:8000/v1").unwrap(),
|
||||
"http://192.168.1.10:8000/v1/audio/transcriptions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -663,7 +663,7 @@ export const en: typeof zhCN = {
|
||||
validateSuccess: 'Connection check passed.',
|
||||
validateFailed: 'Connection check failed.',
|
||||
providerHttpStatus: 'Provider returned HTTP {{status}}. Check the API key permissions or endpoint.',
|
||||
endpointMustUseHttps: 'Endpoint must use HTTPS (localhost/127.0.0.1 are allowed for local testing).',
|
||||
endpointMustUseHttps: 'HTTP endpoints are allowed, but API keys and audio content may leak in transit.',
|
||||
endpointInvalid: 'Endpoint format is invalid.',
|
||||
responseTooLarge: 'Provider response is too large to validate safely.',
|
||||
asrInvalidJson: 'ASR response is not valid JSON.',
|
||||
|
||||
@@ -665,7 +665,7 @@ export const ja: typeof zhCN = {
|
||||
validateSuccess: '接続チェックに合格しました。',
|
||||
validateFailed: '接続チェックに失敗しました。',
|
||||
providerHttpStatus: 'サプライヤーが {{status}} を返しました。API Key 権限またはエンドポイントを確認してください。',
|
||||
endpointMustUseHttps: 'Endpoint は HTTPS を使用する必要があります(localhost/127.0.0.1 を除く)。',
|
||||
endpointMustUseHttps: 'HTTP Endpoint も使用できますが、API Key と音声内容が通信中に漏えいする可能性があります。',
|
||||
endpointInvalid: 'Endpoint の形式が無効です。',
|
||||
responseTooLarge: 'サプライヤーの応答が大きすぎるため、安全のため検証を停止しました。',
|
||||
asrInvalidJson: 'ASR の応答が有効な JSON ではありません。',
|
||||
|
||||
@@ -665,7 +665,7 @@ export const ko: typeof zhCN = {
|
||||
validateSuccess: '연결 확인을 통과했습니다.',
|
||||
validateFailed: '연결 확인에 실패했습니다.',
|
||||
providerHttpStatus: '공급자가 {{status}} 를 반환했습니다. API Key 권한 또는 Endpoint 를 확인해 주세요.',
|
||||
endpointMustUseHttps: 'Endpoint 는 HTTPS 를 사용해야 합니다(localhost/127.0.0.1 제외).',
|
||||
endpointMustUseHttps: 'HTTP Endpoint 를 사용할 수 있지만, API Key 와 음성 내용이 전송 중 유출될 수 있습니다.',
|
||||
endpointInvalid: 'Endpoint 형식이 올바르지 않습니다.',
|
||||
responseTooLarge: '공급자 응답이 너무 커서 안전을 위해 검증을 중단했습니다.',
|
||||
asrInvalidJson: 'ASR 응답이 유효한 JSON 이 아닙니다.',
|
||||
|
||||
@@ -661,7 +661,7 @@ export const zhCN = {
|
||||
validateSuccess: '连接检查通过。',
|
||||
validateFailed: '连接检查未通过。',
|
||||
providerHttpStatus: '供应商接口返回 {{status}},请检查 API Key 权限或 Endpoint。',
|
||||
endpointMustUseHttps: 'Endpoint 必须使用 HTTPS(本地 localhost/127.0.0.1 测试除外)。',
|
||||
endpointMustUseHttps: '允许使用 HTTP Endpoint,但请注意:API Key 和音频内容可能在传输中泄漏。',
|
||||
endpointInvalid: 'Endpoint 格式不合法。',
|
||||
responseTooLarge: '供应商响应过大,已停止验证以保证安全。',
|
||||
asrInvalidJson: 'ASR 响应不是有效 JSON。',
|
||||
|
||||
@@ -663,7 +663,7 @@ export const zhTW: typeof zhCN = {
|
||||
validateSuccess: '連接檢查通過。',
|
||||
validateFailed: '連接檢查未通過。',
|
||||
providerHttpStatus: '供應商接口返回 {{status}},請檢查 API Key 權限或 Endpoint。',
|
||||
endpointMustUseHttps: 'Endpoint 必須使用 HTTPS(本地 localhost/127.0.0.1 測試除外)。',
|
||||
endpointMustUseHttps: '允許使用 HTTP Endpoint,但請注意:API Key 和音訊內容可能在傳輸中外洩。',
|
||||
endpointInvalid: 'Endpoint 格式不合法。',
|
||||
responseTooLarge: '供應商響應過大,已停止驗證以保證安全。',
|
||||
asrInvalidJson: 'ASR 響應不是有效 JSON。',
|
||||
|
||||
@@ -714,55 +714,64 @@ function CredentialField({ label, account, placeholder, mono, mask, defaultValue
|
||||
|
||||
const inputType = mask && !revealed ? 'password' : 'text';
|
||||
const disabled = !loaded;
|
||||
const showInsecureAsrEndpointWarning = account === 'asr.endpoint'
|
||||
&& value.trim().toLowerCase().startsWith('http://');
|
||||
|
||||
return (
|
||||
<SettingRow label={label}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%', maxWidth: 420 }}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={value}
|
||||
placeholder={loaded ? placeholder : t('common.loading')}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
style={{ ...inputStyle, fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
|
||||
/>
|
||||
{defaultValue && !value && loaded && (
|
||||
<button onClick={fillDefault} title={t('settings.providers.fillDefault')} style={iconBtnStyle} disabled={!loaded}>
|
||||
<Icon name="check" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{trailing}
|
||||
{mask && (
|
||||
<button
|
||||
onClick={() => setRevealed(r => !r)}
|
||||
title={revealed ? t('common.hide') : t('common.show')}
|
||||
style={iconBtnStyle}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, width: '100%', maxWidth: 420 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', width: '100%' }}>
|
||||
<input
|
||||
type={inputType}
|
||||
value={value}
|
||||
placeholder={loaded ? placeholder : t('common.loading')}
|
||||
onChange={handleChange}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
style={{ ...inputStyle, fontFamily: mono ? 'var(--ol-font-mono)' : 'inherit' }}
|
||||
/>
|
||||
{defaultValue && !value && loaded && (
|
||||
<button onClick={fillDefault} title={t('settings.providers.fillDefault')} style={iconBtnStyle} disabled={!loaded}>
|
||||
<Icon name="check" size={13} />
|
||||
</button>
|
||||
)}
|
||||
{trailing}
|
||||
{mask && (
|
||||
<button
|
||||
onClick={() => setRevealed(r => !r)}
|
||||
title={revealed ? t('common.hide') : t('common.show')}
|
||||
style={iconBtnStyle}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon name="eye" size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title={t('common.copy')}
|
||||
style={iconBtnStyle}
|
||||
disabled={!value || disabled}
|
||||
>
|
||||
<Icon name="eye" size={14} />
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title={t('common.copy')}
|
||||
style={iconBtnStyle}
|
||||
disabled={!value || disabled}
|
||||
>
|
||||
<Icon name="copy" size={14} />
|
||||
</button>
|
||||
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
|
||||
其它瞬态状态(saving / saved / saveError / copied / copyError)都通过
|
||||
emitSaved 发到右上角统一 toast,不再内联占位。 */}
|
||||
{status === 'readError' && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--ol-warn)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.readFailed')}
|
||||
{/* readError 是字段无法读取的持续错误,留在原位提示用户该字段不可用;
|
||||
其它瞬态状态(saving / saved / saveError / copied / copyError)都通过
|
||||
emitSaved 发到右上角统一 toast,不再内联占位。 */}
|
||||
{status === 'readError' && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: 'var(--ol-warn)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{t('settings.providers.readFailed')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showInsecureAsrEndpointWarning && (
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-warn)', lineHeight: 1.45 }}>
|
||||
{t('settings.providers.endpointMustUseHttps')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user