mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 09:32:24 +00:00
🔐 fix: Gate Shared Startup Config By Link Access (#13897)
* fix: gate shared startup config by link access * fix: satisfy shared config CI checks * fix: align shared config client types * fix: reject expired shared link access
This commit is contained in:
parent
ebb4f15dbe
commit
e807c63d5d
18 changed files with 276 additions and 22 deletions
|
|
@ -185,18 +185,22 @@ describe('GET /api/config', () => {
|
|||
expect(response.body).not.toHaveProperty('customFooter');
|
||||
});
|
||||
|
||||
it('should include public share footer fields when share context is requested', async () => {
|
||||
it('should not include share-only fields when share context is requested', async () => {
|
||||
process.env.ANALYTICS_GTM_ID = 'GTM-XYZ';
|
||||
process.env.CUSTOM_FOOTER = 'public footer text';
|
||||
process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq';
|
||||
process.env.SANDPACK_BUNDLER_URL = 'https://bundler.test';
|
||||
process.env.SANDPACK_STATIC_BUNDLER_URL = 'https://static-bundler.test';
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config?context=share');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.analyticsGtmId).toBe('GTM-XYZ');
|
||||
expect(response.body.customFooter).toBe('public footer text');
|
||||
expect(response.body).not.toHaveProperty('analyticsGtmId');
|
||||
expect(response.body).not.toHaveProperty('customFooter');
|
||||
expect(response.body).not.toHaveProperty('bundlerURL');
|
||||
expect(response.body).not.toHaveProperty('staticBundlerURL');
|
||||
expect(response.body).not.toHaveProperty('helpAndFaqURL');
|
||||
expect(response.body).not.toHaveProperty('allowAccountDeletion');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ const mockGetSharedLinkExpiration = jest.fn();
|
|||
const mockGrantCreationPermissions = jest.fn();
|
||||
const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
|
||||
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
|
||||
const mockBuildSharedLinkStartupPayload = jest.fn();
|
||||
const mockCanAccessSharedLink = jest.fn((_req, _res, next) => next());
|
||||
const mockGetAppConfig = jest.fn();
|
||||
const mockGetTenantId = jest.fn(() => undefined);
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: jest.fn(() => true),
|
||||
|
|
@ -16,6 +20,7 @@ jest.mock('@librechat/api', () => ({
|
|||
ensureLinkPermissions: jest.fn(),
|
||||
isFileSnapshotEnabled: jest.fn(() => true),
|
||||
isFileSnapshotKillSwitchActive: jest.fn(() => false),
|
||||
buildSharedLinkStartupPayload: (...args) => mockBuildSharedLinkStartupPayload(...args),
|
||||
deleteSharedLinkWithCleanup: jest.fn(),
|
||||
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
|
||||
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
|
||||
|
|
@ -23,9 +28,11 @@ jest.mock('@librechat/api', () => ({
|
|||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn() },
|
||||
getTenantId: (...args) => mockGetTenantId(...args),
|
||||
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
|
||||
runAsSystem: jest.fn((fn) => fn()),
|
||||
tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
|
||||
SYSTEM_TENANT_ID: '__SYSTEM__',
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
|
|
@ -84,11 +91,19 @@ jest.mock('~/server/utils/files', () => ({
|
|||
getContentDisposition: jest.fn((name, disposition = 'attachment') => `${disposition}; ${name}`),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next());
|
||||
jest.mock(
|
||||
'~/server/middleware/canAccessSharedLink',
|
||||
() =>
|
||||
(...args) =>
|
||||
mockCanAccessSharedLink(...args),
|
||||
);
|
||||
jest.mock('~/server/middleware/optionalShareFileAuth', () => (_req, _res, next) => next());
|
||||
jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next());
|
||||
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
|
||||
jest.mock('~/server/middleware/config/app', () => (_req, _res, next) => next());
|
||||
jest.mock('~/server/services/Config/app', () => ({
|
||||
getAppConfig: (...args) => mockGetAppConfig(...args),
|
||||
}));
|
||||
|
||||
const { Readable } = require('stream');
|
||||
const { RetentionMode } = require('librechat-data-provider');
|
||||
|
|
@ -129,9 +144,22 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => {
|
|||
return app;
|
||||
};
|
||||
|
||||
describe('share routes retention', () => {
|
||||
describe('share routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetTenantId.mockReturnValue(undefined);
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
interfaceConfig: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
mockBuildSharedLinkStartupPayload.mockReturnValue({
|
||||
appTitle: 'Shared Chat',
|
||||
bundlerURL: 'https://bundler.example.com',
|
||||
interface: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
SHARED_LINKS: {
|
||||
|
|
@ -142,6 +170,45 @@ describe('share routes retention', () => {
|
|||
mockGrantCreationPermissions.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('serves shared startup config after shared-link access is granted', async () => {
|
||||
const response = await request(buildApp()).get('/api/share/share-123/config');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('private, no-store');
|
||||
expect(mockCanAccessSharedLink).toHaveBeenCalled();
|
||||
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
||||
expect(mockBuildSharedLinkStartupPayload).toHaveBeenCalledWith({
|
||||
interfaceConfig: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
appTitle: 'Shared Chat',
|
||||
bundlerURL: 'https://bundler.example.com',
|
||||
interface: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses tenant-scoped app config for shared startup config when tenant context is present', async () => {
|
||||
mockGetTenantId.mockReturnValue('tenant-abc');
|
||||
|
||||
const response = await request(buildApp()).get('/api/share/share-123/config');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-abc' });
|
||||
});
|
||||
|
||||
it('uses base app config for shared startup config in system context', async () => {
|
||||
mockGetTenantId.mockReturnValue('__SYSTEM__');
|
||||
|
||||
const response = await request(buildApp()).get('/api/share/share-123/config');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
||||
});
|
||||
|
||||
it('prevents successful shared message responses from being cached', async () => {
|
||||
getSharedMessages.mockResolvedValue({ shareId: 'share-123', messages: [] });
|
||||
|
||||
|
|
|
|||
|
|
@ -107,9 +107,9 @@ function buildPreLoginPayload() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Public share fields rendered by `client/src/components/Share/ShareView.tsx`.
|
||||
* They remain off the default anonymous config used by login screens, and are
|
||||
* exposed to anonymous callers only when the client asks for share context.
|
||||
* Fields shared by authenticated chat and share-view config. Anonymous share
|
||||
* views receive these through `/api/share/:shareId/config` after share access
|
||||
* checks, not through the generic startup config endpoint.
|
||||
*/
|
||||
function buildPublicSharePayload() {
|
||||
/** @type {Partial<TStartupConfig>} */
|
||||
|
|
@ -215,7 +215,6 @@ router.get('/', async function (req, res) {
|
|||
/** @type {Partial<TStartupConfig>} */
|
||||
const payload = {
|
||||
...preLoginPayload,
|
||||
...(req.query.context === 'share' ? publicSharePayload : {}),
|
||||
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
|
||||
turnstile: baseConfig?.turnstileConfig,
|
||||
...(rum ? { rum } : {}),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const {
|
|||
ensureLinkPermissions,
|
||||
isFileSnapshotEnabled,
|
||||
isFileSnapshotKillSwitchActive,
|
||||
buildSharedLinkStartupPayload,
|
||||
deleteSharedLinkWithCleanup,
|
||||
updateSharedLinkPermissionsExpiration,
|
||||
isActiveExpirationDate,
|
||||
|
|
@ -14,8 +15,10 @@ const {
|
|||
} = require('@librechat/api');
|
||||
const {
|
||||
logger,
|
||||
getTenantId,
|
||||
runAsSystem,
|
||||
tenantStorage,
|
||||
SYSTEM_TENANT_ID,
|
||||
createTempChatExpirationDate,
|
||||
} = require('@librechat/data-schemas');
|
||||
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
|
|
@ -38,6 +41,7 @@ const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth
|
|||
const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const configMiddleware = require('~/server/middleware/config/app');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const router = express.Router();
|
||||
|
||||
const checkSharedLinksAccess = generateCheckAccess({
|
||||
|
|
@ -76,6 +80,14 @@ const runWithTenant = (tenantId, fn) =>
|
|||
* 'failed' on the next poll so the client poller terminates. */
|
||||
const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000;
|
||||
|
||||
const getShareStartupPayload = async () => {
|
||||
const tenantId = getTenantId();
|
||||
const appConfig = await getAppConfig(
|
||||
tenantId && tenantId !== SYSTEM_TENANT_ID ? { tenantId } : { baseOnly: true },
|
||||
);
|
||||
return buildSharedLinkStartupPayload(appConfig);
|
||||
};
|
||||
|
||||
/**
|
||||
* MIME types that are safe to render inline. Everything else (text/html, SVG,
|
||||
* and other active content) is served as an `attachment` so a public viewer
|
||||
|
|
@ -212,6 +224,17 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
|
|||
};
|
||||
|
||||
if (allowSharedLinks) {
|
||||
router.get('/:shareId/config', optionalJwtAuth, canAccessSharedLink, async (_req, res) => {
|
||||
try {
|
||||
const payload = await getShareStartupPayload();
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.status(200).json(payload);
|
||||
} catch (error) {
|
||||
logger.error('Error getting shared startup config:', error);
|
||||
res.status(500).json({ message: 'Error getting shared startup config' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/:shareId',
|
||||
optionalJwtAuth,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type {
|
|||
SandpackProviderProps,
|
||||
SandpackPreviewRef,
|
||||
} from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import type { SandpackStartupConfig } from '~/utils/artifacts';
|
||||
import type { ArtifactFiles } from '~/common';
|
||||
import { sharedFiles, buildSandpackOptions } from '~/utils/artifacts';
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ export const ArtifactPreview = memo(function ({
|
|||
sharedProps: Partial<SandpackProviderProps>;
|
||||
previewRef: MutableRefObject<SandpackPreviewRef>;
|
||||
currentCode?: string;
|
||||
startupConfig?: TStartupConfig;
|
||||
startupConfig?: SandpackStartupConfig;
|
||||
}) {
|
||||
const artifactFiles = useMemo(() => {
|
||||
if (Object.keys(files).length === 0) {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ import * as Tabs from '@radix-ui/react-tabs';
|
|||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useCodeState } from '~/Providers/EditorContext';
|
||||
import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useCodeState } from '~/Providers/EditorContext';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
import { useShareContext } from '~/Providers';
|
||||
|
||||
export default function ArtifactTabs({
|
||||
artifact,
|
||||
|
|
@ -19,7 +20,14 @@ export default function ArtifactTabs({
|
|||
isSharedConvo?: boolean;
|
||||
}) {
|
||||
const { currentCode, setCurrentCode } = useCodeState();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { shareId } = useShareContext();
|
||||
const shouldUseSharedConfig =
|
||||
isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0;
|
||||
const { data: startupConfig } = useGetStartupConfig({ enabled: !shouldUseSharedConfig });
|
||||
const { data: sharedStartupConfig } = useGetSharedStartupConfig(shareId, {
|
||||
enabled: shouldUseSharedConfig,
|
||||
});
|
||||
const resolvedStartupConfig = shouldUseSharedConfig ? sharedStartupConfig : startupConfig;
|
||||
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const lastIdRef = useRef<string | null>(null);
|
||||
|
||||
|
|
@ -55,7 +63,7 @@ export default function ArtifactTabs({
|
|||
previewRef={previewRef}
|
||||
sharedProps={sharedProps}
|
||||
currentCode={currentCode}
|
||||
startupConfig={startupConfig}
|
||||
startupConfig={resolvedStartupConfig}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import { useLocalize } from '~/hooks';
|
|||
|
||||
type FooterProps = {
|
||||
className?: string;
|
||||
startupConfig?: TStartupConfig | null;
|
||||
startupConfig?: FooterStartupConfig | null;
|
||||
};
|
||||
|
||||
type FooterStartupConfig = Pick<Partial<TStartupConfig>, 'analyticsGtmId' | 'customFooter'> & {
|
||||
interface?: Pick<NonNullable<TStartupConfig['interface']>, 'privacyPolicy' | 'termsOfService'>;
|
||||
};
|
||||
|
||||
function Footer({ className, startupConfig }: FooterProps) {
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ import {
|
|||
} from '@librechat/client';
|
||||
import { ThemeSelector, LangSelector } from '~/components/Nav/SettingsTabs/General/Selectors';
|
||||
import { ShareMessagesProvider } from './ShareMessagesProvider';
|
||||
import { useGetSharedStartupConfig } from '~/data-provider';
|
||||
import { ShareArtifactsContainer } from './ShareArtifacts';
|
||||
import { useLocalize, useDocumentTitle } from '~/hooks';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { ShareContext } from '~/Providers';
|
||||
import MessagesView from './MessagesView';
|
||||
import Footer from '../Chat/Footer';
|
||||
|
|
@ -29,9 +29,9 @@ import store from '~/store';
|
|||
|
||||
function SharedView() {
|
||||
const localize = useLocalize();
|
||||
const { data: config } = useGetStartupConfig(undefined, { context: 'share' });
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const { shareId } = useParams();
|
||||
const { data: config } = useGetSharedStartupConfig(shareId);
|
||||
const { data, isLoading } = useGetSharedMessages(shareId ?? '');
|
||||
const dataTree = data && buildTree({ messages: data.messages });
|
||||
const messagesTree = dataTree?.length === 0 ? null : (dataTree ?? null);
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ export const useContextProjectionQuery = (
|
|||
export const startupConfigKey = (isAuthenticated: boolean, context?: t.StartupConfigContext) =>
|
||||
[QueryKeys.startupConfig, isAuthenticated, context ?? 'default'] as const;
|
||||
|
||||
export const sharedStartupConfigKey = (shareId?: string) =>
|
||||
[QueryKeys.sharedStartupConfig, shareId ?? ''] as const;
|
||||
|
||||
export const useGetStartupConfig = (
|
||||
config?: UseQueryOptions<t.TStartupConfig>,
|
||||
options?: { context?: t.StartupConfigContext },
|
||||
|
|
@ -107,3 +110,26 @@ export const useGetStartupConfig = (
|
|||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const useGetSharedStartupConfig = (
|
||||
shareId?: string,
|
||||
config?: UseQueryOptions<t.TSharedLinkStartupConfig>,
|
||||
): QueryObserverResult<t.TSharedLinkStartupConfig> => {
|
||||
const queriesEnabled = useRecoilValue<boolean>(store.queriesEnabled);
|
||||
return useQuery<t.TSharedLinkStartupConfig>(
|
||||
sharedStartupConfigKey(shareId),
|
||||
() => dataService.getSharedStartupConfig(shareId ?? ''),
|
||||
{
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
...config,
|
||||
enabled:
|
||||
(config?.enabled ?? true) === true &&
|
||||
queriesEnabled &&
|
||||
typeof shareId === 'string' &&
|
||||
shareId.length > 0,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -178,9 +178,14 @@ export const sharedOptions: SandpackProviderProps['options'] = {
|
|||
externalResources: [TAILWIND_CDN],
|
||||
};
|
||||
|
||||
export type SandpackStartupConfig = Pick<
|
||||
Partial<TStartupConfig>,
|
||||
'bundlerURL' | 'staticBundlerURL'
|
||||
>;
|
||||
|
||||
export function buildSandpackOptions(
|
||||
template: SandpackProviderProps['template'],
|
||||
startupConfig?: TStartupConfig,
|
||||
startupConfig?: SandpackStartupConfig,
|
||||
): SandpackProviderProps['options'] {
|
||||
if (!startupConfig) {
|
||||
return sharedOptions;
|
||||
|
|
|
|||
|
|
@ -124,6 +124,20 @@ describe('canAccessSharedLink', () => {
|
|||
expect(res._status).toBe(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns 404 when share is expired but ACL still exists', async () => {
|
||||
const link = await createTestLink({ expiredAt: new Date('2020-01-01T00:00:00.000Z') });
|
||||
await grantPublicViewer(link._id);
|
||||
process.env.ALLOW_SHARED_LINKS_PUBLIC = 'true';
|
||||
|
||||
const req = createReq({ params: { shareId: link.shareId } });
|
||||
const res = createRes();
|
||||
const next = jest.fn();
|
||||
await canAccessSharedLink(req, res, next as unknown as NextFunction);
|
||||
|
||||
expect(res._status).toBe(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('public links', () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { ResourceType, PermissionBits } from 'librechat-data-provider';
|
||||
import { getTenantId, runAsSystem, tenantStorage } from '@librechat/data-schemas';
|
||||
import {
|
||||
getTenantId,
|
||||
runAsSystem,
|
||||
tenantStorage,
|
||||
activeExpirationFilter,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { Types, Model } from 'mongoose';
|
||||
|
|
@ -53,7 +58,10 @@ export function createSharedLinkAccessMiddleware(deps: SharedLinkAccessDeps) {
|
|||
|
||||
const SharedLink = mg.models.SharedLink as Model<RawSharedLink>;
|
||||
const findShare = async () =>
|
||||
(await SharedLink.findOne({ shareId }).lean()) as RawSharedLink | null;
|
||||
(await SharedLink.findOne({
|
||||
shareId,
|
||||
...activeExpirationFilter<RawSharedLink>(),
|
||||
}).lean()) as RawSharedLink | null;
|
||||
const rawShare = getTenantId() ? await findShare() : await runAsSystem(findShare);
|
||||
|
||||
if (!rawShare) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { isFileSnapshotEnabled } from './config';
|
||||
import { buildSharedLinkStartupPayload, isFileSnapshotEnabled } from './config';
|
||||
|
||||
const withSharedLinks = (sharedLinks: unknown): AppConfig =>
|
||||
({ interfaceConfig: { sharedLinks } }) as unknown as AppConfig;
|
||||
|
|
@ -41,3 +41,45 @@ describe('isFileSnapshotEnabled', () => {
|
|||
expect(isFileSnapshotEnabled(withSharedLinks({ snapshotFiles: false }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSharedLinkStartupPayload', () => {
|
||||
it('builds the share-view startup allowlist', () => {
|
||||
const payload = buildSharedLinkStartupPayload(
|
||||
{
|
||||
interfaceConfig: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
termsOfService: { externalUrl: 'https://example.com/tos' },
|
||||
modelSelect: true,
|
||||
},
|
||||
} as AppConfig,
|
||||
{
|
||||
ANALYTICS_GTM_ID: 'GTM-XYZ',
|
||||
APP_TITLE: 'Test Chat',
|
||||
CUSTOM_FOOTER: 'Shared footer',
|
||||
SANDPACK_BUNDLER_URL: 'https://bundler.example.com',
|
||||
SANDPACK_STATIC_BUNDLER_URL: 'https://static-bundler.example.com',
|
||||
},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
appTitle: 'Test Chat',
|
||||
analyticsGtmId: 'GTM-XYZ',
|
||||
bundlerURL: 'https://bundler.example.com',
|
||||
staticBundlerURL: 'https://static-bundler.example.com',
|
||||
customFooter: 'Shared footer',
|
||||
interface: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
termsOfService: { externalUrl: 'https://example.com/tos' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults the app title and omits unrelated interface config', () => {
|
||||
const payload = buildSharedLinkStartupPayload(
|
||||
{ interfaceConfig: { modelSelect: true } } as AppConfig,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(payload).toEqual({ appTitle: 'LibreChat' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import type { TSharedLinkStartupConfig } from 'librechat-data-provider';
|
||||
import type { AppConfig } from '@librechat/data-schemas';
|
||||
import { isEnabled } from '~/utils';
|
||||
|
||||
type SharedLinkStartupEnv = NodeJS.ProcessEnv;
|
||||
|
||||
/**
|
||||
* Whether shared links should snapshot the files referenced by the shared chat
|
||||
* snapshot. The `SHARED_LINKS_SNAPSHOT_FILES` env var overrides the yaml
|
||||
|
|
@ -31,3 +34,35 @@ export function isFileSnapshotKillSwitchActive(): boolean {
|
|||
const envValue = process.env.SHARED_LINKS_SNAPSHOT_FILES;
|
||||
return envValue !== undefined && !isEnabled(envValue);
|
||||
}
|
||||
|
||||
export function buildSharedLinkStartupPayload(
|
||||
appConfig?: AppConfig | null,
|
||||
env: SharedLinkStartupEnv = process.env,
|
||||
): TSharedLinkStartupConfig {
|
||||
const payload: TSharedLinkStartupConfig = {
|
||||
appTitle: env.APP_TITLE || 'LibreChat',
|
||||
};
|
||||
|
||||
if (typeof env.ANALYTICS_GTM_ID === 'string') {
|
||||
payload.analyticsGtmId = env.ANALYTICS_GTM_ID;
|
||||
}
|
||||
if (typeof env.SANDPACK_BUNDLER_URL === 'string') {
|
||||
payload.bundlerURL = env.SANDPACK_BUNDLER_URL;
|
||||
}
|
||||
if (typeof env.SANDPACK_STATIC_BUNDLER_URL === 'string') {
|
||||
payload.staticBundlerURL = env.SANDPACK_STATIC_BUNDLER_URL;
|
||||
}
|
||||
if (typeof env.CUSTOM_FOOTER === 'string') {
|
||||
payload.customFooter = env.CUSTOM_FOOTER;
|
||||
}
|
||||
|
||||
const { privacyPolicy, termsOfService } = appConfig?.interfaceConfig ?? {};
|
||||
if (privacyPolicy || termsOfService) {
|
||||
payload.interface = {
|
||||
...(privacyPolicy ? { privacyPolicy } : {}),
|
||||
...(termsOfService ? { termsOfService } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const messagesBranch = () => `${messagesRoot}/branch`;
|
|||
|
||||
const shareRoot = `${BASE_URL}/api/share`;
|
||||
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
||||
export const sharedStartupConfig = (shareId: string) => `${shareMessages(shareId)}/config`;
|
||||
export const getSharedLink = (conversationId: string) => `${shareRoot}/link/${conversationId}`;
|
||||
export const getSharedLinks = (
|
||||
pageSize: number,
|
||||
|
|
|
|||
|
|
@ -1447,6 +1447,19 @@ export type TStartupConfig = {
|
|||
};
|
||||
};
|
||||
|
||||
export type TSharedLinkStartupInterface = Pick<
|
||||
Partial<TInterfaceConfig>,
|
||||
'privacyPolicy' | 'termsOfService'
|
||||
>;
|
||||
|
||||
export type TSharedLinkStartupConfig = Pick<TStartupConfig, 'appTitle'> &
|
||||
Pick<
|
||||
Partial<TStartupConfig>,
|
||||
'analyticsGtmId' | 'bundlerURL' | 'customFooter' | 'staticBundlerURL'
|
||||
> & {
|
||||
interface?: TSharedLinkStartupInterface;
|
||||
};
|
||||
|
||||
export enum OCRStrategy {
|
||||
MISTRAL_OCR = 'mistral_ocr',
|
||||
CUSTOM_OCR = 'custom_ocr',
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ export function getSharedMessages(shareId: string): Promise<t.TSharedMessagesRes
|
|||
return request.get(endpoints.shareMessages(shareId));
|
||||
}
|
||||
|
||||
export function getSharedStartupConfig(shareId: string): Promise<config.TSharedLinkStartupConfig> {
|
||||
return request.get(endpoints.sharedStartupConfig(shareId));
|
||||
}
|
||||
|
||||
export const listSharedLinks = async (
|
||||
params: q.SharedLinksListParams,
|
||||
): Promise<q.SharedLinksResponse> => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export enum QueryKeys {
|
||||
messages = 'messages',
|
||||
sharedMessages = 'sharedMessages',
|
||||
sharedStartupConfig = 'sharedStartupConfig',
|
||||
sharedLinks = 'sharedLinks',
|
||||
allConversations = 'allConversations',
|
||||
archivedConversations = 'archivedConversations',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue