Browse Source

Fix "Open in Slicer" failing for filenames with special characters

Filenames containing / \ ? or # (e.g. "Abzweigdose/Verteilerdose 70mm")
broke the slicer protocol handler. BambuStudio/OrcaSlicer url_decode()
the entire protocol URL before downloading, which decoded %2F back to /,
creating extra path segments that returned 404. Sanitize these characters
to _ in slicer download URLs — the filename is cosmetic (backend resolves
files by archive ID).
maziggy 3 months ago
parent
commit
d92803b557
4 changed files with 19 additions and 9 deletions
  1. 1 0
      CHANGELOG.md
  2. 17 8
      frontend/src/api/client.ts
  3. 0 0
      static/assets/index-CIrY3KrR.js
  4. 1 1
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -9,6 +9,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Timestamps Off by Timezone Offset in Non-UTC Docker Containers** ([#504](https://github.com/maziggy/bambuddy/issues/504)) — All backend timestamps used `datetime.now()` (server local time) or the deprecated `datetime.utcnow()`. The frontend's `parseUTCDate()` assumes timestamps without timezone indicators are UTC and appends `'Z'`, so when the container's timezone wasn't UTC, every stored timestamp was off by the timezone offset. Replaced all database and comparison timestamps with `datetime.now(timezone.utc)` across 16 backend files (~80 call sites). On the frontend, replaced 13 `new Date(backendTimestamp)` calls with `parseUTCDate()` across 8 files to correctly interpret UTC timestamps. Cosmetic timestamps (filenames, user-facing local time formatting) are intentionally left as local time.
 - **"Power Off Printer" Option Not Gated by Control Permission** ([#500](https://github.com/maziggy/bambuddy/issues/500)) — The "Power off printer when done" checkbox in the print modal and the auto power off toggle in the bulk edit modal were accessible to all users regardless of permissions. Users without the `printers:control` permission can now no longer enable auto power off — the checkbox and tri-state toggle are disabled and visually dimmed.
 - **Created Admin Users Can't See Settings Button** ([#503](https://github.com/maziggy/bambuddy/issues/503)) — The sidebar hid the Settings link based on a hardcoded `role === 'user'` check instead of the actual `settings:read` permission, so newly created admin users who had the permission still couldn't see the button. Also, after login the auth state was set directly from the login response instead of re-fetching the full auth status, which could miss permission data. Now uses `hasPermission('settings:read')` for the sidebar check and calls `checkAuthStatus()` after login to load the complete user state including permissions.
+- **"Open in Slicer" Fails for Filenames Containing Special Characters** — Filenames with `/`, `\`, `?`, or `#` (e.g., `Abzweigdose/Verteilerdose 70mm`) caused the slicer protocol handler to fail. The filename is placed in the download URL path and `encodeURIComponent`-encoded, but BambuStudio and OrcaSlicer call `url_decode()` on the entire protocol handler URL before downloading. This decoded `%2F` back to `/`, creating extra path segments that resulted in a 404. The URL filename is purely cosmetic (the backend resolves files by archive ID, not filename), so now sanitizes `/`, `\`, `?`, and `#` to `_` in slicer download URLs.
 
 ## [0.2.1b3] - 2026-02-23
 

+ 17 - 8
frontend/src/api/client.ts

@@ -2808,12 +2808,17 @@ export const api = {
     document.body.removeChild(a);
     window.URL.revokeObjectURL(url);
   },
-  getSource3mfForSlicer: (archiveId: number, filename: string) =>
-    `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  getSource3mfForSlicer: (archiveId: number, filename: string) => {
+    // Sanitize: slicers url_decode() the entire URL, so / \ ? # in filenames break path routing
+    const safe = filename.replace(/[/\\?#]/g, '_');
+    return `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.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')}`,
+  getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) => {
+    const safe = filename.replace(/[/\\?#]/g, '_');
+    return `${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
+  },
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     formData.append('file', file);
@@ -2934,12 +2939,16 @@ export const api = {
     }),
   getArchiveProjectImageUrl: (archiveId: number, imagePath: string) =>
     `${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')}`,
+  getArchiveForSlicer: (id: number, filename: string) => {
+    const safe = filename.replace(/[/\\?#]/g, '_');
+    return `${API_BASE}/archives/${id}/file/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.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')}`,
+  getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) => {
+    const safe = filename.replace(/[/\\?#]/g, '_');
+    return `${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(safe.endsWith('.3mf') ? safe : safe + '.3mf')}`;
+  },
   getArchivePlates: (archiveId: number) =>
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CIrY3KrR.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-oh6lPE3t.js"></script>
+    <script type="module" crossorigin src="/assets/index-CIrY3KrR.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-1Ts9jjQl.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff