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 {
4343 extractTextFromOffice,
4444 download,
4545 getFileText,
46+ getFileExtension,
4647} from './utils.js';
4748import { extension_settings, renderExtensionTemplateAsync, saveMetadataDebounced } from './extensions.js';
4849import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -204,7 +205,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
204205 const fileNamePrefix = `${Date.now()}_${slug}`;
205206 const fileBase64 = await getBase64Async(file);
206207 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
209210 // If file is image
210211 if (file.type.startsWith('image/')) {
@@ -246,6 +247,7 @@ export async function populateFileAttachment(message, inputId = 'file_form_input
246247
247248 } catch (error) {
248249 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`);
249251 } finally {
250252 $('#file_form').trigger('reset');
251253 }
public/scripts/constants.js+1 -1
@@ -27,4 +27,4 @@ export const IGNORE_SYMBOL = Symbol.for('ignore');
2727 * Common video file extensions. Should be the same as supported by Gemini.
2828 * https://ai.google.dev/gemini-api/docs/video-understanding#supported-formats
2929 */
3030export const VIDEO_EXTENSIONS = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', '3gp', 'mkv', 'mpg'];
public/scripts/extensions/caption/index.js+3 -3
@@ -1,4 +1,4 @@
11import { ensureImageFormatSupported, getBase64Async, getFileExtension, isTrueBoolean, saveBase64AsFile } from '../../utils.js';
22import { getContext, getApiUrl, doExtrasFetch, extension_settings, modules, renderExtensionTemplateAsync } from '../../extensions.js';
33import { appendMediaToMessage, eventSource, event_types, getRequestHeaders, saveChatConditional, saveSettingsDebounced, substituteParamsExtended } from '../../../script.js';
44import { getMessageTimeStamp } from '../../RossAscends-mods.js';
@@ -327,11 +327,11 @@ async function getCaptionForFile(file, prompt, quiet) {
327327 setSpinnerIcon();
328328 const context = getContext();
329329 const fileData = await getBase64Async(await ensureImageFormatSupported(file));
330- const base64Format = fileData.split(',')[0].split(';')[0].split('/')[1];
330+ const extension = getFileExtension(file);
331331 const base64Data = fileData.split(',')[1];
332332 const { caption } = await doCaptionRequest(base64Data, fileData, prompt);
333333 if (!quiet) {
334334 const imagePath = await saveBase64AsFile(base64Data, context.name2, '', base64Formatextension);
335335 await sendCaptionedMessage(caption, imagePath);
336336 }
337337 return caption;
public/scripts/extensions/gallery/index.js+6 -21
@@ -8,7 +8,7 @@ import {
88 animation_easing,
99} from '../../../script.js';
1010import { groups, selected_group } from '../../group-chats.js';
1111import { loadFileToDocument, delay, getBase64Async, getSanitizedFilename, saveBase64AsFile, getFileExtension } from '../../utils.js';
1212import { loadMovingUIState } from '../../power-user.js';
1313import { dragElement } from '../../RossAscends-mods.js';
1414import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -341,27 +341,12 @@ async function showCharGallery(deleteModeState = false) {
341341async function uploadFile(file, url) {
342342 try {
343343 // Convert the file to a base64 string
344344 const base64DatafileBase64 = 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 payload
349+ 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}`);
365350 } catch (error) {
366351 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) {
3838 const isVllm = extension_settings.caption.multimodal_api === 'vllm';
3939 const base64Bytes = base64Img.length * 0.75;
4040 const compressionLimit = 2 * 1024 * 1024;
41+ const safeMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
42+ const mimeType = base64Img?.split(';')?.[0]?.split(':')?.[1];
4143 const thumbnailNeeded = ['google', 'openrouter', 'mistral', 'groq', 'vertexai'].includes(extension_settings.caption.multimodal_api);
4244 if ((thumbnailNeeded && base64Bytes > compressionLimit) || isOoba || isKoboldCpp) {
4345 const maxSide = 10242048;
4446 base64Img = await createThumbnail(base64Img, maxSide, maxSide, 'image/jpeg');
47+ } else if (!safeMimeTypes.includes(mimeType)) {
48+ base64Img = await createThumbnail(base64Img, null, null);
4549 }
4650
4751 const proxyUrl = useReverseProxy ? oai_settings.reverse_proxy : '';
public/scripts/openai.js+7 -5
@@ -2949,13 +2949,15 @@ class Message {
29492949 chat_completion_sources.MISTRALAI,
29502950 chat_completion_sources.VERTEXAI,
29512951 ];
2952- if (compressImageSources.includes(oai_settings.chat_completion_source)) {
29532952 const sizeThreshold = 2 * 1024 * 1024;
29542953 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);
29592961 }
29602962 return image;
29612963 }
public/scripts/utils.js+28 -14
@@ -1410,32 +1410,27 @@ export async function getSanitizedFilename(fileName) {
14101410 * Sends a base64 encoded image to the backend to be saved as a file.
14111411 *
14121412 * @param {string} base64Data - The base64 encoded image data.
14131413 * @param {string} characterNamesubFolder - The character name to determine the sub-directory for saving.
14141414 * @param {string} extfileName - The name of the file extensionto forsave the image (e.g., 'jpg',as 'png',(without 'webp'extension).
1415+ * @param {string} extension - The file extension for the image (e.g., 'jpg', 'png', 'webp').
14151416 *
14161417 * @returns {Promise<string>} - Resolves to the saved image's path on the server.
14171418 * Rejects with an error if the upload fails.
14181419 */
14191420export async function saveBase64AsFile(base64Data, characterNamesubFolder, filename = ''fileName, extextension) {
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-
14241421 // Prepare the request body
14251422 const requestBody = {
14261423 image: dataURLbase64Data,
14271424 ch_nameformat: characterNameextension,
1428- filename: String(filename).replace(/\./g, '_'),
1425+ ch_name: subFolder,
1426+ filename: String(fileName).replace(/\./g, '_'),
14291427 };
14301428
14311429 // Send the data URL to your backend using fetch
14321430 const response = await fetch('/api/images/upload', {
14331431 method: 'POST',
1432+ headers: getRequestHeaders(),
14341433 body: JSON.stringify(requestBody),
1435- headers: {
1436- ...getRequestHeaders(),
1437- 'Content-Type': 'application/json',
1438- },
14391434 });
14401435
14411436 // 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 = '',
14491444}
14501445
14511446/**
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+ */
1451+export function getFileExtension(file) {
1452+ return file.name.substring((file.name.lastIndexOf('.') + file.name.length) % file.name.length + 1).toLowerCase().trim();
1453+}
1454+
1455+/**
14521456 * Loads either a CSS or JS file and appends it to the appropriate document section.
14531457 *
14541458 * @param {string} url - The URL of the file to be loaded.
@@ -1554,15 +1558,25 @@ export function createThumbnail(dataUrl, maxWidth = null, maxHeight = null, type
15541558 maxHeight = img.height;
15551559 }
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 {
15571566 if (img.width > img.height) {
15581567 thumbnailHeight = maxWidth / aspectRatio;
15591568 } else {
15601569 thumbnailWidth = maxHeight * aspectRatio;
15611570 }
1571+ }
15621572
15631573 // Set the canvas dimensions and draw the resized image
15641574 canvas.width = thumbnailWidth;
15651575 canvas.height = thumbnailHeight;
1576+ ctx.imageSmoothingEnabled = true;
1577+ ctx.imageSmoothingQuality = 'high';
1578+ ctx.fillStyle = 'white';
1579+ ctx.fillRect(0, 0, thumbnailWidth, thumbnailHeight);
15661580 ctx.drawImage(img, 0, 0, thumbnailWidth, thumbnailHeight);
15671581
15681582 // Convert the canvas to a data URL and resolve the promise
src/constants.js+23 -0
@@ -412,3 +412,26 @@ export const LOG_LEVELS = {
412412 WARN: 2,
413413 ERROR: 3,
414414};
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+ */
420+export 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';
66import sanitize from 'sanitize-filename';
77
88import { clientRelativePath, removeFileExtension, getImages, isPathUnderParent } from '../util.js';
9+import { MEDIA_EXTENSIONS } from '../constants.js';
910
1011/**
1112 * Ensure the directory for the provided file path exists.
@@ -36,17 +37,18 @@ export const router = express.Router();
3637 * @returns {Object} response - The response object containing the path where the image was saved.
3738 */
3839router.post('/upload', async (request, response) => {
39- // Check for image data
40+ try {
4041 if (!request.body || !request.body.image) {
42+ return response.status(400).send({ error: 'No data provided' });
43+ }
44+
45+ const { image, format } = request.body;
46+
47+ if (!image) {
4148 return response.status(400).send({ error: 'No image data provided' });
4249 }
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);
5052 if (!validFormat) {
5153 return response.status(400).send({ error: 'Invalid image format' });
5254 }
@@ -66,7 +68,7 @@ router.post('/upload', async (request, response) => {
6668 }
6769
6870 ensureDirectoryExistence(pathToNewFile);
6971 const imageBuffer = Buffer.from(base64Dataimage, 'base64');
7072 await fs.promises.writeFile(pathToNewFile, new Uint8Array(imageBuffer));
7173 response.send({ path: clientRelativePath(request.user.directories.root, pathToNewFile) });
7274 } catch (error) {