208 lines
7.5 KiB
TypeScript
208 lines
7.5 KiB
TypeScript
import { NextRequest } from 'next/server';
|
|
import { createLogger } from '@/lib/logger';
|
|
import { apiError, apiSuccess } from '@/lib/server/api-response';
|
|
import {
|
|
isServerConfiguredProvider,
|
|
resolveManagedAliDocMindCredentials,
|
|
resolvePDFApiKey,
|
|
resolvePDFBaseUrl,
|
|
} from '@/lib/server/provider-config';
|
|
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
|
|
import { MINERU_CLOUD_DEFAULT_BASE } from '@/lib/pdf/constants';
|
|
import { fetchPinnedPublicUrl, PublicUrlFetchError } from '@/lib/server/public-url-fetch';
|
|
|
|
const log = createLogger('Verify PDF Provider');
|
|
export const runtime = 'nodejs';
|
|
|
|
function publicCloudFetchError(error: unknown) {
|
|
if (
|
|
error instanceof PublicUrlFetchError &&
|
|
(error.code === 'INVALID_URL' ||
|
|
error.code === 'BLOCKED_TARGET' ||
|
|
error.code === 'DNS_FAILURE')
|
|
) {
|
|
return apiError('INVALID_URL', 403, 'Cloud endpoint is not an allowed public URL');
|
|
}
|
|
return apiError('UPSTREAM_ERROR', 502, 'Unable to connect to the cloud provider');
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
let providerId: string | undefined;
|
|
try {
|
|
const body = await req.json();
|
|
providerId = body.providerId;
|
|
const { apiKey, baseUrl, accessKeyId, accessKeySecret } = body;
|
|
|
|
if (!providerId) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'Provider ID is required');
|
|
}
|
|
|
|
// Managed providers are admin-owned: ignore any client-sent key/baseUrl.
|
|
const managed = isServerConfiguredProvider('pdf', providerId);
|
|
|
|
// AliDocMind: verify AK/SK by issuing a lightweight authenticated probe.
|
|
if (providerId === 'alidocmind') {
|
|
let ak: string | undefined;
|
|
let sk: string | undefined;
|
|
let endpoint: string | undefined;
|
|
|
|
if (managed) {
|
|
// Managed: use server-owned credentials + endpoint only. Ignore any
|
|
// client-supplied AK/SK/baseUrl.
|
|
const serverCreds = resolveManagedAliDocMindCredentials();
|
|
if (!serverCreds) {
|
|
return apiError('INTERNAL_ERROR', 500, 'AliDocMind is not configured on the server');
|
|
}
|
|
ak = serverCreds.accessKeyId;
|
|
sk = serverCreds.accessKeySecret;
|
|
endpoint = serverCreds.baseUrl;
|
|
} else {
|
|
// Unmanaged: client credentials only — never fall back to server env.
|
|
ak = (accessKeyId as string | undefined) || undefined;
|
|
sk = (accessKeySecret as string | undefined) || undefined;
|
|
endpoint = (baseUrl as string | undefined) || undefined;
|
|
if (!ak || !sk) {
|
|
return apiError(
|
|
'MISSING_REQUIRED_FIELD',
|
|
400,
|
|
'AccessKey ID and AccessKey Secret are required for AliDocMind',
|
|
);
|
|
}
|
|
// Validate a client-supplied endpoint before we sign a request to it.
|
|
if (endpoint && process.env.NODE_ENV === 'production') {
|
|
const ssrfError = await validateUrlForSSRF(
|
|
endpoint.startsWith('http') ? endpoint : `https://${endpoint}`,
|
|
);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
}
|
|
}
|
|
|
|
const { verifyAliDocMindCredentials } = await import('@/lib/pdf/alidocmind-client');
|
|
const result = await verifyAliDocMindCredentials({
|
|
accessKeyId: ak,
|
|
accessKeySecret: sk,
|
|
endpoint,
|
|
});
|
|
if (!result.ok) {
|
|
return apiError('INVALID_CREDENTIALS', 400, `Authentication failed: ${result.error}`);
|
|
}
|
|
return apiSuccess({ message: 'Connection successful' });
|
|
}
|
|
|
|
// MinerU Cloud: verify by calling the cloud API with the token
|
|
if (providerId === 'mineru-cloud') {
|
|
const clientCloudBase = managed ? undefined : (baseUrl as string | undefined) || undefined;
|
|
|
|
const resolvedApiKey = resolvePDFApiKey(providerId, managed ? undefined : apiKey);
|
|
if (!resolvedApiKey) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'API Key is required for MinerU Cloud');
|
|
}
|
|
|
|
const cloudBase = (
|
|
resolvePDFBaseUrl(providerId, clientCloudBase) || MINERU_CLOUD_DEFAULT_BASE
|
|
).replace(/\/+$/, '');
|
|
|
|
// Probe through a DNS-pinned, public-only connection. The bearer token is
|
|
// never forwarded across a redirect because redirects fail closed.
|
|
let pinnedResponse;
|
|
try {
|
|
pinnedResponse = await fetchPinnedPublicUrl(
|
|
`${cloudBase}/extract-results/batch/test-connection`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${resolvedApiKey}`,
|
|
Accept: 'application/json',
|
|
},
|
|
signal: AbortSignal.timeout(10_000),
|
|
maxResponseBytes: 1024 * 1024,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
return publicCloudFetchError(error);
|
|
}
|
|
|
|
const { response } = pinnedResponse;
|
|
try {
|
|
if (response.status >= 300 && response.status < 400) {
|
|
return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed');
|
|
}
|
|
|
|
// Other responses (including 4xx for "batch not found") mean auth + connectivity works.
|
|
// Only network errors, redirects, 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,
|
|
});
|
|
} finally {
|
|
await pinnedResponse.dispose();
|
|
}
|
|
}
|
|
|
|
// Self-hosted providers: verify by connecting to the base URL
|
|
const clientBaseUrl = managed ? undefined : (baseUrl as string | undefined) || undefined;
|
|
if (clientBaseUrl && process.env.NODE_ENV === 'production') {
|
|
const ssrfError = await validateUrlForSSRF(clientBaseUrl);
|
|
if (ssrfError) {
|
|
return apiError('INVALID_URL', 403, ssrfError);
|
|
}
|
|
}
|
|
|
|
const resolvedBaseUrl = resolvePDFBaseUrl(providerId, clientBaseUrl);
|
|
if (!resolvedBaseUrl) {
|
|
return apiError('MISSING_REQUIRED_FIELD', 400, 'Base URL is required');
|
|
}
|
|
|
|
const resolvedApiKey = resolvePDFApiKey(providerId, managed ? undefined : apiKey);
|
|
|
|
const headers: Record<string, string> = {};
|
|
if (resolvedApiKey) {
|
|
headers['Authorization'] = `Bearer ${resolvedApiKey}`;
|
|
}
|
|
|
|
const response = await fetch(resolvedBaseUrl, {
|
|
headers,
|
|
signal: AbortSignal.timeout(10000),
|
|
redirect: 'manual',
|
|
});
|
|
|
|
if (response.status >= 300 && response.status < 400) {
|
|
return apiError('REDIRECT_NOT_ALLOWED', 403, 'Redirects are not allowed');
|
|
}
|
|
|
|
// MinerU's FastAPI root returns 404 (no root route), but the server is reachable.
|
|
// Any HTTP response (including 404) means the server is up.
|
|
return apiSuccess({
|
|
message: 'Connection successful',
|
|
status: response.status,
|
|
});
|
|
} catch (error) {
|
|
log.error(`PDF provider verification failed [provider=${providerId ?? 'unknown'}]:`, error);
|
|
|
|
let errorMessage = 'Connection failed';
|
|
if (error instanceof Error) {
|
|
if (error.message.includes('ECONNREFUSED')) {
|
|
errorMessage = 'Cannot connect to server, please check the Base URL';
|
|
} else if (error.message.includes('ENOTFOUND')) {
|
|
errorMessage = 'Server not found, please check the Base URL';
|
|
} else if (error.message.includes('timeout') || error.name === 'TimeoutError') {
|
|
errorMessage = 'Connection timed out';
|
|
} else {
|
|
errorMessage = error.message;
|
|
}
|
|
}
|
|
|
|
return apiError('INTERNAL_ERROR', 500, errorMessage);
|
|
}
|
|
}
|