fix(slicer): token-based auth for Open in Slicer downloads (#433)

Slicer protocol handlers (bambustudio://, orcaslicer://) launch the
slicer app which fetches files via HTTP but cannot send auth headers.
The global auth middleware returned 401, causing "importing failed."

Add short-lived, single-use download tokens: the frontend fetches a
token via authenticated POST, then builds a /dl/{token}/{filename} URL
the slicer can access without auth headers. Tokens are validated
server-side (5-min expiry, single-use). Applies to archive files,
source 3MFs, and library files.

Also fix platform-specific URL formats to match slicer source code:
- macOS: bambustudioopen:// with encodeURIComponent
- Windows/Linux: bambustudio://open?file= (was wrongly using macOS scheme on Linux)
- OrcaSlicer: orcaslicer://open?file=
This commit is contained in:
maziggy
2026-02-18 16:37:35 +01:00
parent f13b385480
commit a903816b8a
11 changed files with 727 additions and 451 deletions
+12
View File
@@ -2786,6 +2786,10 @@ export const api = {
},
getSource3mfForSlicer: (archiveId: number, filename: string) =>
`${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
createSourceSlicerToken: (archiveId: number) =>
request<{ token: string }>(`/archives/${archiveId}/source-slicer-token`, { method: 'POST' }),
getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
`${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
const formData = new FormData();
formData.append('file', file);
@@ -2908,6 +2912,10 @@ export const api = {
`${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
getArchiveForSlicer: (id: number, filename: string) =>
`${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
createArchiveSlicerToken: (archiveId: number) =>
request<{ token: string }>(`/archives/${archiveId}/slicer-token`, { method: 'POST' }),
getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
`${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
getArchivePlates: (archiveId: number) =>
request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
@@ -3938,6 +3946,10 @@ export const api = {
deleteLibraryFile: (id: number) =>
request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
createLibrarySlicerToken: (fileId: number) =>
request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>
`${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(filename)}`,
downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
const headers: Record<string, string> = {};
if (authToken) {
+20 -8
View File
@@ -263,17 +263,29 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
const handleOpenInSlicer = () => {
const handleOpenInSlicer = async () => {
if (!canOpenInSlicer) return;
// URL must include .3mf filename for Bambu Studio to recognize the format
const filename = title || 'model';
if (isLibrary) {
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
openInSlicer(downloadUrl, preferredSlicer);
return;
try {
if (isLibrary) {
const { token } = await api.createLibrarySlicerToken(libraryFileId!);
const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
} else {
const { token } = await api.createArchiveSlicerToken(archiveId!);
const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
}
} catch {
// Fallback to direct URL (works when auth is disabled)
if (isLibrary) {
const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
openInSlicer(downloadUrl, preferredSlicer);
} else {
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
}
}
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
};
return (
+38 -16
View File
@@ -105,6 +105,36 @@ function getArchiveFileType(filename: string | null | undefined): string | undef
// formatDate imported from '../utils/date' - handles UTC conversion
/**
* Open an archive file in the slicer.
* Fetches a short-lived download token, then builds a token-authenticated URL
* that bypasses auth middleware (slicer protocol handlers can't send auth headers).
*/
async function openInSlicerWithToken(
archiveId: number,
filename: string,
resourceType: 'file' | 'source',
slicer: SlicerType,
): Promise<void> {
try {
if (resourceType === 'source') {
const { token } = await api.createSourceSlicerToken(archiveId);
const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
openInSlicer(`${window.location.origin}${path}`, slicer);
} else {
const { token } = await api.createArchiveSlicerToken(archiveId);
const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
openInSlicer(`${window.location.origin}${path}`, slicer);
}
} catch {
// Fallback to direct URL (works when auth is disabled)
const path = resourceType === 'source'
? api.getSource3mfForSlicer(archiveId, filename)
: api.getArchiveForSlicer(archiveId, filename);
openInSlicer(`${window.location.origin}${path}`, slicer);
}
}
function ArchiveCard({
archive,
printerName,
@@ -337,8 +367,7 @@ function ArchiveCard({
icon: <ExternalLink className="w-4 h-4" />,
onClick: () => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
},
disabled: !archive.file_path,
title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -349,8 +378,7 @@ function ArchiveCard({
icon: <ExternalLink className="w-4 h-4" />,
onClick: () => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
},
},
]),
@@ -738,8 +766,7 @@ function ArchiveCard({
e.stopPropagation();
// Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, sourceName, 'source', preferredSlicer);
}}
title={t('archives.card.openSource3mf')}
>
@@ -1008,8 +1035,7 @@ function ArchiveCard({
className="min-w-0 p-1 sm:p-1.5"
onClick={() => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
}}
title={t('archives.card.openInBambuStudio')}
>
@@ -1024,8 +1050,7 @@ function ArchiveCard({
className="flex-1 min-w-0"
onClick={() => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
}}
title={t('archives.card.openInBambuStudioToSlice')}
>
@@ -1546,8 +1571,7 @@ function ArchiveListRow({
icon: <ExternalLink className="w-4 h-4" />,
onClick: () => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
},
disabled: !archive.file_path,
title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -1558,8 +1582,7 @@ function ArchiveListRow({
icon: <ExternalLink className="w-4 h-4" />,
onClick: () => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
},
},
]),
@@ -1900,8 +1923,7 @@ function ArchiveListRow({
size="sm"
onClick={() => {
const filename = archive.print_name || archive.filename || 'model';
const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
openInSlicer(downloadUrl, preferredSlicer);
openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
}}
title={t('archives.card.openInBambuStudio')}
>
+30 -10
View File
@@ -1,12 +1,23 @@
/**
* Utility for opening files in slicer applications
*
* Bambu Studio URL protocol is OS-specific:
* - Windows: bambustudio://<encoded-URL>
* - macOS/Linux: bambustudioopen://<encoded-URL>
* Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):
*
* OrcaSlicer uses the same protocol on all platforms:
* - orcaslicer://open?file=<URL>
* Bambu Studio has TWO separate URL handlers:
* 1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>
* - Checks: starts_with("bambustudio://open")
* - Calls url_decode(), then split_str(url, "file=")
* 2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>
* - Checks: starts_with("bambustudioopen://")
* - Strips prefix, then url_decode()
*
* OrcaSlicer Downloader accepts both formats via regex:
* - (orcaslicer|bambustudio|...)://open?file=<URL>
* - bambustudioopen://<URL>
*
* Key insight: Using ?file= query format, the browser's URL parser preserves
* http:// in the query string without any encoding. Only the macOS-specific
* bambustudioopen:// format needs encodeURIComponent (BS calls url_decode).
*/
export type SlicerType = 'bambu_studio' | 'orcaslicer';
@@ -34,8 +45,6 @@ export function detectPlatform(): Platform {
/**
* Open a URL in the specified slicer application.
* Uses a temporary link element to trigger the protocol handler,
* which avoids browser "unknown protocol" blocks on window.location.href.
* @param downloadUrl - The URL to the file to open
* @param slicer - Which slicer to use (defaults to bambu_studio)
*/
@@ -43,15 +52,26 @@ export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_st
let url: string;
if (slicer === 'orcaslicer') {
// OrcaSlicer: ?file= query format — http:// preserved in query string
url = `orcaslicer://open?file=${downloadUrl}`;
} else {
const platform = detectPlatform();
const protocol = platform === 'windows' ? 'bambustudio' : 'bambustudioopen';
url = `${protocol}://${encodeURIComponent(downloadUrl)}`;
if (platform === 'macos') {
// macOS only: bambustudioopen scheme via MacOpenURL() callback.
// Must encode because bare http:// in authority gets mangled by browser.
// BS calls url_decode() after stripping "bambustudioopen://" prefix.
url = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
} else {
// Windows/Linux: bambustudio://open?file= via post_init() CLI args.
// The ?file= query format preserves http:// without encoding.
// IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
// it does NOT process "bambustudioopen://" (that's macOS-only).
url = `bambustudio://open?file=${downloadUrl}`;
}
}
// Use a temporary <a> element to trigger the protocol handler.
// This works more reliably than window.location.href for custom protocols.
// This avoids navigating away from the page (unlike window.location.href).
const link = document.createElement('a');
link.href = url;
link.style.display = 'none';