Przeglądaj źródła

Security hardening (maziggy/bambuddy-security #19)

maziggy 1 tydzień temu
rodzic
commit
a51283ad76
63 zmienionych plików z 596 dodań i 25 usunięć
  1. 27 8
      backend/app/api/routes/smart_plugs.py
  2. 60 0
      backend/app/core/logging_filters.py
  3. 30 0
      backend/app/schemas/smart_plug.py
  4. 28 3
      backend/app/services/log_reader.py
  5. 30 7
      backend/app/services/rest_smart_plug.py
  6. 112 0
      backend/tests/integration/test_smart_plug_rest_fields.py
  7. 178 0
      backend/tests/unit/services/test_log_reader_device_urls.py
  8. 107 0
      backend/tests/unit/services/test_rest_smart_plug.py
  9. 19 0
      frontend/src/__tests__/components/SmartPlugCard.test.tsx
  10. 2 2
      frontend/src/components/SmartPlugCard.tsx
  11. 0 0
      static/assets/AdditionalSection-DzEoTicA.js
  12. 0 0
      static/assets/AmsUnitCard-BPm4BDRq.js
  13. 0 0
      static/assets/ArchivesPage-BqlmfCz_.js
  14. 0 0
      static/assets/AssignToAmsModal-CNMnI7pM.js
  15. 0 0
      static/assets/CamWallPage-CqbAoXUd.js
  16. 0 0
      static/assets/CameraDiagnoseModal-B7H15mSM.js
  17. 0 0
      static/assets/CameraPage-AIIfN_WK.js
  18. 0 0
      static/assets/CameraWall-GFGvfrgJ.js
  19. 0 0
      static/assets/ColumnConfigModal-DSzBM_C8.js
  20. 0 0
      static/assets/ConfigureAmsSlotModal-wtfRkOxk.js
  21. 0 0
      static/assets/ConnectAuthorizePage-eZY7fvnw.js
  22. 0 0
      static/assets/ContextMenu-DiHXxMTX.js
  23. 0 0
      static/assets/ExternalLinkPage-BaW9CMly.js
  24. 0 1
      static/assets/FileManagerPage-DpWonNn8.js
  25. 0 0
      static/assets/FileUploadModal-CGmNdW53.js
  26. 0 0
      static/assets/FinancePage-BV9mP6ur.js
  27. 0 0
      static/assets/GCodeViewerPage-C5uW4whP.js
  28. 0 0
      static/assets/GroupEditPage-Tyzj0-hU.js
  29. 0 0
      static/assets/ImagePreviewModal-Dyr00MDc.js
  30. 0 0
      static/assets/InventoryPage-C7baASWe.js
  31. 0 0
      static/assets/LibraryTrashPage-DzswaKsz.js
  32. 0 0
      static/assets/LoginPage-DfcRMKyg.js
  33. 0 0
      static/assets/MaintenancePage-BtVV5dbQ.js
  34. 0 0
      static/assets/ModelSourcesPage-mI1uBpYL.js
  35. 0 0
      static/assets/NotificationsPage-CwNMwhWI.js
  36. 0 1
      static/assets/PdfPreviewModal-Cp3GhE9F.js
  37. 0 0
      static/assets/PhotoGalleryModal-BeSmUJql.js
  38. 0 0
      static/assets/PrinterLocationsPage-CpmRP3kl.js
  39. 0 0
      static/assets/PrintersPage-D08OCa48.js
  40. 0 0
      static/assets/ProfilesPage-BAornjv1.js
  41. 0 0
      static/assets/ProjectDetailPage-DfD-bfXi.js
  42. 0 0
      static/assets/ProjectsPage-Dz7ORZLH.js
  43. 0 0
      static/assets/QueuePage-BNA8ZSHW.js
  44. 0 0
      static/assets/RichTextEditor-BJilRpaX.js
  45. 0 0
      static/assets/SettingsPage-D3dapKYp.js
  46. 0 0
      static/assets/SetupPage-dZiQgSro.js
  47. 0 0
      static/assets/SliceModal-Vsa8r4ag.js
  48. 0 0
      static/assets/SpoolBuddyAmsPage-BKD9FVng.js
  49. 0 0
      static/assets/SpoolBuddyCalibrationPage-C0h8m5GC.js
  50. 0 0
      static/assets/SpoolBuddyDashboard-C0RBNxrj.js
  51. 0 0
      static/assets/SpoolBuddyInventoryPage-Dw0p8WUB.js
  52. 1 1
      static/assets/SpoolBuddySettingsPage-CPuBs_6l.js
  53. 0 0
      static/assets/SpoolBuddyWriteTagPage-C7nGJy5i.js
  54. 0 0
      static/assets/SpreadsheetPreviewModal-pUDd1N9k.js
  55. 0 0
      static/assets/StatsPage-D7L6BAP4.js
  56. 0 0
      static/assets/StreamOverlayPage-3xqQi4xf.js
  57. 0 0
      static/assets/SystemInfoPage-D8KszCv9.js
  58. 0 0
      static/assets/index-BCcru1D3.js
  59. 1 1
      static/assets/inventorySearch-teFAZL6T.js
  60. 0 0
      static/assets/locationSensorDefaults-BZfiYHsX.js
  61. 0 0
      static/assets/pdf-Q9eIgaTl.js
  62. 0 0
      static/assets/utils-Ttzd3-ik.js
  63. 1 1
      static/index.html

+ 27 - 8
backend/app/api/routes/smart_plugs.py

@@ -32,6 +32,7 @@ from backend.app.schemas.smart_plug import (
     SmartPlugCreate,
     SmartPlugCreate,
     SmartPlugEnergy,
     SmartPlugEnergy,
     SmartPlugResponse,
     SmartPlugResponse,
+    SmartPlugResponseWithSecret,
     SmartPlugStatus,
     SmartPlugStatus,
     SmartPlugTestConnection,
     SmartPlugTestConnection,
     SmartPlugUpdate,
     SmartPlugUpdate,
@@ -60,22 +61,37 @@ logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 router = APIRouter(prefix="/smart-plugs", tags=["smart-plugs"])
 
 
+# Whether the caller may edit plugs, and so gets the REST URLs and bodies.
+CallerEditsPlugs = Depends(probe_permissions_if_auth_enabled(Permission.SMART_PLUGS_UPDATE))
 
 
-@router.get("/", response_model=list[SmartPlugResponse])
+
+def _serialize_plug(plug: SmartPlug, *, can_edit: bool) -> SmartPlugResponse:
+    """The response shape that matches the caller's authority.
+
+    Routes returning it declare no response_model: validating the reader shape
+    again would run the REST URL checks on the fields it leaves out.
+    """
+    if can_edit:
+        return SmartPlugResponseWithSecret.model_validate(plug)
+    return SmartPlugResponse.model_validate(plug)
+
+
+@router.get("/", response_model=None)
 async def list_smart_plugs(
 async def list_smart_plugs(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
     printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
 ):
     """List all smart plugs, minus those powering printers the caller can't see (#1727)."""
     """List all smart plugs, minus those powering printers the caller can't see (#1727)."""
     query = select(SmartPlug).order_by(SmartPlug.name)
     query = select(SmartPlug).order_by(SmartPlug.name)
     if (clause := printer_scope.where(SmartPlug.printer_id)) is not None:
     if (clause := printer_scope.where(SmartPlug.printer_id)) is not None:
         query = query.where(clause)
         query = query.where(clause)
     result = await db.execute(query)
     result = await db.execute(query)
-    return list(result.scalars().all())
+    return [_serialize_plug(plug, can_edit=can_edit) for plug in result.scalars().all()]
 
 
 
 
-@router.post("/", response_model=SmartPlugResponse)
+@router.post("/", response_model=SmartPlugResponseWithSecret)
 async def create_smart_plug(
 async def create_smart_plug(
     data: SmartPlugCreate,
     data: SmartPlugCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
@@ -260,11 +276,12 @@ async def _plugs_for_printer(db: AsyncSession, printer_id: int) -> list[SmartPlu
     return list(result.scalars().all())
     return list(result.scalars().all())
 
 
 
 
-@router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None)
+@router.get("/by-printer/{printer_id}", response_model=None)
 async def get_smart_plug_by_printer(
 async def get_smart_plug_by_printer(
     printer_id: int,
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    can_edit: bool = CallerEditsPlugs,
 ):
 ):
     """Get the main smart plug assigned to a printer.
     """Get the main smart plug assigned to a printer.
 
 
@@ -272,7 +289,8 @@ async def get_smart_plug_by_printer(
     script -- returns the one that best fits the card's power controls. See
     script -- returns the one that best fits the card's power controls. See
     ``_main_plug_rank`` for the order and why.
     ``_main_plug_rank`` for the order and why.
     """
     """
-    return _pick_main_plug(await _plugs_for_printer(db, printer_id))
+    plug = _pick_main_plug(await _plugs_for_printer(db, printer_id))
+    return _serialize_plug(plug, can_edit=can_edit) if plug else None
 
 
 
 
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
 @router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse])
@@ -546,22 +564,23 @@ async def list_ha_sensor_entities(
     return [HASensorEntity(**s) for s in sensors]
     return [HASensorEntity(**s) for s in sensors]
 
 
 
 
-@router.get("/{plug_id}", response_model=SmartPlugResponse)
+@router.get("/{plug_id}", response_model=None)
 async def get_smart_plug(
 async def get_smart_plug(
     plug_id: int,
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
     printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
 ):
     """Get a specific smart plug."""
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     plug = result.scalar_one_or_none()
     if not plug or not printer_scope.allows(plug.printer_id):
     if not plug or not printer_scope.allows(plug.printer_id):
         raise HTTPException(404, "Smart plug not found")
         raise HTTPException(404, "Smart plug not found")
-    return plug
+    return _serialize_plug(plug, can_edit=can_edit)
 
 
 
 
-@router.patch("/{plug_id}", response_model=SmartPlugResponse)
+@router.patch("/{plug_id}", response_model=SmartPlugResponseWithSecret)
 async def update_smart_plug(
 async def update_smart_plug(
     plug_id: int,
     plug_id: int,
     data: SmartPlugUpdate,
     data: SmartPlugUpdate,

+ 60 - 0
backend/app/core/logging_filters.py

@@ -19,6 +19,7 @@ from __future__ import annotations
 import asyncio
 import asyncio
 import logging
 import logging
 import re
 import re
+from urllib.parse import urlsplit
 
 
 # ``scheme://user:secret@host`` — the only URL shape that carries a secret.
 # ``scheme://user:secret@host`` — the only URL shape that carries a secret.
 # Both userinfo parts exclude ``/`` so the match can never run past the
 # Both userinfo parts exclude ``/`` so the match can never run past the
@@ -94,6 +95,65 @@ def redact_query_tokens(text: str | None) -> str | None:
     return QUERY_TOKEN_PATTERN.sub(r"\g<key>[REDACTED]", text)
     return QUERY_TOKEN_PATTERN.sub(r"\g<key>[REDACTED]", text)
 
 
 
 
+# Loggers whose lines name a device by its whole URL, where the path or query
+# can be the device's credential in a shape no pattern recognises (a webhook id,
+# an ``auth_key``). Read back, their URLs are reduced to scheme and host.
+HOST_ONLY_LOGGERS = ("backend.app.services.rest_smart_plug", "httpx")
+
+# Runs to whitespace only: a quote or angle bracket may sit inside a stored
+# URL, and ending there would leave the rest of it in the line.
+_URL_IN_TEXT = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://\S+")
+
+
+def url_host_only(url: str | None) -> str:
+    """A URL reduced to scheme and host (with port) for a log line.
+
+    The login, path, query and fragment go. An ``@`` after the part the parser
+    took for the host means a login it could not find (an unencoded ``?``,
+    ``/`` or ``#`` in the password): that part may then hold the login, so it
+    goes as well.
+    """
+    try:
+        parts = urlsplit(url or "")
+    except ValueError:
+        return "<invalid URL>"
+    if not parts.scheme or not parts.netloc:
+        return "<invalid URL>"
+    if "@" in (url or "")[len(parts.scheme) + 3 + len(parts.netloc) :]:
+        return f"{parts.scheme}://<hidden>"
+    return f"{parts.scheme}://{parts.netloc.rsplit('@', 1)[-1]}"
+
+
+# Masked to the end of the line, in lines from those loggers: the REST plug
+# service's messages carry raw input (a refused URL, unparsed headers) or an
+# error's own text, which may quote a URL or a header's value. Covers every
+# message it has logged.
+_MASKED_TAILS = re.compile(r"(invalid URL: |headers JSON: |returned error: |REST smart plug at \S+?: ).*")
+
+
+# An HTTP client refusing a header quotes the header's value, commonly a token,
+# whichever service sent it: masked to the end of the line.
+_REFUSED_HEADER = re.compile(r"(Illegal header (?:value|name) ).*")
+
+
+def redact_refused_headers(text: str | None) -> str | None:
+    """Mask the header an HTTP client quotes when it refuses one."""
+    if not text or "Illegal header" not in text:
+        return text
+    return _REFUSED_HEADER.sub(r"\1[REDACTED]", text)
+
+
+def redact_device_urls(text: str | None) -> str | None:
+    """Every URL in *text* reduced to :func:`url_host_only`, and the tails in
+    ``_MASKED_TAILS`` masked."""
+    if not text:
+        return text
+    text = _MASKED_TAILS.sub(r"\1[REDACTED]", text)
+    if "://" not in text:
+        return text
+    return _URL_IN_TEXT.sub(lambda match: url_host_only(match.group(0)), text)
+
+
 class QueryTokenRedactFilter(logging.Filter):
 class QueryTokenRedactFilter(logging.Filter):
     """Mask query-string tokens in uvicorn's request and WebSocket log lines.
     """Mask query-string tokens in uvicorn's request and WebSocket log lines.
 
 

+ 30 - 0
backend/app/schemas/smart_plug.py

@@ -6,6 +6,17 @@ from pydantic import BaseModel, Field, field_validator, model_validator
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
 from backend.app.services.secret_fields import SECRET_PLACEHOLDER
 from backend.app.services.secret_fields import SECRET_PLACEHOLDER
 
 
+# A REST plug's URLs and request bodies, shown only to those who may edit plugs.
+REST_EDITOR_FIELDS = (
+    "rest_on_url",
+    "rest_off_url",
+    "rest_status_url",
+    "rest_power_url",
+    "rest_energy_url",
+    "rest_on_body",
+    "rest_off_body",
+)
+
 
 
 class SmartPlugBase(BaseModel):
 class SmartPlugBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
@@ -213,6 +224,25 @@ class SmartPlugResponse(SmartPlugBase):
         """Secrets are write-only: a set one is shown as the placeholder."""
         """Secrets are write-only: a set one is shown as the placeholder."""
         return SECRET_PLACEHOLDER if value else value
         return SECRET_PLACEHOLDER if value else value
 
 
+    @model_validator(mode="after")
+    def _rest_for_editors(self) -> "SmartPlugResponse":
+        """Readers get no REST URLs or bodies: they commonly carry the device's
+        credential, and a plug is switched and polled by its id. After the
+        base checks, which need them."""
+        for field in REST_EDITOR_FIELDS:
+            setattr(self, field, None)
+        return self
+
+
+class SmartPlugResponseWithSecret(SmartPlugResponse):
+    """SmartPlugResponse with the REST URLs and bodies. Returned only to callers
+    who may edit plugs (or with authentication off)."""
+
+    @model_validator(mode="after")
+    def _rest_for_editors(self) -> "SmartPlugResponseWithSecret":
+        """Editors keep them, to edit them."""
+        return self
+
 
 
 class SmartPlugControl(BaseModel):
 class SmartPlugControl(BaseModel):
     action: Literal["on", "off", "toggle"]
     action: Literal["on", "off", "toggle"]

+ 28 - 3
backend/app/services/log_reader.py

@@ -14,7 +14,14 @@ from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.config import settings
 from backend.app.core.config import settings
-from backend.app.core.logging_filters import URL_CREDENTIALS_PATTERN, redact_query_tokens, redact_url_credentials
+from backend.app.core.logging_filters import (
+    HOST_ONLY_LOGGERS,
+    URL_CREDENTIALS_PATTERN,
+    redact_device_urls,
+    redact_query_tokens,
+    redact_refused_headers,
+    redact_url_credentials,
+)
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.user import User
 from backend.app.models.user import User
@@ -64,10 +71,17 @@ def parse_log_line(line: str) -> LogEntry | None:
     return None
     return None
 
 
 
 
+def _logger_of(line: str) -> str | None:
+    match = LOG_LINE_PATTERN.match(line.strip())
+    return match.group(3) if match else None
+
+
 def _redacted(entry: LogEntry) -> LogEntry:
 def _redacted(entry: LogEntry) -> LogEntry:
     # Lines on disk may predate a redaction: masked as they are read, before
     # Lines on disk may predate a redaction: masked as they are read, before
     # any search, so a search cannot confirm a secret either.
     # any search, so a search cannot confirm a secret either.
-    entry.message = redact_url_credentials(redact_query_tokens(entry.message)) or ""
+    entry.message = redact_refused_headers(redact_url_credentials(redact_query_tokens(entry.message))) or ""
+    if entry.logger_name in HOST_ONLY_LOGGERS:
+        entry.message = redact_device_urls(entry.message) or ""
     return entry
     return entry
 
 
 
 
@@ -168,7 +182,15 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
     Regex passes additionally redact credentials in URLs, emails, serials, and
     Regex passes additionally redact credentials in URLs, emails, serials, and
     IP addresses that were not captured by exact matching.
     IP addresses that were not captured by exact matching.
     """
     """
-    # First, replace known sensitive values (database-aware exact matching)
+    # Lines from loggers that name a device by its whole URL keep its host only.
+    # First, while each line's logger can still be read: a known value below
+    # may be part of a logger name.
+    content = "\n".join(
+        redact_device_urls(line) or "" if _logger_of(line) in HOST_ONLY_LOGGERS else line
+        for line in content.split("\n")
+    )
+
+    # Then replace known sensitive values (database-aware exact matching)
     # This catches printer names, usernames, and other arbitrary user-chosen strings
     # This catches printer names, usernames, and other arbitrary user-chosen strings
     # that regex patterns cannot detect
     # that regex patterns cannot detect
     if sensitive_strings:
     if sensitive_strings:
@@ -188,6 +210,9 @@ def sanitize_log_content(content: str, sensitive_strings: dict[str, str] | None
     # filter existed or by a logger it is not attached to.
     # filter existed or by a logger it is not attached to.
     content = redact_query_tokens(content) or ""
     content = redact_query_tokens(content) or ""
 
 
+    # A header an HTTP client refused, quoted with its value.
+    content = redact_refused_headers(content) or ""
+
     # Replace email addresses
     # Replace email addresses
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
     content = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", content)
 
 

+ 30 - 7
backend/app/services/rest_smart_plug.py

@@ -6,12 +6,33 @@ from typing import TYPE_CHECKING, Any
 
 
 import httpx
 import httpx
 
 
+from backend.app.core.logging_filters import redact_query_tokens, redact_url_credentials, url_host_only
+
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.models.smart_plug import SmartPlug
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
+def _log_target(url: str | None) -> str:
+    """The device a REST URL points at, for a log line: scheme and host only.
+
+    The rest of the URL may be the credential (a login, a query token, a
+    webhook id in the path).
+    """
+    return url_host_only(url)
+
+
+def _log_error(error: Exception, url: str) -> str:
+    """What to log of an error: a connection error's text with the URL it may
+    quote reduced to its host; for anything else only its type, as its text
+    may quote a request header."""
+    if not isinstance(error, httpx.ConnectError):
+        return type(error).__name__
+    text = str(error).replace(url, _log_target(url))
+    return redact_query_tokens(redact_url_credentials(text)) or ""
+
+
 class RESTSmartPlugService:
 class RESTSmartPlugService:
     """Service for controlling smart plugs via generic REST/HTTP API.
     """Service for controlling smart plugs via generic REST/HTTP API.
 
 
@@ -95,7 +116,7 @@ class RESTSmartPlugService:
     ) -> httpx.Response | None:
     ) -> httpx.Response | None:
         """Send an HTTP request and return the response."""
         """Send an HTTP request and return the response."""
         if not self._validate_url(url):
         if not self._validate_url(url):
-            logger.warning("Blocked REST request to invalid URL: %s", url)
+            logger.warning("Blocked REST request to invalid URL: %s", _log_target(url))
             return None
             return None
 
 
         try:
         try:
@@ -115,16 +136,18 @@ class RESTSmartPlugService:
                 response.raise_for_status()
                 response.raise_for_status()
                 return response
                 return response
         except httpx.TimeoutException:
         except httpx.TimeoutException:
-            logger.warning("REST smart plug at %s timed out", url)
+            logger.warning("REST smart plug at %s timed out", _log_target(url))
             return None
             return None
         except httpx.HTTPStatusError as e:
         except httpx.HTTPStatusError as e:
-            logger.warning("REST smart plug at %s returned error: %s", url, e)
+            logger.warning("REST smart plug at %s returned HTTP %s", _log_target(url), e.response.status_code)
             return None
             return None
         except httpx.RequestError as e:
         except httpx.RequestError as e:
-            logger.warning("Failed to connect to REST smart plug at %s: %s", url, e)
+            logger.warning("Failed to connect to REST smart plug at %s: %s", _log_target(url), _log_error(e, url))
             return None
             return None
         except Exception as e:
         except Exception as e:
-            logger.error("Unexpected error communicating with REST smart plug at %s: %s", url, e)
+            logger.error(
+                "Unexpected error communicating with REST smart plug at %s: %s", _log_target(url), _log_error(e, url)
+            )
             return None
             return None
 
 
     async def turn_on(self, plug: "SmartPlug") -> bool:
     async def turn_on(self, plug: "SmartPlug") -> bool:
@@ -138,7 +161,7 @@ class RESTSmartPlugService:
         response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)
         response = await self._send_request(plug.rest_on_url, method, headers, plug.rest_on_body)
 
 
         if response is not None:
         if response is not None:
-            logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, plug.rest_on_url)
+            logger.info("Turned ON REST smart plug '%s' via %s %s", plug.name, method, _log_target(plug.rest_on_url))
             return True
             return True
 
 
         logger.warning("Failed to turn ON REST smart plug '%s'", plug.name)
         logger.warning("Failed to turn ON REST smart plug '%s'", plug.name)
@@ -155,7 +178,7 @@ class RESTSmartPlugService:
         response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)
         response = await self._send_request(plug.rest_off_url, method, headers, plug.rest_off_body)
 
 
         if response is not None:
         if response is not None:
-            logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, plug.rest_off_url)
+            logger.info("Turned OFF REST smart plug '%s' via %s %s", plug.name, method, _log_target(plug.rest_off_url))
             return True
             return True
 
 
         logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name)
         logger.warning("Failed to turn OFF REST smart plug '%s'", plug.name)

+ 112 - 0
backend/tests/integration/test_smart_plug_rest_fields.py

@@ -0,0 +1,112 @@
+"""A REST plug's URLs and request bodies reach only those who may edit plugs.
+
+They commonly hold the device's credential: a login or a token in the URL, a
+webhook id in its path, or a token in the body. Readers switch and poll a plug
+by its id and never need them, so they get none; editors keep them to edit.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+REST = {
+    "rest_on_url": "http://admin:on-value@plug.elsewhere.example/relay/0?turn=on",
+    "rest_off_url": "http://plug.elsewhere.example/relay/0?turn=off&auth_key=off-value",
+    "rest_status_url": "http://plug.elsewhere.example/api/webhook/status-value",
+    "rest_power_url": "http://admin:power-value@plug.elsewhere.example/status",
+    "rest_energy_url": "http://admin:energy-value@plug.elsewhere.example/energy",
+    "rest_on_body": '{"token": "body-on-value"}',
+    "rest_off_body": '{"token": "body-off-value"}',
+}
+HIDDEN = tuple(REST)
+
+
+async def _admin(client: AsyncClient) -> dict[str, str]:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "plugadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": "plugadmin", "password": "AdminPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _viewer(client: AsyncClient, admin: dict[str, str]) -> dict[str, str]:
+    group = await client.post(
+        "/api/v1/groups/",
+        headers=admin,
+        json={"name": "plugview", "permissions": ["smart_plugs:read", "printers:read"]},
+    )
+    await client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": "plugviewer", "password": "UserPass1!", "group_ids": [group.json()["id"]]},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": "plugviewer", "password": "UserPass1!"})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _api_key(client: AsyncClient, admin: dict[str, str]) -> dict[str, str]:
+    key = await client.post("/api/v1/api-keys/", headers=admin, json={"name": "reader", "can_read_status": True})
+    return {"X-API-Key": key.json()["key"]}
+
+
+@pytest.fixture
+async def rest_plug(printer_factory, smart_plug_factory):
+    printer = await printer_factory(name="P")
+    return await smart_plug_factory(
+        name="Rest", plug_type="rest", ip_address=None, printer_id=printer.id, rest_method="GET", **REST
+    )
+
+
+async def _bodies(client: AsyncClient, headers: dict[str, str], plug) -> list[dict]:
+    listed = await client.get("/api/v1/smart-plugs/", headers=headers)
+    single = await client.get(f"/api/v1/smart-plugs/{plug.id}", headers=headers)
+    by_printer = await client.get(f"/api/v1/smart-plugs/by-printer/{plug.printer_id}", headers=headers)
+    for response in (listed, single, by_printer):
+        assert response.status_code == 200, response.text
+    return [listed.json()[0], single.json(), by_printer.json()]
+
+
+@pytest.mark.parametrize("who", ["viewer", "api_key"])
+async def test_a_reader_gets_no_rest_urls_or_bodies(async_client: AsyncClient, rest_plug, who):
+    admin = await _admin(async_client)
+    headers = await (_viewer if who == "viewer" else _api_key)(async_client, admin)
+
+    bodies = await _bodies(async_client, headers, rest_plug)
+
+    for body in bodies:
+        assert {field: body[field] for field in HIDDEN} == dict.fromkeys(HIDDEN)
+        assert body["plug_type"] == "rest" and body["name"] == "Rest"
+    assert "-value" not in str(bodies)
+
+
+async def test_an_editor_gets_the_rest_urls_and_bodies(async_client: AsyncClient, rest_plug):
+    admin = await _admin(async_client)
+
+    for body in await _bodies(async_client, admin, rest_plug):
+        assert {field: body[field] for field in HIDDEN} == REST
+
+
+async def test_an_update_answers_with_and_keeps_the_rest_fields(async_client: AsyncClient, rest_plug, db_session):
+    from backend.app.models.smart_plug import SmartPlug
+
+    plug_id = rest_plug.id
+    admin = await _admin(async_client)
+
+    response = await async_client.patch(f"/api/v1/smart-plugs/{plug_id}", headers=admin, json={"name": "Renamed"})
+
+    assert response.status_code == 200, response.text
+    assert {field: response.json()[field] for field in HIDDEN} == REST
+    db_session.expire_all()
+    stored = await db_session.get(SmartPlug, plug_id)
+    assert {field: getattr(stored, field) for field in HIDDEN} == REST
+
+
+async def test_with_authentication_off_the_rest_fields_are_shown(async_client: AsyncClient, rest_plug):
+    response = await async_client.get(f"/api/v1/smart-plugs/{rest_plug.id}")
+
+    assert response.status_code == 200, response.text
+    assert {field: response.json()[field] for field in HIDDEN} == REST

+ 178 - 0
backend/tests/unit/services/test_log_reader_device_urls.py

@@ -0,0 +1,178 @@
+"""Old log lines naming a REST plug's URL come back with its host only.
+
+Earlier versions logged the whole URL when switching or polling a REST plug,
+from the plug service and from the HTTP client. The URL's path and query can be
+the device's credential, in shapes no pattern recognises, so lines from those
+two loggers are reduced to scheme and host when read.
+"""
+
+from backend.app.core.config import settings
+from backend.app.services.log_reader import read_log_entries, sanitize_log_content
+
+PLUG = (
+    "2026-10-01 10:00:00,000 INFO [backend.app.services.rest_smart_plug] [x] "
+    "Turned ON REST smart plug 'P' via GET http://plug.elsewhere.example:8080/relay/0?turn=on&auth_key=old-value-1"
+)
+CLIENT = (
+    "2026-10-01 10:00:01,000 INFO [httpx] [x] "
+    'HTTP Request: GET http://ha.elsewhere.example:8123/api/webhook/old-value-2 "HTTP/1.1 200 OK"'
+)
+LOGIN = (
+    "2026-10-01 10:00:02,000 WARNING [backend.app.services.rest_smart_plug] [x] "
+    "REST smart plug at http://admin:p?old-value-3@plug.elsewhere.example/x timed out"
+)
+OTHER = (
+    "2026-10-01 10:00:03,000 INFO [backend.app.services.spoolman] [x] "
+    "Fetched http://spoolman.elsewhere.example/api/v1/spool"
+)
+SECRETS = ("old-value-1", "old-value-2", "old-value-3")
+
+
+def _write(tmp_path, monkeypatch):
+    (tmp_path / "bambuddy.log").write_text("\n".join([PLUG, CLIENT, LOGIN, OTHER]) + "\n", encoding="utf-8")
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+
+def test_old_device_lines_come_back_with_the_host_only(tmp_path, monkeypatch):
+    _write(tmp_path, monkeypatch)
+
+    entries, _total = read_log_entries(limit=50)
+
+    text = "\n".join(e.message for e in entries)
+    assert not [secret for secret in SECRETS if secret in text]
+    assert "via GET http://plug.elsewhere.example:8080" in text
+    assert "GET http://ha.elsewhere.example:8123 " in text
+    assert "http://spoolman.elsewhere.example/api/v1/spool" in text
+
+
+def test_a_search_cannot_confirm_a_device_secret(tmp_path, monkeypatch):
+    _write(tmp_path, monkeypatch)
+
+    for secret in SECRETS:
+        entries, _total = read_log_entries(limit=50, search=secret)
+        assert entries == [], secret
+
+
+def test_the_support_bundle_keeps_the_host_only():
+    content = sanitize_log_content("\n".join([PLUG, CLIENT, LOGIN, OTHER]))
+
+    assert not [secret for secret in SECRETS if secret in content]
+    assert "spoolman.elsewhere.example/api/v1/spool" in content
+
+
+QUOTED = (
+    "2026-10-01 10:00:04,000 INFO [backend.app.services.rest_smart_plug] [x] "
+    "Turned ON REST smart plug 'P' via GET http://plug.elsewhere.example/api/webhook/ab'old-value-4?auth_key=old-value-5"
+)
+QUOTED_LOGIN = (
+    "2026-10-01 10:00:05,000 WARNING [backend.app.services.rest_smart_plug] [x] "
+    "REST smart plug at http://admin:pa'old-value-6@plug.elsewhere.example/api/webhook/old-value-7 timed out"
+)
+QUOTED_SECRETS = ("old-value-4", "old-value-5", "old-value-6", "old-value-7")
+
+
+def test_a_quote_inside_an_old_url_does_not_end_it(tmp_path, monkeypatch):
+    (tmp_path / "bambuddy.log").write_text("\n".join([QUOTED, QUOTED_LOGIN]) + "\n", encoding="utf-8")
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+    entries, _total = read_log_entries(limit=50)
+    bundle = sanitize_log_content("\n".join([QUOTED, QUOTED_LOGIN]))
+
+    text = "\n".join(e.message for e in entries)
+    assert not [secret for secret in QUOTED_SECRETS if secret in text]
+    assert not [secret for secret in QUOTED_SECRETS if secret in bundle]
+    assert "admin" not in text and "admin" not in bundle
+    for secret in QUOTED_SECRETS:
+        assert read_log_entries(limit=50, search=secret)[0] == [], secret
+
+
+def test_a_known_value_inside_the_logger_name_does_not_unmask_the_line():
+    content = sanitize_log_content("\n".join([PLUG, CLIENT]), {"smart": "[USER]", "httpx": "[USER]"})
+
+    assert not [secret for secret in SECRETS if secret in content]
+
+
+HEADER = (
+    "2026-10-01 10:00:06,000 WARNING [backend.app.services.rest_smart_plug] [x] "
+    "Failed to connect to REST smart plug at http://plug.elsewhere.example/x: "
+    "Illegal header value b'Bearer old-value-8 '"
+)
+AT_AFTER_HOST = (
+    "2026-10-01 10:00:07,000 WARNING [backend.app.services.rest_smart_plug] [x] "
+    "REST smart plug at http://admin:pa@old-value-9?more@plug.elsewhere.example/x timed out"
+)
+
+
+def test_an_old_header_error_and_an_at_after_the_host_come_back_masked(tmp_path, monkeypatch):
+    (tmp_path / "bambuddy.log").write_text("\n".join([HEADER, AT_AFTER_HOST]) + "\n", encoding="utf-8")
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+    text = "\n".join(e.message for e in read_log_entries(limit=50)[0])
+    bundle = sanitize_log_content("\n".join([HEADER, AT_AFTER_HOST]))
+
+    for secret in ("old-value-8", "old-value-9"):
+        assert secret not in text and secret not in bundle, secret
+        assert read_log_entries(limit=50, search=secret)[0] == [], secret
+
+
+# Every message the REST plug service has logged in a shipped version, with a
+# secret in every slot that held a URL, an error's text or raw input.
+_PREFIX = "2026-10-01 10:00:{:02d},000 WARNING [backend.app.services.rest_smart_plug] [x] "
+HISTORICAL = [
+    "Unexpected error communicating with REST smart plug at http://plug.example/sec-01: odd sec-02",
+    "Turned OFF REST smart plug 'P' via GET http://plug.example/relay?auth_key=sec-03",
+    "Turned ON REST smart plug 'P' via GET http://plug.example/api/webhook/sec-04",
+    "Blocked REST request to invalid URL: 192.168.1.5/api/webhook/sec-05",
+    "Blocked REST request to invalid URL: admin:sec-06@192.168.1.5/relay",
+    "Failed to connect to REST smart plug at http://plug.example/sec-07: cannot reach sec-08",
+    "Failed to parse REST headers JSON; sending none",
+    "Failed to parse REST headers JSON: Authorization: Bearer sec-09",
+    "Failed to parse REST headers JSON: {'X-Api-Key': 'sec-10'}",
+    "Failed to turn OFF REST smart plug 'P'",
+    "Failed to turn ON REST smart plug 'P'",
+    "No OFF URL configured for REST plug 'P'",
+    "No ON URL configured for REST plug 'P'",
+    "REST smart plug at http://plug.example/sec-11 returned error: Server error for url 'http://plug.example/sec-12' sec-13",
+    "REST smart plug at http://plug.example/sec-14 timed out",
+]
+HISTORICAL_SECRETS = [f"sec-{n:02d}" for n in range(1, 15)]
+
+
+def test_every_message_the_plug_service_ever_logged_comes_back_without_its_secrets(tmp_path, monkeypatch):
+    lines = [_PREFIX.format(n) + message for n, message in enumerate(HISTORICAL)]
+    (tmp_path / "bambuddy.log").write_text("\n".join(lines) + "\n", encoding="utf-8")
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+    entries, _total = read_log_entries(limit=100)
+    text = "\n".join(e.message for e in entries)
+    bundle = sanitize_log_content("\n".join(lines))
+
+    assert len(entries) == len(HISTORICAL)
+    for secret in HISTORICAL_SECRETS:
+        assert secret not in text, secret
+        assert secret not in bundle, secret
+        assert read_log_entries(limit=100, search=secret)[0] == [], secret
+    assert "Turned ON REST smart plug 'P' via GET http://plug.example" in text
+
+
+def test_a_refused_header_value_is_masked_from_any_logger(tmp_path, monkeypatch):
+    """An HTTP client refusing a header quotes its value, which is commonly a
+    token, whichever service sent it."""
+    line = (
+        "2026-10-01 10:00:30,000 WARNING [backend.app.services.homeassistant] [x] "
+        "Failed to get HA entity state for switch.x: Illegal header value b'Bearer hdr-value-1 '"
+    )
+    name = (
+        "2026-10-01 10:00:31,000 WARNING [backend.app.services.notifications] [x] "
+        "Send failed: Illegal header name b'X-hdr-value-2'"
+    )
+    (tmp_path / "bambuddy.log").write_text(f"{line}\n{name}\n", encoding="utf-8")
+    monkeypatch.setattr(settings, "log_dir", tmp_path)
+
+    text = "\n".join(e.message for e in read_log_entries(limit=50)[0])
+    bundle = sanitize_log_content(f"{line}\n{name}")
+
+    for secret in ("hdr-value-1", "hdr-value-2"):
+        assert secret not in text and secret not in bundle, secret
+        assert read_log_entries(limit=50, search=secret)[0] == [], secret
+    assert "Failed to get HA entity state for switch.x: Illegal header value" in text

+ 107 - 0
backend/tests/unit/services/test_rest_smart_plug.py

@@ -440,3 +440,110 @@ class TestTestConnection:
         result = await service.test_connection("http://169.254.169.254/latest/meta-data/")
         result = await service.test_connection("http://169.254.169.254/latest/meta-data/")
         assert result["success"] is False
         assert result["success"] is False
         assert "cloud metadata" in result["error"].lower()
         assert "cloud metadata" in result["error"].lower()
+
+
+class TestLogsKeepCredentialsOut:
+    """A REST URL may carry the device credential in its login, query or path.
+
+    The log names the device by scheme and host only, also inside an error's
+    own text.
+    """
+
+    URL = "http://admin:login-value@plug.elsewhere.example:8080/api/webhook/path-value?auth_key=query-value"
+
+    @pytest.fixture
+    def plug(self, mock_plug):
+        mock_plug.rest_on_url = self.URL
+        mock_plug.rest_off_url = self.URL
+        return mock_plug
+
+    @staticmethod
+    def _client(side_effect=None):
+        client = MagicMock()
+        client.__aenter__ = AsyncMock(return_value=client)
+        client.__aexit__ = AsyncMock(return_value=False)
+        if side_effect is None:
+            response = MagicMock()
+            response.raise_for_status = MagicMock()
+            client.request = AsyncMock(return_value=response)
+        else:
+            client.request = AsyncMock(side_effect=side_effect)
+        return client
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "error",
+        [
+            None,
+            httpx.TimeoutException("timed out"),
+            httpx.HTTPStatusError(
+                f"Client error '404 Not Found' for url '{URL}'",
+                request=httpx.Request("GET", URL),
+                response=httpx.Response(404, request=httpx.Request("GET", URL)),
+            ),
+            httpx.ConnectError(f"cannot reach {URL}"),
+            RuntimeError(f"odd failure for {URL}"),
+        ],
+    )
+    async def test_switching_logs_only_the_host(self, service, plug, caplog, error):
+        caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
+        with patch("httpx.AsyncClient", return_value=self._client(error)):
+            await service.turn_on(plug)
+            await service.turn_off(plug)
+
+        assert "plug.elsewhere.example:8080" in caplog.text
+        assert "-value" not in caplog.text
+
+    @pytest.mark.asyncio
+    async def test_a_refused_url_logs_only_the_host(self, service, caplog):
+        caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
+
+        await service._send_request("http://admin:login-value@169.254.169.254/path-value?auth_key=query-value")
+
+        assert "169.254.169.254" in caplog.text
+        assert "-value" not in caplog.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize("delimiter", ["?", "/", "#"])
+    async def test_a_login_the_parser_cannot_find_is_hidden_whole(self, service, mock_plug, caplog, delimiter):
+        """An unencoded ? / or # in the password ends the host early: the login
+        cannot be told apart, so none of the URL is logged."""
+        caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
+        mock_plug.rest_on_url = f"http://admin:p{delimiter}w-value@plug.elsewhere.example/x"
+        with patch("httpx.AsyncClient", return_value=self._client(httpx.ConnectError("refused"))):
+            await service.turn_on(mock_plug)
+
+        assert "admin" not in caplog.text
+        assert "-value" not in caplog.text
+        assert "http://<hidden>" in caplog.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://admin:pa@ssw0rd-value?more@plug.elsewhere.example/x",
+            "http://a:Sec@ret-value/Tail@plug.elsewhere.example",
+            "http://u:a@b-value#c@plug.elsewhere.example",
+        ],
+    )
+    async def test_an_at_after_the_host_hides_the_url_whole(self, service, mock_plug, caplog, url):
+        caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
+        mock_plug.rest_on_url = url
+        with patch("httpx.AsyncClient", return_value=self._client(httpx.ConnectError("refused"))):
+            await service.turn_on(mock_plug)
+
+        assert "-value" not in caplog.text
+        assert "http://<hidden>" in caplog.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "error",
+        [httpx.LocalProtocolError("Illegal header value b'Bearer header-value '"), RuntimeError("odd header-value")],
+    )
+    async def test_an_error_that_may_quote_a_header_is_logged_by_its_type(self, service, mock_plug, caplog, error):
+        caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
+        with patch("httpx.AsyncClient", return_value=self._client(error)):
+            await service.turn_on(mock_plug)
+
+        assert "-value" not in caplog.text
+        assert type(error).__name__ in caplog.text

+ 19 - 0
frontend/src/__tests__/components/SmartPlugCard.test.tsx

@@ -81,6 +81,25 @@ describe('SmartPlugCard', () => {
       expect(screen.getByText('My Test Plug')).toBeInTheDocument();
       expect(screen.getByText('My Test Plug')).toBeInTheDocument();
     });
     });
 
 
+    it('names a REST plug by its URL for an editor and by its type for a reader', () => {
+      const { unmount } = render(
+        <SmartPlugCard
+          plug={createMockPlug({ plug_type: 'rest', ip_address: null, rest_on_url: 'http://plug.example/on' })}
+          onEdit={mockOnEdit}
+        />,
+      );
+      expect(screen.getByText('http://plug.example/on')).toBeInTheDocument();
+      unmount();
+
+      render(
+        <SmartPlugCard
+          plug={createMockPlug({ plug_type: 'rest', ip_address: null, rest_on_url: null, rest_off_url: null })}
+          onEdit={mockOnEdit}
+        />,
+      );
+      expect(screen.getByText('REST')).toBeInTheDocument();
+    });
+
     it('renders plug IP address', () => {
     it('renders plug IP address', () => {
       const plug = createMockPlug({ ip_address: '192.168.1.200' });
       const plug = createMockPlug({ ip_address: '192.168.1.200' });
       render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);
       render(<SmartPlugCard plug={plug} onEdit={mockOnEdit} />);

+ 2 - 2
frontend/src/components/SmartPlugCard.tsx

@@ -144,9 +144,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                 <h3 className="font-medium text-white truncate">{plug.name}</h3>
                 <h3 className="font-medium text-white truncate">{plug.name}</h3>
                 <p
                 <p
                   className="text-sm text-bambu-gray truncate"
                   className="text-sm text-bambu-gray truncate"
-                  title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.plug_type === 'rest' ? plug.rest_on_url ?? plug.rest_off_url ?? undefined : plug.ip_address ?? undefined}
+                  title={plug.plug_type === 'mqtt' ? plug.mqtt_topic ?? undefined : plug.plug_type === 'homeassistant' ? plug.ha_entity_id ?? undefined : plug.plug_type === 'rest' ? plug.rest_on_url || plug.rest_off_url || 'REST' : plug.ip_address ?? undefined}
                 >
                 >
-                  {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.plug_type === 'rest' ? (plug.rest_on_url || plug.rest_off_url) : plug.ip_address}
+                  {plug.plug_type === 'mqtt' ? plug.mqtt_topic : plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.plug_type === 'rest' ? (plug.rest_on_url || plug.rest_off_url || 'REST') : plug.ip_address}
                 </p>
                 </p>
               </div>
               </div>
             </div>
             </div>

Plik diff jest za duży
+ 0 - 0
static/assets/AdditionalSection-DzEoTicA.js


Plik diff jest za duży
+ 0 - 0
static/assets/AmsUnitCard-BPm4BDRq.js


Plik diff jest za duży
+ 0 - 0
static/assets/ArchivesPage-BqlmfCz_.js


Plik diff jest za duży
+ 0 - 0
static/assets/AssignToAmsModal-CNMnI7pM.js


Plik diff jest za duży
+ 0 - 0
static/assets/CamWallPage-CqbAoXUd.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraDiagnoseModal-B7H15mSM.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraPage-AIIfN_WK.js


Plik diff jest za duży
+ 0 - 0
static/assets/CameraWall-GFGvfrgJ.js


Plik diff jest za duży
+ 0 - 0
static/assets/ColumnConfigModal-DSzBM_C8.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConfigureAmsSlotModal-wtfRkOxk.js


Plik diff jest za duży
+ 0 - 0
static/assets/ConnectAuthorizePage-eZY7fvnw.js


Plik diff jest za duży
+ 0 - 0
static/assets/ContextMenu-DiHXxMTX.js


Plik diff jest za duży
+ 0 - 0
static/assets/ExternalLinkPage-BaW9CMly.js


Plik diff jest za duży
+ 0 - 1
static/assets/FileManagerPage-DpWonNn8.js


Plik diff jest za duży
+ 0 - 0
static/assets/FileUploadModal-CGmNdW53.js


Plik diff jest za duży
+ 0 - 0
static/assets/FinancePage-BV9mP6ur.js


Plik diff jest za duży
+ 0 - 0
static/assets/GCodeViewerPage-C5uW4whP.js


Plik diff jest za duży
+ 0 - 0
static/assets/GroupEditPage-Tyzj0-hU.js


Plik diff jest za duży
+ 0 - 0
static/assets/ImagePreviewModal-Dyr00MDc.js


Plik diff jest za duży
+ 0 - 0
static/assets/InventoryPage-C7baASWe.js


Plik diff jest za duży
+ 0 - 0
static/assets/LibraryTrashPage-DzswaKsz.js


Plik diff jest za duży
+ 0 - 0
static/assets/LoginPage-DfcRMKyg.js


Plik diff jest za duży
+ 0 - 0
static/assets/MaintenancePage-BtVV5dbQ.js


Plik diff jest za duży
+ 0 - 0
static/assets/ModelSourcesPage-mI1uBpYL.js


Plik diff jest za duży
+ 0 - 0
static/assets/NotificationsPage-CwNMwhWI.js


Plik diff jest za duży
+ 0 - 1
static/assets/PdfPreviewModal-Cp3GhE9F.js


Plik diff jest za duży
+ 0 - 0
static/assets/PhotoGalleryModal-BeSmUJql.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrinterLocationsPage-CpmRP3kl.js


Plik diff jest za duży
+ 0 - 0
static/assets/PrintersPage-D08OCa48.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProfilesPage-BAornjv1.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectDetailPage-DfD-bfXi.js


Plik diff jest za duży
+ 0 - 0
static/assets/ProjectsPage-Dz7ORZLH.js


Plik diff jest za duży
+ 0 - 0
static/assets/QueuePage-BNA8ZSHW.js


Plik diff jest za duży
+ 0 - 0
static/assets/RichTextEditor-BJilRpaX.js


Plik diff jest za duży
+ 0 - 0
static/assets/SettingsPage-D3dapKYp.js


Plik diff jest za duży
+ 0 - 0
static/assets/SetupPage-dZiQgSro.js


Plik diff jest za duży
+ 0 - 0
static/assets/SliceModal-Vsa8r4ag.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BKD9FVng.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-C0h8m5GC.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyDashboard-C0RBNxrj.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-Dw0p8WUB.js


Plik diff jest za duży
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CPuBs_6l.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-C7nGJy5i.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpreadsheetPreviewModal-pUDd1N9k.js


Plik diff jest za duży
+ 0 - 0
static/assets/StatsPage-D7L6BAP4.js


Plik diff jest za duży
+ 0 - 0
static/assets/StreamOverlayPage-3xqQi4xf.js


Plik diff jest za duży
+ 0 - 0
static/assets/SystemInfoPage-D8KszCv9.js


Plik diff jest za duży
+ 0 - 0
static/assets/index-BCcru1D3.js


+ 1 - 1
static/assets/inventorySearch-KEhFjvkL.js → static/assets/inventorySearch-teFAZL6T.js

@@ -1 +1 @@
-import{ft as e}from"./index-D4l8mBj7.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};
+import{ft as e}from"./index-BCcru1D3.js";function t(t,n){let r=n.trim().toLowerCase();if(!r)return!0;let i=r.match(/^#(\d+)$/);return i?t.id===Number(i[1]):String(t.id).includes(r)||t.material.toLowerCase().includes(r)||(t.brand?.toLowerCase().includes(r)??!1)||(t.color_name?.toLowerCase().includes(r)??!1)||(e(t.color_name,t.rgba,t.color_name_is_synthesized)?.toLowerCase().includes(r)??!1)||(t.subtype?.toLowerCase().includes(r)??!1)||(t.note?.toLowerCase().includes(r)??!1)||(t.slicer_filament_name?.toLowerCase().includes(r)??!1)||(t.storage_location?.toLowerCase().includes(r)??!1)||(t.material_number?.toLowerCase().includes(r)??!1)||(t.suppliers?.some(e=>e.supplier_name.toLowerCase().includes(r)||(e.supplier_article_number?.toLowerCase().includes(r)??!1))??!1)}function n(e,n){return n?e.filter(e=>t(e,n)):e}export{n as t};

Plik diff jest za duży
+ 0 - 0
static/assets/locationSensorDefaults-BZfiYHsX.js


Plik diff jest za duży
+ 0 - 0
static/assets/pdf-Q9eIgaTl.js


Plik diff jest za duży
+ 0 - 0
static/assets/utils-Ttzd3-ik.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- 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-D4l8mBj7.js"></script>
+    <script type="module" crossorigin src="/assets/index-BCcru1D3.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C3LdD0iY.js">
     <link rel="modulepreload" crossorigin href="/assets/client-C3LdD0iY.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików