mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 20:16:26 +08:00
feat(server): native safe fetch (#14931)
This commit is contained in:
@@ -2,7 +2,8 @@ import { Logger } from '@nestjs/common';
|
||||
import { GoogleAuth, GoogleAuthOptions } from 'google-auth-library';
|
||||
import z from 'zod';
|
||||
|
||||
import { OneMinute, safeFetch } from '../../../base';
|
||||
import { OneMinute } from '../../../base';
|
||||
import { inferRemoteMimeType } from '../../../native';
|
||||
import { PromptAttachment, StreamObject } from './types';
|
||||
|
||||
export type VertexProviderConfig = {
|
||||
@@ -33,30 +34,7 @@ type CopilotTextStreamPart =
|
||||
}
|
||||
| { type: 'error'; error: unknown };
|
||||
|
||||
const ATTACH_HEAD_PARAMS = { timeoutMs: OneMinute / 12, maxRedirects: 3 };
|
||||
const FORMAT_INFER_MAP: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
mp3: 'audio/mpeg',
|
||||
opus: 'audio/opus',
|
||||
ogg: 'audio/ogg',
|
||||
aac: 'audio/aac',
|
||||
m4a: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
ogv: 'video/ogg',
|
||||
wav: 'audio/wav',
|
||||
png: 'image/png',
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
txt: 'text/plain',
|
||||
md: 'text/plain',
|
||||
mov: 'video/mov',
|
||||
mpeg: 'video/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
avi: 'video/avi',
|
||||
wmv: 'video/wmv',
|
||||
flv: 'video/flv',
|
||||
};
|
||||
const ATTACH_HEAD_TIMEOUT_MS = OneMinute / 12;
|
||||
|
||||
function toBase64Data(data: string, encoding: 'base64' | 'utf8' = 'base64') {
|
||||
return encoding === 'base64'
|
||||
@@ -97,25 +75,7 @@ export async function inferMimeType(url: string) {
|
||||
if (url.startsWith('data:')) {
|
||||
return url.split(';')[0].split(':')[1];
|
||||
}
|
||||
const pathname = new URL(url).pathname;
|
||||
const extension = pathname.split('.').pop();
|
||||
if (extension) {
|
||||
const ext = FORMAT_INFER_MAP[extension];
|
||||
if (ext) {
|
||||
return ext;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const mimeType = await safeFetch(
|
||||
url,
|
||||
{ method: 'HEAD' },
|
||||
ATTACH_HEAD_PARAMS
|
||||
).then(res => res.headers.get('content-type'));
|
||||
if (mimeType) return mimeType;
|
||||
} catch {
|
||||
// ignore and fallback to default
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
return inferRemoteMimeType({ url, timeoutMs: ATTACH_HEAD_TIMEOUT_MS });
|
||||
}
|
||||
|
||||
type CitationIndexedEvent = {
|
||||
|
||||
+21
-29
@@ -1,10 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
Config,
|
||||
readResponseBufferWithLimit,
|
||||
safeFetch,
|
||||
} from '../../../../base';
|
||||
import { Config } from '../../../../base';
|
||||
import { fetchRemoteAttachment } from '../../../../native';
|
||||
|
||||
type FetchRemoteAttachmentOptions = {
|
||||
signal?: AbortSignal;
|
||||
@@ -37,9 +34,12 @@ export class AttachmentMaterializer {
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
private buildFetchOptions(url: URL, trustedHostSuffixes: string[]) {
|
||||
const baseOptions = { timeoutMs: 15_000, maxRedirects: 3 } as const;
|
||||
const baseOptions = {
|
||||
timeoutMs: 15_000,
|
||||
allowPrivateTargetOrigin: false,
|
||||
};
|
||||
if (!env.prod) {
|
||||
return { ...baseOptions, allowPrivateOrigins: new Set([url.origin]) };
|
||||
return { ...baseOptions, allowPrivateTargetOrigin: true };
|
||||
}
|
||||
|
||||
const trustedOrigins = new Set<string>();
|
||||
@@ -80,7 +80,7 @@ export class AttachmentMaterializer {
|
||||
suffix => hostname === suffix || hostname.endsWith(`.${suffix}`)
|
||||
);
|
||||
if (trustedOrigins.has(url.origin) || trustedByHost) {
|
||||
return { ...baseOptions, allowPrivateOrigins: new Set([url.origin]) };
|
||||
return { ...baseOptions, allowPrivateTargetOrigin: true };
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
@@ -91,30 +91,22 @@ export class AttachmentMaterializer {
|
||||
options: FetchRemoteAttachmentOptions
|
||||
) {
|
||||
const parsed = resolveAttachmentFetchUrl(url);
|
||||
const response = await safeFetch(
|
||||
parsed,
|
||||
{ method: 'GET', signal: options.signal },
|
||||
this.buildFetchOptions(parsed, options.trustedHostSuffixes ?? [])
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch attachment: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await readResponseBufferWithLimit(
|
||||
response,
|
||||
options.maxBytes
|
||||
);
|
||||
const headerMimeType = normalizeMimeType(
|
||||
response.headers.get('content-type') || ''
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
const response = await fetchRemoteAttachment({
|
||||
url: parsed.toString(),
|
||||
...this.buildFetchOptions(parsed, options.trustedHostSuffixes ?? []),
|
||||
maxBytes: options.maxBytes,
|
||||
});
|
||||
options.signal?.throwIfAborted();
|
||||
|
||||
return {
|
||||
data: buffer.toString('base64'),
|
||||
data: response.body.toString('base64'),
|
||||
mimeType: options.detectMimeType
|
||||
? options.detectMimeType(buffer, headerMimeType)
|
||||
: headerMimeType,
|
||||
? options.detectMimeType(
|
||||
response.body,
|
||||
normalizeMimeType(response.mimeType)
|
||||
)
|
||||
: normalizeMimeType(response.mimeType),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
BlobQuotaExceeded,
|
||||
CallMetric,
|
||||
Config,
|
||||
fetchBuffer,
|
||||
type FileUpload,
|
||||
OneMB,
|
||||
OnEvent,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { QuotaService } from '../../core/quota';
|
||||
import { fetchRemoteAttachment } from '../../native';
|
||||
|
||||
const REMOTE_BLOB_MAX_BYTES = 20 * OneMB;
|
||||
|
||||
@@ -93,12 +93,15 @@ export class CopilotStorage {
|
||||
|
||||
@CallMetric('ai', 'blob_proxy_remote_url')
|
||||
async handleRemoteLink(userId: string, workspaceId: string, link: string) {
|
||||
const { buffer, type } = await fetchBuffer(
|
||||
link,
|
||||
REMOTE_BLOB_MAX_BYTES,
|
||||
'image/'
|
||||
);
|
||||
const { body, mimeType } = await fetchRemoteAttachment({
|
||||
url: link,
|
||||
maxBytes: REMOTE_BLOB_MAX_BYTES,
|
||||
expectedContentTypePrefix: 'image/',
|
||||
maxImageHeight: 4096,
|
||||
maxImageWidth: 4096,
|
||||
});
|
||||
const buffer = Buffer.from(body);
|
||||
const filename = createHash('sha256').update(buffer).digest('base64url');
|
||||
return this.put(userId, workspaceId, filename, buffer, type);
|
||||
return this.put(userId, workspaceId, filename, buffer, mimeType);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user