mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🧭 fix: Add Base Path Support for Login/Register and Image Paths (#10116)
* fix: add basePath pattern to support login/register and image paths * Fix linter errors * refactor: Update import statements for getBasePath and isEnabled, and add path utility functions with tests - Refactored imports in addImages.js and StableDiffusion.js to use getBasePath from '@librechat/api'. - Consolidated isEnabled and getBasePath imports in validateImageRequest.js. - Introduced new path utility functions in path.ts and corresponding unit tests in path.spec.ts to validate base path extraction logic. * fix: Update domain server base URL in MarkdownComponents and refactor authentication redirection logic - Changed the domain server base URL in MarkdownComponents.tsx to use the API base URL. - Refactored the useAuthRedirect hook to utilize React Router's navigate for redirection instead of window.location, ensuring a smoother SPA experience. - Added unit tests for the useAuthRedirect hook to verify authentication redirection behavior. * test: Mock isEnabled in validateImages.spec.js for improved test isolation - Updated validateImages.spec.js to mock the isEnabled function from @librechat/api, ensuring that tests can run independently of the actual implementation. - Cleared the DOMAIN_CLIENT environment variable before tests to avoid interference with basePath resolution. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
ef3bf0a932
commit
7aa8d49f3a
21 changed files with 717 additions and 30 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
intermediateSteps.push({ observation: imageMarkdown });
|
||||
|
|
@ -139,4 +139,108 @@ describe('addImages', () => {
|
|||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
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: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should not prepend base path when image URL already has base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should correct erroneous URLs with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
responseMessage.text = '';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty base path (root deployment)', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle missing DOMAIN_CLIENT', () => {
|
||||
delete process.env.DOMAIN_CLIENT;
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle observation without image path match', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle nested subdirectories in base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/apps/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle multiple observations with mixed base path scenarios', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex markdown with base path', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
const complexMarkdown = `
|
||||
# Document Title
|
||||

|
||||
Some text between images
|
||||

|
||||
`;
|
||||
intermediateSteps.push({ observation: complexMarkdown });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle URLs that are already absolute', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({ observation: '' });
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe('\n');
|
||||
});
|
||||
|
||||
it('should handle data URLs', () => {
|
||||
process.env.DOMAIN_CLIENT = 'http://localhost:3080/librechat';
|
||||
intermediateSteps.push({
|
||||
observation:
|
||||
'',
|
||||
});
|
||||
addImages(intermediateSteps, responseMessage);
|
||||
expect(responseMessage.text).toBe(
|
||||
'\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: 
|
||||
// - ALWAYS include the markdown url in your final response to show the user: }/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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')}{' '}
|
||||
<a
|
||||
href="/register"
|
||||
href={registerPage()}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_auth_sign_up')}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<p className="my-4 text-center text-sm font-light text-gray-700 dark:text-white">
|
||||
{localize('com_auth_already_have_account')}{' '}
|
||||
<a
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
aria-label="Login"
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<p>{localize('com_auth_reset_password_if_email_exists')}</p>
|
||||
<a
|
||||
className="inline-flex text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
>
|
||||
{localize('com_auth_back_to_login')}
|
||||
</a>
|
||||
|
|
@ -134,7 +135,7 @@ function RequestPasswordReset() {
|
|||
{isLoading ? <Spinner /> : localize('com_auth_continue')}
|
||||
</Button>
|
||||
<a
|
||||
href="/login"
|
||||
href={loginPage()}
|
||||
className="block text-center text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_auth_back_to_login')}
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={imagePath}
|
||||
src={absoluteImageUrl}
|
||||
downloadImage={downloadImage}
|
||||
args={args}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<a
|
||||
|
|
@ -158,3 +158,31 @@ type TParagraphProps = {
|
|||
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
||||
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 <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
202
client/src/routes/__tests__/useAuthRedirect.spec.tsx
Normal file
202
client/src/routes/__tests__/useAuthRedirect.spec.tsx
Normal file
|
|
@ -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 <div data-testid="test-component">Test Component</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: <TestComponent />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <div data-testid="login-page">Login Page</div>,
|
||||
},
|
||||
],
|
||||
{
|
||||
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(<RouterProvider router={router} />);
|
||||
|
||||
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(<RouterProvider router={router} />);
|
||||
|
||||
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(<RouterProvider router={router} />);
|
||||
|
||||
// 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(<RouterProvider router={router} />);
|
||||
|
||||
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(<RouterProvider router={router} />);
|
||||
|
||||
// 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(<RouterProvider router={router} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const testResult = (window as any).__testResult;
|
||||
expect(testResult).toBeDefined();
|
||||
expect(testResult.user).toEqual(mockUser);
|
||||
expect(testResult.isAuthenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
97
packages/api/src/utils/path.spec.ts
Normal file
97
packages/api/src/utils/path.spec.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
25
packages/api/src/utils/path.ts
Normal file
25
packages/api/src/utils/path.ts
Normal file
|
|
@ -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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -60,8 +60,7 @@ describe('sanitizeTitle', () => {
|
|||
});
|
||||
|
||||
it('should handle multiple attributes', () => {
|
||||
const input =
|
||||
'<think reason="test" type="deep" id="1">reasoning</think> Title';
|
||||
const input = '<think reason="test" type="deep" id="1">reasoning</think> Title';
|
||||
expect(sanitizeTitle(input)).toBe('Title');
|
||||
});
|
||||
|
||||
|
|
@ -170,8 +169,7 @@ describe('sanitizeTitle', () => {
|
|||
});
|
||||
|
||||
it('should handle real-world with attributes', () => {
|
||||
const input =
|
||||
'<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
|
||||
const input = '<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
|
||||
expect(sanitizeTitle(input)).toBe('Project Status');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue