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
24 changes: 24 additions & 0 deletions api/server/routes/__tests__/share.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const mockCreateShareContentPreflight = jest.fn((filters, options = {}) => {

jest.mock('@librechat/api', () => ({
resolveDownloadPath: (file) => file.storageKey || file.filepath,
sendOfficePdfPreview: jest.fn(async ({ res }) => res.status(200).send('PDF-PREVIEW')),
assertModelBoundContent: (...args) => mockAssertModelBoundContent(...args),
assertSharedFileMetadataAllowed: (...args) => mockAssertSharedFileMetadataAllowed(...args),
createShareContentPreflight: (...args) => mockCreateShareContentPreflight(...args),
Expand Down Expand Up @@ -1811,6 +1812,29 @@ describe('share-scoped file routes', () => {
expect(response.headers['x-content-type-options']).toBe('nosniff');
});

it('serves a PDF preview of a snapshotted file when preview=pdf is requested', async () => {
const getDownloadStream = jest.fn(async () => Readable.from(['deck']));
mockGetStrategyFunctions.mockReturnValue({ getDownloadStream });
getSharedLinkFile.mockResolvedValue({
file: {
file_id: 'file-1',
source: 'local',
filepath: '/uploads/owner/deck.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
filename: 'deck.pptx',
},
hasSnapshots: true,
});

const response = await request(buildApp()).get(
'/api/share/share-123/files/file-1/download?preview=pdf',
);

expect(response.status).toBe(200);
expect(response.text).toBe('PDF-PREVIEW');
expect(getDownloadStream).toHaveBeenCalled();
});

it('downloads a snapshotted file as an attachment', async () => {
getSharedLinkFile.mockResolvedValue({
file: {
Expand Down
12 changes: 11 additions & 1 deletion api/server/routes/files/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const {
checkToolResourceUploadPermission,
resolveAssistantToolPermissions,
resolveDownloadPath,
sendOfficePdfPreview,
} = require('@librechat/api');
const {
Time,
Expand Down Expand Up @@ -714,7 +715,7 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {

stream.pipe(res);
} else {
if (getDownloadURL && req.query.direct === 'true') {
if (getDownloadURL && req.query.direct === 'true' && req.query.preview !== 'pdf') {
try {
const downloadURL = await getDirectDownloadURL({ req, file });
if (downloadURL) {
Expand All @@ -738,6 +739,15 @@ router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {

const fileStream = await getDownloadStream(req, resolveDownloadPath(file));

if (req.query.preview === 'pdf') {
return await sendOfficePdfPreview({
res,
stream: fileStream,
file,
fileConfig: req.config?.fileConfig,
});
}

fileStream.on('error', (streamError) => {
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);
if (res.headersSent) {
Expand Down
34 changes: 34 additions & 0 deletions api/server/routes/files/files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ jest.mock('sharp', () =>
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
refreshS3FileUrls: jest.fn(),
sendOfficePdfPreview: jest.fn(async ({ res }) => res.status(200).send('PDF-PREVIEW')),
getCodeExecutionBaseUrl: jest.fn((profile, environment) => {
if (environment?.baseURL) {
return environment.baseURL;
Expand Down Expand Up @@ -1178,6 +1179,39 @@ describe('File Routes - Delete with Agent Access', () => {
);
});

it('serves a PDF preview instead of redirecting when preview=pdf is requested', async () => {
const { sendOfficePdfPreview } = require('@librechat/api');
const userFileId = uuidv4();
const getDownloadURL = jest
.fn()
.mockResolvedValue('https://cdn.example.com/deck.pptx?signed');
const getDownloadStream = jest.fn().mockResolvedValue(Readable.from(['deck']));
getStrategyFunctions.mockReturnValue({ getDownloadURL, getDownloadStream });

await createFile({
user: otherUserId,
file_id: userFileId,
filename: 'deck.pptx',
filepath: 'uploads/user/deck.pptx',
bytes: 200,
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
source: FileSources.cloudfront,
});

const response = await request(app).get(
`/files/download/${otherUserId}/${userFileId}?direct=true&preview=pdf`,
);

expect(response.status).toBe(200);
expect(response.text).toBe('PDF-PREVIEW');
expect(getDownloadURL).not.toHaveBeenCalled();
expect(sendOfficePdfPreview).toHaveBeenCalledWith(
expect.objectContaining({
file: expect.objectContaining({ filename: 'deck.pptx' }),
}),
);
});

it('redirects to a direct signed download URL when explicitly requested', async () => {
const userFileId = uuidv4();
const getDownloadURL = jest.fn().mockResolvedValue('https://cdn.example.com/file.pdf?signed');
Expand Down
13 changes: 12 additions & 1 deletion api/server/routes/share.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
recordShareLinkRejection,
traceIdForMessage,
resolveDownloadPath,
sendOfficePdfPreview,
} = require('@librechat/api');
const {
logger,
Expand Down Expand Up @@ -276,8 +277,9 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {

// Redirect to a signed storage URL only when explicitly requested (?direct=true);
// by default stream through the server so blob (XHR) callers work without bucket CORS.
const isPdfPreview = req.query.preview === 'pdf';
const isDirectSource = source === FileSources.s3 || source === FileSources.cloudfront;
if (req.query.direct === 'true' && getDownloadURL && isDirectSource) {
if (req.query.direct === 'true' && getDownloadURL && isDirectSource && !isPdfPreview) {
try {
const url = await getDownloadURL({
req,
Expand All @@ -303,6 +305,15 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
const streamPath = (resolveDownloadPath(file) || '').split('?')[0];
const fileStream = await getDownloadStream(req, streamPath);

if (isPdfPreview) {
return sendOfficePdfPreview({
res,
stream: fileStream,
file,
fileConfig: req.config?.fileConfig,
});
}

res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', getContentDisposition(file.filename, disposition));
res.setHeader(
Expand Down
14 changes: 11 additions & 3 deletions client/src/data-provider/Files/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,16 +166,24 @@ export const useFilePreviewBlob = (
userId?: string,
fileId?: string,
shareId?: string,
preview?: 'pdf',
): QueryObserverResult<Blob> =>
useQuery(
[QueryKeys.fileDownload, 'previewBlob', shareId ? 'share' : 'owner', shareId ?? userId, fileId],
[
QueryKeys.fileDownload,
'previewBlob',
shareId ? 'share' : 'owner',
shareId ?? userId,
fileId,
preview ?? 'file',
],
async () => {
if (!fileId || (!shareId && !userId)) {
throw new Error('Preview identity unavailable');
}
const response = shareId
? await dataService.getSharedFileDownload(shareId, fileId)
: await dataService.getFileDownload(userId!, fileId);
? await dataService.getSharedFileDownload(shareId, fileId, preview && { preview })
: await dataService.getFileDownload(userId!, fileId, preview && { preview });
return response.data;
},
{ enabled: false, retry: false, cacheTime: 0 },
Expand Down
16 changes: 14 additions & 2 deletions client/src/hooks/Artifacts/__tests__/useOfficeFileShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
fillOfficeFileShell,
OFFICE_DOC_DATA_SLOT,
OFFICE_FILE_SHELL_MARKER,
OFFICE_PDF_SHELL_MARKER,
} from 'librechat-data-provider';
import type { Artifact } from '~/common';
import useOfficeFileShell from '../useOfficeFileShell';
Expand Down Expand Up @@ -44,15 +45,26 @@ describe('useOfficeFileShell', () => {
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.content).toBe(fillOfficeFileShell(shell, 'QUJD'));
expect(mockRefetch).toHaveBeenCalledTimes(1);
expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', undefined);
expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', undefined, undefined);
});

it('passes the share id so shared viewers use the share-scoped route', async () => {
mockShareId = 'share-9';
mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) });
const { result } = renderHook(() => useOfficeFileShell(shellArtifact));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', 'share-9');
expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', 'share-9', undefined);
});

it('requests the converted PDF for a PDF shell', async () => {
mockRefetch.mockResolvedValue({ data: new Blob(['ABC']) });
const pdfShell = shell.replace('</head>', `${OFFICE_PDF_SHELL_MARKER}</head>`);
const { result } = renderHook(() =>
useOfficeFileShell({ ...shellArtifact, content: pdfShell }),
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(mockUseFilePreviewBlob).toHaveBeenCalledWith('user-1', 'file-1', undefined, 'pdf');
expect(result.current.content).toBe(fillOfficeFileShell(pdfShell, 'QUJD'));
});

it('keeps the stored shell when the fetch fails', async () => {
Expand Down
6 changes: 4 additions & 2 deletions client/src/hooks/Artifacts/useOfficeFileShell.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { fillOfficeFileShell, isOfficeFileShell } from 'librechat-data-provider';
import { fillOfficeFileShell, isOfficeFileShell, isOfficePdfShell } from 'librechat-data-provider';
import type { Artifact } from '~/common';
import { useFilePreviewBlob } from '~/data-provider';
import { useShareContext } from '~/Providers';
Expand All @@ -25,16 +25,18 @@ export default function useOfficeFileShell(artifact: Artifact | null): {
isLoading: boolean;
} {
const { shareId } = useShareContext();
const stored = artifact?.content;
const preview = stored != null && isOfficePdfShell(stored) ? 'pdf' : undefined;
const { refetch } = useFilePreviewBlob(
artifact?.download?.user,
artifact?.download?.file_id,
shareId,
preview,
);
const refetchRef = useRef(refetch);
refetchRef.current = refetch;
const [filled, setFilled] = useState<FilledShell | null>(null);

const stored = artifact?.content;
const isShell =
stored != null && artifact?.download?.file_id != null && isOfficeFileShell(stored);

Expand Down
33 changes: 27 additions & 6 deletions packages/api/src/files/code/extract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,27 @@ const mockOfficeHtml = jest.fn(
async (_buffer: Buffer, _name: string, _mime: string) => null as string | null,
);
const mockShellRender = jest.fn(
async (_buffer: Buffer, _options: { fileShell?: boolean }) => null as string | null,
async (_buffer: Buffer, _options: { fileShell?: boolean; pdfShell?: boolean }) =>
null as string | null,
);
const mockInlineOptions = jest.fn((_options?: { pdfShell?: boolean }) => undefined);
jest.mock('~/files/documents/html', () => {
const actual =
jest.requireActual<typeof import('~/files/documents/html')>('~/files/documents/html');
return {
pptxToHtml: (buffer: Buffer, options: { fileShell?: boolean }) =>
pptxToHtml: (buffer: Buffer, options: { fileShell?: boolean; pdfShell?: boolean }) =>
mockShellRender(buffer, options),
wordDocToHtml: (buffer: Buffer, options: { fileShell?: boolean }) =>
wordDocToHtml: (buffer: Buffer, options: { fileShell?: boolean; pdfShell?: boolean }) =>
mockShellRender(buffer, options),
bufferToOfficeHtml: (buffer: Buffer, name: string, mime: string) =>
mockOfficeHtml(buffer, name, mime),
bufferToOfficeHtml: (
buffer: Buffer,
name: string,
mime: string,
options?: { pdfShell?: boolean },
) => {
mockInlineOptions(options);
return mockOfficeHtml(buffer, name, mime);
},
officeHtmlBucket: actual.officeHtmlBucket,
};
});
Expand Down Expand Up @@ -901,10 +910,22 @@ describe('office preview shell routing', () => {
const buffer = Buffer.alloc(INLINE_CAP + 1);
const text = await extractCodeArtifactText(buffer, name, mime, category, on);
expect(text).toBe('<html>shell</html>');
expect(mockShellRender).toHaveBeenCalledWith(buffer, { fileShell: true });
expect(mockShellRender).toHaveBeenCalledWith(buffer, { fileShell: true, pdfShell: true });
expect(mockOfficeHtml).not.toHaveBeenCalled();
});

it('lets a deck under the inline cap fall back to a PDF shell when enabled', async () => {
mockInlineOptions.mockClear();
await extractCodeArtifactText(Buffer.alloc(INLINE_CAP), pptx, pptxMime, 'presentation', on);
expect(mockInlineOptions).toHaveBeenCalledWith({ pdfShell: true });
mockInlineOptions.mockClear();
await extractCodeArtifactText(Buffer.alloc(INLINE_CAP), pptx, pptxMime, 'presentation', {
enabled: false,
fileSizeLimit: LIMIT,
});
expect(mockInlineOptions).toHaveBeenCalledWith({ pdfShell: false });
});

it('renders a shell for a 4 MB deck', async () => {
const text = await extractCodeArtifactText(
Buffer.alloc(4 * 1024 * 1024),
Expand Down
17 changes: 10 additions & 7 deletions packages/api/src/files/code/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,15 +302,15 @@ const renderOfficeHtml = async (
buffer: Buffer,
name: string,
mimeType: string,
fileShell = false,
{ fileShell = false, pdfShell = false }: { fileShell?: boolean; pdfShell?: boolean } = {},
): Promise<string | null> => {
const render = (): Promise<string | null> => {
if (!fileShell) {
return bufferToOfficeHtml(buffer, name, mimeType);
return bufferToOfficeHtml(buffer, name, mimeType, { pdfShell });
}
return officeHtmlBucket(name, mimeType) === 'docx'
? wordDocToHtml(buffer, { fileShell })
: pptxToHtml(buffer, { fileShell });
? wordDocToHtml(buffer, { fileShell, pdfShell })
: pptxToHtml(buffer, { fileShell, pdfShell });
};
try {
const html = await officeHtmlLimit(() =>
Expand Down Expand Up @@ -383,9 +383,12 @@ export async function extractCodeArtifactText(
* text gate keep the artifact off the panel and fall back to the
* regular download UI, matching what PPTX already does. */
if (hasOfficeHtmlPath(name, mimeType)) {
const fileShell =
buffer.length > INLINE_OFFICE_BYTES && isShellEligible(name, mimeType, officePreview);
return await renderOfficeHtml(buffer, name, mimeType, fileShell);
const shellEligible = isShellEligible(name, mimeType, officePreview);
const fileShell = buffer.length > INLINE_OFFICE_BYTES && shellEligible;
return await renderOfficeHtml(buffer, name, mimeType, {
fileShell,
pdfShell: shellEligible,
});
}
if (category === 'other') {
return null;
Expand Down
Loading
Loading