fix: harden file previews and downloads
This commit is contained in:
@@ -221,7 +221,10 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
{!isDir && downloadFile && (
|
{!isDir && downloadFile && (
|
||||||
<Item onClick={(e: React.MouseEvent) => {
|
<Item onClick={(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void downloadFile(node.path);
|
void downloadFile(node.path).catch((error) => {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||||
|
});
|
||||||
}}>
|
}}>
|
||||||
<Icon name="download" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
|
<Icon name="download" className="mr-2 h-4 w-4" /> {t('sidebarFilesTree.menu.save')}
|
||||||
</Item>
|
</Item>
|
||||||
|
|||||||
@@ -358,13 +358,13 @@ const isJsonFile = (path: string): boolean => {
|
|||||||
return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson';
|
return ext === 'json' || ext === 'jsonc' || ext === 'json5' || ext === 'geojson';
|
||||||
};
|
};
|
||||||
|
|
||||||
const isHtmlFile = (path: string): boolean => {
|
const isHtmlFile = (path: string): boolean => {
|
||||||
if (!path) return false;
|
if (!path) return false;
|
||||||
const ext = path.toLowerCase().split('.').pop();
|
const ext = path.toLowerCase().split('.').pop();
|
||||||
return ext === 'html' || ext === 'htm';
|
return ext === 'html' || ext === 'htm';
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FileRowProps {
|
interface FileRowProps {
|
||||||
node: FileNode;
|
node: FileNode;
|
||||||
root: string;
|
root: string;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
@@ -478,7 +478,10 @@ const FileRow: React.FC<FileRowProps> = ({
|
|||||||
{!isDir && downloadFile && (
|
{!isDir && downloadFile && (
|
||||||
<Item onClick={(e: React.MouseEvent) => {
|
<Item onClick={(e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void downloadFile(node.path);
|
void downloadFile(node.path).catch((error) => {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||||
|
});
|
||||||
}}>
|
}}>
|
||||||
<Icon name="download" className="mr-2 size-4" /> {t('sidebarFilesTree.menu.save')}
|
<Icon name="download" className="mr-2 size-4" /> {t('sidebarFilesTree.menu.save')}
|
||||||
</Item>
|
</Item>
|
||||||
@@ -769,10 +772,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
|
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
|
||||||
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
|
const effectiveSelectedPath = React.useMemo(() => {
|
||||||
|
if (selectedPath) {
|
||||||
|
const comparableSelected = toComparablePath(selectedPath);
|
||||||
|
if (openPaths.some((path) => toComparablePath(path) === comparableSelected)) {
|
||||||
|
return selectedPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return openPaths[0] ?? null;
|
||||||
|
}, [openPaths, selectedPath]);
|
||||||
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
|
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
|
||||||
const selectedFilePath = selectedFile?.path ?? '';
|
const selectedFilePath = selectedFile?.path ?? '';
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!root || !selectedPath) return;
|
||||||
|
const comparableSelected = toComparablePath(selectedPath);
|
||||||
|
const selectedIsOpen = openPaths.some((path) => toComparablePath(path) === comparableSelected);
|
||||||
|
if (!selectedIsOpen) {
|
||||||
|
setSelectedPath(root, openPaths[0] ?? null);
|
||||||
|
}
|
||||||
|
}, [openPaths, root, selectedPath, setSelectedPath]);
|
||||||
|
|
||||||
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
|
const selectedFileIsOutsideWorkspace = Boolean(root && selectedFilePath && !isPathWithinRoot(selectedFilePath, root));
|
||||||
const selectedOutsideFileGrant = selectedFileIsOutsideWorkspace ? getOutsideFileGrant(selectedFilePath) : undefined;
|
const selectedOutsideFileGrant = selectedFileIsOutsideWorkspace ? getOutsideFileGrant(selectedFilePath) : undefined;
|
||||||
const selectedFileReadOptions = React.useMemo(
|
const selectedFileReadOptions = React.useMemo(
|
||||||
@@ -825,7 +846,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const [fileLoading, setFileLoading] = React.useState(false);
|
const [fileLoading, setFileLoading] = React.useState(false);
|
||||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||||
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
||||||
|
const desktopImageBlobUrlRef = React.useRef<string>('');
|
||||||
const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState('');
|
const [imageAssetAuthReadyKey, setImageAssetAuthReadyKey] = React.useState('');
|
||||||
|
const [htmlAssetAuthReadyKey, setHtmlAssetAuthReadyKey] = React.useState('');
|
||||||
const [pdfAssetAuthReadyKey, setPdfAssetAuthReadyKey] = React.useState('');
|
const [pdfAssetAuthReadyKey, setPdfAssetAuthReadyKey] = React.useState('');
|
||||||
|
|
||||||
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
||||||
@@ -2835,6 +2858,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
|
const pdfAssetAuthKey = selectedFile?.path && isSelectedPdf
|
||||||
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
? `${selectedFile.path}|${selectedFileReadOptions.allowOutsideWorkspace ? 'outside' : 'workspace'}|${selectedFileReadOptions.outsideFileGrant ?? ''}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const htmlAssetAuthKey = selectedFile?.path && isHtml && htmlViewMode === 'preview' && !runtime.isVSCode
|
||||||
|
? selectedFile.path
|
||||||
|
: '';
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!imageAssetAuthKey) {
|
if (!imageAssetAuthKey) {
|
||||||
@@ -2857,6 +2884,33 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
|
const isImageAssetAuthLoading = Boolean(imageAssetAuthKey && imageAssetAuthReadyKey !== imageAssetAuthKey);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!htmlAssetAuthKey) {
|
||||||
|
setHtmlAssetAuthReadyKey('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setHtmlAssetAuthReadyKey('');
|
||||||
|
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
||||||
|
.then((token) => {
|
||||||
|
if (!cancelled && token) {
|
||||||
|
setHtmlAssetAuthReadyKey(htmlAssetAuthKey);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [htmlAssetAuthKey, t]);
|
||||||
|
|
||||||
|
const isHtmlAssetAuthLoading = Boolean(htmlAssetAuthKey && htmlAssetAuthReadyKey !== htmlAssetAuthKey);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!pdfAssetAuthKey) {
|
if (!pdfAssetAuthKey) {
|
||||||
setPdfAssetAuthReadyKey('');
|
setPdfAssetAuthReadyKey('');
|
||||||
@@ -2918,21 +2972,45 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const resolveDesktopImage = async () => {
|
const resolveDesktopImage = async () => {
|
||||||
if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) {
|
if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) {
|
||||||
setDesktopImageSrc('');
|
if (desktopImageBlobUrlRef.current) {
|
||||||
return;
|
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
|
||||||
}
|
desktopImageBlobUrlRef.current = '';
|
||||||
|
}
|
||||||
setFileError(null);
|
setDesktopImageSrc('');
|
||||||
|
return;
|
||||||
const srcPromise = files.readFileBinary
|
}
|
||||||
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
|
|
||||||
: Promise.resolve(getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', {
|
setFileError(null);
|
||||||
path: selectedFile.path,
|
|
||||||
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
if (desktopImageBlobUrlRef.current) {
|
||||||
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
|
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
|
||||||
}));
|
desktopImageBlobUrlRef.current = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcPromise = files.readFileBinary
|
||||||
|
? files.readFileBinary(selectedFile.path, selectedFileReadOptions).then((result) => result.dataUrl)
|
||||||
|
: (async () => {
|
||||||
|
const response = await runtimeFetch('/api/fs/raw', {
|
||||||
|
query: {
|
||||||
|
path: selectedFile.path,
|
||||||
|
allowOutsideWorkspace: selectedFileReadOptions.allowOutsideWorkspace ? 'true' : undefined,
|
||||||
|
outsideFileGrant: selectedFileReadOptions.outsideFileGrant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(t('filesView.error.readFileFailed'));
|
||||||
|
}
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (cancelled) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
desktopImageBlobUrlRef.current = url;
|
||||||
|
return url;
|
||||||
|
})();
|
||||||
|
|
||||||
await srcPromise
|
await srcPromise
|
||||||
.then((src) => {
|
.then((src) => {
|
||||||
@@ -2940,11 +3018,15 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
setDesktopImageSrc(src);
|
setDesktopImageSrc(src);
|
||||||
setLoadedFilePath(selectedFile.path);
|
setLoadedFilePath(selectedFile.path);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (!cancelled) {
|
if (desktopImageBlobUrlRef.current) {
|
||||||
setDesktopImageSrc('');
|
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
|
||||||
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
desktopImageBlobUrlRef.current = '';
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setDesktopImageSrc('');
|
||||||
|
setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||||
setLoadedFilePath(null);
|
setLoadedFilePath(null);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -2957,10 +3039,19 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
|
|
||||||
void resolveDesktopImage();
|
void resolveDesktopImage();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
|
}, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path, selectedFileReadOptions, t]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (desktopImageBlobUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(desktopImageBlobUrlRef.current);
|
||||||
|
desktopImageBlobUrlRef.current = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
|
const handleCloseDialog = React.useCallback(() => setActiveDialog(null), []);
|
||||||
|
|
||||||
@@ -3353,7 +3444,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const fn = files.downloadFile;
|
const fn = files.downloadFile;
|
||||||
if (fn) void fn(selectedFile.path);
|
if (fn) void fn(selectedFile.path).catch((error) => {
|
||||||
|
console.error('Download failed:', error);
|
||||||
|
toast.error(t('sidebarFilesTree.toast.operationFailed'));
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="size-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
|
className="size-6 p-0 hover:bg-transparent focus-visible:bg-transparent active:bg-transparent"
|
||||||
title={t('filesView.editor.saveFile')}
|
title={t('filesView.editor.saveFile')}
|
||||||
@@ -3702,22 +3796,30 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
|
) : selectedFile && isHtml && htmlViewMode === 'preview' ? (
|
||||||
<div className="h-full overflow-hidden">
|
isHtmlAssetAuthLoading ? (
|
||||||
<iframe
|
<div className="flex h-full items-center justify-center text-muted-foreground typography-ui-label">
|
||||||
srcDoc={(() => {
|
{t('common.loading')}
|
||||||
// Inject base tag for relative paths (CSS/JS/images) to work
|
</div>
|
||||||
const basePath = selectedFile.path.substring(0, selectedFile.path.lastIndexOf('/') + 1);
|
) : (
|
||||||
if (!basePath) return fileContent;
|
<div className="h-full overflow-hidden">
|
||||||
const baseTag = `<base href="${runtime.isDesktop ? basePath : basePath}">`;
|
<iframe
|
||||||
return fileContent.replace(/<head([^>]*)>/i, `<head$1>${baseTag}`);
|
src={!runtime.isVSCode && htmlAssetAuthReadyKey === htmlAssetAuthKey ? (() => {
|
||||||
})()}
|
const encoded = selectedFile.path.split('/').map((segment) => encodeURIComponent(segment)).join('/');
|
||||||
className="w-full h-full border-none"
|
return getRuntimeUrlResolver().authenticatedAsset(`/api/fs/serve${encoded.startsWith('/') ? encoded : `/${encoded}`}`);
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
})() : undefined}
|
||||||
title={t('filesView.editor.htmlPreviewTitle')}
|
srcDoc={runtime.isVSCode ? (() => {
|
||||||
/>
|
const basePath = selectedFile.path.substring(0, selectedFile.path.lastIndexOf('/') + 1);
|
||||||
</div>
|
if (!basePath) return fileContent;
|
||||||
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
|
return fileContent.replace(/<head([^>]*)>/i, `<head$1><base href="${basePath}">`);
|
||||||
|
})() : undefined}
|
||||||
|
className="w-full h-full border-none"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
|
title={t('filesView.editor.htmlPreviewTitle')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : selectedFile && canUseShikiFileView && textViewMode === 'view' ? (
|
||||||
renderShikiFileView(selectedFile, draftContent)
|
renderShikiFileView(selectedFile, draftContent)
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
|||||||
- `POST /api/fs/mkdir`
|
- `POST /api/fs/mkdir`
|
||||||
- `GET /api/fs/read`
|
- `GET /api/fs/read`
|
||||||
- `GET /api/fs/raw`
|
- `GET /api/fs/raw`
|
||||||
|
- `GET /api/fs/serve/:path(*)`
|
||||||
- `POST /api/fs/write`
|
- `POST /api/fs/write`
|
||||||
- `POST /api/fs/delete`
|
- `POST /api/fs/delete`
|
||||||
- `POST /api/fs/rename`
|
- `POST /api/fs/rename`
|
||||||
|
|||||||
@@ -98,6 +98,38 @@ const createGitCheckIgnoreTimeoutMs = () => {
|
|||||||
return 2500;
|
return 2500;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILE_MIME_MAP = Object.freeze({
|
||||||
|
'.html': 'text/html',
|
||||||
|
'.htm': 'text/html',
|
||||||
|
'.css': 'text/css',
|
||||||
|
'.js': 'application/javascript',
|
||||||
|
'.mjs': 'application/javascript',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.wasm': 'application/wasm',
|
||||||
|
'.xml': 'application/xml',
|
||||||
|
'.txt': 'text/plain',
|
||||||
|
'.md': 'text/markdown',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.csv': 'text/csv',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.ttf': 'font/ttf',
|
||||||
|
'.eot': 'application/vnd.ms-fontobject',
|
||||||
|
'.mp3': 'audio/mpeg',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_SERVE_BYTES = 100 * 1024 * 1024;
|
||||||
|
|
||||||
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
|
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
|
||||||
// Anything outside this allowlist (including any non-git command) runs normally
|
// Anything outside this allowlist (including any non-git command) runs normally
|
||||||
// — we never cache arbitrary exec.
|
// — we never cache arbitrary exec.
|
||||||
@@ -869,6 +901,67 @@ export const registerFsRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get(/^\/api\/fs\/serve\/(.+)$/, async (req, res) => {
|
||||||
|
const rawPath = req.params[0] || '';
|
||||||
|
if (!rawPath) {
|
||||||
|
return res.status(400).json({ error: 'Path is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||||
|
return res.status(403).json({ error: 'allowOutsideWorkspace is not permitted for this endpoint' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.resolve('/', rawPath);
|
||||||
|
const resolved = await resolveReadPathFromContext({
|
||||||
|
req,
|
||||||
|
targetPath: filePath,
|
||||||
|
resolveProjectDirectory,
|
||||||
|
path,
|
||||||
|
os,
|
||||||
|
normalizeDirectoryPath,
|
||||||
|
openchamberUserConfigRoot,
|
||||||
|
});
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return res.status(400).json({ error: resolved.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||||
|
fsPromises.realpath(resolved.resolved),
|
||||||
|
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
||||||
|
return res.status(403).json({ error: 'Access to file denied' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fsPromises.stat(canonicalPath);
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
||||||
|
}
|
||||||
|
if (stats.size > MAX_SERVE_BYTES) {
|
||||||
|
return res.status(413).json({ error: 'File too large to serve' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(canonicalPath).toLowerCase();
|
||||||
|
const mimeType = FILE_MIME_MAP[ext] || 'application/octet-stream';
|
||||||
|
const content = await fsPromises.readFile(canonicalPath);
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
return res.type(mimeType).send(content);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error;
|
||||||
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||||
|
return res.status(404).json({ error: 'File not found' });
|
||||||
|
}
|
||||||
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||||
|
return res.status(403).json({ error: 'Access to file denied' });
|
||||||
|
}
|
||||||
|
console.error('Failed to serve file:', error);
|
||||||
|
return res.status(500).json({ error: (error && error.message) || 'Failed to serve file' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/fs/write', async (req, res) => {
|
app.post('/api/fs/write', async (req, res) => {
|
||||||
const { path: filePath, content } = req.body || {};
|
const { path: filePath, content } = req.body || {};
|
||||||
if (!filePath || typeof filePath !== 'string') {
|
if (!filePath || typeof filePath !== 'string') {
|
||||||
|
|||||||
@@ -270,14 +270,19 @@ const getUrlAuthTokenFromRequest = (req) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getRequestPathname = (req) => {
|
const getRequestPathname = (req) => {
|
||||||
if (typeof req?.path === 'string' && req.path) return req.path;
|
|
||||||
const rawUrl = req?.originalUrl || req?.url;
|
const rawUrl = req?.originalUrl || req?.url;
|
||||||
if (typeof rawUrl !== 'string' || !rawUrl) return '';
|
if (typeof rawUrl === 'string' && rawUrl) {
|
||||||
try {
|
try {
|
||||||
return new URL(rawUrl, 'http://localhost').pathname;
|
return new URL(rawUrl, 'http://localhost').pathname;
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
// Fall through to Express' derived path fields.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (typeof req?.baseUrl === 'string' && req.baseUrl && typeof req?.path === 'string' && req.path) {
|
||||||
|
return `${req.baseUrl}${req.path}`.replace(/\/+/g, '/');
|
||||||
|
}
|
||||||
|
if (typeof req?.path === 'string' && req.path) return req.path;
|
||||||
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWebSocketUpgrade = (req) => {
|
const isWebSocketUpgrade = (req) => {
|
||||||
@@ -292,6 +297,8 @@ const isUrlAuthReadableHttpPath = (pathname) => {
|
|||||||
|| pathname === '/api/openchamber/events'
|
|| pathname === '/api/openchamber/events'
|
||||||
|| pathname === '/api/notifications/stream'
|
|| pathname === '/api/notifications/stream'
|
||||||
|| pathname === '/api/fs/raw'
|
|| pathname === '/api/fs/raw'
|
||||||
|
|| pathname === '/api/fs/serve'
|
||||||
|
|| pathname.startsWith('/api/fs/serve/')
|
||||||
|| pathname.startsWith('/api/preview/proxy/')
|
|| pathname.startsWith('/api/preview/proxy/')
|
||||||
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname)
|
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname)
|
||||||
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname);
|
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname);
|
||||||
|
|||||||
@@ -182,6 +182,37 @@ describe('ui auth client credential seam', () => {
|
|||||||
expect(await auth.ensureSessionToken(urlReq, urlRes)).toBe('client:device-1');
|
expect(await auth.ensureSessionToken(urlReq, urlRes)).toBe('client:device-1');
|
||||||
expect(await auth.resolveAuthContext(urlReq, urlRes, { allowUrlToken: false })).toBe(null);
|
expect(await auth.resolveAuthContext(urlReq, urlRes, { allowUrlToken: false })).toBe(null);
|
||||||
|
|
||||||
|
const serveReq = { method: 'GET', path: '/api/fs/serve/tmp/index.html', url: `/api/fs/serve/tmp/index.html?oc_url_token=${encodeURIComponent(urlToken)}`, headers: {} };
|
||||||
|
const serveRes = createResponse();
|
||||||
|
let serveCalled = false;
|
||||||
|
await auth.requireAuth(serveReq, serveRes, () => {
|
||||||
|
serveCalled = true;
|
||||||
|
});
|
||||||
|
expect(serveCalled).toBe(true);
|
||||||
|
|
||||||
|
const absoluteServeReq = { method: 'GET', path: '/api/fs/serve/Users/test/project/preview-test.html', url: `/api/fs/serve/Users/test/project/preview-test.html?oc_url_token=${encodeURIComponent(urlToken)}`, headers: {} };
|
||||||
|
const absoluteServeRes = createResponse();
|
||||||
|
let absoluteServeCalled = false;
|
||||||
|
await auth.requireAuth(absoluteServeReq, absoluteServeRes, () => {
|
||||||
|
absoluteServeCalled = true;
|
||||||
|
});
|
||||||
|
expect(absoluteServeCalled).toBe(true);
|
||||||
|
|
||||||
|
const mountedServeReq = {
|
||||||
|
method: 'GET',
|
||||||
|
baseUrl: '/api',
|
||||||
|
path: '/fs/serve/Users/test/project/preview-test.html',
|
||||||
|
originalUrl: `/api/fs/serve/Users/test/project/preview-test.html?oc_url_token=${encodeURIComponent(urlToken)}`,
|
||||||
|
url: `/fs/serve/Users/test/project/preview-test.html?oc_url_token=${encodeURIComponent(urlToken)}`,
|
||||||
|
headers: {},
|
||||||
|
};
|
||||||
|
const mountedServeRes = createResponse();
|
||||||
|
let mountedServeCalled = false;
|
||||||
|
await auth.requireAuth(mountedServeReq, mountedServeRes, () => {
|
||||||
|
mountedServeCalled = true;
|
||||||
|
});
|
||||||
|
expect(mountedServeCalled).toBe(true);
|
||||||
|
|
||||||
const arbitraryGetReq = { method: 'GET', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
const arbitraryGetReq = { method: 'GET', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||||
const arbitraryGetRes = createResponse();
|
const arbitraryGetRes = createResponse();
|
||||||
let arbitraryGetCalled = false;
|
let arbitraryGetCalled = false;
|
||||||
|
|||||||
@@ -243,12 +243,21 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({
|
|||||||
|
|
||||||
async downloadFile(path: string): Promise<void> {
|
async downloadFile(path: string): Promise<void> {
|
||||||
const target = normalizePath(path);
|
const target = normalizePath(path);
|
||||||
const url = urls.rawFile(target, { download: true });
|
const response = await runtimeFetch('/api/fs/raw', {
|
||||||
|
query: { path: target, download: true },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = target.split('/').pop() || 'file';
|
a.download = target.split('/').pop() || 'file';
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user