mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-12 09:45:47 +00:00
🪧 fix: Emit the Configured-Footer Answer With the HTML Shell (#15886)
* 🪧 fix: Emit the Configured-Footer Answer With the HTML Shell The chat surface lays out against whether the deployment configured a footer (customFooter, privacy policy, terms of service). The client answered that from localStorage — whatever the last resolved `/api/config` recorded — so the answer was a guess on a first-ever visit and could be stale for a deployment whose configuration changed. The backend already knows when it serves client/dist/index.html, so it says so there: `injectConfiguredFooterBootstrap` writes `hasConfiguredFooter` into `window.__LIBRECHAT_CONFIG__` ahead of the app's own scripts, once at boot from the base app config, and the client reads it on its first render instead of remembering the last answer. The `configured-footer` storage atom is gone. Client and server share one predicate (`hasConfiguredFooter` in librechat-data-provider) so the two cannot disagree, and the shell injection is generalized (`injectBootstrapConfig`), with the query-devtools flag now going through it. * 🧷 fix: Keep Bootstrap Values Literal and State the Shell's Contract `injectBootstrapConfig` passed its script as a replacement string, so a `$&`, `$1` or `$'` inside an injected value was expanded by `String.replace` instead of reaching the client. Both insertion points take a replacement function now, and the spec reads its values back off the global through an explicit result type rather than an unchecked dictionary. The shell answers from the deployment's own configuration, which a per-tenant, role or user override of `interface.privacyPolicy` or `termsOfService` can disagree with — `/api/config` resolves the caller's and the client prefers it. That contract was overstated in three comments ("a deployment-lifetime fact", "the same one the config will give"); they now say what the answer is and what resolves the rest. Two scenarios cover the disagreement end to end: a document served without the answer, and a policy link only this caller's config carries.
This commit is contained in:
parent
91e00bbe6e
commit
8e6e6f062d
19 changed files with 536 additions and 80 deletions
|
|
@ -37,6 +37,7 @@ const {
|
|||
initializeFileStorage,
|
||||
loadToolApprovalHooks,
|
||||
maybeInjectQueryDevtoolsBootstrap,
|
||||
injectConfiguredFooterBootstrap,
|
||||
preAuthTenantMiddleware,
|
||||
requestContextMiddleware,
|
||||
configureServerTimeouts,
|
||||
|
|
@ -551,6 +552,15 @@ if (cluster.isMaster) {
|
|||
}
|
||||
}
|
||||
|
||||
/* The composer lays out against whether a footer bar sits beneath it, and
|
||||
`/api/config` answers that only after it has painted. One shell serves
|
||||
every request, before there is a caller whose overrides could be resolved,
|
||||
so the answer is the deployment's base configuration, like index.js. */
|
||||
indexHTML = injectConfiguredFooterBootstrap(indexHTML, {
|
||||
customFooter: process.env.CUSTOM_FOOTER,
|
||||
interfaceConfig: baseAppConfig?.interfaceConfig,
|
||||
});
|
||||
|
||||
const cspPolicy = createCspPolicy();
|
||||
const shellCache = shellCacheHeaders(cspPolicy != null);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const {
|
|||
setPluginHookSource,
|
||||
loadToolApprovalHooks,
|
||||
maybeInjectQueryDevtoolsBootstrap,
|
||||
injectConfiguredFooterBootstrap,
|
||||
preAuthTenantMiddleware,
|
||||
requestContextMiddleware,
|
||||
registerShutdownTask,
|
||||
|
|
@ -284,6 +285,16 @@ const startServer = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
/* The composer lays out against whether a footer bar sits beneath it, and
|
||||
`/api/config` answers that only after it has painted. One shell serves every
|
||||
request, before there is a caller whose overrides could be resolved, so the
|
||||
answer is the deployment's base configuration; `/api/config` resolves the
|
||||
caller's and the client prefers it. */
|
||||
indexHTML = injectConfiguredFooterBootstrap(indexHTML, {
|
||||
customFooter: process.env.CUSTOM_FOOTER,
|
||||
interfaceConfig: appConfig?.interfaceConfig,
|
||||
});
|
||||
|
||||
const cspPolicy = createCspPolicy();
|
||||
const shellCache = shellCacheHeaders(cspPolicy != null);
|
||||
|
||||
|
|
|
|||
|
|
@ -280,6 +280,9 @@ describe('Server Configuration', () => {
|
|||
mongoServer = await MongoMemoryServer.create();
|
||||
process.env.MONGO_URI = mongoServer.getUri();
|
||||
process.env.PORT = '0'; // Use a random available port
|
||||
/* This deployment configures a footer, so the shell it serves has to say so
|
||||
before any `/api/config` request: the composer lays out against it. */
|
||||
process.env.CUSTOM_FOOTER = 'Operator policy footer';
|
||||
/* index.js listens at module scope and exports only the app, so capture the server to close it. */
|
||||
const listenSpy = jest.spyOn(express.application, 'listen');
|
||||
app = require('~/server');
|
||||
|
|
@ -294,6 +297,7 @@ describe('Server Configuration', () => {
|
|||
await promisify(server.close).call(server);
|
||||
await mongoServer.stop();
|
||||
await mongoose.disconnect();
|
||||
delete process.env.CUSTOM_FOOTER;
|
||||
});
|
||||
|
||||
it('should return OK for /health', async () => {
|
||||
|
|
@ -391,6 +395,19 @@ describe('Server Configuration', () => {
|
|||
expect(directIndexResponse.text).toContain('"enableQueryDevtools":true');
|
||||
});
|
||||
|
||||
it('serves the configured-footer answer with the shell', async () => {
|
||||
const [fallbackResponse, indexResponse] = await Promise.all([
|
||||
request(app).get('/this/does/not/exist'),
|
||||
request(app).get('/index.html'),
|
||||
]);
|
||||
|
||||
for (const response of [fallbackResponse, indexResponse]) {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toContain('window.__LIBRECHAT_CONFIG__');
|
||||
expect(response.text).toContain('"hasConfiguredFooter":true');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return 500 for unknown errors via ErrorController', async () => {
|
||||
// Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated
|
||||
|
||||
|
|
|
|||
|
|
@ -676,8 +676,13 @@ export type TThread = { id: string; createdAt: string };
|
|||
declare global {
|
||||
interface Window {
|
||||
google_tag_manager?: unknown;
|
||||
/** Answers the server emits with the document, ahead of the app's own
|
||||
* scripts, for questions the first render must not guess at. */
|
||||
__LIBRECHAT_CONFIG__?: {
|
||||
enableQueryDevtools?: boolean;
|
||||
/** Whether this deployment configured footer content of its own, so the
|
||||
* composer reserves the footer bar's band on its first frame. */
|
||||
hasConfiguredFooter?: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
|
|||
|
||||
/** A conversation carries a footer only for configured content, and the
|
||||
* composer's clearance has to account for the bar when it does — including
|
||||
* while the config is still in flight, so a cold load does not jump. */
|
||||
* before the config answers, so a cold load does not jump. */
|
||||
const configuredFooter = useConfiguredFooter();
|
||||
|
||||
const methods = useForm<ChatFormValues>({
|
||||
|
|
@ -114,10 +114,10 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
|
|||
(conversationId === Constants.NEW_CONVO || !conversationId);
|
||||
|
||||
/** A footer bar renders beneath the composer on the welcome screen always, and
|
||||
* in a conversation when the deployment configured one. `present` already
|
||||
* carries the remembered answer while the config is in flight, so this is the
|
||||
* same value before and after it resolves. */
|
||||
const footerBelow = isLandingPage || configuredFooter.present;
|
||||
* in a conversation when the deployment configured one. The shell already
|
||||
* carried that answer, so this is the same value before and after the config
|
||||
* resolves. */
|
||||
const footerBelow = isLandingPage || configuredFooter;
|
||||
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
|
||||
const isProjectLandingPage = isLandingPage && project != null;
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
|
|||
{/* The generic disclaimer is the welcome screen's; a
|
||||
deployment's own footer, privacy policy and terms
|
||||
stay with the conversation that always showed them. */}
|
||||
{!isLandingPage && configuredFooter.present && <Footer configuredOnly />}
|
||||
{!isLandingPage && configuredFooter && <Footer configuredOnly />}
|
||||
</div>
|
||||
</div>
|
||||
{isLandingPage && <Footer />}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import React, { useEffect, memo } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import TagManager from 'react-gtm-module';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { Constants, hasConfiguredFooter } from 'librechat-data-provider';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { configuredFooterAtom } from './footerMemory';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type FooterProps = {
|
||||
|
|
@ -24,15 +22,17 @@ type FooterStartupConfig = Pick<Partial<TStartupConfig>, 'analyticsGtmId' | 'cus
|
|||
interface?: Pick<NonNullable<TStartupConfig['interface']>, 'privacyPolicy' | 'termsOfService'>;
|
||||
};
|
||||
|
||||
export type ConfiguredFooter = {
|
||||
/** Whether a footer bar belongs under the composer in a conversation: the
|
||||
* deployment configured a custom footer, a privacy policy or terms of
|
||||
* service. Before the startup config answers this is the answer the last
|
||||
* load recorded, which is what keeps a cold load from laying out twice. */
|
||||
present: boolean;
|
||||
/** The startup config has answered on this load. */
|
||||
resolved: boolean;
|
||||
};
|
||||
/**
|
||||
* Whether the deployment configured footer content of its own, as the server
|
||||
* said when it served this document (`injectConfiguredFooterBootstrap`).
|
||||
*
|
||||
* Read once at module load: it is a property of the document, and an answer
|
||||
* that arrived a render later would be the guess this replaces. A shell that
|
||||
* carries no answer — the Vite dev server serves `client/index.html` itself —
|
||||
* reads as no footer, which is the default deployment.
|
||||
*/
|
||||
const shellHasConfiguredFooter =
|
||||
typeof window !== 'undefined' && window.__LIBRECHAT_CONFIG__?.hasConfiguredFooter === true;
|
||||
|
||||
/**
|
||||
* What a conversation has to render beneath its composer. The conversation
|
||||
|
|
@ -41,26 +41,16 @@ export type ConfiguredFooter = {
|
|||
* zero-height wrapper, so a composer that did not reserve it would be painted
|
||||
* over. Both decisions read this one answer so they cannot disagree.
|
||||
*
|
||||
* While `/api/config` is in flight the answer is the one this deployment gave
|
||||
* last (`configuredFooterAtom`), so a cold load lays out once instead of
|
||||
* guessing and correcting. Only a successful response replaces it: a request
|
||||
* that exhausted its retries has answered nothing.
|
||||
* Until `/api/config` answers, that answer is the shell's, which is the
|
||||
* deployment's own configuration (`librechat.yaml` plus `CUSTOM_FOOTER`). A DB
|
||||
* config override that adds or removes a policy link for the caller's tenant,
|
||||
* role or user is resolved only by `/api/config`, so on such a deployment the
|
||||
* resolved answer — the one that wins here — can differ from the shell's.
|
||||
*/
|
||||
export function useConfiguredFooter(): ConfiguredFooter {
|
||||
export function useConfiguredFooter(): boolean {
|
||||
const { data: config, isSuccess } = useGetStartupConfig();
|
||||
const [remembered, remember] = useAtom(configuredFooterAtom);
|
||||
const configured =
|
||||
typeof config?.customFooter === 'string' ||
|
||||
config?.interface?.privacyPolicy?.externalUrl != null ||
|
||||
config?.interface?.termsOfService?.externalUrl != null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && configured !== remembered) {
|
||||
remember(configured);
|
||||
}
|
||||
}, [configured, isSuccess, remembered, remember]);
|
||||
|
||||
return { present: isSuccess ? configured : remembered, resolved: isSuccess };
|
||||
return isSuccess ? hasConfiguredFooter(config) : shellHasConfiguredFooter;
|
||||
}
|
||||
|
||||
function Footer({ className, startupConfig, configuredOnly = false }: FooterProps) {
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@ const failedControlLocaleKey = (reason?: string) => {
|
|||
};
|
||||
|
||||
export default function SubagentThreadPanel({ selection }: { selection: ActiveSubagentPanel }) {
|
||||
/** `present` carries the remembered answer while the config is in flight, so
|
||||
* this surface lays out once, like the composer beside it. */
|
||||
const clearsFooter = useConfiguredFooter().present;
|
||||
/** The shell carries the deployment's answer, so this surface lays out once,
|
||||
* like the composer beside it. */
|
||||
const clearsFooter = useConfiguredFooter();
|
||||
const localize = useLocalize();
|
||||
const panelStore = useStore();
|
||||
const { showToast } = useToastContext();
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { createStorageAtom } from '~/store/jotai-utils';
|
||||
|
||||
/**
|
||||
* Whether this deployment configured footer content of its own, as of the last
|
||||
* time `/api/config` answered. Feature-owned and persisted: the chat surface
|
||||
* both writes it (when the config resolves) and reads it (while the next load's
|
||||
* config is still in flight), and a deployment's footer configuration is a
|
||||
* deployment-lifetime fact, so remembering it is what lets the composer lay out
|
||||
* once instead of guessing and correcting.
|
||||
*
|
||||
* `getOnInit` gives the stored value on the first render, which is the whole
|
||||
* point — a value that arrived an effect later would be the guess again.
|
||||
*/
|
||||
export const configuredFooterAtom = createStorageAtom<boolean>('configured-footer', false);
|
||||
|
|
@ -5,10 +5,11 @@ import { getE2EUser } from '../../../setup/user';
|
|||
import { deleteConversations, deleteMessagesByConversation, seedConversations } from '../db';
|
||||
|
||||
/**
|
||||
* Whether a conversation carries a footer is an answer from the startup config,
|
||||
* and on a cold load of `/c/<id>` that answer arrives after the composer has
|
||||
* already been painted. Guessing "no footer" until then moves the composer twice
|
||||
* — down to the bare clearance, then back up when the footer appears.
|
||||
* Whether a conversation carries a footer decides where its composer sits, and
|
||||
* on a cold load of `/c/<id>` the startup config answers only after the composer
|
||||
* has already been painted. The server knows when it serves the document, so it
|
||||
* says so there and the composer lays out once — on a first-ever visit, with
|
||||
* nothing remembered from a previous one.
|
||||
*/
|
||||
|
||||
const COMPOSER = '[data-testid="composer-surface"]';
|
||||
|
|
@ -19,14 +20,18 @@ declare global {
|
|||
interface Window {
|
||||
/** Every composer bottom painted since the document started. */
|
||||
__composerBottomSamples?: number[];
|
||||
/** The answers the server emitted with the document. */
|
||||
__LIBRECHAT_CONFIG__?: { hasConfiguredFooter?: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
/** A configured footer whose answer is deliberately slow, plus a sampler that
|
||||
* runs from the document's first frame. Both are in place before the app's own
|
||||
* scripts, so nothing about the app's own timing is assumed. */
|
||||
/** A deployment whose footer the shell already reports: the e2e deployment
|
||||
* configures none, so its own answer in the served document is flipped to the
|
||||
* one a configured deployment would emit. The `/api/config` answer that agrees
|
||||
* is deliberately slow — the composer has to be in its final position long
|
||||
* before it lands. */
|
||||
async function serveSlowConfiguredFooter(page: Page) {
|
||||
await page.route('**/api/config', async (route) => {
|
||||
const response = await route.fetch();
|
||||
|
|
@ -36,12 +41,14 @@ async function serveSlowConfiguredFooter(page: Page) {
|
|||
await delayed.promise;
|
||||
await route.fulfill({ response, json: { ...config, customFooter: CUSTOM_FOOTER } });
|
||||
});
|
||||
await installSampler(page);
|
||||
await installSampler(page, (html) =>
|
||||
html.replace('"hasConfiguredFooter":false', '"hasConfiguredFooter":true'),
|
||||
);
|
||||
}
|
||||
|
||||
/** A sampler that records the composer's bottom from the document's first frame,
|
||||
* served with the HTML so it is in place before the app's own scripts. */
|
||||
async function installSampler(page: Page) {
|
||||
async function installSampler(page: Page, serveShell: (html: string) => string = (html) => html) {
|
||||
await page.route('**/*', async (route) => {
|
||||
if (route.request().resourceType() !== 'document') {
|
||||
return route.fallback();
|
||||
|
|
@ -63,7 +70,7 @@ async function installSampler(page: Page) {
|
|||
}
|
||||
})();
|
||||
</script>`;
|
||||
const body = (await response.text()).replace('<head>', `<head>${sampler}`);
|
||||
const body = serveShell((await response.text()).replace('<head>', `<head>${sampler}`));
|
||||
await route.fulfill({ response, body });
|
||||
});
|
||||
}
|
||||
|
|
@ -80,12 +87,14 @@ test.describe('cold load clearance', () => {
|
|||
|
||||
try {
|
||||
await serveSlowConfiguredFooter(page);
|
||||
/** The first visit is what records the deployment's answer; the load under
|
||||
* test is every one after it, where the composer must lay out once. */
|
||||
await page.goto(`/c/${conversationId}`, { timeout: 15000 });
|
||||
await expect(page.getByText(CUSTOM_FOOTER)).toBeVisible({ timeout: 15000 });
|
||||
/** A first-ever visit: nothing about this deployment is remembered, and
|
||||
* nothing has to be — the document carries the answer. */
|
||||
await page.goto(`/c/${conversationId}`, { timeout: 15000 });
|
||||
await expect(page.locator(COMPOSER)).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => window.__LIBRECHAT_CONFIG__?.hasConfiguredFooter),
|
||||
'the shell did not carry the deployment’s footer answer',
|
||||
).toBe(true);
|
||||
/** Past the delayed config answer and any correction it would cause. */
|
||||
await expect(page.getByText(CUSTOM_FOOTER)).toBeVisible({ timeout: 15000 });
|
||||
await page.waitForTimeout(800);
|
||||
|
|
|
|||
148
e2e/specs/mock/scenarios/footer-shell-answer.spec.ts
Normal file
148
e2e/specs/mock/scenarios/footer-shell-answer.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import { getE2EUser } from '../../../setup/user';
|
||||
import { deleteConversations, deleteMessagesByConversation, seedConversations } from '../db';
|
||||
|
||||
/**
|
||||
* The document says whether the deployment configured a footer, and the composer
|
||||
* lays out against that answer before `/api/config` has one. The answer is the
|
||||
* deployment's own configuration, so two cases have to keep working: a document
|
||||
* that carries no answer at all (the Vite dev server serves `client/index.html`
|
||||
* itself, and a proxy could strip the script), and a caller whose resolved
|
||||
* configuration disagrees with it — a per-tenant, role or user config override
|
||||
* of `interface.privacyPolicy` is resolved only by `/api/config`. In both, the
|
||||
* resolved answer is the one that decides, and the bar never covers the
|
||||
* composer once it has.
|
||||
*
|
||||
* The footer is hidden below `sm`, so this runs on the desktop viewport it
|
||||
* describes.
|
||||
*/
|
||||
|
||||
const COMPOSER = '[data-testid="composer-surface"]';
|
||||
const CUSTOM_FOOTER = 'Operator policy footer';
|
||||
const SHELL_SENTINEL = 'data-librechat-configured-footer="true"';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__LIBRECHAT_CONFIG__?: { hasConfiguredFooter?: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
/** Serves the shell through `rewrite`, so a test can take the server's own
|
||||
* answer out of the document or leave it as the deployment emitted it. */
|
||||
async function serveShell(page: Page, rewrite: (html: string) => string) {
|
||||
await page.route('**/*', async (route) => {
|
||||
if (route.request().resourceType() !== 'document') {
|
||||
return route.fallback();
|
||||
}
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: rewrite(await response.text()) });
|
||||
});
|
||||
}
|
||||
|
||||
/** Answers the startup config with footer configuration the shell did not
|
||||
* report, which is what a config override for this caller looks like. */
|
||||
async function serveResolvedConfig(page: Page, overrides: Record<string, unknown>) {
|
||||
await page.route('**/api/config', async (route) => {
|
||||
const response = await route.fetch();
|
||||
const config = await response.json();
|
||||
await route.fulfill({ response, json: { ...config, ...overrides } });
|
||||
});
|
||||
}
|
||||
|
||||
/** The footer bar is absolutely positioned in a zero-height wrapper: nothing but
|
||||
* the composer's own clearance keeps it off the composer's action row. */
|
||||
async function expectFooterBelowComposer(page: Page, footer: Locator) {
|
||||
const composerBox = await page.locator(COMPOSER).boundingBox();
|
||||
const footerBox = await footer.boundingBox();
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(footerBox).not.toBeNull();
|
||||
expect(footerBox?.y ?? 0, 'the footer overlaps the composer').toBeGreaterThanOrEqual(
|
||||
(composerBox?.y ?? 0) + (composerBox?.height ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('footer answer in the shell', () => {
|
||||
test('a document with no footer answer still loads and follows the config @scenario:a-shell-without-the-footer-answer-follows-the-config', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
const conversationId = randomUUID();
|
||||
await seedConversations(getE2EUser().email, [
|
||||
{ conversationId, title: 'Shell without an answer', updatedAt: new Date() },
|
||||
]);
|
||||
|
||||
try {
|
||||
await serveResolvedConfig(page, { customFooter: CUSTOM_FOOTER });
|
||||
await serveShell(page, (html) =>
|
||||
html.replace(new RegExp(`<script [^>]*${SHELL_SENTINEL}[^>]*>[\\s\\S]*?</script>`), ''),
|
||||
);
|
||||
|
||||
await page.goto(`/c/${conversationId}`, { timeout: 15000 });
|
||||
await expect(page.locator(COMPOSER)).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => window.__LIBRECHAT_CONFIG__?.hasConfiguredFooter),
|
||||
'the document was expected to carry no answer',
|
||||
).toBeUndefined();
|
||||
|
||||
/** No answer reads as the default deployment, and the resolved config is
|
||||
* what puts the bar there — with the clearance it needs. */
|
||||
const footer = page.getByText(CUSTOM_FOOTER);
|
||||
await expect(footer).toBeVisible({ timeout: 15000 });
|
||||
await expectFooterBelowComposer(page, footer);
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message input' });
|
||||
await input.fill('still reachable');
|
||||
await expect(input).toHaveValue('still reachable');
|
||||
} finally {
|
||||
await deleteMessagesByConversation([conversationId]);
|
||||
await deleteConversations([conversationId]);
|
||||
}
|
||||
});
|
||||
|
||||
test('a policy link this caller alone has still clears the composer @scenario:an-override-policy-link-clears-the-composer', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
const conversationId = randomUUID();
|
||||
await seedConversations(getE2EUser().email, [
|
||||
{ conversationId, title: 'Override policy link', updatedAt: new Date() },
|
||||
]);
|
||||
|
||||
try {
|
||||
/** The harness deployment configures no footer, so its document says so:
|
||||
* this caller's resolved configuration is the one that disagrees. */
|
||||
await serveResolvedConfig(page, {
|
||||
interface: { privacyPolicy: { externalUrl: 'https://example.com/privacy' } },
|
||||
});
|
||||
|
||||
await page.goto(`/c/${conversationId}`, { timeout: 15000 });
|
||||
await expect(page.locator(COMPOSER)).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => window.__LIBRECHAT_CONFIG__?.hasConfiguredFooter),
|
||||
'the deployment was expected to report no configured footer',
|
||||
).toBe(false);
|
||||
|
||||
const policyLink = page.getByRole('link', { name: /privacy/i });
|
||||
await expect(policyLink).toBeVisible({ timeout: 15000 });
|
||||
await expect(policyLink).toHaveAttribute('href', 'https://example.com/privacy');
|
||||
await expectFooterBelowComposer(page, policyLink);
|
||||
|
||||
/** The bar arrived after the first paint, so the check that matters is the
|
||||
* composer's action row still taking its own clicks. */
|
||||
const composerBox = await page.locator(COMPOSER).boundingBox();
|
||||
await page
|
||||
.locator(COMPOSER)
|
||||
.click({ position: { x: 40, y: (composerBox?.height ?? 40) - 8 } });
|
||||
const input = page.getByRole('textbox', { name: 'Message input' });
|
||||
await input.fill('still reachable');
|
||||
await expect(input).toHaveValue('still reachable');
|
||||
} finally {
|
||||
await deleteMessagesByConversation([conversationId]);
|
||||
await deleteConversations([conversationId]);
|
||||
}
|
||||
});
|
||||
});
|
||||
75
packages/api/src/html/bootstrap.spec.ts
Normal file
75
packages/api/src/html/bootstrap.spec.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { injectBootstrapConfig } from './bootstrap';
|
||||
|
||||
const SHELL = '<html><head></head><body></body></html>';
|
||||
|
||||
/** Every value these tests inject, as the client reads it back off the global. */
|
||||
interface TestBootstrap {
|
||||
first?: boolean;
|
||||
second?: number;
|
||||
note?: string;
|
||||
flag?: boolean;
|
||||
}
|
||||
|
||||
/** Runs the shell's injected scripts the way a browser would, so the assertions
|
||||
* are about what the client receives rather than about the markup's spelling. */
|
||||
const bootstrapOf = (html: string): TestBootstrap => {
|
||||
const scripts = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)].map(
|
||||
(match) => match[1],
|
||||
);
|
||||
return new Function('window', `${scripts.join('\n')}\nreturn window.__LIBRECHAT_CONFIG__;`)(
|
||||
{},
|
||||
) as TestBootstrap;
|
||||
};
|
||||
|
||||
describe('injectBootstrapConfig', () => {
|
||||
it('merges answers so one injection does not drop another', () => {
|
||||
const html = injectBootstrapConfig(
|
||||
injectBootstrapConfig(SHELL, {
|
||||
sentinel: 'data-librechat-first="true"',
|
||||
values: { first: true },
|
||||
}),
|
||||
{ sentinel: 'data-librechat-second="true"', values: { second: 2 } },
|
||||
);
|
||||
|
||||
expect(bootstrapOf(html)).toEqual({ first: true, second: 2 });
|
||||
});
|
||||
|
||||
it('escapes markup in a value so it cannot close the script', () => {
|
||||
const note = '</script><script>alert(1)</script>';
|
||||
const html = injectBootstrapConfig(SHELL, {
|
||||
sentinel: 'data-librechat-test="true"',
|
||||
values: { note },
|
||||
});
|
||||
|
||||
expect(html).not.toContain('<script>alert(1)</script>');
|
||||
expect(html.match(/<\/script>/g)).toHaveLength(1);
|
||||
expect(bootstrapOf(html)).toEqual({ note });
|
||||
});
|
||||
|
||||
it('keeps `$` sequences in a value literal instead of expanding them', () => {
|
||||
const note = "$& $` $' $1 $$";
|
||||
|
||||
expect(
|
||||
bootstrapOf(
|
||||
injectBootstrapConfig(SHELL, { sentinel: 'data-librechat-test="true"', values: { note } }),
|
||||
).note,
|
||||
).toBe(note);
|
||||
expect(
|
||||
bootstrapOf(
|
||||
injectBootstrapConfig('<html><body class="x"></body></html>', {
|
||||
sentinel: 'data-librechat-test="true"',
|
||||
values: { note },
|
||||
}),
|
||||
).note,
|
||||
).toBe(note);
|
||||
});
|
||||
|
||||
it('lands ahead of the app in a document with no head at all', () => {
|
||||
const html = injectBootstrapConfig('<html><body class="x"><script></script></body></html>', {
|
||||
sentinel: 'data-librechat-test="true"',
|
||||
values: { flag: true },
|
||||
});
|
||||
|
||||
expect(html).toContain('<body class="x"><script data-librechat-test="true">');
|
||||
});
|
||||
});
|
||||
42
packages/api/src/html/bootstrap.ts
Normal file
42
packages/api/src/html/bootstrap.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* The global the HTML shell hands to the client for answers the server already
|
||||
* has when it serves the document. Merged rather than replaced, so several
|
||||
* injections compose in any order.
|
||||
*/
|
||||
const BOOTSTRAP_GLOBAL = 'window.__LIBRECHAT_CONFIG__';
|
||||
|
||||
export interface BootstrapInjection {
|
||||
/** Attribute marking this injection's script, so serving one shell through
|
||||
* two injectors — or the same one twice — does not stack it. */
|
||||
sentinel: string;
|
||||
values: Record<string, boolean | number | string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes server-known values into the shell's `<head>` (or the top of `<body>`
|
||||
* in a document without one), ahead of the app's own scripts so the first
|
||||
* render reads them. The caller stamps the CSP nonce afterwards.
|
||||
*/
|
||||
export const injectBootstrapConfig = (
|
||||
html: string,
|
||||
{ sentinel, values }: BootstrapInjection,
|
||||
): string => {
|
||||
if (html.includes(sentinel)) {
|
||||
return html;
|
||||
}
|
||||
|
||||
/** A `<` in the payload could close the inline script; it never has to be raw. */
|
||||
const payload = JSON.stringify(values).replace(/</g, '\\u003c');
|
||||
const script = `<script ${sentinel}>${BOOTSTRAP_GLOBAL}=Object.assign({},${BOOTSTRAP_GLOBAL},${payload});</script>`;
|
||||
|
||||
/* Replacements are functions: a `$&`, `$1` or `$'` inside a value is data, and
|
||||
a replacement string would have `String.replace` expand it instead. */
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', () => `${script}</head>`);
|
||||
}
|
||||
|
||||
return html.replace(
|
||||
/<body([^>]*)>/i,
|
||||
(_match, attributes: string) => `<body${attributes}>${script}`,
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { injectBootstrapConfig } from './bootstrap';
|
||||
|
||||
export const QUERY_DEVTOOLS_HEADER = 'x-librechat-enable-query-devtools';
|
||||
|
||||
const QUERY_DEVTOOLS_SENTINEL = 'data-librechat-query-devtools="true"';
|
||||
const QUERY_DEVTOOLS_BOOTSTRAP = `<script ${QUERY_DEVTOOLS_SENTINEL}>window.__LIBRECHAT_CONFIG__=Object.assign({},window.__LIBRECHAT_CONFIG__,{"enableQueryDevtools":true});</script>`;
|
||||
|
||||
export interface QueryDevtoolsRequest {
|
||||
get(header: string): string | undefined;
|
||||
|
|
@ -10,18 +11,6 @@ export interface QueryDevtoolsRequest {
|
|||
export const shouldEnableQueryDevtools = (req: QueryDevtoolsRequest): boolean =>
|
||||
req.get(QUERY_DEVTOOLS_HEADER) === '1';
|
||||
|
||||
const injectQueryDevtoolsBootstrap = (html: string): string => {
|
||||
if (html.includes(QUERY_DEVTOOLS_SENTINEL)) {
|
||||
return html;
|
||||
}
|
||||
|
||||
if (html.includes('</head>')) {
|
||||
return html.replace('</head>', `${QUERY_DEVTOOLS_BOOTSTRAP}</head>`);
|
||||
}
|
||||
|
||||
return html.replace(/<body([^>]*)>/i, `<body$1>${QUERY_DEVTOOLS_BOOTSTRAP}`);
|
||||
};
|
||||
|
||||
export const maybeInjectQueryDevtoolsBootstrap = (
|
||||
html: string,
|
||||
req: QueryDevtoolsRequest,
|
||||
|
|
@ -30,5 +19,8 @@ export const maybeInjectQueryDevtoolsBootstrap = (
|
|||
return html;
|
||||
}
|
||||
|
||||
return injectQueryDevtoolsBootstrap(html);
|
||||
return injectBootstrapConfig(html, {
|
||||
sentinel: QUERY_DEVTOOLS_SENTINEL,
|
||||
values: { enableQueryDevtools: true },
|
||||
});
|
||||
};
|
||||
|
|
|
|||
66
packages/api/src/html/footer.spec.ts
Normal file
66
packages/api/src/html/footer.spec.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { injectConfiguredFooterBootstrap } from './footer';
|
||||
import { applyCspNonce } from '~/security/csp';
|
||||
|
||||
const SHELL =
|
||||
'<!DOCTYPE html><html><head><title>LibreChat</title></head>' +
|
||||
'<body><div id="root"></div><script type="module" src="/assets/index.js"></script></body></html>';
|
||||
|
||||
const flagOf = (html: string): boolean | undefined => {
|
||||
const match = /"hasConfiguredFooter":(true|false)/.exec(html);
|
||||
return match == null ? undefined : match[1] === 'true';
|
||||
};
|
||||
|
||||
describe('injectConfiguredFooterBootstrap', () => {
|
||||
it('answers yes for each kind of configured footer content', () => {
|
||||
expect(flagOf(injectConfiguredFooterBootstrap(SHELL, { customFooter: 'Operator' }))).toBe(true);
|
||||
expect(
|
||||
flagOf(
|
||||
injectConfiguredFooterBootstrap(SHELL, {
|
||||
interfaceConfig: { privacyPolicy: { externalUrl: 'https://example.com/privacy' } },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
flagOf(
|
||||
injectConfiguredFooterBootstrap(SHELL, {
|
||||
interfaceConfig: { termsOfService: { externalUrl: 'https://example.com/terms' } },
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('answers no for a deployment that configured nothing', () => {
|
||||
expect(flagOf(injectConfiguredFooterBootstrap(SHELL, {}))).toBe(false);
|
||||
expect(flagOf(injectConfiguredFooterBootstrap(SHELL))).toBe(false);
|
||||
expect(
|
||||
flagOf(
|
||||
injectConfiguredFooterBootstrap(SHELL, {
|
||||
customFooter: undefined,
|
||||
interfaceConfig: { privacyPolicy: {}, termsOfService: {} },
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('places the answer ahead of the app that reads it', () => {
|
||||
const html = injectConfiguredFooterBootstrap(SHELL, { customFooter: 'Operator' });
|
||||
|
||||
expect(html).toContain('window.__LIBRECHAT_CONFIG__');
|
||||
expect(html.indexOf('hasConfiguredFooter')).toBeLessThan(html.indexOf('/assets/index.js'));
|
||||
});
|
||||
|
||||
it('takes the nonce a strict CSP requires to run it', () => {
|
||||
const html = applyCspNonce(
|
||||
injectConfiguredFooterBootstrap(SHELL, { customFooter: 'Operator' }),
|
||||
'abc123',
|
||||
);
|
||||
|
||||
expect(html).toContain('<script nonce="abc123" data-librechat-configured-footer="true">');
|
||||
});
|
||||
|
||||
it('does not stack a second copy on a shell that already carries the answer', () => {
|
||||
const once = injectConfiguredFooterBootstrap(SHELL, { customFooter: 'Operator' });
|
||||
|
||||
expect(injectConfiguredFooterBootstrap(once, { customFooter: 'Operator' })).toBe(once);
|
||||
});
|
||||
});
|
||||
45
packages/api/src/html/footer.ts
Normal file
45
packages/api/src/html/footer.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { hasConfiguredFooter } from 'librechat-data-provider';
|
||||
import type { TInterfaceConfig } from 'librechat-data-provider';
|
||||
import { injectBootstrapConfig } from './bootstrap';
|
||||
|
||||
const CONFIGURED_FOOTER_SENTINEL = 'data-librechat-configured-footer="true"';
|
||||
|
||||
/** What the deployment configured, as the server holds it: the footer env var
|
||||
* and the interface config the app loaded. */
|
||||
export interface ConfiguredFooterSource {
|
||||
customFooter?: string | null;
|
||||
interfaceConfig?: Pick<TInterfaceConfig, 'privacyPolicy' | 'termsOfService'> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits the deployment's footer answer with the HTML shell.
|
||||
*
|
||||
* A conversation's composer lays out against whether a footer bar sits beneath
|
||||
* it, and `/api/config` answers that only after the composer has painted. The
|
||||
* server already knows when it serves the document, so it says so and no client
|
||||
* has to guess: a first-ever visit to a configured deployment paints the
|
||||
* composer in its final position instead of correcting by the bar's height.
|
||||
* The answer is the deployment's own configuration — `librechat.yaml` plus
|
||||
* `CUSTOM_FOOTER` — because the document is served before there is a caller to
|
||||
* resolve: a per-tenant, role or user config override of `privacyPolicy` or
|
||||
* `termsOfService` is resolved by `/api/config`, and the client prefers that
|
||||
* resolved answer over this one. Reading the effective config here would cost
|
||||
* an override query on the document that carries the conversation's LCP, and
|
||||
* still could not resolve role or user overrides pre-authentication.
|
||||
*
|
||||
* Always injected, including the negative answer, so an absent flag means "no
|
||||
* server said" (the Vite dev server) rather than "no footer".
|
||||
*/
|
||||
export const injectConfiguredFooterBootstrap = (
|
||||
html: string,
|
||||
source?: ConfiguredFooterSource | null,
|
||||
): string =>
|
||||
injectBootstrapConfig(html, {
|
||||
sentinel: CONFIGURED_FOOTER_SENTINEL,
|
||||
values: {
|
||||
hasConfiguredFooter: hasConfiguredFooter({
|
||||
customFooter: source?.customFooter ?? undefined,
|
||||
interface: source?.interfaceConfig ?? null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
|
@ -1 +1,3 @@
|
|||
export * from './bootstrap';
|
||||
export * from './devtools';
|
||||
export * from './footer';
|
||||
|
|
|
|||
26
packages/data-provider/src/footer.spec.ts
Normal file
26
packages/data-provider/src/footer.spec.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { hasConfiguredFooter } from './footer';
|
||||
|
||||
describe('hasConfiguredFooter', () => {
|
||||
it('is true for any content the deployment configured itself', () => {
|
||||
expect(hasConfiguredFooter({ customFooter: 'Operator policy' })).toBe(true);
|
||||
/** An operator who set the footer to nothing still set it. */
|
||||
expect(hasConfiguredFooter({ customFooter: '' })).toBe(true);
|
||||
expect(
|
||||
hasConfiguredFooter({ interface: { privacyPolicy: { externalUrl: 'https://x/privacy' } } }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
hasConfiguredFooter({ interface: { termsOfService: { externalUrl: 'https://x/terms' } } }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a deployment that configured none of it', () => {
|
||||
expect(hasConfiguredFooter()).toBe(false);
|
||||
expect(hasConfiguredFooter(null)).toBe(false);
|
||||
expect(hasConfiguredFooter({})).toBe(false);
|
||||
/** The links carry the policy sections, so a section without one is not a
|
||||
* footer: this is what the bar renders from. */
|
||||
expect(hasConfiguredFooter({ interface: { privacyPolicy: {}, termsOfService: {} } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
31
packages/data-provider/src/footer.ts
Normal file
31
packages/data-provider/src/footer.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { TStartupConfig } from './config';
|
||||
|
||||
/**
|
||||
* The startup-config fields that put a footer bar beneath a conversation's
|
||||
* composer. Narrow on purpose: the server answers the same question while it
|
||||
* serves the HTML shell, reading these fields off its own configuration instead
|
||||
* of assembling a startup payload it has no request for.
|
||||
*/
|
||||
export type TConfiguredFooterSource = Pick<Partial<TStartupConfig>, 'customFooter'> & {
|
||||
interface?: Pick<
|
||||
NonNullable<TStartupConfig['interface']>,
|
||||
'privacyPolicy' | 'termsOfService'
|
||||
> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a deployment configured footer content of its own — a custom footer,
|
||||
* a privacy policy or terms of service.
|
||||
*
|
||||
* The footer bar is absolutely positioned in a zero-height wrapper, so the
|
||||
* composer above it is what reserves its band: this answer decides both the
|
||||
* bar and the clearance. The client and the server share it because the two
|
||||
* answering differently is exactly the layout correction it exists to remove.
|
||||
*/
|
||||
export function hasConfiguredFooter(source?: TConfiguredFooterSource | null): boolean {
|
||||
return (
|
||||
typeof source?.customFooter === 'string' ||
|
||||
source?.interface?.privacyPolicy?.externalUrl != null ||
|
||||
source?.interface?.termsOfService?.externalUrl != null
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ export * from './azure';
|
|||
export * from './bedrock';
|
||||
export * from './balance';
|
||||
export * from './config';
|
||||
export * from './footer';
|
||||
export * from './langchain';
|
||||
export * from './filters';
|
||||
export * from './file-config';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue