diff --git a/api/app/clients/output_parsers/addImages.js b/api/app/clients/output_parsers/addImages.js index 4b5019279a..f0860ef8bd 100644 --- a/api/app/clients/output_parsers/addImages.js +++ b/api/app/clients/output_parsers/addImages.js @@ -1,3 +1,4 @@ +const { getBasePath } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); /** @@ -32,6 +33,8 @@ function addImages(intermediateSteps, responseMessage) { return; } + const basePath = getBasePath(); + // Correct any erroneous URLs in the responseMessage.text first intermediateSteps.forEach((step) => { const { observation } = step; @@ -44,12 +47,14 @@ function addImages(intermediateSteps, responseMessage) { return; } const essentialImagePath = match[0]; + const fullImagePath = `${basePath}${essentialImagePath}`; const regex = /!\[.*?\]\((.*?)\)/g; let matchErroneous; while ((matchErroneous = regex.exec(responseMessage.text)) !== null) { - if (matchErroneous[1] && !matchErroneous[1].startsWith('/images/')) { - responseMessage.text = responseMessage.text.replace(matchErroneous[1], essentialImagePath); + if (matchErroneous[1] && !matchErroneous[1].startsWith(`${basePath}/images/`)) { + // Replace with the full path including base path + responseMessage.text = responseMessage.text.replace(matchErroneous[1], fullImagePath); } } }); @@ -61,9 +66,23 @@ function addImages(intermediateSteps, responseMessage) { return; } const observedImagePath = observation.match(/!\[[^(]*\]\([^)]*\)/g); - if (observedImagePath && !responseMessage.text.includes(observedImagePath[0])) { - responseMessage.text += '\n' + observedImagePath[0]; - logger.debug('[addImages] added image from intermediateSteps:', observedImagePath[0]); + if (observedImagePath) { + // Fix the image path to include base path if it doesn't already + let imageMarkdown = observedImagePath[0]; + const urlMatch = imageMarkdown.match(/\(([^)]+)\)/); + if ( + urlMatch && + urlMatch[1] && + !urlMatch[1].startsWith(`${basePath}/images/`) && + urlMatch[1].startsWith('/images/') + ) { + imageMarkdown = imageMarkdown.replace(urlMatch[1], `${basePath}${urlMatch[1]}`); + } + + if (!responseMessage.text.includes(imageMarkdown)) { + responseMessage.text += '\n' + imageMarkdown; + logger.debug('[addImages] added image from intermediateSteps:', imageMarkdown); + } } }); } diff --git a/api/app/clients/output_parsers/addImages.spec.js b/api/app/clients/output_parsers/addImages.spec.js index 7c5a04137e..ef4dd22c0b 100644 --- a/api/app/clients/output_parsers/addImages.spec.js +++ b/api/app/clients/output_parsers/addImages.spec.js @@ -74,7 +74,7 @@ describe('addImages', () => { it('should append correctly from a real scenario', () => { responseMessage.text = - 'Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there\'s a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?'; + "Here is the generated image based on your request. It depicts a surreal landscape filled with floating musical notes. The style is impressionistic, with vibrant sunset hues dominating the scene. At the center, there's a silhouette of a grand piano, adding a dreamy emotion to the overall image. This could serve as a unique and creative music album cover. Would you like to make any changes or generate another image?"; const originalText = responseMessage.text; const imageMarkdown = '![generated image](/images/img-RnVWaYo2Yg4x3e0isICiMuf5.png)'; intermediateSteps.push({ observation: imageMarkdown }); @@ -139,4 +139,108 @@ describe('addImages', () => { addImages(intermediateSteps, responseMessage); expect(responseMessage.text).toBe('\n![image1](/images/image1.png)'); }); + + describe('basePath functionality', () => { + let originalDomainClient; + + beforeEach(() => { + originalDomainClient = process.env.DOMAIN_CLIENT; + }); + + afterEach(() => { + process.env.DOMAIN_CLIENT = originalDomainClient; + }); + + it('should prepend base path to image URLs when DOMAIN_CLIENT is set', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)'); + }); + + it('should not prepend base path when image URL already has base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](/librechat/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/librechat/images/test.png)'); + }); + + it('should correct erroneous URLs with base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + responseMessage.text = '![desc](sandbox:/images/test.png)'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('![desc](/librechat/images/test.png)'); + }); + + it('should handle empty base path (root deployment)', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/images/test.png)'); + }); + + it('should handle missing DOMAIN_CLIENT', () => { + delete process.env.DOMAIN_CLIENT; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/images/test.png)'); + }); + + it('should handle observation without image path match', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](not-an-image-path)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](not-an-image-path)'); + }); + + it('should handle nested subdirectories in base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat'; + intermediateSteps.push({ observation: '![desc](/images/test.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](/apps/librechat/images/test.png)'); + }); + + it('should handle multiple observations with mixed base path scenarios', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc1](/images/test1.png)' }); + intermediateSteps.push({ observation: '![desc2](/librechat/images/test2.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe( + '\n![desc1](/librechat/images/test1.png)\n![desc2](/librechat/images/test2.png)', + ); + }); + + it('should handle complex markdown with base path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const complexMarkdown = ` + # Document Title + ![image1](/images/image1.png) + Some text between images + ![image2](/images/image2.png) + `; + intermediateSteps.push({ observation: complexMarkdown }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![image1](/librechat/images/image1.png)'); + }); + + it('should handle URLs that are already absolute', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ observation: '![desc](https://example.com/image.png)' }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe('\n![desc](https://example.com/image.png)'); + }); + + it('should handle data URLs', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + intermediateSteps.push({ + observation: + '![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)', + }); + addImages(intermediateSteps, responseMessage); + expect(responseMessage.text).toBe( + '\n![desc](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==)', + ); + }); + }); }); diff --git a/api/app/clients/tools/structured/StableDiffusion.js b/api/app/clients/tools/structured/StableDiffusion.js index 05687923e6..3a1ea831d3 100644 --- a/api/app/clients/tools/structured/StableDiffusion.js +++ b/api/app/clients/tools/structured/StableDiffusion.js @@ -8,6 +8,7 @@ const { v4: uuidv4 } = require('uuid'); const { Tool } = require('@langchain/core/tools'); const { logger } = require('@librechat/data-schemas'); const { FileContext, ContentTypes } = require('librechat-data-provider'); +const { getBasePath } = require('@librechat/api'); const paths = require('~/config/paths'); const displayMessage = @@ -36,7 +37,7 @@ class StableDiffusionAPI extends Tool { this.description_for_model = `// Generate images and visuals using text. // Guidelines: // - ALWAYS use {{"prompt": "7+ detailed keywords", "negative_prompt": "7+ detailed keywords"}} structure for queries. -// - ALWAYS include the markdown url in your final response to show the user: ![caption](/images/id.png) +// - ALWAYS include the markdown url in your final response to show the user: ![caption](${getBasePath()}/images/id.png) // - Visually describe the moods, details, structures, styles, and/or proportions of the image. Remember, the focus is on visual attributes. // - Craft your input by "showing" and not "telling" the imagery. Think in terms of what you'd want to see in a photograph or a painting. // - Here's an example for generating a realistic portrait photo of a man: diff --git a/api/server/middleware/spec/validateImages.spec.js b/api/server/middleware/spec/validateImages.spec.js index b35050a14e..ebf5eafc8a 100644 --- a/api/server/middleware/spec/validateImages.spec.js +++ b/api/server/middleware/spec/validateImages.spec.js @@ -1,11 +1,14 @@ const jwt = require('jsonwebtoken'); -const { isEnabled } = require('@librechat/api'); const createValidateImageRequest = require('~/server/middleware/validateImageRequest'); +// Mock only isEnabled, keep getBasePath real so it reads process.env.DOMAIN_CLIENT jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), isEnabled: jest.fn(), })); +const { isEnabled } = require('@librechat/api'); + describe('validateImageRequest middleware', () => { let req, res, next, validateImageRequest; const validObjectId = '65cfb246f7ecadb8b1e8036b'; @@ -23,6 +26,7 @@ describe('validateImageRequest middleware', () => { next = jest.fn(); process.env.JWT_REFRESH_SECRET = 'test-secret'; process.env.OPENID_REUSE_TOKENS = 'false'; + delete process.env.DOMAIN_CLIENT; // Clear for tests without basePath // Default: OpenID token reuse disabled isEnabled.mockReturnValue(false); @@ -296,4 +300,175 @@ describe('validateImageRequest middleware', () => { expect(res.send).toHaveBeenCalledWith('Access Denied'); }); }); + + describe('basePath functionality', () => { + let originalDomainClient; + + beforeEach(() => { + originalDomainClient = process.env.DOMAIN_CLIENT; + }); + + afterEach(() => { + process.env.DOMAIN_CLIENT = originalDomainClient; + }); + + test('should validate image paths with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should validate agent avatar paths with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/agent-avatar.png`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should reject image paths without base path when DOMAIN_CLIENT is set', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Access Denied'); + }); + + test('should handle empty base path (root deployment)', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle missing DOMAIN_CLIENT', async () => { + delete process.env.DOMAIN_CLIENT; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle nested subdirectories in base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/apps/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should prevent path traversal with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/../../../etc/passwd`; + + await validateImageRequest(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Access Denied'); + }); + + test('should handle URLs with query parameters and base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg?version=1`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle URLs with fragments and base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg#section`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle HTTPS URLs with base path', async () => { + process.env.DOMAIN_CLIENT = 'https://example.com/librechat'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle invalid DOMAIN_CLIENT gracefully', async () => { + process.env.DOMAIN_CLIENT = 'not-a-valid-url'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}`; + req.originalUrl = `/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + + test('should handle OpenID flow with base path', async () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + process.env.OPENID_REUSE_TOKENS = 'true'; + const validToken = jwt.sign( + { id: validObjectId, exp: Math.floor(Date.now() / 1000) + 3600 }, + process.env.JWT_REFRESH_SECRET, + ); + req.headers.cookie = `refreshToken=${validToken}; token_provider=openid; openid_user_id=${validToken}`; + req.originalUrl = `/librechat/images/${validObjectId}/test.jpg`; + + await validateImageRequest(req, res, next); + expect(next).toHaveBeenCalled(); + }); + }); }); diff --git a/api/server/middleware/validateImageRequest.js b/api/server/middleware/validateImageRequest.js index b456a4d572..b74ed7225e 100644 --- a/api/server/middleware/validateImageRequest.js +++ b/api/server/middleware/validateImageRequest.js @@ -1,7 +1,7 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); -const { isEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { isEnabled, getBasePath } = require('@librechat/api'); const OBJECT_ID_LENGTH = 24; const OBJECT_ID_PATTERN = /^[0-9a-f]{24}$/i; @@ -124,14 +124,21 @@ function createValidateImageRequest(secureImageLinks) { return res.status(403).send('Access Denied'); } - const agentAvatarPattern = /^\/images\/[a-f0-9]{24}\/agent-[^/]*$/; + const basePath = getBasePath(); + const imagesPath = `${basePath}/images`; + + const agentAvatarPattern = new RegExp( + `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[a-f0-9]{24}/agent-[^/]*$`, + ); if (agentAvatarPattern.test(fullPath)) { logger.debug('[validateImageRequest] Image request validated'); return next(); } const escapedUserId = userIdForPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pathPattern = new RegExp(`^/images/${escapedUserId}/[^/]+$`); + const pathPattern = new RegExp( + `^${imagesPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/${escapedUserId}/[^/]+$`, + ); if (pathPattern.test(fullPath)) { logger.debug('[validateImageRequest] Image request validated'); diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 39b47a7d64..f048e3ddf5 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -15,6 +15,7 @@ const { filterFilesByAgentAccess } = require('~/server/services/Files/permission const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { createFile, getFiles, updateFile } = require('~/models/File'); +const { getBasePath } = require('~/server/utils'); /** * Process OpenAI image files, convert to target format, save and return file metadata. @@ -41,11 +42,12 @@ const processCodeOutput = async ({ const appConfig = req.config; const currentDate = new Date(); const baseURL = getCodeBaseURL(); + const basePath = getBasePath(); const fileExt = path.extname(name); if (!fileExt || !imageExtRegex.test(name)) { return { filename: name, - filepath: `/api/files/code/download/${session_id}/${id}`, + filepath: `${basePath}/api/files/code/download/${session_id}/${id}`, /** Note: expires 24 hours after creation */ expiresAt: currentDate.getTime() + 86400000, conversationId, diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index d9ebd41cc1..cade120e17 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { ErrorTypes } from 'librechat-data-provider'; +import { ErrorTypes, registerPage } from 'librechat-data-provider'; import { OpenIDIcon, useToastContext } from '@librechat/client'; import { useOutletContext, useSearchParams } from 'react-router-dom'; import type { TLoginLayoutContext } from '~/common'; @@ -104,7 +104,7 @@ function Login() { {' '} {localize('com_auth_no_account')}{' '} {localize('com_auth_sign_up')} diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index da36d21e49..3766bdff50 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -4,6 +4,7 @@ import { Turnstile } from '@marsidev/react-turnstile'; import { ThemeContext, Spinner, Button, isDark } from '@librechat/client'; import { useNavigate, useOutletContext, useLocation } from 'react-router-dom'; import { useRegisterUserMutation } from 'librechat-data-provider/react-query'; +import { loginPage } from 'librechat-data-provider'; import type { TRegisterUser, TError } from 'librechat-data-provider'; import type { TLoginLayoutContext } from '~/common'; import { useLocalize, TranslationKeys } from '~/hooks'; @@ -213,7 +214,7 @@ const Registration: React.FC = () => {

{localize('com_auth_already_have_account')}{' '} diff --git a/client/src/components/Auth/RequestPasswordReset.tsx b/client/src/components/Auth/RequestPasswordReset.tsx index 9f50a7e42c..10996847fe 100644 --- a/client/src/components/Auth/RequestPasswordReset.tsx +++ b/client/src/components/Auth/RequestPasswordReset.tsx @@ -3,6 +3,7 @@ import { useState, ReactNode } from 'react'; import { Spinner, Button } from '@librechat/client'; import { useOutletContext } from 'react-router-dom'; import { useRequestPasswordResetMutation } from 'librechat-data-provider/react-query'; +import { loginPage } from 'librechat-data-provider'; import type { TRequestPasswordReset, TRequestPasswordResetResponse } from 'librechat-data-provider'; import type { TLoginLayoutContext } from '~/common'; import type { FC } from 'react'; @@ -26,7 +27,7 @@ const ResetPasswordBodyText = () => {

{localize('com_auth_reset_password_if_email_exists')}

{localize('com_auth_back_to_login')} @@ -134,7 +135,7 @@ function RequestPasswordReset() { {isLoading ? : localize('com_auth_continue')} {localize('com_auth_back_to_login')} diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index 450ed0fc22..8b1395ee88 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useMemo } from 'react'; import { Skeleton } from '@librechat/client'; import { LazyLoadImage } from 'react-lazy-load-image-component'; +import { apiBaseUrl } from 'librechat-data-provider'; import { cn, scaleImage } from '~/utils'; import DialogImage from './DialogImage'; @@ -36,6 +37,24 @@ const Image = ({ const handleImageLoad = () => setIsLoaded(true); + // Fix image path to include base path for subdirectory deployments + const absoluteImageUrl = useMemo(() => { + if (!imagePath) return imagePath; + + // If it's already an absolute URL or doesn't start with /images/, return as is + if ( + imagePath.startsWith('http') || + imagePath.startsWith('data:') || + !imagePath.startsWith('/images/') + ) { + return imagePath; + } + + // Get the base URL and prepend it to the image path + const baseURL = apiBaseUrl(); + return `${baseURL}${imagePath}`; + }, [imagePath]); + const { width: scaledWidth, height: scaledHeight } = useMemo( () => scaleImage({ @@ -48,7 +67,7 @@ const Image = ({ const downloadImage = async () => { try { - const response = await fetch(imagePath); + const response = await fetch(absoluteImageUrl); if (!response.ok) { throw new Error(`Failed to fetch image: ${response.status}`); } @@ -67,7 +86,7 @@ const Image = ({ } catch (error) { console.error('Download failed:', error); const link = document.createElement('a'); - link.href = imagePath; + link.href = absoluteImageUrl; link.download = altText || 'image.png'; document.body.appendChild(link); link.click(); @@ -97,7 +116,7 @@ const Image = ({ 'opacity-100 transition-opacity duration-100', isLoaded ? 'opacity-100' : 'opacity-0', )} - src={imagePath} + src={absoluteImageUrl} style={{ width: `${scaledWidth}`, height: 'auto', @@ -117,7 +136,7 @@ const Image = ({ diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 7ade775647..42136384d4 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -14,7 +14,7 @@ import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import { langSubset, preprocessLaTeX } from '~/utils'; import { unicodeCitation } from '~/components/Web'; -import { code, a, p } from './MarkdownComponents'; +import { code, a, p, img } from './MarkdownComponents'; import store from '~/store'; type TContentProps = { @@ -81,6 +81,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => { code, a, p, + img, artifact: Artifact, citation: Citation, 'highlighted-text': HighlightedText, diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index ed69c677b2..fa94cbac82 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo, useRef, useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { useToastContext } from '@librechat/client'; -import { PermissionTypes, Permissions, dataService } from 'librechat-data-provider'; +import { PermissionTypes, Permissions, apiBaseUrl } from 'librechat-data-provider'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; import useHasAccess from '~/hooks/Roles/useHasAccess'; import { useFileDownload } from '~/data-provider'; @@ -135,7 +135,7 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => { props.onClick = handleDownload; props.target = '_blank'; - const domainServerBaseUrl = dataService.getDomainServerBaseUrl(); + const domainServerBaseUrl = `${apiBaseUrl()}/api`; return ( { return

{children}

; }); + +type TImageProps = { + src?: string; + alt?: string; + title?: string; + className?: string; + style?: React.CSSProperties; +}; + +export const img: React.ElementType = memo(({ src, alt, title, className, style }: TImageProps) => { + // Get the base URL from the API endpoints + const baseURL = apiBaseUrl(); + + // If src starts with /images/, prepend the base URL + const fixedSrc = useMemo(() => { + if (!src) return src; + + // If it's already an absolute URL or doesn't start with /images/, return as is + if (src.startsWith('http') || src.startsWith('data:') || !src.startsWith('/images/')) { + return src; + } + + // Prepend base URL to the image path + return `${baseURL}${src}`; + }, [src, baseURL]); + + return {alt}; +}); diff --git a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx index d553e6b708..65efe2f256 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx @@ -6,7 +6,7 @@ import supersub from 'remark-supersub'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import type { PluggableList } from 'unified'; -import { code, codeNoExecution, a, p } from './MarkdownComponents'; +import { code, codeNoExecution, a, p, img } from './MarkdownComponents'; import { CodeBlockProvider, ArtifactProvider } from '~/Providers'; import MarkdownErrorBoundary from './MarkdownErrorBoundary'; import { langSubset } from '~/utils'; @@ -44,6 +44,7 @@ const MarkdownLite = memo( code: codeExecution ? code : codeNoExecution, a, p, + img, } as { [nodeType: string]: React.ElementType; } diff --git a/client/src/routes/__tests__/useAuthRedirect.spec.tsx b/client/src/routes/__tests__/useAuthRedirect.spec.tsx new file mode 100644 index 0000000000..19226aa29f --- /dev/null +++ b/client/src/routes/__tests__/useAuthRedirect.spec.tsx @@ -0,0 +1,202 @@ +/* eslint-disable i18next/no-literal-string */ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import useAuthRedirect from '../useAuthRedirect'; +import { useAuthContext } from '~/hooks'; + +// Polyfill Request for React Router in test environment +if (typeof Request === 'undefined') { + global.Request = class Request { + constructor( + public url: string, + public init?: RequestInit, + ) {} + } as any; +} + +jest.mock('~/hooks', () => ({ + useAuthContext: jest.fn(), +})); + +/** + * TestComponent that uses the useAuthRedirect hook and exposes its return value + */ +function TestComponent() { + const result = useAuthRedirect(); + // Expose result for assertions + (window as any).__testResult = result; + return
Test Component
; +} + +/** + * Creates a test router with optional basename to verify navigation works correctly + * with subdirectory deployments (e.g., /librechat) + */ +const createTestRouter = (basename = '/') => { + // When using basename, initialEntries must include the basename + const initialEntry = basename === '/' ? '/' : `${basename}/`; + + return createMemoryRouter( + [ + { + path: '/', + element: , + }, + { + path: '/login', + element:
Login Page
, + }, + ], + { + basename, + initialEntries: [initialEntry], + }, + ); +}; + +describe('useAuthRedirect', () => { + beforeEach(() => { + (window as any).__testResult = undefined; + }); + + afterEach(() => { + jest.clearAllMocks(); + (window as any).__testResult = undefined; + }); + + it('should not redirect when user is authenticated', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: { id: '123', email: 'test@example.com' }, + isAuthenticated: true, + }); + + const router = createTestRouter(); + const { getByTestId } = render(); + + expect(router.state.location.pathname).toBe('/'); + expect(getByTestId('test-component')).toBeInTheDocument(); + + // Wait for the timeout (300ms) plus a buffer + await new Promise((resolve) => setTimeout(resolve, 400)); + + // Should still be on home page, not redirected + expect(router.state.location.pathname).toBe('/'); + expect(getByTestId('test-component')).toBeInTheDocument(); + }); + + it('should redirect to /login when user is not authenticated', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createTestRouter(); + const { getByTestId, queryByTestId } = render(); + + expect(router.state.location.pathname).toBe('/'); + expect(getByTestId('test-component')).toBeInTheDocument(); + + // Wait for the redirect to happen (300ms timeout + navigation) + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/login'); + expect(getByTestId('login-page')).toBeInTheDocument(); + expect(queryByTestId('test-component')).not.toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + // Verify navigation used replace (history has only 1 entry) + // This prevents users from hitting back to return to protected pages + expect(router.state.historyAction).toBe('REPLACE'); + }); + + it('should respect router basename when redirecting (subdirectory deployment)', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + // Test with basename="/librechat" (simulates subdirectory deployment) + const router = createTestRouter('/librechat'); + const { getByTestId } = render(); + + // Full pathname includes basename + expect(router.state.location.pathname).toBe('/librechat/'); + + // Wait for the redirect - router handles basename internally + await waitFor( + () => { + // Router state pathname includes the full path with basename + expect(router.state.location.pathname).toBe('/librechat/login'); + expect(getByTestId('login-page')).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + // The key point: navigate('/login', { replace: true }) works correctly with basename + // The router automatically prepends the basename to create the full URL + expect(router.state.historyAction).toBe('REPLACE'); + }); + + it('should use React Router navigate (not window.location) for SPA experience', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createTestRouter('/librechat'); + const { getByTestId } = render(); + + await waitFor( + () => { + expect(router.state.location.pathname).toBe('/librechat/login'); + expect(getByTestId('login-page')).toBeInTheDocument(); + }, + { timeout: 1000 }, + ); + + // The fact that navigation worked within the router proves we're using + // navigate() and not window.location.href (which would cause a full reload + // and break the test entirely). This maintains the SPA experience. + expect(router.state.location.pathname).toBe('/librechat/login'); + }); + + it('should clear timeout on unmount', async () => { + (useAuthContext as jest.Mock).mockReturnValue({ + user: null, + isAuthenticated: false, + }); + + const router = createTestRouter(); + const { unmount } = render(); + + // Unmount immediately before timeout fires + unmount(); + + // Wait past the timeout period + await new Promise((resolve) => setTimeout(resolve, 400)); + + // Should still be at home, not redirected (timeout was cleared) + expect(router.state.location.pathname).toBe('/'); + }); + + it('should return user and isAuthenticated values', async () => { + const mockUser = { id: '123', email: 'test@example.com' }; + (useAuthContext as jest.Mock).mockReturnValue({ + user: mockUser, + isAuthenticated: true, + }); + + const router = createTestRouter(); + render(); + + await waitFor(() => { + const testResult = (window as any).__testResult; + expect(testResult).toBeDefined(); + expect(testResult.user).toEqual(mockUser); + expect(testResult.isAuthenticated).toBe(true); + }); + }); +}); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index ed93982a23..050f42796b 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -7,6 +7,7 @@ export * from './env'; export * from './events'; export * from './files'; export * from './generators'; +export * from './path'; export * from './key'; export * from './latex'; export * from './llm'; diff --git a/packages/api/src/utils/path.spec.ts b/packages/api/src/utils/path.spec.ts new file mode 100644 index 0000000000..fef54b8594 --- /dev/null +++ b/packages/api/src/utils/path.spec.ts @@ -0,0 +1,97 @@ +import { logger } from '@librechat/data-schemas'; +import type { Logger } from '@librechat/agents'; +import { getBasePath } from './path'; + +describe('getBasePath', () => { + let originalDomainClient: string | undefined; + + beforeEach(() => { + originalDomainClient = process.env.DOMAIN_CLIENT; + }); + + afterEach(() => { + process.env.DOMAIN_CLIENT = originalDomainClient; + }); + + it('should return empty string when DOMAIN_CLIENT is not set', () => { + delete process.env.DOMAIN_CLIENT; + expect(getBasePath()).toBe(''); + }); + + it('should return empty string when DOMAIN_CLIENT is root path', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/'; + expect(getBasePath()).toBe(''); + }); + + it('should return base path for subdirectory deployment', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat'; + expect(getBasePath()).toBe('/librechat'); + }); + + it('should return base path without trailing slash', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat/'; + expect(getBasePath()).toBe('/librechat'); + }); + + it('should handle nested subdirectories', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat'; + expect(getBasePath()).toBe('/apps/librechat'); + }); + + it('should handle HTTPS URLs', () => { + process.env.DOMAIN_CLIENT = 'https://example.com/librechat'; + expect(getBasePath()).toBe('/librechat'); + }); + + it('should handle URLs with query parameters', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat?param=value'; + expect(getBasePath()).toBe('/librechat'); + }); + + it('should handle URLs with fragments', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat#section'; + expect(getBasePath()).toBe('/librechat'); + }); + + it('should return empty string for invalid URL', () => { + process.env.DOMAIN_CLIENT = 'not-a-valid-url'; + // Accepts (infoObject: object), return value is not used + const loggerSpy = jest.spyOn(logger, 'warn').mockImplementation(() => { + return logger as unknown as Logger; + }); + expect(getBasePath()).toBe(''); + expect(loggerSpy).toHaveBeenCalledWith( + 'Error parsing DOMAIN_CLIENT for base path:', + expect.objectContaining({ + message: 'Invalid URL', + }), + ); + loggerSpy.mockRestore(); + }); + + it('should handle empty string DOMAIN_CLIENT', () => { + process.env.DOMAIN_CLIENT = ''; + expect(getBasePath()).toBe(''); + }); + + it('should handle undefined DOMAIN_CLIENT', () => { + process.env.DOMAIN_CLIENT = undefined; + expect(getBasePath()).toBe(''); + }); + + it('should handle null DOMAIN_CLIENT', () => { + // @ts-expect-error Testing null case + process.env.DOMAIN_CLIENT = null; + expect(getBasePath()).toBe(''); + }); + + it('should handle URLs with ports', () => { + process.env.DOMAIN_CLIENT = 'http://localhost:8080/librechat'; + expect(getBasePath()).toBe('/librechat'); + }); + + it('should handle URLs with subdomains', () => { + process.env.DOMAIN_CLIENT = 'https://app.example.com/librechat'; + expect(getBasePath()).toBe('/librechat'); + }); +}); diff --git a/packages/api/src/utils/path.ts b/packages/api/src/utils/path.ts new file mode 100644 index 0000000000..8eca853174 --- /dev/null +++ b/packages/api/src/utils/path.ts @@ -0,0 +1,25 @@ +import { logger } from '@librechat/data-schemas'; + +/** + * Gets the base path from the DOMAIN_CLIENT environment variable. + * This is useful for constructing URLs when LibreChat is served from a subdirectory. + * @returns {string} The base path (e.g., '/librechat' or '') + */ +export function getBasePath(): string { + if (!process.env.DOMAIN_CLIENT) { + return ''; + } + + try { + const clientUrl = new URL(process.env.DOMAIN_CLIENT); + // Keep consistent with the logic in api/server/index.js + const baseHref = clientUrl.pathname.endsWith('/') + ? clientUrl.pathname.slice(0, -1) // Remove trailing slash for path construction + : clientUrl.pathname; + + return baseHref === '/' ? '' : baseHref; + } catch (error) { + logger.warn('Error parsing DOMAIN_CLIENT for base path:', error); + return ''; + } +} diff --git a/packages/api/src/utils/sanitizeTitle.spec.ts b/packages/api/src/utils/sanitizeTitle.spec.ts index df03d1b7b4..35a1fc1f1b 100644 --- a/packages/api/src/utils/sanitizeTitle.spec.ts +++ b/packages/api/src/utils/sanitizeTitle.spec.ts @@ -60,8 +60,7 @@ describe('sanitizeTitle', () => { }); it('should handle multiple attributes', () => { - const input = - 'reasoning Title'; + const input = 'reasoning Title'; expect(sanitizeTitle(input)).toBe('Title'); }); @@ -170,8 +169,7 @@ describe('sanitizeTitle', () => { }); it('should handle real-world with attributes', () => { - const input = - '\nStep 1\nStep 2\n Project Status'; + const input = '\nStep 1\nStep 2\n Project Status'; expect(sanitizeTitle(input)).toBe('Project Status'); }); }); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index abcd5a1ec9..65b8d0f2fa 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -149,6 +149,10 @@ export const resetPassword = () => `${BASE_URL}/api/auth/resetPassword`; export const verifyEmail = () => `${BASE_URL}/api/user/verify`; +// Auth page URLs (for client-side navigation and redirects) +export const loginPage = () => `${BASE_URL}/login`; +export const registerPage = () => `${BASE_URL}/register`; + export const resendVerificationEmail = () => `${BASE_URL}/api/user/verify/resend`; export const plugins = () => `${BASE_URL}/api/plugins`; diff --git a/packages/data-provider/src/index.ts b/packages/data-provider/src/index.ts index f96f6b0249..9082b3416e 100644 --- a/packages/data-provider/src/index.ts +++ b/packages/data-provider/src/index.ts @@ -33,6 +33,7 @@ export * from './accessPermissions'; export * from './keys'; /* api call helpers */ export * from './headers-helpers'; +export { loginPage, registerPage, apiBaseUrl } from './api-endpoints'; export { default as request } from './request'; export { dataService }; import * as dataService from './data-service'; diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts index 8ca3980c7e..fd5d42d76a 100644 --- a/packages/data-provider/src/request.ts +++ b/packages/data-provider/src/request.ts @@ -135,7 +135,7 @@ if (typeof window !== 'undefined') { `Refresh token failed from shared link, attempting request to ${originalRequest.url}`, ); } else { - window.location.href = '/login'; + window.location.href = endpoints.loginPage(); } } catch (err) { processQueue(err as AxiosError, null);