chore: drop old client support (#14369)

This commit is contained in:
DarkSky
2026-02-05 02:49:33 +08:00
committed by GitHub
parent de29e8300a
commit 403f16b404
103 changed files with 3293 additions and 997 deletions
@@ -105,10 +105,6 @@ class RemoveContextDocInput {
class AddContextFileInput {
@Field(() => String)
contextId!: string;
// @TODO(@darkskygit): remove this after client lower then 0.22 has been disconnected
@Field(() => String, { nullable: true, deprecationReason: 'Never used' })
blobId!: string | undefined;
}
@InputType()
@@ -1672,42 +1672,12 @@ const imageActions: Prompt[] = [
},
],
},
// TODO(@darkskygit): deprecated, remove it after <0.22 version is outdated
{
name: 'debug:action:fal-remove-bg',
action: 'Remove background',
model: 'imageutils/rembg',
messages: [],
},
{
name: 'debug:action:fal-face-to-sticker',
action: 'Convert to sticker',
model: 'face-to-sticker',
messages: [],
},
{
name: 'debug:action:fal-teed',
action: 'fal-teed',
model: 'workflowutils/teed',
messages: [{ role: 'user', content: '{{content}}' }],
},
{
name: 'debug:action:fal-sd15',
action: 'image',
model: 'lcm-sd15-i2i',
messages: [],
},
{
name: 'debug:action:fal-upscaler',
action: 'Clearer',
model: 'clarity-upscaler',
messages: [
{
role: 'user',
content: 'best quality, 8K resolution, highres, clarity, {{content}}',
},
],
},
];
const modelActions: Prompt[] = [
@@ -24,7 +24,9 @@ import {
CopilotPromptInvalid,
CopilotProviderNotSupported,
CopilotProviderSideError,
fetchBuffer,
metrics,
OneMB,
UserFriendlyError,
} from '../../../base';
import { CopilotProvider } from './provider';
@@ -673,14 +675,12 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
for (const [idx, entry] of attachments.entries()) {
const url = typeof entry === 'string' ? entry : entry.attachment;
const resp = await fetch(url);
if (resp.ok) {
const type = resp.headers.get('content-type');
if (type && type.startsWith('image/')) {
const buffer = new Uint8Array(await resp.arrayBuffer());
const file = new File([buffer], `${idx}.png`, { type });
form.append('image[]', file);
}
try {
const { buffer, type } = await fetchBuffer(url, 10 * OneMB, 'image/');
const file = new File([buffer], `${idx}.png`, { type });
form.append('image[]', file);
} catch {
continue;
}
}
@@ -12,11 +12,22 @@ import {
import { GoogleAuth, GoogleAuthOptions } from 'google-auth-library';
import z, { ZodType } from 'zod';
import {
bufferToArrayBuffer,
fetchBuffer,
OneMinute,
ResponseTooLargeError,
safeFetch,
SsrfBlockedError,
} from '../../../base';
import { CustomAITools } from '../tools';
import { PromptMessage, StreamObject } from './types';
type ChatMessage = CoreUserMessage | CoreAssistantMessage;
const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
const ATTACH_HEAD_PARAMS = { timeoutMs: OneMinute / 12, maxRedirects: 3 };
const SIMPLE_IMAGE_URL_REGEX = /^(https?:\/\/|data:image\/)/;
const FORMAT_INFER_MAP: Record<string, string> = {
pdf: 'application/pdf',
@@ -42,6 +53,11 @@ const FORMAT_INFER_MAP: Record<string, string> = {
flv: 'video/flv',
};
async function fetchArrayBuffer(url: string): Promise<ArrayBuffer> {
const { buffer } = await fetchBuffer(url, ATTACHMENT_MAX_BYTES);
return bufferToArrayBuffer(buffer);
}
export async function inferMimeType(url: string) {
if (url.startsWith('data:')) {
return url.split(';')[0].split(':')[1];
@@ -53,12 +69,15 @@ export async function inferMimeType(url: string) {
if (ext) {
return ext;
}
const mimeType = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
}).then(res => res.headers.get('Content-Type'));
if (mimeType) {
return mimeType;
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';
@@ -106,7 +125,16 @@ export async function chatToGPTMessage(
if (SIMPLE_IMAGE_URL_REGEX.test(attachment)) {
const data =
attachment.startsWith('data:') || useBase64Attachment
? await fetch(attachment).then(r => r.arrayBuffer())
? await fetchArrayBuffer(attachment).catch(error => {
// Avoid leaking internal details for blocked URLs.
if (
error instanceof SsrfBlockedError ||
error instanceof ResponseTooLargeError
) {
throw new Error('Attachment URL is not allowed');
}
throw error;
})
: new URL(attachment);
if (mediaType.startsWith('image/')) {
contents.push({ type: 'image', image: data, mediaType });
@@ -7,7 +7,9 @@ import {
BlobQuotaExceeded,
CallMetric,
Config,
fetchBuffer,
type FileUpload,
OneMB,
OnEvent,
readBuffer,
type StorageProvider,
@@ -16,6 +18,8 @@ import {
} from '../../base';
import { QuotaService } from '../../core/quota';
const REMOTE_BLOB_MAX_BYTES = 20 * OneMB;
@Injectable()
export class CopilotStorage {
public provider!: StorageProvider;
@@ -88,9 +92,8 @@ export class CopilotStorage {
@CallMetric('ai', 'blob_proxy_remote_url')
async handleRemoteLink(userId: string, workspaceId: string, link: string) {
const response = await fetch(link);
const buffer = new Uint8Array(await response.arrayBuffer());
const { buffer } = await fetchBuffer(link, REMOTE_BLOB_MAX_BYTES, 'image/');
const filename = createHash('sha256').update(buffer).digest('base64url');
return this.put(userId, workspaceId, filename, Buffer.from(buffer));
return this.put(userId, workspaceId, filename, buffer);
}
}
@@ -13,6 +13,7 @@ import { ConnectedAccount } from '@prisma/client';
import type { Request, Response } from 'express';
import {
ActionForbidden,
Config,
InvalidAuthState,
InvalidOauthCallbackState,
@@ -57,6 +58,9 @@ export class OAuthController {
if (!unknownProviderName) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
if (!clientNonce) {
throw new MissingOauthQueryParameter({ name: 'client_nonce' });
}
const providerName = OAuthProviderName[unknownProviderName];
const provider = this.providerFactory.get(providerName);
@@ -67,6 +71,10 @@ export class OAuthController {
const pkce = provider.requiresPkce ? this.oauth.createPkcePair() : null;
if (redirectUri && !this.url.isAllowedRedirectUri(redirectUri)) {
throw new ActionForbidden();
}
const state = await this.oauth.saveOAuthState({
provider: providerName,
redirectUri,
@@ -173,16 +181,6 @@ export class OAuthController {
);
}
// TODO(@fengmk2): clientNonce should be required after the client version >= 0.21.0
if (
state.clientNonce &&
state.clientNonce !== clientNonce &&
// apple sign in with nonce stored in id token
state.provider !== OAuthProviderName.Apple
) {
throw new InvalidAuthState();
}
if (!state.provider) {
throw new MissingOauthQueryParameter({ name: 'provider' });
}
@@ -193,6 +191,13 @@ export class OAuthController {
throw new UnknownOauthProvider({ name: state.provider ?? 'unknown' });
}
if (
state.provider !== OAuthProviderName.Apple &&
(!clientNonce || !state.clientNonce || state.clientNonce !== clientNonce)
) {
throw new InvalidAuthState();
}
let tokens: Tokens;
try {
tokens = await provider.getToken(code, state);
@@ -221,7 +226,7 @@ export class OAuthController {
state.provider === OAuthProviderName.Apple &&
(!state.client || state.client === 'web')
) {
return res.redirect(this.url.link(state.redirectUri ?? '/'));
return this.url.safeRedirect(res, state.redirectUri ?? '/');
}
res.send({
@@ -1,38 +1,17 @@
import {
Context,
registerEnumType,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { Request } from 'express';
import semver from 'semver';
import { registerEnumType, ResolveField, Resolver } from '@nestjs/graphql';
import { getClientVersionFromRequest } from '../../base';
import { ServerConfigType } from '../../core/config/types';
import { OAuthProviderName } from './config';
import { OAuthProviderFactory } from './factory';
registerEnumType(OAuthProviderName, { name: 'OAuthProviderType' });
const APPLE_OAUTH_PROVIDER_MIN_VERSION = new semver.Range('>=0.22.0', {
includePrerelease: true,
});
@Resolver(() => ServerConfigType)
export class OAuthResolver {
constructor(private readonly factory: OAuthProviderFactory) {}
@ResolveField(() => [OAuthProviderName])
oauthProviders(@Context() ctx: { req: Request }) {
// Apple oauth provider is not supported in client version < 0.22.0
const providers = this.factory.providers;
if (providers.includes(OAuthProviderName.Apple)) {
const version = getClientVersionFromRequest(ctx.req);
if (!version || !APPLE_OAUTH_PROVIDER_MIN_VERSION.test(version)) {
return providers.filter(p => p !== OAuthProviderName.Apple);
}
}
return providers;
oauthProviders() {
return this.factory.providers;
}
}
@@ -7,10 +7,23 @@ import {
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import type {
Request as ExpressRequest,
Response as ExpressResponse,
} from 'express';
import { HTMLRewriter } from 'htmlrewriter';
import { BadRequest, Cache, URLHelper, UseNamedGuard } from '../../base';
import {
BadRequest,
Cache,
readResponseBufferWithLimit,
ResponseTooLargeError,
safeFetch,
SsrfBlockedError,
type SSRFBlockReason,
URLHelper,
UseNamedGuard,
} from '../../base';
import { Public } from '../../core/auth';
import { WorkerService } from './service';
import type { LinkPreviewRequest, LinkPreviewResponse } from './types';
@@ -28,6 +41,25 @@ import { decodeWithCharset } from './utils/encoding';
// cache for 30 minutes
const CACHE_TTL = 1000 * 60 * 30;
const MAX_REDIRECTS = 3;
const FETCH_TIMEOUT_MS = 10_000;
const IMAGE_PROXY_MAX_BYTES = 10 * 1024 * 1024;
const LINK_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
function toBadRequestReason(reason: SSRFBlockReason) {
switch (reason) {
case 'disallowed_protocol':
case 'url_has_credentials':
case 'blocked_hostname':
case 'blocked_ip':
case 'invalid_url':
return 'Invalid URL';
case 'unresolvable_hostname':
return 'Failed to resolve hostname';
case 'too_many_redirects':
return 'Too many redirects';
}
}
@Public()
@UseNamedGuard('selfhost')
@@ -45,14 +77,33 @@ export class WorkerController {
return this.service.allowedOrigins;
}
@Options('/image-proxy')
imageProxyOption(
@Req() request: ExpressRequest,
@Res() resp: ExpressResponse
) {
const origin = request.headers.origin;
return resp
.status(204)
.header({
...getCorsHeaders(origin),
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
.send();
}
@Get('/image-proxy')
async imageProxy(@Req() req: Request, @Res() resp: Response) {
const origin = req.headers.origin ?? '';
async imageProxy(@Req() req: ExpressRequest, @Res() resp: ExpressResponse) {
const origin = req.headers.origin;
const referer = req.headers.referer;
if (
(origin && !isOriginAllowed(origin, this.allowedOrigin)) ||
(referer && !isRefererAllowed(referer, this.allowedOrigin))
) {
const originAllowed = origin
? isOriginAllowed(origin, this.allowedOrigin)
: false;
const refererAllowed = referer
? isRefererAllowed(referer, this.allowedOrigin)
: false;
if (!originAllowed && !refererAllowed) {
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
throw new BadRequest('Invalid header');
}
@@ -79,24 +130,66 @@ export class WorkerController {
return resp
.status(200)
.header({
'Access-Control-Allow-Origin': origin,
Vary: 'Origin',
...getCorsHeaders(origin),
...(origin ? { Vary: 'Origin' } : {}),
'Access-Control-Allow-Methods': 'GET',
'Content-Type': 'image/*',
})
.send(buffer);
}
const response = await fetch(
new Request(targetURL.toString(), {
method: 'GET',
headers: cloneHeader(req.headers),
})
);
let response: Response;
try {
response = await safeFetch(
targetURL.toString(),
{ method: 'GET', headers: cloneHeader(req.headers) },
{ timeoutMs: FETCH_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS }
);
} catch (error) {
if (error instanceof SsrfBlockedError) {
const reason = error.data?.reason as SSRFBlockReason | undefined;
this.logger.warn('Blocked image proxy target', {
url: imageURL,
reason,
context: (error as any).context,
});
throw new BadRequest(toBadRequestReason(reason ?? 'invalid_url'));
}
if (error instanceof ResponseTooLargeError) {
this.logger.warn('Image proxy response too large', {
url: imageURL,
limitBytes: error.data?.limitBytes,
receivedBytes: error.data?.receivedBytes,
});
throw new BadRequest('Response too large');
}
this.logger.error('Failed to fetch image', {
origin,
url: imageURL,
error,
});
throw new BadRequest('Failed to fetch image');
}
if (response.ok) {
const contentType = response.headers.get('Content-Type');
if (contentType?.startsWith('image/')) {
const buffer = Buffer.from(await response.arrayBuffer());
let buffer: Buffer;
try {
buffer = await readResponseBufferWithLimit(
response,
IMAGE_PROXY_MAX_BYTES
);
} catch (error) {
if (error instanceof ResponseTooLargeError) {
this.logger.warn('Image proxy response too large', {
url: imageURL,
limitBytes: error.data?.limitBytes,
receivedBytes: error.data?.receivedBytes,
});
throw new BadRequest('Response too large');
}
throw error;
}
await this.cache.set(cachedUrl, buffer.toString('base64'), {
ttl: CACHE_TTL,
});
@@ -104,8 +197,8 @@ export class WorkerController {
return resp
.status(200)
.header({
'Access-Control-Allow-Origin': origin ?? 'null',
Vary: 'Origin',
...getCorsHeaders(origin),
...(origin ? { Vary: 'Origin' } : {}),
'Access-Control-Allow-Methods': 'GET',
'Content-Type': contentType,
'Content-Disposition': contentDisposition,
@@ -124,17 +217,20 @@ export class WorkerController {
this.logger.error('Failed to fetch image', {
origin,
url: imageURL,
status: resp.status,
status: response.status,
});
throw new BadRequest('Failed to fetch image');
}
}
@Options('/link-preview')
linkPreviewOption(@Req() request: Request, @Res() resp: Response) {
linkPreviewOption(
@Req() request: ExpressRequest,
@Res() resp: ExpressResponse
) {
const origin = request.headers.origin;
return resp
.status(200)
.status(204)
.header({
...getCorsHeaders(origin),
'Access-Control-Allow-Methods': 'POST, OPTIONS',
@@ -145,15 +241,18 @@ export class WorkerController {
@Post('/link-preview')
async linkPreview(
@Req() request: Request,
@Res() resp: Response
): Promise<Response> {
@Req() request: ExpressRequest,
@Res() resp: ExpressResponse
): Promise<ExpressResponse> {
const origin = request.headers.origin;
const referer = request.headers.referer;
if (
(origin && !isOriginAllowed(origin, this.allowedOrigin)) ||
(referer && !isRefererAllowed(referer, this.allowedOrigin))
) {
const originAllowed = origin
? isOriginAllowed(origin, this.allowedOrigin)
: false;
const refererAllowed = referer
? isRefererAllowed(referer, this.allowedOrigin)
: false;
if (!originAllowed && !refererAllowed) {
this.logger.error('Invalid Origin', { origin, referer });
throw new BadRequest('Invalid header');
}
@@ -183,9 +282,13 @@ export class WorkerController {
.send(cachedResponse);
}
const response = await fetch(targetURL, {
headers: cloneHeader(request.headers),
});
const method: 'GET' | 'HEAD' = requestBody?.head ? 'HEAD' : 'GET';
const response = await safeFetch(
targetURL.toString(),
{ method, headers: cloneHeader(request.headers) },
{ timeoutMs: FETCH_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS }
);
this.logger.debug('Fetched URL', {
origin,
url: targetURL,
@@ -211,7 +314,12 @@ export class WorkerController {
};
if (response.body) {
const resp = await decodeWithCharset(response, res);
const body = await readResponseBufferWithLimit(
response,
LINK_PREVIEW_MAX_BYTES
);
const limitedResponse = new Response(body, response);
const resp = await decodeWithCharset(limitedResponse, res);
const rewriter = new HTMLRewriter()
.on('meta', {
@@ -287,7 +395,11 @@ export class WorkerController {
{
// head default path of favicon
const faviconUrl = new URL('/favicon.ico?v=2', response.url);
const faviconResponse = await fetch(faviconUrl, { method: 'HEAD' });
const faviconResponse = await safeFetch(
faviconUrl.toString(),
{ method: 'HEAD' },
{ timeoutMs: FETCH_TIMEOUT_MS, maxRedirects: MAX_REDIRECTS }
);
if (faviconResponse.ok) {
appendUrl(faviconUrl.toString(), res.favicons);
}
@@ -311,6 +423,25 @@ export class WorkerController {
})
.send(json);
} catch (error) {
if (error instanceof SsrfBlockedError) {
const reason = error.data?.reason as SSRFBlockReason | undefined;
this.logger.warn('Blocked link preview target', {
origin,
url: requestBody?.url,
reason,
context: (error as any).context,
});
throw new BadRequest(toBadRequestReason(reason ?? 'invalid_url'));
}
if (error instanceof ResponseTooLargeError) {
this.logger.warn('Link preview response too large', {
origin,
url: requestBody?.url,
limitBytes: error.data?.limitBytes,
receivedBytes: error.data?.receivedBytes,
});
throw new BadRequest('Response too large');
}
this.logger.error('Error fetching URL', {
origin,
url: targetURL,