Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions api/server/routes/__tests__/config.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down
71 changes: 69 additions & 2 deletions api/server/routes/__tests__/share.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -16,16 +20,19 @@ 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()),
}));

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', () => ({
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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: {
Expand All @@ -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: [] });

Expand Down
7 changes: 3 additions & 4 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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>} */
Expand Down Expand Up @@ -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 } : {}),
Expand Down
23 changes: 23 additions & 0 deletions api/server/routes/share.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ const {
ensureLinkPermissions,
isFileSnapshotEnabled,
isFileSnapshotKillSwitchActive,
buildSharedLinkStartupPayload,
deleteSharedLinkWithCleanup,
updateSharedLinkPermissionsExpiration,
isActiveExpirationDate,
getSharedLinkExpiration,
} = require('@librechat/api');
const {
logger,
getTenantId,
runAsSystem,
tenantStorage,
SYSTEM_TENANT_ID,
createTempChatExpirationDate,
} = require('@librechat/data-schemas');
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject expired shares before returning config

When a shared link has expired but its ACL entry has not yet been TTL-cleaned, canAccessSharedLink can still call next() because it looks up the raw SharedLink and checks ACL only; the existing message/file paths then re-check activeExpirationFilter in getSharedMessages/getSharedLinkFile, but this new config path immediately returns the payload. That lets /api/share/:shareId/config disclose the gated footer/analytics/bundler config for a link that /api/share/:shareId would 404, so please re-read the active share or add the expiration check before building the payload.

Useful? React with 馃憤聽/ 馃憥.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f46c4bd. The shared-link access middleware now reads only active, non-expired shares with activeExpirationFilter before checking ACL/public access, so /api/share/:shareId/config cannot return config for expired links waiting on TTL cleanup. Added a regression test covering an expired shared link with a still-valid PUBLIC ACL. Verified with: cd packages/api && npx jest src/shared-links/access.test.ts --runInBand --coverage=false; cd api && npx jest server/routes/tests/share.spec.js --runInBand; npx tsc --noEmit -p packages/api/tsconfig.json; eslint/sort-imports/prettier checks for touched files.

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,
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Artifacts/ArtifactPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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) {
Expand Down
16 changes: 12 additions & 4 deletions client/src/components/Artifacts/ArtifactTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);

Expand Down Expand Up @@ -55,7 +63,7 @@ export default function ArtifactTabs({
previewRef={previewRef}
sharedProps={sharedProps}
currentCode={currentCode}
startupConfig={startupConfig}
startupConfig={resolvedStartupConfig}
/>
</Tabs.Content>
</div>
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/Chat/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Share/ShareView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions client/src/data-provider/Endpoints/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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,
},
);
};
7 changes: 6 additions & 1 deletion client/src/utils/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions packages/api/src/shared-links/access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading