Преглед изворни кода

fix(slicer): drop the legacy library:read from the slice gate (#2725)

The desktop handoff accepted library:read alongside read_all/read_own, on
the reasoning that default groups do not carry it and requiring it would
lock out Operators and Viewers. The permission grants nothing in that
position: the slicer-token endpoint gates on
require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN), and
neither that dependency nor User.has_permission expands the legacy name,
so a group holding only library:read gets a 403 there. It cannot reach
the File Manager to try, either - GET /library/folders gates on the same
pair - and the library:read -> library:read_own migration in
core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
custom role that still carries it stays stuck rather than being upgraded.
custom role that still carries it stays stuck rather than being upgraded.

Accepting it only enabled a menu item the server refuses, and the failure
is indistinguishable from "no slicer installed" once the catch hands the
unauthenticated URL over. Removed, with the comment recording the reason
so the next reader does not re-add it, and a test that pins it.

---

refactor(slicer): share one sliceable-file-type rule (#2725)

The File Manager and the 3D preview decide the same thing about the same
file and each held its own list of extensions - which is how they came to
disagree, offering a desktop handoff for an STL whose own preview showed
"Open in Slicer" greyed out. Making the two lists identical fixed the
symptom and left the drift, so SLICEABLE_FILE_TYPES now lives in
utils/slicer.ts with isSliceableFileType for a stored file_type and
isSliceableFilename for a name.

The filename form still rules out the compound extensions explicitly,
since .gcode.3mf ends with .3mf; the type form does not need to, because
classify_file_type stores that one whole.

Both test files mocked the whole slicer module, which would have replaced
the new predicates with undefined - switched to importOriginal so only
openInSlicer is stubbed. That is the better shape regardless: the tests
now exercise the rule the component runs instead of a copy declared
beside them.

Carries the rebuilt bundle. The CSS hash moves with it - the split button
introduces Tailwind classes the previous build had no reason to emit.
maziggy пре 1 месец
родитељ
комит
fb4c130bb2

+ 6 - 5
frontend/src/__tests__/components/ModelViewerModal.test.tsx

@@ -29,12 +29,13 @@ vi.mock('../../components/GcodeViewer', () => ({
   ),
   ),
 }));
 }));
 
 
-vi.mock('../../utils/slicer', () => ({
+// Only the protocol-handler launch is stubbed — it would navigate the jsdom
+// window. Everything else in the module is a pure predicate, so keep the real
+// implementations: re-declaring them here would let the file-type rule these
+// tests assert on drift away from the one the component actually runs.
+vi.mock('../../utils/slicer', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../../utils/slicer')>()),
   openInSlicer: vi.fn(),
   openInSlicer: vi.fn(),
-  resolveDesktopSlicer: vi.fn(
-    (openInSlicer?: string, preferredSlicer?: string) =>
-      (openInSlicer ?? preferredSlicer ?? 'bambu_studio') as 'bambu_studio' | 'orcaslicer',
-  ),
 }));
 }));
 
 
 const mockCapabilities = {
 const mockCapabilities = {

+ 32 - 10
frontend/src/__tests__/pages/FileManagerPage.test.tsx

@@ -12,12 +12,13 @@ import { setAuthToken } from '../../api/client';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { server } from '../mocks/server';
 import { server } from '../mocks/server';
 
 
-vi.mock('../../utils/slicer', () => ({
+// Only the protocol-handler launch is stubbed — it would navigate the jsdom
+// window. Everything else in the module is a pure predicate, so keep the real
+// implementations: isSliceableFilename decides which rows even offer the
+// action these tests click.
+vi.mock('../../utils/slicer', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../../utils/slicer')>()),
   openInSlicer: vi.fn(),
   openInSlicer: vi.fn(),
-  resolveDesktopSlicer: vi.fn(
-    (openInSlicer?: string, preferredSlicer?: string) =>
-      (openInSlicer ?? preferredSlicer ?? 'bambu_studio') as 'bambu_studio' | 'orcaslicer',
-  ),
 }));
 }));
 
 
 vi.mock('../../components/SliceModal', () => ({
 vi.mock('../../components/SliceModal', () => ({
@@ -1239,11 +1240,12 @@ describe('FileManagerPage', () => {
       expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
       expect(within(card).queryByText('Slice')).not.toBeInTheDocument();
     });
     });
 
 
-    // Permission gating is the security-relevant half of the slice action:
-    // the in-app API path needs library:upload (the permission the backend
-    // enforces on the slicer-token endpoint), the desktop handoff mirrors the
-    // backend's ownership check and needs library:read_all / library:read_own
-    // (legacy library:read also accepted).
+    // Permission gating is the security-relevant half of the slice action: the
+    // in-app API path needs library:upload, and the desktop handoff mirrors the
+    // ownership check the slicer-token endpoint runs — library:read_all or
+    // library:read_own. The legacy library:read is deliberately not accepted;
+    // it satisfies neither the token endpoint nor the folder listing that gets
+    // a user to this page at all.
     const mockAuthUser = (permissions: string[]) => {
     const mockAuthUser = (permissions: string[]) => {
       setAuthToken('test-token', 'session');
       setAuthToken('test-token', 'session');
       server.use(
       server.use(
@@ -1322,6 +1324,26 @@ describe('FileManagerPage', () => {
       expect(sliceItem).not.toBeDisabled();
       expect(sliceItem).not.toBeDisabled();
     });
     });
 
 
+    it('does not accept the legacy library:read for the desktop handoff', async () => {
+      // require_ownership_permission(LIBRARY_READ_ALL, LIBRARY_READ_OWN) does no
+      // legacy expansion, so this group 403s on the slicer-token endpoint.
+      // Enabling the item would offer an action the server refuses, and the
+      // failure would look like "no slicer installed" once the fallback URL is
+      // handed over.
+      mockAuthUser(['library:read']);
+      const user = userEvent.setup();
+      render(<FileManagerPage />);
+
+      await waitFor(() => expect(screen.getByText('bracket.stl')).toBeInTheDocument());
+
+      const card = await openMenu(user, 'bracket.stl');
+      const sliceItem = within(card).getByText('Slice').closest('button');
+      expect(sliceItem).toBeDisabled();
+
+      await user.click(sliceItem!);
+      expect(openInSlicer).not.toHaveBeenCalled();
+    });
+
     it('slices from the list-view button when the slicer API is disabled', async () => {
     it('slices from the list-view button when the slicer API is disabled', async () => {
       const user = userEvent.setup();
       const user = userEvent.setup();
       render(<FileManagerPage />);
       render(<FileManagerPage />);

+ 6 - 6
frontend/src/components/ModelViewerModal.tsx

@@ -7,7 +7,7 @@ import { GcodeViewer } from './GcodeViewer';
 import { Button } from './Button';
 import { Button } from './Button';
 import { api, withStreamToken } from '../api/client';
 import { api, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
-import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
 
 
 type ViewTab = '3d' | 'gcode';
 type ViewTab = '3d' | 'gcode';
@@ -373,11 +373,11 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
   }, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
 
 
   // Which file types can be handed to a desktop slicer via the URL protocol
   // Which file types can be handed to a desktop slicer via the URL protocol
-  // handler — and sliced in-app via the sidecar. Kept in step with
-  // `isSliceableFilename()` in FileManagerPage so a file's card-menu "Slice"
-  // and its 3D-preview slicer button never disagree on the same type.
-  const normalizedFileType = (fileType || '').toLowerCase();
-  const slicerReadyType = ['3mf', 'stl', 'step', 'stp'].includes(normalizedFileType);
+  // handler — and sliced in-app via the sidecar. Shares its list with
+  // `isSliceableFilename()`, which the File Manager's card menu and list row
+  // use, so a file's "Slice" action and its 3D-preview slicer button can no
+  // longer disagree about the same file.
+  const slicerReadyType = isSliceableFileType(fileType);
   const canOpenInSlicer = isLibrary ? slicerReadyType : true;
   const canOpenInSlicer = isLibrary ? slicerReadyType : true;
 
 
   // When the user has the in-app Slicer API enabled (Settings → Workflow →
   // When the user has the in-app Slicer API enabled (Settings → Workflow →

+ 17 - 15
frontend/src/pages/FileManagerPage.tsx

@@ -74,7 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
 import { formatFileSize } from '../utils/file';
 import { formatFileSize } from '../utils/file';
-import { openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
+import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
@@ -743,14 +743,6 @@ function isSlicedFilename(filename: string): boolean {
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
   return lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf');
 }
 }
 
 
-// Files that can be fed to the slicer sidecar (model geometry inputs).
-// Excludes .gcode.* (already sliced) and any other non-model formats.
-function isSliceableFilename(filename: string): boolean {
-  const lower = filename.toLowerCase();
-  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
-  return lower.endsWith('.stl') || lower.endsWith('.3mf') || lower.endsWith('.step') || lower.endsWith('.stp');
-}
-
 // File Card
 // File Card
 interface FileCardProps {
 interface FileCardProps {
   file: LibraryFileListItem;
   file: LibraryFileListItem;
@@ -1172,16 +1164,26 @@ export function FileManagerPage() {
     }
     }
   }, [preferredSlicer, showToast, t]);
   }, [preferredSlicer, showToast, t]);
 
 
-  // Slice permission: API mode needs upload rights, desktop handoff is a download.
-  // The handoff mirrors the backend's ownership check on the slicer-token
-  // endpoint (library:read_all / library:read_own); `library:read` is a legacy
-  // permission default groups don't carry, so requiring it would disable the
-  // handoff for Operators and Viewers.
+  // Slice permission: API mode needs upload rights, the desktop handoff is a
+  // download. Each mirrors what the backend enforces on the endpoint that
+  // branch actually calls, so the UI never offers an action the server refuses.
+  //
+  // Deliberately NOT accepting the legacy `library:read` on the handoff branch.
+  // It looks like the safe back-compat term to include, but the slicer-token
+  // endpoint gates on require_ownership_permission(LIBRARY_READ_ALL,
+  // LIBRARY_READ_OWN), and neither that dependency nor User.has_permission
+  // expands the legacy name — so a group holding only `library:read` gets a 403
+  // there. It cannot reach this page to find out either: GET /library/folders
+  // gates on the same pair. Accepting it here would only enable a menu item
+  // that fails, and the `library:read` -> `library:read_own` migration in
+  // core/database.py runs only over the groups named in DEFAULT_GROUPS, so a
+  // custom role that still carries it is genuinely stuck rather than silently
+  // upgraded.
   const canSlice = useCallback(() => {
   const canSlice = useCallback(() => {
     if (settings?.use_slicer_api) {
     if (settings?.use_slicer_api) {
       return hasPermission('library:upload');
       return hasPermission('library:upload');
     }
     }
-    return hasAnyPermission('library:read_all', 'library:read_own', 'library:read');
+    return hasAnyPermission('library:read_all', 'library:read_own');
   }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
   }, [settings?.use_slicer_api, hasPermission, hasAnyPermission]);
   const { data: folders, isLoading: foldersLoading } = useQuery({
   const { data: folders, isLoading: foldersLoading } = useQuery({
     queryKey: ['library-folders'],
     queryKey: ['library-folders'],

+ 37 - 0
frontend/src/utils/slicer.ts

@@ -41,6 +41,43 @@ export function resolveDesktopSlicer(
   return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
   return openInSlicer ?? preferredSlicer ?? 'bambu_studio';
 }
 }
 
 
+/**
+ * File types a slicer can be handed — both by the desktop URI handler and by
+ * the in-app sidecar. Source geometry only: a sliced file is an output, and
+ * neither slicer has anything to do with one.
+ *
+ * Lives here rather than beside either caller because both the File Manager
+ * (which has a filename) and the 3D preview (which has a `LibraryFile.file_type`)
+ * decide the same thing about the same file. They used to hold separate lists,
+ * and the two disagreed — a card menu offered a desktop handoff for an STL
+ * whose own 3D preview showed "Open in Slicer" greyed out.
+ */
+export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
+
+/**
+ * Does a `LibraryFile.file_type` name a sliceable source file?
+ *
+ * The backend stores compound extensions whole — a sliced 3MF classifies as
+ * `gcode.3mf`, not `3mf` (`classify_file_type` in `api/routes/library.py`) — so
+ * membership alone is enough to exclude sliced output here.
+ */
+export function isSliceableFileType(fileType?: string | null): boolean {
+  const normalized = (fileType || '').toLowerCase();
+  return (SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
+}
+
+/**
+ * Does a filename name a sliceable source file?
+ *
+ * Checked against the name rather than a stored type, so the compound
+ * extensions have to be ruled out explicitly: `.gcode.3mf` ends with `.3mf`.
+ */
+export function isSliceableFilename(filename: string): boolean {
+  const lower = filename.toLowerCase();
+  if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
+  return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
+}
+
 /**
 /**
  * Detect the user's operating system
  * Detect the user's operating system
  */
  */

Разлика између датотеке није приказан због своје велике величине
+ 0 - 1
static/assets/index-B3jj6-fz.css


Разлика између датотеке није приказан због своје велике величине
+ 1 - 0
static/assets/index-DDSj68H5.css


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
static/assets/index-fpDLI8Il.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-yKwaHTh1.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-B3jj6-fz.css">
+    <script type="module" crossorigin src="/assets/index-fpDLI8Il.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DDSj68H5.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Неке датотеке нису приказане због велике количине промена