mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
🔨 chore(google): Support External URL file input with SSRF validation to optimize transmission (#12657)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: yutengjing <ytj2713151713@gmail.com>
This commit is contained in:
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "جارٍ التحضير للرفع...",
|
||||
"upload.preview.status.processing": "جارٍ معالجة الملف...",
|
||||
"upload.validation.unsupportedFileType": "نوع الملف غير مدعوم: {{files}}. الصور المدعومة: JPG، PNG، GIF، WebP. المستندات المدعومة تشمل PDF، Word، Excel، PowerPoint، Markdown، النص، CSV، JSON، وملفات التعليمات البرمجية.",
|
||||
"upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو 20 ميغابايت. الحجم الحالي هو {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "يجب ألا يتجاوز حجم ملف الفيديو {{maxSize}}. الحجم الحالي هو {{actualSize}}.",
|
||||
"viewMode.fullWidth": "العرض الكامل",
|
||||
"viewMode.normal": "قياسي",
|
||||
"viewMode.wideScreen": "شاشة عريضة",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Подготовка за качване...",
|
||||
"upload.preview.status.processing": "Обработка на файла...",
|
||||
"upload.validation.unsupportedFileType": "Неподдържан тип файл: {{files}}. Поддържани изображения: JPG, PNG, GIF, WebP. Поддържани документи включват PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлове с код.",
|
||||
"upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава 20MB. Текущият размер е {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Размерът на видеофайла не трябва да надвишава {{maxSize}}. Текущият размер е {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Пълна ширина",
|
||||
"viewMode.normal": "Стандартен",
|
||||
"viewMode.wideScreen": "Широкоекранен",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Vorbereitung zum Hochladen...",
|
||||
"upload.preview.status.processing": "Datei wird verarbeitet...",
|
||||
"upload.validation.unsupportedFileType": "Nicht unterstützter Dateityp: {{files}}. Unterstützte Bilder: JPG, PNG, GIF, WebP. Unterstützte Dokumente umfassen PDF, Word, Excel, PowerPoint, Markdown, Text, CSV, JSON und Code-Dateien.",
|
||||
"upload.validation.videoSizeExceeded": "Die Videodatei darf 20 MB nicht überschreiten. Aktuelle Größe: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Die Videodatei darf {{maxSize}} nicht überschreiten. Aktuelle Größe: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Volle Breite",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Breitbild",
|
||||
|
||||
@@ -865,7 +865,7 @@
|
||||
"upload.preview.status.pending": "Preparing to upload...",
|
||||
"upload.preview.status.processing": "Processing file...",
|
||||
"upload.validation.unsupportedFileType": "Unsupported file type: {{files}}. Supported images: JPG, PNG, GIF, WebP. Supported documents include PDF, Word, Excel, PowerPoint, Markdown, text, CSV, JSON, and code files.",
|
||||
"upload.validation.videoSizeExceeded": "Video file size must not exceed 20MB. Current file size is {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Video file size must not exceed {{maxSize}}. Current file size is {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Full Width",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Widescreen",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Preparando para subir...",
|
||||
"upload.preview.status.processing": "Procesando archivo...",
|
||||
"upload.validation.unsupportedFileType": "Tipo de archivo no compatible: {{files}}. Imágenes compatibles: JPG, PNG, GIF, WebP. Documentos compatibles incluyen PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON y archivos de código.",
|
||||
"upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los 20MB. Tamaño actual: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "El tamaño del archivo de video no debe superar los {{maxSize}}. Tamaño actual: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Ancho completo",
|
||||
"viewMode.normal": "Estándar",
|
||||
"viewMode.wideScreen": "Pantalla ancha",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "در حال آمادهسازی برای بارگذاری...",
|
||||
"upload.preview.status.processing": "در حال پردازش فایل...",
|
||||
"upload.validation.unsupportedFileType": "نوع فایل پشتیبانی نشده: {{files}}. تصاویر پشتیبانی شده: JPG، PNG، GIF، WebP. اسناد پشتیبانی شده شامل PDF، Word، Excel، PowerPoint، Markdown، متن، CSV، JSON و فایلهای کد هستند.",
|
||||
"upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از ۲۰ مگابایت باشد. حجم فعلی: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "حجم فایل ویدیو نباید بیش از {{maxSize}} باشد. حجم فعلی: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "تمامعرض",
|
||||
"viewMode.normal": "استاندارد",
|
||||
"viewMode.wideScreen": "نمای عریض",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Préparation au téléversement...",
|
||||
"upload.preview.status.processing": "Traitement du fichier...",
|
||||
"upload.validation.unsupportedFileType": "Type de fichier non pris en charge : {{files}}. Images prises en charge : JPG, PNG, GIF, WebP. Les documents pris en charge incluent PDF, Word, Excel, PowerPoint, Markdown, texte, CSV, JSON et fichiers de code.",
|
||||
"upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser 20 Mo. Taille actuelle : {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "La taille du fichier vidéo ne doit pas dépasser {{maxSize}}. Taille actuelle : {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Pleine largeur",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Grand écran",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Preparazione al caricamento...",
|
||||
"upload.preview.status.processing": "Elaborazione file...",
|
||||
"upload.validation.unsupportedFileType": "Tipo di file non supportato: {{files}}. Immagini supportate: JPG, PNG, GIF, WebP. Documenti supportati includono PDF, Word, Excel, PowerPoint, Markdown, testo, CSV, JSON e file di codice.",
|
||||
"upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i 20MB. Dimensione attuale: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "La dimensione del file video non deve superare i {{maxSize}}. Dimensione attuale: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Larghezza completa",
|
||||
"viewMode.normal": "Standard",
|
||||
"viewMode.wideScreen": "Widescreen",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "アップロードの準備中…",
|
||||
"upload.preview.status.processing": "ファイル処理中…",
|
||||
"upload.validation.unsupportedFileType": "サポートされていないファイルタイプ: {{files}}。サポートされている画像形式: JPG、PNG、GIF、WebP。サポートされているドキュメント形式: PDF、Word、Excel、PowerPoint、Markdown、テキスト、CSV、JSON、コードファイル。",
|
||||
"upload.validation.videoSizeExceeded": "ビデオファイルは 20MB を超えることはできません。現在は {{actualSize}} です",
|
||||
"upload.validation.videoSizeExceeded": "ビデオファイルは {{maxSize}} を超えることはできません。現在は {{actualSize}} です",
|
||||
"viewMode.fullWidth": "全幅表示",
|
||||
"viewMode.normal": "標準",
|
||||
"viewMode.wideScreen": "ワイドスクリーン",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "업로드 준비 중…",
|
||||
"upload.preview.status.processing": "파일 처리 중…",
|
||||
"upload.validation.unsupportedFileType": "지원되지 않는 파일 형식: {{files}}. 지원되는 이미지: JPG, PNG, GIF, WebP. 지원되는 문서: PDF, Word, Excel, PowerPoint, Markdown, 텍스트, CSV, JSON 및 코드 파일.",
|
||||
"upload.validation.videoSizeExceeded": "비디오 파일은 20MB를 초과할 수 없습니다. 현재 {{actualSize}}입니다",
|
||||
"upload.validation.videoSizeExceeded": "비디오 파일은 {{maxSize}}를 초과할 수 없습니다. 현재 {{actualSize}}입니다",
|
||||
"viewMode.fullWidth": "전체 너비",
|
||||
"viewMode.normal": "일반",
|
||||
"viewMode.wideScreen": "와이드스크린",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Voorbereiden op upload...",
|
||||
"upload.preview.status.processing": "Bestand verwerken...",
|
||||
"upload.validation.unsupportedFileType": "Niet-ondersteund bestandstype: {{files}}. Ondersteunde afbeeldingen: JPG, PNG, GIF, WebP. Ondersteunde documenten zijn onder andere PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON en codebestanden.",
|
||||
"upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan 20MB. Huidige grootte is {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "De bestandsgrootte van de video mag niet groter zijn dan {{maxSize}}. Huidige grootte is {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Volledige breedte",
|
||||
"viewMode.normal": "Standaard",
|
||||
"viewMode.wideScreen": "Widescreen",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Przygotowywanie do przesłania...",
|
||||
"upload.preview.status.processing": "Przetwarzanie pliku...",
|
||||
"upload.validation.unsupportedFileType": "Nieobsługiwany typ pliku: {{files}}. Obsługiwane obrazy: JPG, PNG, GIF, WebP. Obsługiwane dokumenty obejmują PDF, Word, Excel, PowerPoint, Markdown, tekst, CSV, JSON i pliki kodu.",
|
||||
"upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać 20 MB. Obecny rozmiar pliku to {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Rozmiar pliku wideo nie może przekraczać {{maxSize}}. Obecny rozmiar pliku to {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Pełna szerokość",
|
||||
"viewMode.normal": "Standardowy",
|
||||
"viewMode.wideScreen": "Szeroki ekran",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Preparando para envio...",
|
||||
"upload.preview.status.processing": "Processando arquivo...",
|
||||
"upload.validation.unsupportedFileType": "Tipo de arquivo não suportado: {{files}}. Imagens suportadas: JPG, PNG, GIF, WebP. Documentos suportados incluem PDF, Word, Excel, PowerPoint, Markdown, texto, CSV, JSON e arquivos de código.",
|
||||
"upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder 20MB. Tamanho atual: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "O tamanho do vídeo não deve exceder {{maxSize}}. Tamanho atual: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Largura Total",
|
||||
"viewMode.normal": "Padrão",
|
||||
"viewMode.wideScreen": "Tela Larga",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Подготовка к загрузке...",
|
||||
"upload.preview.status.processing": "Обработка файла...",
|
||||
"upload.validation.unsupportedFileType": "Неподдерживаемый тип файла: {{files}}. Поддерживаемые изображения: JPG, PNG, GIF, WebP. Поддерживаемые документы: PDF, Word, Excel, PowerPoint, Markdown, текст, CSV, JSON и файлы кода.",
|
||||
"upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать 20 МБ. Текущий размер: {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Размер видеофайла не должен превышать {{maxSize}}. Текущий размер: {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Полная ширина",
|
||||
"viewMode.normal": "Стандартный",
|
||||
"viewMode.wideScreen": "Широкий экран",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Yüklemeye hazırlanıyor...",
|
||||
"upload.preview.status.processing": "Dosya işleniyor...",
|
||||
"upload.validation.unsupportedFileType": "Desteklenmeyen dosya türü: {{files}}. Desteklenen görseller: JPG, PNG, GIF, WebP. Desteklenen belgeler: PDF, Word, Excel, PowerPoint, Markdown, metin, CSV, JSON ve kod dosyaları.",
|
||||
"upload.validation.videoSizeExceeded": "Video dosya boyutu 20MB'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Video dosya boyutu {{maxSize}}'ı geçmemelidir. Mevcut dosya boyutu {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Tam Genişlik",
|
||||
"viewMode.normal": "Standart",
|
||||
"viewMode.wideScreen": "Geniş Ekran",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "Đang chuẩn bị tải lên...",
|
||||
"upload.preview.status.processing": "Đang xử lý tệp...",
|
||||
"upload.validation.unsupportedFileType": "Loại tệp không được hỗ trợ: {{files}}. Hình ảnh được hỗ trợ: JPG, PNG, GIF, WebP. Các tài liệu được hỗ trợ bao gồm PDF, Word, Excel, PowerPoint, Markdown, văn bản, CSV, JSON và tệp mã.",
|
||||
"upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá 20MB. Kích thước hiện tại là {{actualSize}}.",
|
||||
"upload.validation.videoSizeExceeded": "Kích thước tệp video không được vượt quá {{maxSize}}. Kích thước hiện tại là {{actualSize}}.",
|
||||
"viewMode.fullWidth": "Toàn chiều rộng",
|
||||
"viewMode.normal": "Chuẩn",
|
||||
"viewMode.wideScreen": "Toàn màn hình",
|
||||
|
||||
@@ -865,7 +865,7 @@
|
||||
"upload.preview.status.pending": "准备上传…",
|
||||
"upload.preview.status.processing": "文件处理中…",
|
||||
"upload.validation.unsupportedFileType": "不支持的文件类型:{{files}}。支持的图片格式:JPG、PNG、GIF、WebP。支持的文档包括PDF、Word、Excel、PowerPoint、Markdown、文本、CSV、JSON和代码文件。",
|
||||
"upload.validation.videoSizeExceeded": "视频文件不能超过 20MB。当前为 {{actualSize}}",
|
||||
"upload.validation.videoSizeExceeded": "视频文件不能超过 {{maxSize}}。当前为 {{actualSize}}",
|
||||
"viewMode.fullWidth": "全宽显示",
|
||||
"viewMode.normal": "普通",
|
||||
"viewMode.wideScreen": "宽屏",
|
||||
|
||||
@@ -846,7 +846,7 @@
|
||||
"upload.preview.status.pending": "準備上傳...",
|
||||
"upload.preview.status.processing": "檔案處理中...",
|
||||
"upload.validation.unsupportedFileType": "不支援的檔案類型:{{files}}。支援的圖片格式:JPG、PNG、GIF、WebP。支援的文件包括 PDF、Word、Excel、PowerPoint、Markdown、文字檔、CSV、JSON 和程式碼檔案。",
|
||||
"upload.validation.videoSizeExceeded": "影片檔案大小不能超過 20MB,當前檔案大小為 {{actualSize}}",
|
||||
"upload.validation.videoSizeExceeded": "影片檔案大小不能超過 {{maxSize}},當前檔案大小為 {{actualSize}}",
|
||||
"viewMode.fullWidth": "全寬顯示",
|
||||
"viewMode.normal": "一般",
|
||||
"viewMode.wideScreen": "寬螢幕",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@lobechat/business-model-bank": "workspace:*",
|
||||
"@lobechat/business-model-runtime": "workspace:*",
|
||||
"@lobechat/const": "workspace:*",
|
||||
"@lobechat/ssrf-safe-fetch": "workspace:*",
|
||||
"@lobechat/utils": "workspace:*",
|
||||
"async-retry": "^1.3.3",
|
||||
"dayjs": "^1.11.19",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @vitest-environment node
|
||||
import * as imageToBase64Module from '@lobechat/utils';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types';
|
||||
import { parseDataUri } from '../../utils/uriParser';
|
||||
import { isPublicExternalUrl, parseDataUri, validateExternalUrl } from '../../utils/uriParser';
|
||||
import {
|
||||
buildGoogleMessage,
|
||||
buildGoogleMessages,
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
|
||||
// Mock the utils
|
||||
vi.mock('../../utils/uriParser', () => ({
|
||||
isPublicExternalUrl: vi.fn().mockReturnValue(false),
|
||||
parseDataUri: vi.fn(),
|
||||
validateExternalUrl: vi.fn().mockResolvedValue({ isValid: false, reason: 'mocked' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/imageToBase64', () => ({
|
||||
@@ -34,6 +36,10 @@ describe('google contextBuilders', () => {
|
||||
});
|
||||
|
||||
describe('buildGooglePart', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should handle text type messages', async () => {
|
||||
const content: UserMessageContentPart = {
|
||||
text: 'Hello',
|
||||
@@ -140,6 +146,161 @@ describe('google contextBuilders', () => {
|
||||
expect(imageToBase64Module.imageUrlToBase64).toHaveBeenCalledWith(imageUrl);
|
||||
});
|
||||
|
||||
it('should use fileData for external URL images on gemini-3+', async () => {
|
||||
const imageUrl = 'https://example.com/image.png';
|
||||
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
base64: null,
|
||||
mimeType: null,
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
|
||||
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
|
||||
contentLength: 1024,
|
||||
contentType: 'image/png',
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const imageToBase64Spy = vi
|
||||
.spyOn(imageToBase64Module, 'imageUrlToBase64')
|
||||
.mockResolvedValueOnce({
|
||||
base64: 'mockBase64Data',
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
const content: UserMessageContentPart = {
|
||||
image_url: { url: imageUrl },
|
||||
type: 'image_url',
|
||||
};
|
||||
|
||||
const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' });
|
||||
|
||||
expect(result).toEqual({
|
||||
fileData: {
|
||||
fileUri: imageUrl,
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
});
|
||||
|
||||
expect(imageToBase64Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to inlineData when external URL validation fails for HEIC', async () => {
|
||||
const imageUrl = 'https://example.com/image.heic';
|
||||
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
base64: null,
|
||||
mimeType: null,
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
|
||||
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
|
||||
contentLength: 1024,
|
||||
contentType: 'image/heic',
|
||||
isValid: false,
|
||||
reason: 'Unsupported content type: image/heic',
|
||||
});
|
||||
|
||||
const imageToBase64Spy = vi
|
||||
.spyOn(imageToBase64Module, 'imageUrlToBase64')
|
||||
.mockResolvedValueOnce({
|
||||
base64: 'mockBase64Data',
|
||||
mimeType: 'image/heic',
|
||||
});
|
||||
|
||||
const content: UserMessageContentPart = {
|
||||
image_url: { url: imageUrl },
|
||||
type: 'image_url',
|
||||
};
|
||||
|
||||
const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' });
|
||||
|
||||
expect(result).toEqual({
|
||||
inlineData: {
|
||||
data: 'mockBase64Data',
|
||||
mimeType: 'image/heic',
|
||||
},
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
});
|
||||
|
||||
expect(imageToBase64Spy).toHaveBeenCalledWith(imageUrl);
|
||||
});
|
||||
|
||||
it('should force inlineData for external URL images on gemini-2.5 and earlier', async () => {
|
||||
const imageUrl = 'https://example.com/image.png';
|
||||
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
base64: null,
|
||||
mimeType: null,
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
const imageToBase64Spy = vi
|
||||
.spyOn(imageToBase64Module, 'imageUrlToBase64')
|
||||
.mockResolvedValueOnce({
|
||||
base64: 'mockBase64Data',
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
const content: UserMessageContentPart = {
|
||||
image_url: { url: imageUrl },
|
||||
type: 'image_url',
|
||||
};
|
||||
|
||||
const result = await buildGooglePart(content, { model: 'gemini-2.5-flash' });
|
||||
|
||||
expect(result).toEqual({
|
||||
inlineData: {
|
||||
data: 'mockBase64Data',
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
});
|
||||
|
||||
expect(imageToBase64Spy).toHaveBeenCalledWith(imageUrl);
|
||||
expect(isPublicExternalUrl).not.toHaveBeenCalled();
|
||||
expect(validateExternalUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when external URL exceeds size limit', async () => {
|
||||
const imageUrl = 'https://example.com/large-image.png';
|
||||
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
base64: null,
|
||||
mimeType: 'image/png',
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
|
||||
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
|
||||
contentLength: 120 * 1024 * 1024,
|
||||
contentType: 'image/png',
|
||||
isTooLarge: true,
|
||||
isValid: false,
|
||||
reason: 'File too large: 120MB',
|
||||
});
|
||||
|
||||
const imageToBase64Spy = vi
|
||||
.spyOn(imageToBase64Module, 'imageUrlToBase64')
|
||||
.mockResolvedValueOnce({
|
||||
base64: 'mockBase64Data',
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
|
||||
const content: UserMessageContentPart = {
|
||||
image_url: { url: imageUrl },
|
||||
type: 'image_url',
|
||||
};
|
||||
|
||||
await expect(buildGooglePart(content, { model: 'gemini-3-flash' })).rejects.toThrow(
|
||||
RangeError,
|
||||
);
|
||||
expect(imageToBase64Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw TypeError for unsupported image URL types', async () => {
|
||||
const unsupportedImageUrl = 'unsupported://example.com/image.png';
|
||||
|
||||
@@ -182,6 +343,46 @@ describe('google contextBuilders', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use fileData for external URL videos on gemini-3+', async () => {
|
||||
const videoUrl = 'https://example.com/video.mp4';
|
||||
|
||||
vi.mocked(parseDataUri).mockReturnValueOnce({
|
||||
base64: null,
|
||||
mimeType: null,
|
||||
type: 'url',
|
||||
});
|
||||
|
||||
vi.mocked(isPublicExternalUrl).mockReturnValueOnce(true);
|
||||
vi.mocked(validateExternalUrl).mockResolvedValueOnce({
|
||||
contentLength: 1024,
|
||||
contentType: 'video/mp4',
|
||||
isValid: true,
|
||||
});
|
||||
|
||||
const imageToBase64Spy = vi
|
||||
.spyOn(imageToBase64Module, 'imageUrlToBase64')
|
||||
.mockResolvedValueOnce({
|
||||
base64: 'mockVideoBase64Data',
|
||||
mimeType: 'video/mp4',
|
||||
});
|
||||
|
||||
const content: UserMessageContentPart = {
|
||||
type: 'video_url',
|
||||
video_url: { url: videoUrl },
|
||||
};
|
||||
|
||||
const result = await buildGooglePart(content, { model: 'gemini-3-flash-preview' });
|
||||
|
||||
expect(result).toEqual({
|
||||
fileData: {
|
||||
fileUri: videoUrl,
|
||||
mimeType: 'video/mp4',
|
||||
},
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
});
|
||||
expect(imageToBase64Spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return undefined for unsupported SVG image (base64)', async () => {
|
||||
const svgBase64 =
|
||||
'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==';
|
||||
|
||||
@@ -8,14 +8,15 @@ import { imageUrlToBase64, resolveImageMimeTypeFromBase64 } from '@lobechat/util
|
||||
|
||||
import type { ChatCompletionTool, OpenAIChatMessage, UserMessageContentPart } from '../../types';
|
||||
import { safeParseJSON } from '../../utils/safeParseJSON';
|
||||
import { parseDataUri } from '../../utils/uriParser';
|
||||
import { isPublicExternalUrl, parseDataUri, validateExternalUrl } from '../../utils/uriParser';
|
||||
|
||||
const GOOGLE_SUPPORTED_IMAGE_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/jpg', // non-standard but widely used alias for image/jpeg
|
||||
'image/webp',
|
||||
'image/heic',
|
||||
'image/heif',
|
||||
]);
|
||||
|
||||
const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is string =>
|
||||
@@ -30,11 +31,66 @@ const isImageTypeSupported = (mimeType: string | null | undefined): mimeType is
|
||||
*/
|
||||
export const GEMINI_MAGIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
|
||||
|
||||
const getGeminiMajorVersion = (model?: string) => {
|
||||
if (!model) return null;
|
||||
|
||||
// Examples:
|
||||
// - gemini-3-flash-preview
|
||||
// - gemini-2.5-flash
|
||||
const match = model.match(/gemini-(\d+)(?:\.(\d+))?/i);
|
||||
if (!match?.[1]) return null;
|
||||
|
||||
const major = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(major) ? major : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* External HTTP / Signed URLs support varies by model generation.
|
||||
* In practice, Gemini 3+ supports `fileData.fileUri` for external URLs reliably,
|
||||
* while earlier models often require `inlineData`.
|
||||
* Returns false for unversioned model IDs (e.g. gemini-pro) to avoid request failures.
|
||||
*/
|
||||
const supportsExternalUrlFileData = (model?: string) => {
|
||||
const major = getGeminiMajorVersion(model);
|
||||
if (major === null) return false;
|
||||
return major >= 3;
|
||||
};
|
||||
|
||||
const buildExternalUrlFileDataPart = async (
|
||||
url: string,
|
||||
options?: { model?: string },
|
||||
): Promise<Part | undefined> => {
|
||||
if (!supportsExternalUrlFileData(options?.model) || !isPublicExternalUrl(url)) return undefined;
|
||||
|
||||
const validation = await validateExternalUrl(url);
|
||||
if (validation.isValid) {
|
||||
return {
|
||||
fileData: {
|
||||
fileUri: url,
|
||||
mimeType: validation.contentType,
|
||||
},
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
};
|
||||
}
|
||||
|
||||
if (validation.isTooLarge) {
|
||||
throw new RangeError(validation.reason || 'External URL file too large');
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert OpenAI content part to Google Part format
|
||||
*
|
||||
* TODO: urlContext tool only supports files up to 34MB. In the future, we should
|
||||
* detect file URLs in the conversation and use External URL feature (fileData.fileUri)
|
||||
* for files larger than 34MB to avoid urlContext limitations.
|
||||
* @see https://ai.google.dev/gemini-api/docs/file-input-methods
|
||||
*/
|
||||
export const buildGooglePart = async (
|
||||
content: UserMessageContentPart,
|
||||
options?: { model?: string },
|
||||
): Promise<Part | undefined> => {
|
||||
switch (content.type) {
|
||||
default: {
|
||||
@@ -67,12 +123,19 @@ export const buildGooglePart = async (
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
const { base64, mimeType } = await imageUrlToBase64(content.image_url.url);
|
||||
const url = content.image_url.url;
|
||||
|
||||
if (!isImageTypeSupported(mimeType)) return undefined;
|
||||
const externalUrlPart = await buildExternalUrlFileDataPart(url, options);
|
||||
if (externalUrlPart) return externalUrlPart;
|
||||
|
||||
// Fallback: convert URL to base64 (for private/local URLs or failed validation)
|
||||
const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(url);
|
||||
const resolvedMimeType = urlMimeType || mimeType;
|
||||
|
||||
if (!isImageTypeSupported(resolvedMimeType)) return undefined;
|
||||
|
||||
return {
|
||||
inlineData: { data: base64, mimeType },
|
||||
inlineData: { data: urlBase64, mimeType: resolvedMimeType || 'image/png' },
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
};
|
||||
}
|
||||
@@ -95,12 +158,18 @@ export const buildGooglePart = async (
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
const url = content.video_url.url;
|
||||
|
||||
const externalUrlPart = await buildExternalUrlFileDataPart(url, options);
|
||||
if (externalUrlPart) return externalUrlPart;
|
||||
|
||||
// Fallback: convert URL to base64
|
||||
// Use imageUrlToBase64 for SSRF protection (works for any binary data including videos)
|
||||
// Note: This might need size/duration limits for practical use
|
||||
const { base64, mimeType } = await imageUrlToBase64(content.video_url.url);
|
||||
const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(url);
|
||||
|
||||
return {
|
||||
inlineData: { data: base64, mimeType },
|
||||
inlineData: { data: urlBase64, mimeType: urlMimeType },
|
||||
thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE,
|
||||
};
|
||||
}
|
||||
@@ -116,6 +185,7 @@ export const buildGooglePart = async (
|
||||
export const buildGoogleMessage = async (
|
||||
message: OpenAIChatMessage,
|
||||
toolCallNameMap?: Map<string, string>,
|
||||
options?: { model?: string },
|
||||
): Promise<Content> => {
|
||||
const content = message.content as string | UserMessageContentPart[];
|
||||
|
||||
@@ -191,7 +261,7 @@ export const buildGoogleMessage = async (
|
||||
if (typeof content === 'string')
|
||||
return [{ text: content, thoughtSignature: GEMINI_MAGIC_THOUGHT_SIGNATURE }];
|
||||
|
||||
const parts = await Promise.all(content.map(async (c) => await buildGooglePart(c)));
|
||||
const parts = await Promise.all(content.map(async (c) => await buildGooglePart(c, options)));
|
||||
return parts.filter(Boolean) as Part[];
|
||||
};
|
||||
|
||||
@@ -204,7 +274,10 @@ export const buildGoogleMessage = async (
|
||||
/**
|
||||
* Convert messages from the OpenAI format to Google GenAI SDK format
|
||||
*/
|
||||
export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promise<Content[]> => {
|
||||
export const buildGoogleMessages = async (
|
||||
messages: OpenAIChatMessage[],
|
||||
options?: { model?: string },
|
||||
): Promise<Content[]> => {
|
||||
const toolCallNameMap = new Map<string, string>();
|
||||
|
||||
// Build tool call id to name mapping
|
||||
@@ -220,7 +293,7 @@ export const buildGoogleMessages = async (messages: OpenAIChatMessage[]): Promis
|
||||
|
||||
const pools = messages
|
||||
.filter((message) => message.role !== 'function')
|
||||
.map(async (msg) => await buildGoogleMessage(msg, toolCallNameMap));
|
||||
.map(async (msg) => await buildGoogleMessage(msg, toolCallNameMap, options));
|
||||
|
||||
const contents = await Promise.all(pools);
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
||||
thinkingLevel,
|
||||
}) as ThinkingConfig;
|
||||
|
||||
const contents = await buildGoogleMessages(payload.messages);
|
||||
const contents = await buildGoogleMessages(payload.messages, { model });
|
||||
|
||||
const controller = new AbortController();
|
||||
const originalSignal = options?.signal;
|
||||
@@ -328,7 +328,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
||||
*/
|
||||
async generateObject(payload: GenerateObjectPayload, options?: GenerateObjectOptions) {
|
||||
// Convert OpenAI messages to Google format
|
||||
const contents = await buildGoogleMessages(payload.messages);
|
||||
const contents = await buildGoogleMessages(payload.messages, { model: payload.model });
|
||||
const pricing = await getModelPricing(payload.model, this.provider);
|
||||
|
||||
// Handle tools-based structured output
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ssrfSafeFetch } from '@lobechat/ssrf-safe-fetch';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { parseDataUri } from './uriParser';
|
||||
import { parseDataUri, validateExternalUrl } from './uriParser';
|
||||
|
||||
vi.mock('@lobechat/ssrf-safe-fetch', () => ({
|
||||
ssrfSafeFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockHeadResponse = (headers: Record<string, string>, status = 200) =>
|
||||
new Response(null, { headers, status, statusText: status === 200 ? 'OK' : 'Error' });
|
||||
|
||||
describe('parseDataUri', () => {
|
||||
it('should parse a valid data URI', () => {
|
||||
@@ -58,3 +66,113 @@ describe('parseDataUri', () => {
|
||||
expect(result).toEqual({ base64: largePadding, mimeType: 'image/png', type: 'base64' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExternalUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should accept a supported external URL with a valid content length', async () => {
|
||||
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
|
||||
mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/png' }),
|
||||
);
|
||||
|
||||
const result = await validateExternalUrl('https://example.com/image.png');
|
||||
|
||||
expect(result).toEqual({
|
||||
contentLength: 1024,
|
||||
contentType: 'image/png',
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should normalize image/jpg to image/jpeg', async () => {
|
||||
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
|
||||
mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/jpg' }),
|
||||
);
|
||||
|
||||
const result = await validateExternalUrl('https://example.com/image.jpg');
|
||||
|
||||
expect(result).toEqual({
|
||||
contentLength: 1024,
|
||||
contentType: 'image/jpeg',
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept supported external video URLs', async () => {
|
||||
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
|
||||
mockHeadResponse({ 'content-length': '1024', 'content-type': 'video/mp4' }),
|
||||
);
|
||||
|
||||
const result = await validateExternalUrl('https://example.com/video.mp4');
|
||||
|
||||
expect(result).toEqual({
|
||||
contentLength: 1024,
|
||||
contentType: 'video/mp4',
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject supported MIME types when Content-Length is missing', async () => {
|
||||
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
|
||||
mockHeadResponse({ 'content-type': 'image/png' }),
|
||||
);
|
||||
|
||||
const result = await validateExternalUrl('https://example.com/image.png');
|
||||
|
||||
expect(result).toEqual({
|
||||
contentLength: 0,
|
||||
contentType: 'image/png',
|
||||
isValid: false,
|
||||
reason: 'Missing or invalid Content-Length header',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject supported MIME types when Content-Length is invalid', async () => {
|
||||
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
|
||||
mockHeadResponse({ 'content-length': 'unknown', 'content-type': 'image/png' }),
|
||||
);
|
||||
|
||||
const result = await validateExternalUrl('https://example.com/image.png');
|
||||
|
||||
expect(result).toEqual({
|
||||
contentLength: 0,
|
||||
contentType: 'image/png',
|
||||
isValid: false,
|
||||
reason: 'Missing or invalid Content-Length header',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject unsupported MIME types', async () => {
|
||||
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
|
||||
mockHeadResponse({ 'content-length': '1024', 'content-type': 'image/svg+xml' }),
|
||||
);
|
||||
|
||||
const result = await validateExternalUrl('https://example.com/image.svg');
|
||||
|
||||
expect(result).toEqual({
|
||||
contentLength: 1024,
|
||||
contentType: 'image/svg+xml',
|
||||
isValid: false,
|
||||
reason: 'Unsupported content type: image/svg+xml',
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject files larger than the external URL limit', async () => {
|
||||
const tooLarge = 101 * 1024 * 1024;
|
||||
vi.mocked(ssrfSafeFetch).mockResolvedValueOnce(
|
||||
mockHeadResponse({ 'content-length': String(tooLarge), 'content-type': 'image/png' }),
|
||||
);
|
||||
|
||||
const result = await validateExternalUrl('https://example.com/large.png');
|
||||
|
||||
expect(result).toEqual({
|
||||
contentLength: tooLarge,
|
||||
contentType: 'image/png',
|
||||
isTooLarge: true,
|
||||
isValid: false,
|
||||
reason: `File too large: ${tooLarge} bytes (max ${100 * 1024 * 1024} bytes)`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ssrfSafeFetch } from '@lobechat/ssrf-safe-fetch';
|
||||
|
||||
interface UriParserResult {
|
||||
base64: string | null;
|
||||
mimeType: string | null;
|
||||
@@ -29,3 +31,181 @@ export const parseDataUri = (dataUri: string): UriParserResult => {
|
||||
return { base64: null, mimeType: null, type: null };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* MIME types supported by Google Gemini External URL feature
|
||||
* @see https://ai.google.dev/gemini-api/docs/file-input-methods#supported-content-types
|
||||
*/
|
||||
const GOOGLE_EXTERNAL_URL_SUPPORTED_TYPES = new Set([
|
||||
// Text file types
|
||||
'text/html',
|
||||
'text/css',
|
||||
'text/plain',
|
||||
'text/xml',
|
||||
'text/csv',
|
||||
'text/rtf',
|
||||
'text/javascript',
|
||||
// Application file types
|
||||
'application/json',
|
||||
'application/pdf',
|
||||
// Image file types
|
||||
'image/bmp',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
// Video file types
|
||||
'video/3gpp',
|
||||
'video/avi',
|
||||
'video/mp4',
|
||||
'video/mpeg',
|
||||
'video/mpg',
|
||||
'video/quicktime',
|
||||
'video/webm',
|
||||
'video/wmv',
|
||||
'video/x-flv',
|
||||
]);
|
||||
|
||||
const normalizeExternalContentType = (contentType: string): string => {
|
||||
// Some servers return non-standard alias `image/jpg` for JPEG files.
|
||||
// Normalize it to the standard type to avoid unnecessary fallback to inline base64.
|
||||
if (contentType === 'image/jpg') return 'image/jpeg';
|
||||
|
||||
return contentType;
|
||||
};
|
||||
|
||||
const parseContentLength = (contentLength: string | null): number | null => {
|
||||
const normalized = contentLength?.trim();
|
||||
if (!normalized || !/^\d+$/.test(normalized)) return null;
|
||||
|
||||
const value = Number(normalized);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maximum file size limits for Google Gemini file input
|
||||
* @see https://ai.google.dev/gemini-api/docs/file-input-methods#method-comparison
|
||||
*
|
||||
* External URLs: 100MB for all file types
|
||||
* Inline data: 100MB general, 50MB for PDFs
|
||||
*/
|
||||
const MAX_EXTERNAL_URL_SIZE = 100 * 1024 * 1024; // 100MB for external URLs (all types)
|
||||
const MAX_INLINE_DATA_SIZE = 100 * 1024 * 1024; // 100MB for inline data (general)
|
||||
const MAX_INLINE_PDF_SIZE = 50 * 1024 * 1024; // 50MB for inline PDFs only
|
||||
|
||||
export { MAX_INLINE_DATA_SIZE, MAX_INLINE_PDF_SIZE };
|
||||
|
||||
export interface ExternalUrlValidation {
|
||||
/** Content-Length from response headers */
|
||||
contentLength: number;
|
||||
/** Content-Type from response headers */
|
||||
contentType: string;
|
||||
/** Whether the URL was rejected due to size limit */
|
||||
isTooLarge?: boolean;
|
||||
/** Whether the URL is valid for external URL usage */
|
||||
isValid: boolean;
|
||||
/** Reason for invalid URL */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is an external HTTP(S) URL
|
||||
* SSRF protection is enforced by ssrfSafeFetch during validation
|
||||
*/
|
||||
export const isPublicExternalUrl = (url: string): boolean => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate an external URL for Google Gemini file input
|
||||
* Performs a HEAD request to check Content-Length and Content-Type
|
||||
*
|
||||
* @param url - The URL to validate
|
||||
* @returns Validation result with content info
|
||||
*/
|
||||
export const validateExternalUrl = async (url: string): Promise<ExternalUrlValidation> => {
|
||||
try {
|
||||
// Perform HEAD request to get headers without downloading the file
|
||||
const res = await ssrfSafeFetch(
|
||||
url,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'LobeChat/1.0 (https://lobehub.com)',
|
||||
},
|
||||
method: 'HEAD',
|
||||
},
|
||||
{
|
||||
allowIPAddressList: [],
|
||||
allowPrivateIPAddress: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
contentLength: 0,
|
||||
contentType: '',
|
||||
isValid: false,
|
||||
reason: `HTTP ${res.status}: ${res.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const contentLength = parseContentLength(res.headers.get('content-length'));
|
||||
const contentType = normalizeExternalContentType(
|
||||
(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(),
|
||||
);
|
||||
|
||||
// Check MIME type support
|
||||
if (!GOOGLE_EXTERNAL_URL_SUPPORTED_TYPES.has(contentType)) {
|
||||
return {
|
||||
contentLength: contentLength || 0,
|
||||
contentType,
|
||||
isValid: false,
|
||||
reason: `Unsupported content type: ${contentType}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (contentLength === null) {
|
||||
return {
|
||||
contentLength: 0,
|
||||
contentType,
|
||||
isValid: false,
|
||||
reason: 'Missing or invalid Content-Length header',
|
||||
};
|
||||
}
|
||||
|
||||
// Check file size - External URLs support 100MB for all file types
|
||||
// (Unlike inline data where PDFs are limited to 50MB)
|
||||
if (contentLength > MAX_EXTERNAL_URL_SIZE) {
|
||||
return {
|
||||
contentLength,
|
||||
contentType,
|
||||
isTooLarge: true,
|
||||
isValid: false,
|
||||
reason: `File too large: ${contentLength} bytes (max ${MAX_EXTERNAL_URL_SIZE} bytes)`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
contentLength,
|
||||
contentType,
|
||||
isValid: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
contentLength: 0,
|
||||
contentType: '',
|
||||
isValid: false,
|
||||
reason: `Failed to validate URL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,52 +2,56 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { validateVideoFileSize } from './videoValidation';
|
||||
|
||||
const createMockFile = (size: number, type: string): File => ({ size, type }) as File;
|
||||
|
||||
describe('validateVideoFileSize', () => {
|
||||
it('should return valid for non-video files', () => {
|
||||
const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
const mockFile = createMockFile(4, 'text/plain');
|
||||
const result = validateVideoFileSize(mockFile);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.actualSize).toBeUndefined();
|
||||
expect(result.maxSize).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return valid for video files under 20MB', () => {
|
||||
const mockVideoFile = new File(['x'.repeat(10 * 1024 * 1024)], 'video.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
it('should return valid for video files under 100MB', () => {
|
||||
const mockVideoFile = createMockFile(10 * 1024 * 1024, 'video/mp4');
|
||||
const result = validateVideoFileSize(mockVideoFile);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.actualSize).toBe('10.0 MB');
|
||||
});
|
||||
|
||||
it('should return invalid for video files over 20MB', () => {
|
||||
const mockLargeVideoFile = new File(['x'.repeat(25 * 1024 * 1024)], 'large-video.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
it('should return valid for video files under 100MB (25MB)', () => {
|
||||
const mockLargeVideoFile = createMockFile(25 * 1024 * 1024, 'video/mp4');
|
||||
const result = validateVideoFileSize(mockLargeVideoFile);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.actualSize).toBe('25.0 MB');
|
||||
});
|
||||
|
||||
it('should return invalid for video files exactly at 20MB limit plus 1 byte', () => {
|
||||
const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024 + 1)], 'boundary.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
it('should return invalid for video files over 100MB', () => {
|
||||
const mockLargeVideoFile = createMockFile(120 * 1024 * 1024, 'video/mp4');
|
||||
const result = validateVideoFileSize(mockLargeVideoFile);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.actualSize).toBe('120.0 MB');
|
||||
expect(result.maxSize).toBe('100.0 MB');
|
||||
});
|
||||
|
||||
it('should return invalid for video files exactly at 100MB limit plus 1 byte', () => {
|
||||
const mockBoundaryFile = createMockFile(100 * 1024 * 1024 + 1, 'video/mp4');
|
||||
const result = validateVideoFileSize(mockBoundaryFile);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.actualSize).toBe('20.0 MB');
|
||||
expect(result.actualSize).toBe('100.0 MB');
|
||||
});
|
||||
|
||||
it('should return valid for video files exactly at 20MB limit', () => {
|
||||
const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024)], 'boundary.mp4', {
|
||||
type: 'video/mp4',
|
||||
});
|
||||
it('should return valid for video files exactly at 100MB limit', () => {
|
||||
const mockBoundaryFile = createMockFile(100 * 1024 * 1024, 'video/mp4');
|
||||
const result = validateVideoFileSize(mockBoundaryFile);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.actualSize).toBe('20.0 MB');
|
||||
expect(result.actualSize).toBe('100.0 MB');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { formatSize } from '../format';
|
||||
|
||||
const VIDEO_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB in bytes
|
||||
const VIDEO_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB in bytes
|
||||
|
||||
export interface VideoValidationResult {
|
||||
actualSize?: string;
|
||||
isValid: boolean;
|
||||
maxSize?: string;
|
||||
}
|
||||
|
||||
export const validateVideoFileSize = (file: File): VideoValidationResult => {
|
||||
@@ -17,5 +18,6 @@ export const validateVideoFileSize = (file: File): VideoValidationResult => {
|
||||
return {
|
||||
actualSize: formatSize(file.size),
|
||||
isValid,
|
||||
maxSize: formatSize(VIDEO_SIZE_LIMIT),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -412,7 +412,10 @@ const PlusAction = memo(() => {
|
||||
const validation = validateVideoFileSize(file);
|
||||
if (!validation.isValid) {
|
||||
message.error(
|
||||
t('upload.validation.videoSizeExceeded', { actualSize: validation.actualSize }),
|
||||
t('upload.validation.videoSizeExceeded', {
|
||||
actualSize: validation.actualSize,
|
||||
maxSize: validation.maxSize,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ const FileUpload = memo(() => {
|
||||
message.error(
|
||||
t('upload.validation.videoSizeExceeded', {
|
||||
actualSize: validation.actualSize,
|
||||
maxSize: validation.maxSize,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
@@ -163,6 +164,7 @@ const FileUpload = memo(() => {
|
||||
message.error(
|
||||
t('upload.validation.videoSizeExceeded', {
|
||||
actualSize: validation.actualSize,
|
||||
maxSize: validation.maxSize,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
|
||||
@@ -968,7 +968,7 @@ export default {
|
||||
'upload.validation.unsupportedFileType':
|
||||
'Unsupported file type: {{files}}. Supported images: JPG, PNG, GIF, WebP. Supported documents include PDF, Word, Excel, PowerPoint, Markdown, text, CSV, JSON, and code files.',
|
||||
'upload.validation.videoSizeExceeded':
|
||||
'Video file size must not exceed 20MB. Current file size is {{actualSize}}.',
|
||||
'Video file size must not exceed {{maxSize}}. Current file size is {{actualSize}}.',
|
||||
'viewMode.fullWidth': 'Full Width',
|
||||
'viewMode.normal': 'Standard',
|
||||
'viewMode.wideScreen': 'Widescreen',
|
||||
|
||||
Reference in New Issue
Block a user