Fix MS Office preview for private/local URLs (#17044)
## Summary Fixes #16900 - MS Office documents (doc, docx, ppt, pptx, xls, xlsx, odt) cannot be previewed when hosted on local/private networks. ## Problem Microsoft's Office Online viewer (`view.officeapps.live.com`) requires documents to be publicly accessible from the internet. For self-hosted Twenty instances or local development, files are not reachable by Microsoft's servers, causing the viewer to fail with "An error occurred". ## Solution Detect private/local URLs upfront and show a helpful message with a download button instead of letting the viewer fail silently. **Detected private URLs:** - `localhost`, `127.0.0.1`, `[::1]` - Private IP ranges: `10.x.x.x`, `172.16-31.x.x`, `192.168.x.x` - Local domain patterns: `.local`, `.localhost`, `.internal` ## Alternatives Explored (that don't work) We investigated more sophisticated detection approaches: 1. **postMessage events** - Microsoft's viewer doesn't send any error messages we can listen to 2. **Fetching the embed URL** - Blocked by CORS (Microsoft doesn't set `Access-Control-Allow-Origin`) 3. **Reading iframe content** - Cross-origin restrictions prevent JavaScript access The URL-based detection is the most reliable approach for catching the common cases where preview will definitely fail. ## Testing 1. Upload an Office file (docx, pptx, xlsx) on a local Twenty instance 2. Try to preview it 3. Should see "This file cannot be previewed because it is hosted locally" with a Download button
This commit is contained in:
@@ -13,6 +13,16 @@ import { IconDownload } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { getFileNameAndExtension } from '~/utils/file/getFileNameAndExtension';
|
||||
|
||||
const MS_OFFICE_EXTENSIONS = [
|
||||
'doc',
|
||||
'docx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'odt',
|
||||
];
|
||||
|
||||
const StyledDocumentViewerContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -58,6 +68,11 @@ const StyledMessage = styled.div`
|
||||
max-width: 400px;
|
||||
`;
|
||||
|
||||
const StyledLightMessage = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.xl};
|
||||
@@ -69,6 +84,53 @@ type DocumentViewerProps = {
|
||||
documentUrl: string;
|
||||
};
|
||||
|
||||
// MS Office Online viewer requires documents to be publicly accessible from the internet.
|
||||
// For private/local URLs, Microsoft's servers cannot fetch the document.
|
||||
//
|
||||
// We explored more sophisticated detection approaches but they don't work:
|
||||
// - postMessage: Microsoft's viewer doesn't send any error messages
|
||||
// - Fetching the embed URL: Blocked by CORS (no Access-Control-Allow-Origin header)
|
||||
// - Reading iframe content: Cross-origin restrictions prevent access
|
||||
//
|
||||
// This simple URL-based detection catches the most common cases (localhost, private IPs)
|
||||
// where the preview will definitely fail. For edge cases on non-standard private networks,
|
||||
// users will see Microsoft's error page but can still download the file.
|
||||
//
|
||||
// See: https://github.com/twentyhq/twenty/issues/16900
|
||||
const isPrivateUrl = (url: string): boolean => {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
if (
|
||||
hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname === '[::1]'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipParts = hostname.split('.').map(Number);
|
||||
if (ipParts.length === 4 && ipParts.every((part) => !isNaN(part))) {
|
||||
if (ipParts[0] === 10) return true;
|
||||
if (ipParts[0] === 172 && ipParts[1] >= 16 && ipParts[1] <= 31)
|
||||
return true;
|
||||
if (ipParts[0] === 192 && ipParts[1] === 168) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hostname.endsWith('.local') ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.internal')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const MIME_TYPE_MAPPING: Record<
|
||||
(typeof PREVIEWABLE_EXTENSIONS)[number],
|
||||
string
|
||||
@@ -107,6 +169,7 @@ export const DocumentViewer = ({
|
||||
const fileExtension = extension?.toLowerCase().replace('.', '') ?? '';
|
||||
const fileCategory = getFileType(documentName);
|
||||
const isPreviewable = PREVIEWABLE_EXTENSIONS.includes(fileExtension);
|
||||
const isMsOfficeFile = MS_OFFICE_EXTENSIONS.includes(fileExtension);
|
||||
|
||||
const mimeType = PREVIEWABLE_EXTENSIONS.includes(fileExtension)
|
||||
? MIME_TYPE_MAPPING[fileExtension]
|
||||
@@ -158,6 +221,27 @@ export const DocumentViewer = ({
|
||||
</StyledDocumentViewerContainer>
|
||||
);
|
||||
|
||||
if (isMsOfficeFile && isPrivateUrl(documentUrl)) {
|
||||
return (
|
||||
<StyledDocumentViewerContainer>
|
||||
<StyledUnavailablePreviewContainer>
|
||||
<StyledLightMessage>
|
||||
<Trans>
|
||||
This file cannot be previewed because it is hosted locally.
|
||||
</Trans>
|
||||
</StyledLightMessage>
|
||||
<Button
|
||||
Icon={IconDownload}
|
||||
title={t`Download`}
|
||||
onClick={() => downloadFile(documentUrl, documentName)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
/>
|
||||
</StyledUnavailablePreviewContainer>
|
||||
</StyledDocumentViewerContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledDocumentViewerContainer>
|
||||
<DocViewer
|
||||
|
||||
Reference in New Issue
Block a user