From 4537bfac512b6d83ab6df7ea43eb7daf435a7c8b Mon Sep 17 00:00:00 2001 From: saltbo Date: Thu, 30 Apr 2026 09:32:49 -0400 Subject: [PATCH] feat(preview): add microsoft office viewer --- e2e/share-access.spec.ts | 44 +++++++++ .../routes/share-public.integration.test.ts | 17 ++++ server/routes/shares.ts | 7 ++ .../preview/file-preview-content.tsx | 3 + src/components/preview/office-preview.tsx | 91 +++++++++++++++++++ src/i18n/locales/en.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/file-types.test.ts | 24 +++++ src/lib/file-types.ts | 16 +++- 9 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/components/preview/office-preview.tsx diff --git a/e2e/share-access.spec.ts b/e2e/share-access.spec.ts index ac789dcc..638f9309 100644 --- a/e2e/share-access.spec.ts +++ b/e2e/share-access.spec.ts @@ -39,6 +39,18 @@ const fileShare = { rootRef: 'root-file-ref', } +const officeShare = { + ...fileShare, + token: 'office-share-e2e', + matter: { + name: 'Quarterly Report.docx', + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + size: 4096, + isFolder: false, + }, + rootRef: 'root-office-ref', +} + async function mockFolderShare(page: Page) { await page.route(`**/api/shares/${folderShare.token}`, (route) => { if (route.request().method() !== 'GET') return route.continue() @@ -93,6 +105,23 @@ async function mockFileShare(page: Page) { ) } +async function mockOfficeShare(page: Page) { + const resolvedDownloadUrl = 'https://files.example.test/Quarterly%20Report.docx' + + await page.route(`**/api/shares/${officeShare.token}`, (route) => { + if (route.request().method() !== 'GET') return route.continue() + return route.fulfill({ json: officeShare }) + }) + + await page.route(`**/api/shares/${officeShare.token}/objects/${officeShare.rootRef}*`, (route) => { + const url = new URL(route.request().url()) + if (url.searchParams.get('downloadUrl') === '1') { + return route.fulfill({ json: { downloadUrl: resolvedDownloadUrl } }) + } + return route.fulfill({ body: 'office-doc-placeholder', contentType: officeShare.matter.type }) + }) +} + async function expectNoHorizontalOverflow(page: Page) { const hasHScroll = await page.evaluate( () => document.documentElement.scrollWidth > document.documentElement.clientWidth, @@ -150,6 +179,21 @@ test.describe('Share access page shell', () => { await expectNoHorizontalOverflow(page) }) + test('office file share embeds Microsoft Office Viewer with the existing download URL @desktop', async ({ page }) => { + await mockOfficeShare(page) + await page.goto(`/s/${officeShare.token}`) + + await expect(page.getByTestId('page-header')).toContainText('Quarterly Report.docx') + const frame = page.frameLocator('iframe[title="Quarterly Report.docx"]') + await expect(frame.owner()).toBeVisible() + + const src = await frame.owner().getAttribute('src') + expect(src).toContain('https://view.officeapps.live.com/op/embed.aspx?src=') + expect(decodeURIComponent(src ?? '')).toContain('https://files.example.test/Quarterly%20Report.docx') + await expect(page.getByText('Microsoft Office Viewer requires a public file URL')).toHaveCount(0) + await expectNoHorizontalOverflow(page) + }) + test('signed-in visitor sees avatar dropdown in the topbar @desktop', async ({ page }) => { await signUpAndGoToFiles(page) await mockFolderShare(page) diff --git a/server/routes/share-public.integration.test.ts b/server/routes/share-public.integration.test.ts index a56b1393..082589bf 100644 --- a/server/routes/share-public.integration.test.ts +++ b/server/routes/share-public.integration.test.ts @@ -304,6 +304,23 @@ describe('GET /api/shares/:token/objects/:ref — root file', () => { expect(res.headers.get('cache-control')).toBe('no-store') }) + it('returns JSON downloadUrl for public share when requested by preview', async () => { + const { app, db } = await createTestApp() + await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + const creatorId = await getUserId(db) + await insertFile(db, orgId, { id: 'dl-url1', name: 'report.docx' }) + const share = await createShare(db, { matterId: 'dl-url1', orgId, creatorId, kind: 'landing' }) + + const rootRef = await fetchRootRef(app, share.token) + const res = await app.request(`/api/shares/${share.token}/objects/${rootRef}?downloadUrl=1`, { redirect: 'manual' }) + + expect(res.status).toBe(200) + expect(res.headers.get('cache-control')).toBe('no-store') + await expect(res.json()).resolves.toEqual({ downloadUrl: MOCK_PRESIGN_URL }) + }) + it('returns 401 when password required and no cookie', async () => { const { app, db } = await createTestApp() await authedHeaders(app) diff --git a/server/routes/shares.ts b/server/routes/shares.ts index 8574f420..1784bdb8 100644 --- a/server/routes/shares.ts +++ b/server/routes/shares.ts @@ -219,6 +219,7 @@ export const publicShares = new Hono() .get('/:token/objects/:ref', async (c) => { const token = c.req.param('token') const ref = c.req.param('ref') + const returnUrl = c.req.query('downloadUrl') === '1' const db = c.get('platform').db const resolved = await resolveShareByToken(db, token) @@ -270,6 +271,12 @@ export const publicShares = new Hono() if (!storage) return c.json({ error: 'Storage not found' }, 404) const url = await s3.presignDownload(storage, targetMatter.object, targetMatter.name, PRESIGN_TTL_SECS) + if (returnUrl) { + const res = c.json({ downloadUrl: url }) + res.headers.set('Cache-Control', 'no-store') + return res + } + const res = c.redirect(url, 302) res.headers.set('Cache-Control', 'no-store') return res diff --git a/src/components/preview/file-preview-content.tsx b/src/components/preview/file-preview-content.tsx index b030eb4b..49db6f92 100644 --- a/src/components/preview/file-preview-content.tsx +++ b/src/components/preview/file-preview-content.tsx @@ -13,6 +13,7 @@ export interface PreviewFile { } const PdfPreview = lazy(() => import('./pdf-preview').then((m) => ({ default: m.PdfPreview }))) +const OfficePreview = lazy(() => import('./office-preview').then((m) => ({ default: m.OfficePreview }))) const TextPreview = lazy(() => import('./text-preview').then((m) => ({ default: m.TextPreview }))) const MediaPreview = lazy(() => import('./media-preview').then((m) => ({ default: m.MediaPreview }))) @@ -42,6 +43,8 @@ function FilePreviewContentInner({ file, previewType }: { file: PreviewFile; pre return {file.name} case 'pdf': return + case 'office': + return case 'markdown': case 'code': case 'text': diff --git a/src/components/preview/office-preview.tsx b/src/components/preview/office-preview.tsx new file mode 100644 index 00000000..33941782 --- /dev/null +++ b/src/components/preview/office-preview.tsx @@ -0,0 +1,91 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface OfficePreviewProps { + url: string + filename: string +} + +function toAbsoluteUrl(url: string): string { + return new URL(url, window.location.origin).href +} + +function shouldResolveDownloadUrl(url: string): boolean { + const absoluteUrl = toAbsoluteUrl(url) + return new URL(absoluteUrl).origin === window.location.origin && absoluteUrl.includes('/api/shares/') +} + +function withDownloadUrlQuery(url: string): string { + const target = new URL(url, window.location.origin) + target.searchParams.set('downloadUrl', '1') + return target.href +} + +export function buildOfficeViewerUrl(url: string): string { + const absoluteUrl = toAbsoluteUrl(url) + return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(absoluteUrl)}` +} + +export function OfficePreview({ url, filename }: OfficePreviewProps) { + const { t } = useTranslation() + const [resolvedUrl, setResolvedUrl] = useState(url) + const [error, setError] = useState(false) + const needsResolution = shouldResolveDownloadUrl(url) + const [resolving, setResolving] = useState(needsResolution) + const absoluteUrl = toAbsoluteUrl(resolvedUrl) + const viewerUrl = useMemo(() => buildOfficeViewerUrl(resolvedUrl), [resolvedUrl]) + const isLocalUrl = absoluteUrl.startsWith('http://localhost') || absoluteUrl.startsWith('http://127.0.0.1') + + useEffect(() => { + let cancelled = false + setResolvedUrl(url) + setError(false) + setResolving(shouldResolveDownloadUrl(url)) + + if (!shouldResolveDownloadUrl(url)) return + + fetch(withDownloadUrlQuery(url), { credentials: 'include' }) + .then((res) => { + if (!res.ok) throw new Error('Failed to resolve download URL') + return res.json() as Promise<{ downloadUrl?: string }> + }) + .then((body) => { + if (!cancelled && body.downloadUrl) setResolvedUrl(body.downloadUrl) + }) + .catch(() => { + if (!cancelled) setError(true) + }) + .finally(() => { + if (!cancelled) setResolving(false) + }) + + return () => { + cancelled = true + } + }, [url]) + + return ( +
+ {error &&
{t('preview.loadError')}
} + {!resolving && isLocalUrl && ( +
+ {t('preview.officeLocalHint')} +
+ )} + {resolving ? ( +
+ {t('common.loading')} +
+ ) : ( +