Refactor saveBase64AsFile uploads (#4200) * Refactor saveBase64AsFile uploads * Add request body check * Extract server-side constants * Allow .jfif media attachments * Allow .bmp uploads * Enhance image prompt handling: support additional MIME types and prevent upscaling in thumbnails * Convert file extension to lowercase * Enhance thumbnail creation: improve image quality and add white background * Add toast for error in media upload

dbe01110349816e3a71438d9c7d7a064b5d6be8e

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
9 files changed, +88 -56Showing whitespace changes
public/scripts/chats.js+3 -1
@@ -43,6 +43,7 @@ import {
43 extractTextFromOffice,43 extractTextFromOffice,
44 download,44 download,
45 getFileText,45 getFileText,
46 getFileExtension,
46} from './utils.js';47} from './utils.js';
47import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';48import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
48import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';49import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -204,7 +205,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
204 const fileNamePrefix = `${Date.now()}_${slug}`;205 const fileNamePrefix = `${Date.now()}_${slug}`;
205 const fileBase64 = await getBase64Async(file);206 const fileBase64 = await getBase64Async(file);
206 let base64Data = fileBase64.split(',')[1];207 let base64Data = fileBase64.split(',')[1];
207 const extension = file.name.substring((file.name.lastIndexOf('.') + file.name.length) % file.name.length + 1);208 const extension = getFileExtension(file);
208209
209 // If file is image210 // If file is image
210 if (file.type.startsWith('image/')) {211 if (file.type.startsWith('image/')) {
@@ -246,6 +247,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
246247
247 } catch (error) {248 } catch (error) {
248 console.error('Could not upload file', error);249 console.error('Could not upload file', error);
250 toastr.error(t`Either the file is corrupted or its format is not supported.`, t`Could not upload the file`);
249 } finally {251 } finally {
250 $('#file_form').trigger('reset');252 $('#file_form').trigger('reset');
251 }253 }
public/scripts/constants.js+1 -1
@@ -27,4 +27,4 @@ export const IGNORE_SYMBOL = Symbol.for('ignore');
27 * Common video file extensions. Should be the same as supported by Gemini.27 * Common video file extensions. Should be the same as supported by Gemini.
28 * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats28 * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats
29 */29 */
30export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'];30export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg'];
public/scripts/extensions/caption/index.js+3 -3
@@ -1,4 +1,4 @@
1import { ensureImageFormatSupported, getBase64Async, isTrueBoolean, saveBase64AsFile } from '../../utils.js';1import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
2import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';2import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
3import { appendMediaToMessage, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';3import { appendMediaToMessage, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
4import { getMessageTimeStamp } from '../../RossAscends-mods.js';4import { getMessageTimeStamp } from '../../RossAscends-mods.js';
@@ -327,11 +327,11 @@ async function getCaptionForFile(file, prompt, quiet) {
327 setSpinnerIcon();327 setSpinnerIcon();
328 const context = getContext();328 const context = getContext();
329 const fileData = await getBase64Async(await ensureImageFormatSupported(file));329 const fileData = await getBase64Async(await ensureImageFormatSupported(file));
330 const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1];330 const extension = getFileExtension(file);
331 const base64Data = fileData.split(',')[1];331 const base64Data = fileData.split(',')[1];
332 const { caption } = await doCaptionRequest(base64Data, fileData, prompt);332 const { caption } = await doCaptionRequest(base64Data, fileData, prompt);
333 if (!quiet) {333 if (!quiet) {
334 const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Format);334 const imagePath = await saveBase64AsFile(base64Data, context.name2, '', extension);
335 await sendCaptionedMessage(caption, imagePath);335 await sendCaptionedMessage(caption, imagePath);
336 }336 }
337 return caption;337 return caption;
public/scripts/extensions/gallery/index.js+6 -21
@@ -8,7 +8,7 @@ import {
8 animation_easing,8 animation_easing,
9} from '../../../script.js';9} from '../../../script.js';
10import { groups, selected_group } from '../../group-chats.js';10import { groups, selected_group } from '../../group-chats.js';
11import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename } from '../../utils.js';11import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension } from '../../utils.js';
12import { loadMovingUIState } from '../../power-user.js';12import { loadMovingUIState } from '../../power-user.js';
13import { dragElement } from '../../RossAscends-mods.js';13import { dragElement } from '../../RossAscends-mods.js';
14import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';14import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -341,27 +341,12 @@ async function showCharGallery(deleteModeState = false) {
341async function uploadFile(file, url) {341async function uploadFile(file, url) {
342 try {342 try {
343 // Convert the file to a base64 string343 // Convert the file to a base64 string
344 const base64Data = await getBase64Async(file);344 const fileBase64 = await getBase64Async(file);
345 const base64Data = fileBase64.split(',')[1];
346 const extension = getFileExtension(file);
347 const path = await saveBase64AsFile(base64Data, url, '', extension);
345348
346 // Create the payload349 toastr.success(t`File uploaded successfully. Saved at: ${path}`);
347 const payload = {
348 image: base64Data,
349 ch_name: url,
350 };
351
352 const response = await fetch('/api/images/upload', {
353 method: 'POST',
354 headers: getRequestHeaders(),
355 body: JSON.stringify(payload),
356 });
357
358 if (!response.ok) {
359 throw new Error(`HTTP error! Status: ${response.status}`);
360 }
361
362 const result = await response.json();
363
364 toastr.success(t`File uploaded successfully. Saved at: ${result.path}`);
365 } catch (error) {350 } catch (error) {
366 console.error('There was an issue uploading the file:', error);351 console.error('There was an issue uploading the file:', error);
367352
public/scripts/extensions/shared.js+6 -2
@@ -38,10 +38,14 @@ export async function getMultimodalCaption(base64Img, prompt) {
38 const isVllm = extension_settings.caption.multimodal_api === 'vllm';38 const isVllm = extension_settings.caption.multimodal_api === 'vllm';
39 const base64Bytes = base64Img.length * 0.75;39 const base64Bytes = base64Img.length * 0.75;
40 const compressionLimit = 2 * 1024 * 1024;40 const compressionLimit = 2 * 1024 * 1024;
41 const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
42 const mimeType = base64Img?.split(';')?.[0]?.split(':')?.[1];
41 const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api);43 const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api);
42 if ((thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) {44 if ((thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) {
43 const maxSide = 1024;45 const maxSide = 2048;
44 base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg');46 base64Img = await createThumbnail(base64Img, maxSide, maxSide);
47 } else if (!safeMimeTypes.includes(mimeType)) {
48 base64Img = await createThumbnail(base64Img, null, null);
45 }49 }
4650
47 const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : '';51 const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : '';
public/scripts/openai.js+7 -5
@@ -2949,13 +2949,15 @@ class Message {
2949 chat_completion_sources.MISTRALAI,2949 chat_completion_sources.MISTRALAI,
2950 chat_completion_sources.VERTEXAI,2950 chat_completion_sources.VERTEXAI,
2951 ];2951 ];
2952 if (compressImageSources.includes(oai_settings.chat_completion_source)) {
2953 const sizeThreshold = 2 * 1024 * 1024;2952 const sizeThreshold = 2 * 1024 * 1024;
2954 const dataSize = image.length * 0.75;2953 const dataSize = image.length * 0.75;
2955 const maxSide = 1024;2954 const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
2956 if (dataSize > sizeThreshold) {2955 const mimeType = image?.split(';')?.[0]?.split(':')?.[1];
2957 image = await createThumbnail(image, maxSide);2956 if (compressImageSources.includes(oai_settings.chat_completion_source) && dataSize > sizeThreshold) {
2958 }2957 const maxSide = 2048;
2958 image = await createThumbnail(image, maxSide, maxSide);
2959 } else if (!safeMimeTypes.includes(mimeType)) {
2960 image = await createThumbnail(image, null, null);
2959 }2961 }
2960 return image;2962 return image;
2961 }2963 }
public/scripts/utils.js+28 -14
@@ -1410,32 +1410,27 @@ export async function getSanitizedFilename(fileName) {
1410 * Sends a base64 encoded image to the backend to be saved as a file.1410 * Sends a base64 encoded image to the backend to be saved as a file.
1411 *1411 *
1412 * @param {string} base64Data - The base64 encoded image data.1412 * @param {string} base64Data - The base64 encoded image data.
1413 * @param {string} characterName - The character name to determine the sub-directory for saving.1413 * @param {string} subFolder - The character name to determine the sub-directory for saving.
1414 * @param {string} ext - The file extension for the image (e.g., 'jpg', 'png', 'webp').1414 * @param {string} fileName - The name of the file to save the image as (without extension).
1415 * @param {string} extension - The file extension for the image (e.g., 'jpg', 'png', 'webp').
1415 *1416 *
1416 * @returns {Promise<string>} - Resolves to the saved image's path on the server.1417 * @returns {Promise<string>} - Resolves to the saved image's path on the server.
1417 * Rejects with an error if the upload fails.1418 * Rejects with an error if the upload fails.
1418 */1419 */
1419export async function saveBase64AsFile(base64Data, characterName, filename = '', ext) {1420export async function saveBase64AsFile(base64Data, subFolder, fileName, extension) {
1420 // Construct the full data URL
1421 const format = ext; // Extract the file extension (jpg, png, webp)
1422 const dataURL = `data:image/${format};base64,${base64Data}`;
1423
1424 // Prepare the request body1421 // Prepare the request body
1425 const requestBody = {1422 const requestBody = {
1426 image: dataURL,1423 image: base64Data,
1427 ch_name: characterName,1424 format: extension,
1428 filename: String(filename).replace(/\./g, '_'),1425 ch_name: subFolder,
1426 filename: String(fileName).replace(/\./g, '_'),
1429 };1427 };
14301428
1431 // Send the data URL to your backend using fetch1429 // Send the data URL to your backend using fetch
1432 const response = await fetch('/api/images/upload', {1430 const response = await fetch('/api/images/upload', {
1433 method: 'POST',1431 method: 'POST',
1432 headers: getRequestHeaders(),
1434 body: JSON.stringify(requestBody),1433 body: JSON.stringify(requestBody),
1435 headers: {
1436 ...getRequestHeaders(),
1437 'Content-Type': 'application/json',
1438 },
1439 });1434 });
14401435
1441 // If the response is successful, get the saved image path from the server's response1436 // If the response is successful, get the saved image path from the server's response
@@ -1449,6 +1444,15 @@ export async function saveBase64AsFile(base64Data, characterName, filename = '',
1449}1444}
14501445
1451/**1446/**
1447 * Gets the file extension from a File object.
1448 * @param {File} file The file to get the extension from
1449 * @returns {string} The file extension of the given file
1450 */
1451export function getFileExtension(file) {
1452 return file.name.substring((file.name.lastIndexOf('.') + file.name.length) % file.name.length + 1).toLowerCase().trim();
1453}
1454
1455/**
1452 * Loads either a CSS or JS file and appends it to the appropriate document section.1456 * Loads either a CSS or JS file and appends it to the appropriate document section.
1453 *1457 *
1454 * @param {string} url - The URL of the file to be loaded.1458 * @param {string} url - The URL of the file to be loaded.
@@ -1554,15 +1558,25 @@ export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type
1554 maxHeight = img.height;1558 maxHeight = img.height;
1555 }1559 }
15561560
1561 // Do not upscale if image is already smaller than max dimensions
1562 if (img.width <= maxWidth && img.height <= maxHeight) {
1563 thumbnailWidth = img.width;
1564 thumbnailHeight = img.height;
1565 } else {
1557 if (img.width > img.height) {1566 if (img.width > img.height) {
1558 thumbnailHeight = maxWidth / aspectRatio;1567 thumbnailHeight = maxWidth / aspectRatio;
1559 } else {1568 } else {
1560 thumbnailWidth = maxHeight * aspectRatio;1569 thumbnailWidth = maxHeight * aspectRatio;
1561 }1570 }
1571 }
15621572
1563 // Set the canvas dimensions and draw the resized image1573 // Set the canvas dimensions and draw the resized image
1564 canvas.width = thumbnailWidth;1574 canvas.width = thumbnailWidth;
1565 canvas.height = thumbnailHeight;1575 canvas.height = thumbnailHeight;
1576 ctx.imageSmoothingEnabled = true;
1577 ctx.imageSmoothingQuality = 'high';
1578 ctx.fillStyle = 'white';
1579 ctx.fillRect(0, 0, thumbnailWidth, thumbnailHeight);
1566 ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight);1580 ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight);
15671581
1568 // Convert the canvas to a data URL and resolve the promise1582 // Convert the canvas to a data URL and resolve the promise
src/constants.js+23 -0
@@ -412,3 +412,26 @@ export const LOG_LEVELS = {
412 WARN: 2,412 WARN: 2,
413 ERROR: 3,413 ERROR: 3,
414};414};
415
416/**
417 * An array of supported media file extensions.
418 * This is used to validate file uploads and ensure that only supported media types are processed.
419 */
420export const MEDIA_EXTENSIONS = [
421 'bmp',
422 'png',
423 'jpg',
424 'webp',
425 'jpeg',
426 'jfif',
427 'gif',
428 'mp4',
429 'avi',
430 'mov',
431 'wmv',
432 'flv',
433 'webm',
434 '3gp',
435 'mkv',
436 'mpg',
437];
src/endpoints/images.js+11 -9
@@ -6,6 +6,7 @@ import express from 'express';
6import sanitize from 'sanitize-filename';6import sanitize from 'sanitize-filename';
77
8import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js';8import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js';
9import { MEDIA_EXTENSIONS } from '../constants.js';
910
10/**11/**
11 * Ensure the directory for the provided file path exists.12 * Ensure the directory for the provided file path exists.
@@ -36,17 +37,18 @@ export const router = express.Router();
36 * @returns {Object} response - The response object containing the path where the image was saved.37 * @returns {Object} response - The response object containing the path where the image was saved.
37 */38 */
38router.post('/upload', async (request, response) => {39router.post('/upload', async (request, response) => {
39 // Check for image data40 try {
40 if (!request.body || !request.body.image) {41 if (!request.body) {
42 return response.status(400).send({ error: 'No data provided' });
43 }
44
45 const { image, format } = request.body;
46
47 if (!image) {
41 return response.status(400).send({ error: 'No image data provided' });48 return response.status(400).send({ error: 'No image data provided' });
42 }49 }
4350
44 try {51 const validFormat = MEDIA_EXTENSIONS.includes(format);
45 // Extracting the base64 data and the image format
46 const splitParts = request.body.image.split(',');
47 const format = splitParts[0].split(';')[0].split('/')[1];
48 const base64Data = splitParts[1];
49 const validFormat = ['png', 'jpg', 'webp', 'jpeg', 'gif', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv'].includes(format);
50 if (!validFormat) {52 if (!validFormat) {
51 return response.status(400).send({ error: 'Invalid image format' });53 return response.status(400).send({ error: 'Invalid image format' });
52 }54 }
@@ -66,7 +68,7 @@ router.post('/upload', async (request, response) => {
66 }68 }
6769
68 ensureDirectoryExistence(pathToNewFile);70 ensureDirectoryExistence(pathToNewFile);
69 const imageBuffer = Buffer.from(base64Data, 'base64');71 const imageBuffer = Buffer.from(image, 'base64');
70 await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer));72 await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer));
71 response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) });73 response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) });
72 } catch (error) {74 } catch (error) {