Kaynağa Gözat

Security hardening (maziggy/bambuddy-security #19)

maziggy 1 hafta önce
ebeveyn
işleme
a51283ad76
63 değiştirilmiş dosya ile 596 ekleme ve 25 silme
  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,
     SmartPlugEnergy,
     SmartPlugResponse,
+    SmartPlugResponseWithSecret,
     SmartPlugStatus,
     SmartPlugTestConnection,
     SmartPlugUpdate,
@@ -60,22 +61,37 @@ logger = logging.getLogger(__name__)
 
 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(
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
     """List all smart plugs, minus those powering printers the caller can't see (#1727)."""
     query = select(SmartPlug).order_by(SmartPlug.name)
     if (clause := printer_scope.where(SmartPlug.printer_id)) is not None:
         query = query.where(clause)
     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(
     data: SmartPlugCreate,
     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())
 
 
-@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(
     printer_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePrinterPermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
+    can_edit: bool = CallerEditsPlugs,
 ):
     """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
     ``_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])
@@ -546,22 +564,23 @@ async def list_ha_sensor_entities(
     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(
     plug_id: int,
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ),
     printer_scope: PrinterScope = RequestPrinterScope,
+    can_edit: bool = CallerEditsPlugs,
 ):
     """Get a specific smart plug."""
     result = await db.execute(select(SmartPlug).where(SmartPlug.id == plug_id))
     plug = result.scalar_one_or_none()
     if not plug or not printer_scope.allows(plug.printer_id):
         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(
     plug_id: int,
     data: SmartPlugUpdate,

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

@@ -19,6 +19,7 @@ from __future__ import annotations
 import asyncio
 import logging
 import re
+from urllib.parse import urlsplit
 
 # ``scheme://user:secret@host`` — the only URL shape that carries a secret.
 # 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)
 
 
+# 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):
     """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.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):
     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."""
         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):
     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 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.settings import Settings
 from backend.app.models.user import User
@@ -64,10 +71,17 @@ def parse_log_line(line: str) -> LogEntry | 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:
     # Lines on disk may predate a redaction: masked as they are read, before
     # 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
 
 
@@ -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
     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
     # that regex patterns cannot detect
     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.
     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
     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
 
+from backend.app.core.logging_filters import redact_query_tokens, redact_url_credentials, url_host_only
+
 if TYPE_CHECKING:
     from backend.app.models.smart_plug import SmartPlug
 
 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:
     """Service for controlling smart plugs via generic REST/HTTP API.
 
@@ -95,7 +116,7 @@ class RESTSmartPlugService:
     ) -> httpx.Response | None:
         """Send an HTTP request and return the response."""
         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
 
         try:
@@ -115,16 +136,18 @@ class RESTSmartPlugService:
                 response.raise_for_status()
                 return response
         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
         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
         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
         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
 
     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)
 
         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
 
         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)
 
         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
 
         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/")
         assert result["success"] is False
         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();
     });
 
+    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', () => {
       const plug = createMockPlug({ ip_address: '192.168.1.200' });
       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>
                 <p
                   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>
               </div>
             </div>

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/AdditionalSection-DzEoTicA.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/AmsUnitCard-BPm4BDRq.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ArchivesPage-BqlmfCz_.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/AssignToAmsModal-CNMnI7pM.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CamWallPage-CqbAoXUd.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CameraDiagnoseModal-B7H15mSM.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CameraPage-AIIfN_WK.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/CameraWall-GFGvfrgJ.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ColumnConfigModal-DSzBM_C8.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ConfigureAmsSlotModal-wtfRkOxk.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ConnectAuthorizePage-eZY7fvnw.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ContextMenu-DiHXxMTX.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ExternalLinkPage-BaW9CMly.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
static/assets/FileManagerPage-DpWonNn8.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/FileUploadModal-CGmNdW53.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/FinancePage-BV9mP6ur.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/GCodeViewerPage-C5uW4whP.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/GroupEditPage-Tyzj0-hU.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ImagePreviewModal-Dyr00MDc.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/InventoryPage-C7baASWe.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/LibraryTrashPage-DzswaKsz.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/LoginPage-DfcRMKyg.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/MaintenancePage-BtVV5dbQ.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ModelSourcesPage-mI1uBpYL.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/NotificationsPage-CwNMwhWI.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 1
static/assets/PdfPreviewModal-Cp3GhE9F.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/PhotoGalleryModal-BeSmUJql.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/PrinterLocationsPage-CpmRP3kl.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/PrintersPage-D08OCa48.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ProfilesPage-BAornjv1.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ProjectDetailPage-DfD-bfXi.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/ProjectsPage-Dz7ORZLH.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/QueuePage-BNA8ZSHW.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/RichTextEditor-BJilRpaX.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SettingsPage-D3dapKYp.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SetupPage-dZiQgSro.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SliceModal-Vsa8r4ag.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BKD9FVng.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-C0h8m5GC.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyDashboard-C0RBNxrj.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-Dw0p8WUB.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CPuBs_6l.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-C7nGJy5i.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SpreadsheetPreviewModal-pUDd1N9k.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/StatsPage-D7L6BAP4.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/StreamOverlayPage-3xqQi4xf.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/SystemInfoPage-D8KszCv9.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 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};

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/locationSensorDefaults-BZfiYHsX.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/pdf-Q9eIgaTl.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/utils-Ttzd3-ik.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <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/client-C3LdD0iY.js">
     <link rel="modulepreload" crossorigin href="/assets/QueryClientProvider-DzWmuIjj.js">

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor