Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/api/verify-pdf-provider/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { resolvePDFApiKey, resolvePDFBaseUrl } from '@/lib/server/provider-config';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
import { MINERU_CLOUD_DEFAULT_BASE } from '@/lib/pdf/constants';

const log = createLogger('Verify PDF Provider');

Expand All @@ -17,6 +18,54 @@ export async function POST(req: NextRequest) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'Provider ID is required');
}

// MinerU Cloud: verify by calling the cloud API with the token
if (providerId === 'mineru-cloud') {
const resolvedApiKey = resolvePDFApiKey(providerId, apiKey);
if (!resolvedApiKey) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'API Key is required for MinerU Cloud');
}

const clientCloudBase = (baseUrl as string | undefined) || undefined;
if (clientCloudBase && process.env.NODE_ENV === 'production') {
const ssrfError = await validateUrlForSSRF(clientCloudBase);
if (ssrfError) {
return apiError('INVALID_URL', 403, ssrfError);
}
}

const cloudBase = (
clientCloudBase ||
resolvePDFBaseUrl(providerId) ||
MINERU_CLOUD_DEFAULT_BASE
).replace(/\/+$/, '');

// Probe the batch endpoint with an empty body to verify auth
const response = await fetch(`${cloudBase}/extract-results/batch/test-connection`, {
headers: {
Authorization: `Bearer ${resolvedApiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(10000),
});

// Any response (including 4xx for "batch not found") means auth + connectivity works
// Only network errors or 401/403 indicate a problem
if (response.status === 401 || response.status === 403) {
const text = await response.text().catch(() => '');
return apiError(
'INTERNAL_ERROR',
500,
`Authentication failed: ${text || response.statusText}`,
);
}

return apiSuccess({
message: 'Connection successful',
status: response.status,
});
}

// Self-hosted providers: verify by connecting to the base URL
const clientBaseUrl = (baseUrl as string | undefined) || undefined;
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
Expand Down
227 changes: 153 additions & 74 deletions components/settings/pdf-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,15 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
const pdfProvider = PDF_PROVIDERS[selectedProviderId];
const isServerConfigured = !!pdfProvidersConfig[selectedProviderId]?.isServerConfigured;
const providerConfig = pdfProvidersConfig[selectedProviderId];
const hasApiKey = !!providerConfig?.apiKey;
const hasBaseUrl = !!providerConfig?.baseUrl;
const needsRemoteConfig = selectedProviderId === 'mineru';

const isCloud = selectedProviderId === 'mineru-cloud';
const isSelfHosted = selectedProviderId === 'mineru';
const needsRemoteConfig = isSelfHosted || isCloud;

// For cloud: test requires API key (user-entered or server-configured); for self-hosted: test requires base URL
const canTest = isCloud ? hasApiKey || isServerConfigured : hasBaseUrl || isServerConfigured;

// Reset state when provider changes
const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId);
Expand All @@ -56,9 +63,6 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
}

const handleTestConnection = async () => {
const baseUrl = providerConfig?.baseUrl;
if (!baseUrl) return;

setTestStatus('testing');
setTestMessage('');

Expand All @@ -69,7 +73,7 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
body: JSON.stringify({
providerId: selectedProviderId,
apiKey: providerConfig?.apiKey || '',
baseUrl,
baseUrl: providerConfig?.baseUrl || '',
}),
});

Expand Down Expand Up @@ -98,80 +102,148 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
</div>
)}

{/* Base URL + API Key Configuration (for remote providers like MinerU) */}
{/* Configuration section (for remote providers) */}
{(needsRemoteConfig || isServerConfigured) && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-sm">{t('settings.pdfBaseUrl')}</Label>
<div className="flex gap-2">
<Input
name={`pdf-base-url-${selectedProviderId}`}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder="http://localhost:8080"
value={providerConfig?.baseUrl || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { baseUrl: e.target.value })
}
className="text-sm"
/>
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={testStatus === 'testing' || !hasBaseUrl}
className="gap-1.5 shrink-0"
>
{testStatus === 'testing' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
{/* API Key — shown first for cloud, second for self-hosted */}
{isCloud && (
<div className="space-y-2">
<Label className="text-sm">{t('settings.pdfApiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
name={`pdf-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
isServerConfigured
? t('settings.optionalOverride')
: t('settings.mineruCloudApiKeyPlaceholder')
}
value={providerConfig?.apiKey || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { apiKey: e.target.value })
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={testStatus === 'testing' || !canTest}
className="gap-1.5 shrink-0"
>
{testStatus === 'testing' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
</div>
</div>
)}

{/* Base URL */}
{(isSelfHosted || isCloud) && (
<div className="space-y-2">
<Label className="text-sm">
{t('settings.pdfBaseUrl')}
{isCloud && (
<span className="text-muted-foreground ml-1 font-normal">
({t('settings.optional')})
</span>
)}
</Label>
<div className="flex gap-2">
<Input
name={`pdf-base-url-${selectedProviderId}`}
autoComplete="off"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={isCloud ? 'https://mineru.net/api/v4' : 'http://localhost:8080'}
value={providerConfig?.baseUrl || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { baseUrl: e.target.value })
}
className="text-sm"
/>
{/* Test button for self-hosted (next to base URL) */}
{isSelfHosted && (
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={testStatus === 'testing' || !canTest}
className="gap-1.5 shrink-0"
>
{testStatus === 'testing' ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Zap className="h-3.5 w-3.5" />
{t('settings.testConnection')}
</>
)}
</Button>
)}
</Button>
</div>
</div>
</div>
)}

<div className="space-y-2">
<Label className="text-sm">
{t('settings.pdfApiKey')}
<span className="text-muted-foreground ml-1 font-normal">
({t('settings.optional')})
</span>
</Label>
<div className="relative">
<Input
name={`pdf-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
isServerConfigured ? t('settings.optionalOverride') : t('settings.enterApiKey')
}
value={providerConfig?.apiKey || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, {
apiKey: e.target.value,
})
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
{/* API Key for self-hosted (optional, second column) */}
{isSelfHosted && (
<div className="space-y-2">
<Label className="text-sm">
{t('settings.pdfApiKey')}
<span className="text-muted-foreground ml-1 font-normal">
({t('settings.optional')})
</span>
</Label>
<div className="relative">
<Input
name={`pdf-api-key-${selectedProviderId}`}
type={showApiKey ? 'text' : 'password'}
autoComplete="new-password"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
placeholder={
isServerConfigured
? t('settings.optionalOverride')
: t('settings.enterApiKey')
}
value={providerConfig?.apiKey || ''}
onChange={(e) =>
setPDFProviderConfig(selectedProviderId, { apiKey: e.target.value })
}
className="font-mono text-sm pr-10"
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
)}
</div>

{/* Test result message */}
Expand All @@ -195,12 +267,19 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {

{/* Request URL Preview */}
{(() => {
if (isCloud) {
const base = providerConfig?.baseUrl || 'https://mineru.net/api/v4';
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {base}/file-urls/batch
</p>
);
}
const effectiveBaseUrl = providerConfig?.baseUrl || '';
if (!effectiveBaseUrl) return null;
const fullUrl = effectiveBaseUrl + '/file_parse';
return (
<p className="text-xs text-muted-foreground break-all">
{t('settings.requestUrl')}: {fullUrl}
{t('settings.requestUrl')}: {effectiveBaseUrl}/file_parse
</p>
);
})()}
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@
"providerQwenASR": "Qwen ASR (Alibaba Cloud Bailian)",
"providerUnpdf": "unpdf (Built-in)",
"providerMinerU": "MinerU",
"providerMinerUCloud": "MinerU (Cloud)",
"browserNativeTTSNote": "Browser Native TTS requires no configuration and is completely free, using system built-in voices",
"testTTS": "Test TTS",
"testASR": "Test ASR",
Expand Down Expand Up @@ -797,6 +798,7 @@
"mineruLocalDescription": "MinerU supports local deployment with advanced PDF parsing (tables, formulas, layout analysis). Requires deploying MinerU service first.",
"mineruServerAddress": "Local MinerU server address (e.g., http://localhost:8080)",
"mineruApiKeyOptional": "Only required if server has authentication enabled",
"mineruCloudApiKeyPlaceholder": "Enter MinerU Cloud API Key",
"optionalApiKey": "Optional API Key",
"featureText": "Text Extraction",
"featureImages": "Image Extraction",
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@
"providerQwenASR": "Qwen ASR(Alibaba Cloud百錬)",
"providerUnpdf": "unpdf(組み込み)",
"providerMinerU": "MinerU",
"providerMinerUCloud": "MinerU(クラウド)",
"browserNativeTTSNote": "ブラウザネイティブTTSは設定不要で完全無料です。システム内蔵のボイスを使用します",
"testTTS": "TTSをテスト",
"testASR": "ASRをテスト",
Expand Down Expand Up @@ -797,6 +798,7 @@
"mineruLocalDescription": "MinerUはローカルデプロイに対応し、高度なPDF解析(表、数式、レイアウト分析)が可能です。事前にMinerUサービスのデプロイが必要です。",
"mineruServerAddress": "ローカルMinerUサーバーアドレス(例:http://localhost:8080)",
"mineruApiKeyOptional": "サーバーで認証が有効な場合のみ必要",
"mineruCloudApiKeyPlaceholder": "MinerU Cloud API キーを入力",
"optionalApiKey": "APIキー(任意)",
"featureText": "テキスト抽出",
"featureImages": "画像抽出",
Expand Down
2 changes: 2 additions & 0 deletions lib/i18n/locales/ru-RU.json
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@
"providerQwenASR": "Qwen ASR (Alibaba Cloud Bailian)",
"providerUnpdf": "unpdf (встроенный)",
"providerMinerU": "MinerU",
"providerMinerUCloud": "MinerU (Облако)",
"browserNativeTTSNote": "Встроенный TTS браузера не требует настройки и полностью бесплатен, использует системные голоса",
"testTTS": "Тест TTS",
"testASR": "Тест ASR",
Expand Down Expand Up @@ -797,6 +798,7 @@
"mineruLocalDescription": "MinerU поддерживает локальное развёртывание с расширенной обработкой PDF (таблицы, формулы, анализ макета). Требуется предварительное развёртывание сервиса MinerU.",
"mineruServerAddress": "Адрес локального сервера MinerU (напр., http://localhost:8080)",
"mineruApiKeyOptional": "Требуется только если на сервере включена аутентификация",
"mineruCloudApiKeyPlaceholder": "Введите API ключ MinerU Cloud",
"optionalApiKey": "Необязательный API-ключ",
"featureText": "Извлечение текста",
"featureImages": "Извлечение изображений",
Expand Down
Loading
Loading