🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh (#6720)

* 🔧 feat: Add configurable S3 URL refresh expiry time

* fix: Set default width and height for URLIcon component in case container style results in NaN

* refactor: Enhance auto-save functionality with debounced restore methods

* feat: Add support for additionalProperties in JSON schema conversion to Zod

* test: Add tests for additionalProperties handling in JSON schema to Zod conversion

* chore: Reorder import statements for better readability in ask route

* fix: Handle additional successful response status code (200) in SSE error handler

* fix: add missing rate limiting middleware for bedrock and agent chat routes

* fix: update moderation middleware to check feature flag before processing requests

* fix: add moderation middleware to chat routes for text moderation

* Revert "refactor: Enhance auto-save functionality with debounced restore methods"

This reverts commit d2e4134d1f.

* refactor: Move base64 encoding/decoding functions to top-level scope and optimize input handling
This commit is contained in:
Danny Avila 2025-04-03 20:42:56 -04:00 committed by GitHub
parent 95ecd05046
commit 953e9732d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 300 additions and 73 deletions

View file

@ -1,10 +1,13 @@
const axios = require('axios');
const { ErrorTypes } = require('librechat-data-provider');
const { isEnabled } = require('~/server/utils');
const denyRequest = require('./denyRequest');
const { logger } = require('~/config');
async function moderateText(req, res, next) {
if (process.env.OPENAI_MODERATION === 'true') {
if (!isEnabled(process.env.OPENAI_MODERATION)) {
return next();
}
try {
const { text } = req.body;
@ -34,7 +37,6 @@ async function moderateText(req, res, next) {
const errorMessage = 'error in moderation check';
return await denyRequest(req, res, errorMessage);
}
}
next();
}

View file

@ -3,6 +3,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider');
const {
setHeaders,
handleAbort,
moderateText,
// validateModel,
generateCheckAccess,
validateConvoAccess,
@ -14,6 +15,7 @@ const addTitle = require('~/server/services/Endpoints/agents/title');
const router = express.Router();
router.use(moderateText);
router.post('/abort', handleAbort());
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);

View file

@ -1,21 +1,40 @@
const express = require('express');
const router = express.Router();
const {
uaParser,
checkBan,
requireJwtAuth,
// concurrentLimiter,
// messageIpLimiter,
// messageUserLimiter,
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
} = require('~/server/middleware');
const { isEnabled } = require('~/server/utils');
const { v1 } = require('./v1');
const chat = require('./chat');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
const router = express.Router();
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
router.use('/', v1);
router.use('/chat', chat);
const chatRouter = express.Router();
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
chatRouter.use(concurrentLimiter);
}
if (isEnabled(LIMIT_MESSAGE_IP)) {
chatRouter.use(messageIpLimiter);
}
if (isEnabled(LIMIT_MESSAGE_USER)) {
chatRouter.use(messageUserLimiter);
}
chatRouter.use('/', chat);
router.use('/chat', chatRouter);
module.exports = router;

View file

@ -1,10 +1,4 @@
const express = require('express');
const openAI = require('./openAI');
const custom = require('./custom');
const google = require('./google');
const anthropic = require('./anthropic');
const gptPlugins = require('./gptPlugins');
const { isEnabled } = require('~/server/utils');
const { EModelEndpoint } = require('librechat-data-provider');
const {
uaParser,
@ -15,6 +9,12 @@ const {
messageUserLimiter,
validateConvoAccess,
} = require('~/server/middleware');
const { isEnabled } = require('~/server/utils');
const gptPlugins = require('./gptPlugins');
const anthropic = require('./anthropic');
const custom = require('./custom');
const google = require('./google');
const openAI = require('./openAI');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};

View file

@ -4,6 +4,7 @@ const router = express.Router();
const {
setHeaders,
handleAbort,
moderateText,
// validateModel,
// validateEndpoint,
buildEndpointOption,
@ -12,6 +13,7 @@ const { initializeClient } = require('~/server/services/Endpoints/bedrock');
const AgentController = require('~/server/controllers/agents/request');
const addTitle = require('~/server/services/Endpoints/agents/title');
router.use(moderateText);
router.post('/abort', handleAbort());
/**

View file

@ -1,19 +1,35 @@
const express = require('express');
const router = express.Router();
const {
uaParser,
checkBan,
requireJwtAuth,
// concurrentLimiter,
// messageIpLimiter,
// messageUserLimiter,
messageIpLimiter,
concurrentLimiter,
messageUserLimiter,
} = require('~/server/middleware');
const { isEnabled } = require('~/server/utils');
const chat = require('./chat');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
const router = express.Router();
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
router.use(concurrentLimiter);
}
if (isEnabled(LIMIT_MESSAGE_IP)) {
router.use(messageIpLimiter);
}
if (isEnabled(LIMIT_MESSAGE_USER)) {
router.use(messageUserLimiter);
}
router.use('/chat', chat);
module.exports = router;

View file

@ -16,6 +16,7 @@ const bucketName = process.env.AWS_BUCKET_NAME;
const defaultBasePath = 'images';
let s3UrlExpirySeconds = 7 * 24 * 60 * 60;
let s3RefreshExpiryMs = null;
if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) {
const parsed = parseInt(process.env.S3_URL_EXPIRY_SECONDS, 10);
@ -29,6 +30,19 @@ if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) {
}
}
if (process.env.S3_REFRESH_EXPIRY_MS !== null && process.env.S3_REFRESH_EXPIRY_MS) {
const parsed = parseInt(process.env.S3_REFRESH_EXPIRY_MS, 10);
if (!isNaN(parsed) && parsed > 0) {
s3RefreshExpiryMs = parsed;
logger.info(`[S3] Using custom refresh expiry time: ${s3RefreshExpiryMs}ms`);
} else {
logger.warn(
`[S3] Invalid S3_REFRESH_EXPIRY_MS value: "${process.env.S3_REFRESH_EXPIRY_MS}". Using default refresh logic.`,
);
}
}
/**
* Constructs the S3 key based on the base path, user ID, and file name.
*/
@ -293,8 +307,16 @@ function needsRefresh(signedUrl, bufferSeconds) {
// Check if it's close to expiration
const now = new Date();
const bufferTime = new Date(now.getTime() + bufferSeconds * 1000);
// If S3_REFRESH_EXPIRY_MS is set, use it to determine if URL is expired
if (s3RefreshExpiryMs !== null) {
const urlCreationTime = dateObj.getTime();
const urlAge = now.getTime() - urlCreationTime;
return urlAge >= s3RefreshExpiryMs;
}
// Otherwise use the default buffer-based logic
const bufferTime = new Date(now.getTime() + bufferSeconds * 1000);
return expiresAtDate <= bufferTime;
} catch (error) {
logger.error('Error checking URL expiration:', error);

View file

@ -55,8 +55,8 @@ export const URLIcon = memo(
onError={handleImageError}
loading="lazy"
decoding="async"
width={Number(containerStyle.width)}
height={Number(containerStyle.height)}
width={Number(containerStyle.width) || 20}
height={Number(containerStyle.height) || 20}
/>
</div>
);

View file

@ -11,6 +11,28 @@ const clearDraft = debounce((id?: string | null) => {
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`);
}, 2500);
const encodeBase64 = (plainText: string): string => {
try {
const textBytes = new TextEncoder().encode(plainText);
return btoa(String.fromCharCode(...textBytes));
} catch (e) {
return '';
}
};
const decodeBase64 = (base64String: string): string => {
try {
const bytes = atob(base64String);
const uint8Array = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
uint8Array[i] = bytes.charCodeAt(i);
}
return new TextDecoder().decode(uint8Array);
} catch (e) {
return '';
}
};
export const useAutoSave = ({
conversationId,
textAreaRef,
@ -30,28 +52,6 @@ export const useAutoSave = ({
const fileIds = useMemo(() => Array.from(files.keys()), [files]);
const { data: fileList } = useGetFiles<TFile[]>();
const encodeBase64 = (plainText: string): string => {
try {
const textBytes = new TextEncoder().encode(plainText);
return btoa(String.fromCharCode(...textBytes));
} catch (e) {
return '';
}
};
const decodeBase64 = (base64String: string): string => {
try {
const bytes = atob(base64String);
const uint8Array = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
uint8Array[i] = bytes.charCodeAt(i);
}
return new TextDecoder().decode(uint8Array);
} catch (e) {
return '';
}
};
const restoreFiles = useCallback(
(id: string) => {
const filesDraft = JSON.parse(
@ -126,16 +126,17 @@ export const useAutoSave = ({
return;
}
const handleInput = debounce(() => {
if (textAreaRef?.current && textAreaRef.current.value) {
const handleInput = debounce((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
if (value) {
localStorage.setItem(
`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`,
encodeBase64(textAreaRef.current.value),
encodeBase64(value),
);
} else {
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`);
}
}, 1000);
}, 750);
const textArea = textAreaRef?.current;
if (textArea) {

View file

@ -645,7 +645,7 @@ export default function useEventHandlers({
} else {
cancelHandler(data, submission);
}
} else if (response.status === 204) {
} else if (response.status === 204 || response.status === 200) {
const responseMessage = {
...submission.initialResponse,
};

View file

@ -1,4 +1,3 @@
/* eslint-disable jest/no-conditional-expect */
/* eslint-disable @typescript-eslint/no-explicit-any */
// zod.spec.ts
import { z } from 'zod';
@ -468,6 +467,156 @@ describe('convertJsonSchemaToZod', () => {
});
});
describe('additionalProperties handling', () => {
it('should allow any additional properties when additionalProperties is true', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
name: { type: 'string' },
},
additionalProperties: true,
};
const zodSchema = convertJsonSchemaToZod(schema);
// Should accept the defined property
expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' });
// Should also accept additional properties of any type
expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 });
expect(zodSchema?.parse({ name: 'John', isActive: true })).toEqual({
name: 'John',
isActive: true,
});
expect(zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toEqual({
name: 'John',
tags: ['tag1', 'tag2'],
});
});
it('should validate additional properties according to schema when additionalProperties is an object', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
name: { type: 'string' },
},
additionalProperties: { type: 'number' },
};
const zodSchema = convertJsonSchemaToZod(schema);
// Should accept the defined property
expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' });
// Should accept additional properties that match the additionalProperties schema
expect(zodSchema?.parse({ name: 'John', age: 30, score: 100 })).toEqual({
name: 'John',
age: 30,
score: 100,
});
// Should reject additional properties that don't match the additionalProperties schema
expect(() => zodSchema?.parse({ name: 'John', isActive: true })).toThrow();
expect(() => zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toThrow();
});
it('should strip additional properties when additionalProperties is false or not specified', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
additionalProperties: false,
};
const zodSchema = convertJsonSchemaToZod(schema);
// Should accept the defined properties
expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 });
// Current implementation strips additional properties when additionalProperties is false
const objWithExtra = { name: 'John', age: 30, isActive: true };
expect(zodSchema?.parse(objWithExtra)).toEqual({ name: 'John', age: 30 });
// Test with additionalProperties not specified (should behave the same)
const schemaWithoutAdditionalProps: JsonSchemaType = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
};
const zodSchemaWithoutAdditionalProps = convertJsonSchemaToZod(schemaWithoutAdditionalProps);
expect(zodSchemaWithoutAdditionalProps?.parse({ name: 'John', age: 30 })).toEqual({
name: 'John',
age: 30,
});
// Current implementation strips additional properties when additionalProperties is not specified
const objWithExtra2 = { name: 'John', age: 30, isActive: true };
expect(zodSchemaWithoutAdditionalProps?.parse(objWithExtra2)).toEqual({
name: 'John',
age: 30,
});
});
it('should handle complex nested objects with additionalProperties', () => {
const schema: JsonSchemaType = {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
name: { type: 'string' },
profile: {
type: 'object',
properties: {
bio: { type: 'string' },
},
additionalProperties: true,
},
},
additionalProperties: { type: 'string' },
},
},
additionalProperties: false,
};
const zodSchema = convertJsonSchemaToZod(schema);
const validData = {
user: {
name: 'John',
profile: {
bio: 'Developer',
location: 'New York', // Additional property allowed in profile
website: 'https://example.com', // Additional property allowed in profile
},
role: 'admin', // Additional property of type string allowed in user
level: 'senior', // Additional property of type string allowed in user
},
};
expect(zodSchema?.parse(validData)).toEqual(validData);
// Current implementation strips additional properties at the top level
// when additionalProperties is false
const dataWithExtraTopLevel = {
user: { name: 'John' },
extraField: 'not allowed', // This should be stripped
};
expect(zodSchema?.parse(dataWithExtraTopLevel)).toEqual({ user: { name: 'John' } });
// Should reject additional properties in user that don't match the string type
expect(() =>
zodSchema?.parse({
user: {
name: 'John',
age: 30, // Not a string
},
}),
).toThrow();
});
});
describe('empty object handling', () => {
it('should return undefined for empty object schemas when allowEmptyObject is false', () => {
const emptyObjectSchemas = [

View file

@ -7,6 +7,7 @@ export type JsonSchemaType = {
properties?: Record<string, JsonSchemaType>;
required?: string[];
description?: string;
additionalProperties?: boolean | JsonSchemaType;
};
function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
@ -72,7 +73,20 @@ export function convertJsonSchemaToZod(
} else {
objectSchema = objectSchema.partial();
}
// Handle additionalProperties for open-ended objects
if (schema.additionalProperties === true) {
// This allows any additional properties with any type
zodSchema = objectSchema.passthrough();
} else if (typeof schema.additionalProperties === 'object') {
// For specific additional property types
const additionalSchema = convertJsonSchemaToZod(
schema.additionalProperties as JsonSchemaType,
);
zodSchema = objectSchema.catchall(additionalSchema as z.ZodType);
} else {
zodSchema = objectSchema;
}
} else {
zodSchema = z.unknown();
}