Przeglądaj źródła

Security hardening (maziggy/bambuddy-security #29)

maziggy 1 tydzień temu
rodzic
commit
f43be8098e

+ 0 - 8
.basedpyright/baseline.json

@@ -75,14 +75,6 @@
                     "lineCount": 1
                     "lineCount": 1
                 }
                 }
             },
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 30,
-                    "endColumn": 67,
-                    "lineCount": 1
-                }
-            },
             {
             {
                 "code": "reportOptionalMemberAccess",
                 "code": "reportOptionalMemberAccess",
                 "range": {
                 "range": {

+ 65 - 13
backend/app/api/routes/archives.py

@@ -14,6 +14,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Reques
 from fastapi.responses import FileResponse, Response
 from fastapi.responses import FileResponse, Response
 from sqlalchemy import and_, case, func, or_, select
 from sqlalchemy import and_, case, func, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.sql.elements import ColumnElement
 
 
 from backend.app.core import database
 from backend.app.core import database
 from backend.app.core.auth import (
 from backend.app.core.auth import (
@@ -177,6 +178,17 @@ async def _claimed_timelapse_stems(db, printer_id: int | None, exclude_archive_i
     return {Path(p).stem for p in rows.scalars().all() if p}
     return {Path(p).stem for p in rows.scalars().all() if p}
 
 
 
 
+def _visible_archive_clauses(user, can_read_all: bool, printer_scope: PrinterScope) -> list:
+    """WHERE clauses for the archives a caller may see, as _ensure_archive_visible decides one by one."""
+    clauses = []
+    if (scope_clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        clauses.append(scope_clause)
+    if not can_read_all:
+        # Own archives only; an ownerless one needs read-all, and no user sees none.
+        clauses.append(PrintArchive.created_by_id == user.id if user is not None else PrintArchive.id.is_(None))
+    return clauses
+
+
 def _ensure_archive_visible(
 def _ensure_archive_visible(
     archive: PrintArchive | None,
     archive: PrintArchive | None,
     user: User | None,
     user: User | None,
@@ -450,8 +462,10 @@ async def list_archives(
         printer_scope=printer_scope,
         printer_scope=printer_scope,
     )
     )
 
 
-    # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries)
-    duplicate_hashes, duplicate_name_hash_pairs = await service.get_duplicate_hashes_and_names()
+    # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries),
+    # counted among the archives this caller may see, as the duplicates view lists them.
+    visible = _visible_archive_clauses(user, can_read_all, printer_scope)
+    duplicate_hashes, duplicate_name_hash_pairs = await service.get_duplicate_hashes_and_names(visible)
 
 
     # Batch-load duplicate groups once for the current page keys.
     # Batch-load duplicate groups once for the current page keys.
     duplicate_hashes_in_page = {
     duplicate_hashes_in_page = {
@@ -482,7 +496,7 @@ async def list_archives(
                 PrintArchive.created_at,
                 PrintArchive.created_at,
                 PrintArchive.content_hash,
                 PrintArchive.content_hash,
                 func.lower(PrintArchive.print_name).label("print_name_lower"),
                 func.lower(PrintArchive.print_name).label("print_name_lower"),
-            ).where(or_(*duplicate_group_conditions), PrintArchive.deleted_at.is_(None))
+            ).where(or_(*duplicate_group_conditions), PrintArchive.deleted_at.is_(None), *visible)
         )
         )
 
 
         duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
         duplicate_groups_by_hash: dict[str, list[tuple[int, datetime]]] = defaultdict(list)
@@ -617,6 +631,7 @@ async def no_3mf_warning(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Whether to nudge the user about a print that archived without its 3MF,
     """Whether to nudge the user about a print that archived without its 3MF,
     and why. True iff any archive in the last 30 days was created via the
     and why. True iff any archive in the last 30 days was created via the
@@ -654,6 +669,8 @@ async def no_3mf_warning(
     ]
     ]
     if user is not None and not can_read_all:
     if user is not None and not can_read_all:
         conditions.append(PrintArchive.created_by_id == user.id)
         conditions.append(PrintArchive.created_by_id == user.id)
+    if (scope_clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        conditions.append(scope_clause)
     result = await db.execute(select(PrintArchive.extra_data).where(*conditions))
     result = await db.execute(select(PrintArchive.extra_data).where(*conditions))
     reasons: set[str] = set()
     reasons: set[str] = set()
     has_fallback = False
     has_fallback = False
@@ -1633,6 +1650,7 @@ async def get_all_tags(
             Permission.ARCHIVES_READ_OWN,
             Permission.ARCHIVES_READ_OWN,
         )
         )
     ),
     ),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """List all unique tags with usage counts.
     """List all unique tags with usage counts.
 
 
@@ -1640,9 +1658,11 @@ async def get_all_tags(
     """
     """
     user, can_read_all = auth_result
     user, can_read_all = auth_result
     # Query all archives with non-null tags
     # Query all archives with non-null tags
-    tag_conditions = [PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None)]
+    tag_conditions: list[ColumnElement[bool]] = [PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None)]
     if user is not None and not can_read_all:
     if user is not None and not can_read_all:
         tag_conditions.append(PrintArchive.created_by_id == user.id)
         tag_conditions.append(PrintArchive.created_by_id == user.id)
+    if (scope_clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        tag_conditions.append(scope_clause)
     result = await db.execute(select(PrintArchive.tags).where(*tag_conditions))
     result = await db.execute(select(PrintArchive.tags).where(*tag_conditions))
     all_tags_rows = result.all()
     all_tags_rows = result.all()
 
 
@@ -1668,6 +1688,7 @@ async def rename_tag(
     request: Request,
     request: Request,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Rename a tag across all archives.
     """Rename a tag across all archives.
 
 
@@ -1685,7 +1706,11 @@ async def rename_tag(
 
 
     # Find all archives containing the old tag
     # Find all archives containing the old tag
     result = await db.execute(
     result = await db.execute(
-        select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+        select(PrintArchive).where(
+            PrintArchive.tags.isnot(None),
+            PrintArchive.deleted_at.is_(None),
+            *_visible_archive_clauses(None, True, printer_scope),
+        )
     )
     )
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
 
 
@@ -1716,6 +1741,7 @@ async def delete_tag(
     tag_name: str,
     tag_name: str,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Delete a tag from all archives.
     """Delete a tag from all archives.
 
 
@@ -1723,7 +1749,11 @@ async def delete_tag(
     """
     """
     # Find all archives containing the tag
     # Find all archives containing the tag
     result = await db.execute(
     result = await db.execute(
-        select(PrintArchive).where(PrintArchive.tags.isnot(None), PrintArchive.deleted_at.is_(None))
+        select(PrintArchive).where(
+            PrintArchive.tags.isnot(None),
+            PrintArchive.deleted_at.is_(None),
+            *_visible_archive_clauses(None, True, printer_scope),
+        )
     )
     )
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
 
 
@@ -1766,6 +1796,7 @@ async def get_archive(
         content_hash=archive.content_hash,
         content_hash=archive.content_hash,
         print_name=archive.print_name,
         print_name=archive.print_name,
         makerworld_model_id=makerworld_id,
         makerworld_model_id=makerworld_id,
+        visible=_visible_archive_clauses(user, can_read_all, printer_scope),
     )
     )
     run_aggregates = await _load_run_aggregates(db, [archive.id])
     run_aggregates = await _load_run_aggregates(db, [archive.id])
     return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
     return archive_to_response(archive, duplicates, run_aggregate=run_aggregates.get(archive.id))
@@ -1860,7 +1891,9 @@ async def find_similar_archives(
 
 
     service = ArchiveComparisonService(db)
     service = ArchiveComparisonService(db)
     try:
     try:
-        return await service.find_similar_archives(archive_id, limit=limit)
+        return await service.find_similar_archives(
+            archive_id, limit=limit, visible=_visible_archive_clauses(user, can_read_all, printer_scope)
+        )
     except ValueError as e:
     except ValueError as e:
         raise HTTPException(404, str(e))
         raise HTTPException(404, str(e))
 
 
@@ -2133,12 +2166,17 @@ async def rescan_archive(
 async def recalculate_all_costs(
 async def recalculate_all_costs(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Recalculate costs for all archives based on filament usage and prices."""
     """Recalculate costs for all archives based on filament usage and prices."""
 
 
     from backend.app.api.routes.settings import get_setting
     from backend.app.api.routes.settings import get_setting
 
 
-    result = await db.execute(select(PrintArchive))
+    # Only the archives of the caller's printers, as the archive list shows them.
+    query = select(PrintArchive)
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
 
 
     # Load all filaments for lookup
     # Load all filaments for lookup
@@ -2211,11 +2249,16 @@ async def recalculate_all_costs(
 async def rescan_all_archives(
 async def rescan_all_archives(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Rescan all archives and update their metadata."""
     """Rescan all archives and update their metadata."""
     from backend.app.services.archive import ThreeMFParser
     from backend.app.services.archive import ThreeMFParser
 
 
-    result = await db.execute(select(PrintArchive))
+    # Only the archives of the caller's printers, as the archive list shows them.
+    query = select(PrintArchive)
+    if (clause := printer_scope.where(PrintArchive.printer_id)) is not None:
+        query = query.where(clause)
+    result = await db.execute(query)
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
 
 
     updated = 0
     updated = 0
@@ -2280,6 +2323,7 @@ async def get_archive_duplicates(
         content_hash=archive.content_hash,
         content_hash=archive.content_hash,
         print_name=archive.print_name,
         print_name=archive.print_name,
         makerworld_model_id=makerworld_id,
         makerworld_model_id=makerworld_id,
+        visible=_visible_archive_clauses(user, can_read_all, printer_scope),
     )
     )
     return {"duplicates": duplicates, "count": len(duplicates)}
     return {"duplicates": duplicates, "count": len(duplicates)}
 
 
@@ -2288,9 +2332,14 @@ async def get_archive_duplicates(
 async def backfill_content_hashes(
 async def backfill_content_hashes(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Compute and store content hashes for all archives missing them."""
     """Compute and store content hashes for all archives missing them."""
-    result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash.is_(None)))
+    result = await db.execute(
+        select(PrintArchive).where(
+            PrintArchive.content_hash.is_(None), *_visible_archive_clauses(None, True, printer_scope)
+        )
+    )
     archives = list(result.scalars().all())
     archives = list(result.scalars().all())
 
 
     updated = 0
     updated = 0
@@ -5468,6 +5517,7 @@ async def upload_source_3mf_by_name(
     print_name: str = Query(None, description="Match archive by print name"),
     print_name: str = Query(None, description="Match archive by print name"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_UPDATE_ALL),
+    printer_scope: PrinterScope = RequestPrinterScope,
 ):
 ):
     """Upload source 3MF and match to archive by print name.
     """Upload source 3MF and match to archive by print name.
 
 
@@ -5488,9 +5538,11 @@ async def upload_source_3mf_by_name(
             print_name = print_name[:-7]
             print_name = print_name[:-7]
 
 
     # Find matching archive - try exact match first, then fuzzy
     # Find matching archive - try exact match first, then fuzzy
+    # Only an archive on the caller's printers can be matched, as it is listed.
+    visible = _visible_archive_clauses(None, True, printer_scope)
     result = await db.execute(
     result = await db.execute(
         select(PrintArchive)
         select(PrintArchive)
-        .where(PrintArchive.print_name == print_name)
+        .where(PrintArchive.print_name == print_name, *visible)
         .order_by(PrintArchive.created_at.desc())
         .order_by(PrintArchive.created_at.desc())
         .limit(1)
         .limit(1)
     )
     )
@@ -5500,7 +5552,7 @@ async def upload_source_3mf_by_name(
         # Try matching filename without .gcode.3mf
         # Try matching filename without .gcode.3mf
         result = await db.execute(
         result = await db.execute(
             select(PrintArchive)
             select(PrintArchive)
-            .where(PrintArchive.filename.like(f"{print_name}%"))
+            .where(PrintArchive.filename.like(f"{print_name}%"), *visible)
             .order_by(PrintArchive.created_at.desc())
             .order_by(PrintArchive.created_at.desc())
             .limit(1)
             .limit(1)
         )
         )
@@ -5510,7 +5562,7 @@ async def upload_source_3mf_by_name(
         # Try case-insensitive partial match on print_name
         # Try case-insensitive partial match on print_name
         result = await db.execute(
         result = await db.execute(
             select(PrintArchive)
             select(PrintArchive)
-            .where(PrintArchive.print_name.ilike(f"%{print_name}%"))
+            .where(PrintArchive.print_name.ilike(f"%{print_name}%"), *visible)
             .order_by(PrintArchive.created_at.desc())
             .order_by(PrintArchive.created_at.desc())
             .limit(1)
             .limit(1)
         )
         )

+ 34 - 7
backend/app/core/websocket.py

@@ -31,6 +31,31 @@ def _may_receive(connection: WebSocket, printer_id: int | None) -> bool:
     return scope is not None and scope.allows(printer_id)
     return scope is not None and scope.allows(printer_id)
 
 
 
 
+# A printer id no limited scope holds: what an archive event carries when its
+# printer cannot be told, so only sockets that see every printer receive it.
+_UNKNOWN_PRINTER = -1
+
+
+async def _archive_printer_id(archive_id: Any) -> int | None:
+    """The printer an archive belongs to (None: printer-less, seen by everyone)."""
+    from sqlalchemy import select
+
+    from backend.app.core.database import async_session
+    from backend.app.models.archive import PrintArchive
+
+    if not isinstance(archive_id, int):
+        return _UNKNOWN_PRINTER
+    try:
+        async with async_session() as db:
+            row = (await db.execute(select(PrintArchive.printer_id).where(PrintArchive.id == archive_id))).first()
+    except Exception:
+        logger.exception("Could not look up the printer of archive %s for a live update", archive_id)
+        return _UNKNOWN_PRINTER
+    if row is None:
+        return _UNKNOWN_PRINTER
+    return row[0]
+
+
 class ConnectionManager:
 class ConnectionManager:
     """Manages WebSocket connections and broadcasts."""
     """Manages WebSocket connections and broadcasts."""
 
 
@@ -257,13 +282,15 @@ class ConnectionManager:
         )
         )
 
 
     async def send_archive_updated(self, archive: dict):
     async def send_archive_updated(self, archive: dict):
-        """Notify clients that an archive was updated."""
-        await self.broadcast(
-            {
-                "type": "archive_updated",
-                "data": archive,
-            }
-        )
+        """Notify clients that an archive was updated.
+
+        Only sockets that may see the archive's printer receive it: callers send
+        just the changed fields, so the printer is looked up here when missing.
+        """
+        message: dict[str, Any] = {"type": "archive_updated", "data": archive}
+        if _message_printer_id(message) is None:
+            message["printer_id"] = await _archive_printer_id(archive.get("id"))
+        await self.broadcast(message)
 
 
     async def send_queue_item_uploading(
     async def send_queue_item_uploading(
         self,
         self,

+ 14 - 3
backend/app/services/archive.py

@@ -6,8 +6,10 @@ import os
 import re
 import re
 import shutil
 import shutil
 import zipfile
 import zipfile
+from collections.abc import Sequence
 from datetime import date, datetime, time, timezone
 from datetime import date, datetime, time, timezone
 from pathlib import Path
 from pathlib import Path
+from typing import Any
 
 
 from defusedxml import ElementTree as ET
 from defusedxml import ElementTree as ET
 from sqlalchemy import and_, or_, select, text
 from sqlalchemy import and_, or_, select, text
@@ -1110,9 +1112,13 @@ class ArchiveService:
                 sha256.update(chunk)
                 sha256.update(chunk)
         return sha256.hexdigest()
         return sha256.hexdigest()
 
 
-    async def get_duplicate_hashes_and_names(self) -> tuple[set[str], set[tuple[str, str]]]:
+    async def get_duplicate_hashes_and_names(
+        self, visible: Sequence[Any] = ()
+    ) -> tuple[set[str], set[tuple[str, str]]]:
         """Get all content hashes and (print name, hash) pairs that appear more than once.
         """Get all content hashes and (print name, hash) pairs that appear more than once.
 
 
+        ``visible`` limits the count to archives the caller may see.
+
         For hashes: returns all hashes with > 1 archive (true duplicates).
         For hashes: returns all hashes with > 1 archive (true duplicates).
         For name/hash pairs: returns only pairs that have > 1 archive
         For name/hash pairs: returns only pairs that have > 1 archive
                      (i.e., same file archived multiple times, not different files with same name).
                      (i.e., same file archived multiple times, not different files with same name).
@@ -1127,7 +1133,7 @@ class ArchiveService:
         # duplicate even though the user only sees one row.
         # duplicate even though the user only sees one row.
         result = await self.db.execute(
         result = await self.db.execute(
             select(PrintArchive.content_hash)
             select(PrintArchive.content_hash)
-            .where(PrintArchive.content_hash.isnot(None), PrintArchive.deleted_at.is_(None))
+            .where(PrintArchive.content_hash.isnot(None), PrintArchive.deleted_at.is_(None), *visible)
             .group_by(PrintArchive.content_hash)
             .group_by(PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
             .having(func.count(PrintArchive.id) > 1)
         )
         )
@@ -1141,6 +1147,7 @@ class ArchiveService:
                 PrintArchive.print_name.isnot(None),
                 PrintArchive.print_name.isnot(None),
                 PrintArchive.content_hash.isnot(None),
                 PrintArchive.content_hash.isnot(None),
                 PrintArchive.deleted_at.is_(None),
                 PrintArchive.deleted_at.is_(None),
+                *visible,
             )
             )
             .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             .group_by(func.lower(PrintArchive.print_name), PrintArchive.content_hash)
             .having(func.count(PrintArchive.id) > 1)
             .having(func.count(PrintArchive.id) > 1)
@@ -1155,9 +1162,12 @@ class ArchiveService:
         content_hash: str | None = None,
         content_hash: str | None = None,
         print_name: str | None = None,
         print_name: str | None = None,
         makerworld_model_id: str | None = None,
         makerworld_model_id: str | None = None,
+        visible: Sequence[Any] = (),
     ) -> list[dict]:
     ) -> list[dict]:
         """Find duplicate archives based on hash or name matching.
         """Find duplicate archives based on hash or name matching.
 
 
+        ``visible`` holds WHERE clauses limiting matches to archives the caller may see.
+
         Returns list of dicts with id, print_name, created_at, match_type.
         Returns list of dicts with id, print_name, created_at, match_type.
         """
         """
         duplicates = []
         duplicates = []
@@ -1171,6 +1181,7 @@ class ArchiveService:
                         PrintArchive.content_hash == content_hash,
                         PrintArchive.content_hash == content_hash,
                         PrintArchive.id != archive_id,
                         PrintArchive.id != archive_id,
                         PrintArchive.deleted_at.is_(None),
                         PrintArchive.deleted_at.is_(None),
+                        *visible,
                     )
                     )
                 )
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .order_by(PrintArchive.created_at.desc())
@@ -1190,7 +1201,7 @@ class ArchiveService:
         # Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual
         # Prefer strict name+hash matching when hash exists; fallback to name-only for legacy/manual
         # archives that may not have a content_hash.
         # archives that may not have a content_hash.
         if print_name or makerworld_model_id:
         if print_name or makerworld_model_id:
-            conditions = [PrintArchive.id != archive_id, PrintArchive.deleted_at.is_(None)]
+            conditions = [PrintArchive.id != archive_id, PrintArchive.deleted_at.is_(None), *visible]
 
 
             name_conditions = []
             name_conditions = []
             if print_name:
             if print_name:

+ 9 - 0
backend/app/services/archive_comparison.py

@@ -1,3 +1,6 @@
+from collections.abc import Sequence
+from typing import Any
+
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 from sqlalchemy.orm import selectinload
@@ -181,12 +184,14 @@ class ArchiveComparisonService:
         self,
         self,
         archive_id: int,
         archive_id: int,
         limit: int = 10,
         limit: int = 10,
+        visible: Sequence[Any] = (),
     ) -> list[dict]:
     ) -> list[dict]:
         """Find archives with similar settings for comparison.
         """Find archives with similar settings for comparison.
 
 
         Args:
         Args:
             archive_id: The archive to find similar ones for
             archive_id: The archive to find similar ones for
             limit: Maximum number of results
             limit: Maximum number of results
+            visible: WHERE clauses limiting matches to archives the caller may see
 
 
         Returns:
         Returns:
             List of similar archives with match reasons
             List of similar archives with match reasons
@@ -210,6 +215,7 @@ class ArchiveComparisonService:
                     PrintArchive.id != archive_id,
                     PrintArchive.id != archive_id,
                     PrintArchive.print_name == reference.print_name,
                     PrintArchive.print_name == reference.print_name,
                     PrintArchive.deleted_at.is_(None),
                     PrintArchive.deleted_at.is_(None),
+                    *visible,
                 )
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .order_by(PrintArchive.created_at.desc())
                 .limit(limit)
                 .limit(limit)
@@ -236,6 +242,7 @@ class ArchiveComparisonService:
                     PrintArchive.id != archive_id,
                     PrintArchive.id != archive_id,
                     PrintArchive.content_hash == reference.content_hash,
                     PrintArchive.content_hash == reference.content_hash,
                     PrintArchive.deleted_at.is_(None),
                     PrintArchive.deleted_at.is_(None),
+                    *visible,
                 )
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .order_by(PrintArchive.created_at.desc())
                 .limit(limit - len(similar))
                 .limit(limit - len(similar))
@@ -262,6 +269,8 @@ class ArchiveComparisonService:
                 .where(
                 .where(
                     PrintArchive.id != archive_id,
                     PrintArchive.id != archive_id,
                     PrintArchive.filament_type == reference.filament_type,
                     PrintArchive.filament_type == reference.filament_type,
+                    PrintArchive.deleted_at.is_(None),
+                    *visible,
                 )
                 )
                 .order_by(PrintArchive.created_at.desc())
                 .order_by(PrintArchive.created_at.desc())
                 .limit(limit - len(similar))
                 .limit(limit - len(similar))

+ 152 - 0
backend/tests/integration/test_bulk_archive_routes_scope.py

@@ -0,0 +1,152 @@
+"""Bulk archive actions keep to the caller's printers.
+
+Recalculating costs and rescanning all archives went over every archive,
+so a member of a group limited to some printers rewrote other printers'
+archives and got their ids back in the error list.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+from backend.app.models.archive import PrintArchive
+from backend.tests.integration.test_printer_scope_1727 import TEAM_PERMISSIONS, _admin_token, _auth, _group, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _member(client: AsyncClient, printer_ids: list[int]) -> dict[str, str]:
+    admin = await _admin_token(client)
+    perms = await _group(client, admin, "perms", permissions=[*TEAM_PERMISSIONS, "archives:update_all"])
+    team = await _group(client, admin, "team", printer_ids=printer_ids)
+    return _auth((await _user(client, admin, "member", [perms, team]))[0])
+
+
+async def test_rescan_all_reports_only_the_team_printers_archives(
+    async_client: AsyncClient, printer_factory, archive_factory
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    # No file behind either archive, so each one lands in the error list.
+    on_a = await archive_factory(a.id, file_path="archives/missing-a.3mf")
+    await archive_factory(b.id, file_path="archives/missing-b.3mf")
+    member = await _member(async_client, [a.id])
+
+    response = await async_client.post("/api/v1/archives/rescan-all", headers=member)
+
+    assert response.status_code == 200, response.text
+    assert [error["id"] for error in response.json()["errors"]] == [on_a.id]
+
+
+async def test_recalculating_costs_leaves_other_printers_alone(
+    async_client: AsyncClient, db_session, printer_factory, archive_factory
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    on_a = await archive_factory(a.id, cost=99.0, filament_used_grams=100, filament_type="PLA")
+    on_b = await archive_factory(b.id, cost=99.0, filament_used_grams=100, filament_type="PLA")
+    ids = (on_a.id, on_b.id)
+    member = await _member(async_client, [a.id])
+
+    response = await async_client.post("/api/v1/archives/recalculate-costs", headers=member)
+
+    assert response.status_code == 200, response.text
+    db_session.expire_all()
+    costs = dict(
+        (await db_session.execute(select(PrintArchive.id, PrintArchive.cost).where(PrintArchive.id.in_(ids)))).all()
+    )
+    assert costs[ids[0]] == 2.5  # 100 g at the default 25.00 per kg
+    assert costs[ids[1]] == 99.0
+
+
+async def test_tags_keep_to_the_team_printers(async_client: AsyncClient, db_session, printer_factory, archive_factory):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    on_a = await archive_factory(a.id, tags="shared, alpha")
+    on_b = await archive_factory(b.id, tags="shared, beta")
+    ids = (on_a.id, on_b.id)
+    member = await _member(async_client, [a.id])
+
+    listed = await async_client.get("/api/v1/archives/tags", headers=member)
+    renamed = await async_client.put("/api/v1/archives/tags/shared", headers=member, json={"new_name": "common"})
+    deleted = await async_client.delete("/api/v1/archives/tags/common", headers=member)
+
+    assert sorted(t["name"] for t in listed.json()) == ["alpha", "shared"]
+    assert (renamed.json()["affected"], deleted.json()["affected"]) == (1, 1)
+    db_session.expire_all()
+    tags = dict(
+        (await db_session.execute(select(PrintArchive.id, PrintArchive.tags).where(PrintArchive.id.in_(ids)))).all()
+    )
+    assert tags[ids[1]] == "shared, beta"
+
+
+async def test_duplicates_and_similar_show_only_visible_archives(
+    async_client: AsyncClient, printer_factory, archive_factory
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    mine = await archive_factory(a.id, print_name="Benchy", content_hash="h1")
+    same_team = await archive_factory(a.id, print_name="Benchy", content_hash="h1")
+    await archive_factory(b.id, print_name="Benchy", content_hash="h1")
+    member = await _member(async_client, [a.id])
+
+    duplicates = await async_client.get(f"/api/v1/archives/{mine.id}/duplicates", headers=member)
+    detail = await async_client.get(f"/api/v1/archives/{mine.id}", headers=member)
+    similar = await async_client.get(f"/api/v1/archives/{mine.id}/similar", headers=member)
+
+    assert [d["id"] for d in duplicates.json()["duplicates"]] == [same_team.id]
+    assert [d["id"] for d in detail.json()["duplicates"]] == [same_team.id]
+    assert {s["archive"]["id"] for s in similar.json()} == {same_team.id}
+
+
+async def test_backfill_hashes_reports_only_the_team_printers_archives(
+    async_client: AsyncClient, printer_factory, archive_factory
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    on_a = await archive_factory(a.id, file_path="archives/none-a.3mf", content_hash=None)
+    await archive_factory(b.id, file_path="archives/none-b.3mf", content_hash=None)
+    member = await _member(async_client, [a.id])
+
+    response = await async_client.post("/api/v1/archives/backfill-hashes", headers=member)
+
+    assert [e["id"] for e in response.json()["errors"]] == [on_a.id]
+
+
+async def test_uploading_a_source_by_name_matches_only_the_team_printers(
+    async_client: AsyncClient, printer_factory, archive_factory
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    await archive_factory(b.id, print_name="Secret Part")
+    member = await _member(async_client, [a.id])
+
+    response = await async_client.post(
+        "/api/v1/archives/upload-source",
+        headers=member,
+        params={"print_name": "Secret"},
+        files={"file": ("Secret Part.3mf", b"PK\x03\x04", "application/octet-stream")},
+    )
+
+    assert response.status_code == 404, response.text
+    assert "Secret Part" not in response.text
+
+
+async def test_the_list_counts_duplicates_among_visible_archives_only(
+    async_client: AsyncClient, printer_factory, archive_factory
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    await archive_factory(b.id, print_name="Benchy", content_hash="h2")  # older, on B
+    mine = await archive_factory(a.id, print_name="Benchy", content_hash="h2")
+    member = await _member(async_client, [a.id])
+
+    listed = {row["id"]: row for row in (await async_client.get("/api/v1/archives/", headers=member)).json()}
+
+    assert listed[mine.id]["duplicate_count"] in (0, None)
+    assert listed[mine.id]["original_archive_id"] is None
+
+
+async def test_the_no_3mf_warning_ignores_other_printers(async_client: AsyncClient, printer_factory, archive_factory):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    await archive_factory(b.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
+    member = await _member(async_client, [a.id])
+
+    response = await async_client.get("/api/v1/archives/no-3mf-warning", headers=member)
+
+    assert response.json() == {"has_fallback": False, "reason": None}

+ 43 - 0
backend/tests/unit/test_archive_updated_scope.py

@@ -0,0 +1,43 @@
+"""Live archive updates reach only sockets that may see the archive's printer."""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.core import websocket
+from backend.app.core.printer_scope import PrinterScope
+
+pytestmark = pytest.mark.asyncio
+
+
+def _socket(printer_ids):
+    scope = PrinterScope() if printer_ids is None else PrinterScope(frozenset(printer_ids))
+    return SimpleNamespace(state=SimpleNamespace(bambuddy_printer_scope=scope), send_text=AsyncMock())
+
+
+@pytest.mark.parametrize(
+    ("archive_printer", "expected"),
+    [
+        (1, {"team_a": True, "team_b": False, "everyone": True}),
+        (None, {"team_a": True, "team_b": True, "everyone": True}),  # printer-less: in every scope
+        (websocket._UNKNOWN_PRINTER, {"team_a": False, "team_b": False, "everyone": True}),
+    ],
+)
+async def test_an_update_follows_the_archive_printer(archive_printer, expected):
+    manager = websocket.ConnectionManager()
+    sockets = {"team_a": _socket({1}), "team_b": _socket({2}), "everyone": _socket(None)}
+    manager.active_connections = list(sockets.values())
+
+    with patch.object(websocket, "_archive_printer_id", new=AsyncMock(return_value=archive_printer)):
+        await manager.send_archive_updated({"id": 7, "cost": 1.5})
+
+    assert {name: s.send_text.await_count == 1 for name, s in sockets.items()} == expected
+
+
+async def test_an_archive_that_cannot_be_looked_up_reaches_only_unrestricted_sockets():
+    with patch("backend.app.core.database.async_session", side_effect=RuntimeError("db down")):
+        assert await websocket._archive_printer_id(7) == websocket._UNKNOWN_PRINTER
+    assert await websocket._archive_printer_id(None) == websocket._UNKNOWN_PRINTER