Просмотр исходного кода

feat(system): appliance locale defaults endpoint + frontend i18n bootstrap

  Closes the cross-repo contract started in bambuddy-appliance: the firstboot
  wizard writes /etc/bambuddy/local.toml with the user's hostname / timezone /
  locale, but nothing on the main app side read it. Hostname + timezone are
  already applied by the appliance's firstboot.sh via hostnamectl /
  timedatectl. This PR closes the loop for the third field — locale — so the
  language the user picked in the wizard actually shows up on first SPA load.

  backend/app/core/local_config.py

  New module. read_local_toml(path) returns a LocalConfig TypedDict
  ({hostname?, timezone?, locale?}) parsed from /etc/bambuddy/local.toml.
  Defensive on every failure mode -- missing file returns {}, invalid TOML
  returns {} + log warning, non-string values dropped with warning. The
  reader never raises; a malformed config never blocks startup.

  backend/app/api/routes/system.py

  New endpoint GET /system/appliance. Returns {hostname, timezone, locale}
  with null for any field not present in the TOML. No auth required: the
  frontend i18n bootstrap reads this before auth might be set up, and the
  contents are user-set defaults, not secrets. The function calls
  read_local_toml() with no args (default path) so tests can monkeypatch
  the module's read_local_toml reference to inject fixtures.

  frontend/src/i18n/index.ts

  One-shot applyApplianceLocale() runs after i18n.init(). Gated by a
  bambuddy_appliance_locale_consumed localStorage flag so it runs at most
  once per appliance. Fetches /api/v1/system/appliance, validates the
  returned locale against supportedLngs, calls i18n.changeLanguage if
  valid. Silent .catch() because the endpoint absent / unreachable means
  non-appliance install or dev environment -- we leave the LanguageDetector's
  choice in place. The consumed flag is set on success; future loads skip
  the fetch entirely. Won't override a user's explicit language pick (the
  language picker writes to a separate localStorage key, bambutrack_language).
maziggy 2 месяцев назад
Родитель
Сommit
f4a4d6dceb

+ 3 - 0
CHANGELOG.md

@@ -4,6 +4,9 @@ All notable changes to Bambuddy will be documented in this file.
 
 ## [0.2.5b1] - Unreleased
 
+### Added
+- **Appliance locale defaults endpoint** — `GET /api/v1/system/appliance` returns the hostname/timezone/locale the Bambuddy Appliance setup wizard collects into `/etc/bambuddy/local.toml` during firstboot. New `backend/app/core/local_config.py::read_local_toml` parses the file defensively (missing file → empty dict, invalid TOML → empty dict + warning, non-string values dropped with a warning), so a malformed file never blocks startup. Endpoint returns `{hostname, timezone, locale}` with `null` for any field not present, requires no auth (the frontend i18n bootstrap fetches it before auth might be set up, and the contents are user-set defaults, not secrets). On the frontend, `i18n/index.ts` runs a one-shot `applyApplianceLocale()` hook after init: gated by a `bambuddy_appliance_locale_consumed` localStorage flag so it runs exactly once per appliance, fetches the endpoint, and `i18n.changeLanguage(...)`s if the returned locale is in the supported set. Non-appliance installs (Docker, manual) silently no-op when the file or endpoint is absent. The appliance writes the file via its setup wizard (separate repo: `bambuddy-appliance`); this PR closes the loop for the locale field — hostname and timezone are still applied by the appliance's firstboot.sh via `hostnamectl`/`timedatectl` and don't need a main-app reader. Backend test coverage: 9 unit cases for the reader (missing/empty/comment-only/full/partial/invalid/non-string/unknown-keys/escaped-quotes), 4 integration cases for the endpoint (nulls when no file, full values, partial values, no-auth-required).
+
 ### Security
 - **Vite 7 → 8 major bump** — Bambuddy's frontend now builds with Vite 8 (`^7.3.2` → `^8.0.16`) and the matching plugin-react release (`@vitejs/plugin-react` `^5.1.1` → `^5.2.0`). Headline architectural change: Vite 8 swaps Rollup for **Rolldown** as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitive `esbuild` floor to 0.28.1, which closes the last open advisory in the audit chain. **Bambuddy-side surface audited:** `vite.config.ts` uses only stable contracts that survived the v8 cut — `defineConfig`, the `Connect` type, the custom `serveGcodeViewer` `configureServer` middleware plugin (proxies `/gcode-viewer/*` to the repo's sibling `gcode_viewer/` directory in dev), the `server.proxy` with WebSocket upgrade for `/api/v1/ws`, `build.outDir`/`emptyOutDir`/`chunkSizeWarningLimit`, and `resolve.alias` for `@`. `base: '/'` regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest@4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. **Node:** vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. **What this is NOT:** plugin-react v6 — that line requires `babel-plugin-react-compiler` + `@rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean.
 - **Frontend dependency bumps** — Routine version updates across the runtime, build, and test dependency surface. **Runtime:** `dompurify` 3.4.0 → 3.4.10. `package.json` floor raised from `^3.4.0` to `^3.4.10` so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list with `selectedcontent` + `command` + `commandfor` (all valid modern HTML, harmless for our two default-allow-list call sites), and `ProjectPageModal` is unaffected anyway because it sets an explicit `ALLOWED_TAGS` / `ALLOWED_ATTR` whitelist. **Build / lint / test tooling (transitive, dev-only):** `@babel/core` 7.29.0 → 7.29.7 (pulled by `@vitejs/plugin-react` and `eslint-plugin-react-hooks`), `vite` 7.3.2 → 7.3.5, `markdown-it` 14.1.1 → 14.2.0 (pulled by `@tiptap/extension-link` → `@tiptap/pm` → `prosemirror-markdown`; Bambuddy never calls `markdown-it.render` directly so the change is transparent), `js-yaml` 4.1.1 → 4.2.0 (pulled by `eslint`), `form-data` 4.0.5 → 4.0.6 + `ws` 8.20.1 → 8.21.0 (both pulled by `jsdom` in the test runtime). All bumps inside existing semver ranges except `dompurify`. No source changes required.

+ 18 - 0
backend/app/api/routes/system.py

@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
+from backend.app.core.local_config import read_local_toml
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
@@ -603,3 +604,20 @@ async def get_system_health(
     """
     sensitive_strings = await collect_sensitive_strings(db)
     return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+
+
+@router.get("/appliance")
+async def get_appliance_defaults():
+    """Expose the hostname/timezone/locale the appliance setup wizard collected.
+
+    Read from /etc/bambuddy/local.toml; absent on non-appliance installs, in
+    which case all fields are null. No auth required — the frontend i18n
+    bootstrap reads this BEFORE auth might be set up, and the contents are
+    purely user-set defaults (no secrets).
+    """
+    config = read_local_toml()
+    return {
+        "hostname": config.get("hostname"),
+        "timezone": config.get("timezone"),
+        "locale": config.get("locale"),
+    }

+ 64 - 0
backend/app/core/local_config.py

@@ -0,0 +1,64 @@
+"""
+Read /etc/bambuddy/local.toml — the file the appliance setup wizard writes
+during firstboot to capture the user's hostname, timezone, and locale.
+
+Universal across install shapes:
+
+- On the Bambuddy Appliance: the wizard writes this file before bambuddy.service
+  starts; we read it on every startup to surface defaults to the frontend.
+- On Docker / manual installs: the file is absent; we degrade silently. An
+  operator who wants to seed defaults can drop their own local.toml into the
+  expected path or override via DATA_DIR.
+
+The reader is read-only and side-effect-free. It does NOT call hostnamectl
+or timedatectl — that's the appliance's firstboot.sh responsibility (it has
+the root privileges to do so and runs before this process exists). What we
+do here is expose the values the wizard collected so the frontend i18n
+bootstrap can pick the right initial language.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import TypedDict
+
+import tomllib
+
+log = logging.getLogger(__name__)
+
+DEFAULT_PATH = Path("/etc/bambuddy/local.toml")
+
+
+class LocalConfig(TypedDict, total=False):
+    hostname: str
+    timezone: str
+    locale: str
+
+
+def read_local_toml(path: Path = DEFAULT_PATH) -> LocalConfig:
+    """Read the appliance local.toml. Missing / invalid file returns empty dict.
+
+    Only the keys actually present in the file are returned — the caller checks
+    `if "locale" in config:` rather than relying on defaults. Non-string values
+    are dropped with a warning to keep this defensive on a hand-edited file.
+    """
+    if not path.is_file():
+        return {}
+    try:
+        with path.open("rb") as f:
+            data = tomllib.load(f)
+    except (OSError, tomllib.TOMLDecodeError) as exc:
+        log.warning("local.toml at %s could not be parsed: %s", path, exc)
+        return {}
+
+    result: LocalConfig = {}
+    for key in ("hostname", "timezone", "locale"):
+        value = data.get(key)
+        if value is None:
+            continue
+        if not isinstance(value, str):
+            log.warning("local.toml: %r is %s, expected str — ignoring", key, type(value).__name__)
+            continue
+        result[key] = value  # type: ignore[literal-required]
+    return result

+ 75 - 0
backend/tests/integration/test_system_api.py

@@ -473,3 +473,78 @@ class TestSystemHealthAPI:
         ids = [f["signature_id"] for f in result["findings"]]
         assert "ftp-auth-rejected" in ids
         assert result["summary"]["layer8"] >= 1
+
+
+class TestSystemApplianceAPI:
+    """Integration tests for GET /api/v1/system/appliance (appliance locale defaults)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_returns_nulls_when_no_local_toml(
+        self, async_client: AsyncClient, tmp_path, monkeypatch
+    ):
+        """Non-appliance install: file is absent, every field is null."""
+        from backend.app.api.routes import system as system_routes
+
+        absent = tmp_path / "nope.toml"
+        monkeypatch.setattr(
+            system_routes,
+            "read_local_toml",
+            lambda: __import__("backend.app.core.local_config", fromlist=["read_local_toml"]).read_local_toml(absent),
+        )
+
+        response = await async_client.get("/api/v1/system/appliance")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body == {"hostname": None, "timezone": None, "locale": None}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_returns_wizard_values(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """Appliance install: wizard's local.toml values surface verbatim."""
+        from backend.app.api.routes import system as system_routes
+        from backend.app.core import local_config
+
+        toml = tmp_path / "local.toml"
+        toml.write_text('hostname = "workshop-pi"\ntimezone = "Europe/Berlin"\nlocale = "de"\n')
+        monkeypatch.setattr(system_routes, "read_local_toml", lambda: local_config.read_local_toml(toml))
+
+        response = await async_client.get("/api/v1/system/appliance")
+
+        assert response.status_code == 200
+        assert response.json() == {
+            "hostname": "workshop-pi",
+            "timezone": "Europe/Berlin",
+            "locale": "de",
+        }
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_partial(self, async_client: AsyncClient, tmp_path, monkeypatch):
+        """Only locale set: hostname + timezone surface as null."""
+        from backend.app.api.routes import system as system_routes
+        from backend.app.core import local_config
+
+        toml = tmp_path / "local.toml"
+        toml.write_text('locale = "ja"\n')
+        monkeypatch.setattr(system_routes, "read_local_toml", lambda: local_config.read_local_toml(toml))
+
+        response = await async_client.get("/api/v1/system/appliance")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["locale"] == "ja"
+        assert body["hostname"] is None
+        assert body["timezone"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_appliance_endpoint_requires_no_auth(self, async_client: AsyncClient):
+        """The frontend i18n bootstrap reads this before auth might be set up.
+
+        The endpoint must respond 200 even when auth is enabled and the caller
+        is unauthenticated — its contents are non-secret (user-set defaults).
+        """
+        response = await async_client.get("/api/v1/system/appliance")
+        assert response.status_code == 200

+ 92 - 0
backend/tests/unit/test_local_config.py

@@ -0,0 +1,92 @@
+"""
+Tests for backend.app.core.local_config — the reader for
+/etc/bambuddy/local.toml that the appliance setup wizard writes.
+
+Defensive on bad input: every failure mode returns an empty dict
+(never raises), so a malformed file never blocks startup.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from backend.app.core.local_config import read_local_toml
+
+
+def test_missing_file_returns_empty(tmp_path: Path):
+    assert read_local_toml(tmp_path / "nope.toml") == {}
+
+
+def test_empty_file_returns_empty(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text("")
+    assert read_local_toml(path) == {}
+
+
+def test_comment_only_file_returns_empty(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text("# Written by bambuddy-wizard during firstboot.\n")
+    assert read_local_toml(path) == {}
+
+
+def test_full_config_parses(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text(
+        "# Written by bambuddy-wizard during firstboot.\n"
+        'hostname = "workshop-pi"\n'
+        'timezone = "Europe/Berlin"\n'
+        'locale = "de"\n'
+    )
+    result = read_local_toml(path)
+    assert result == {
+        "hostname": "workshop-pi",
+        "timezone": "Europe/Berlin",
+        "locale": "de",
+    }
+
+
+def test_partial_config_only_returns_present_keys(tmp_path: Path):
+    path = tmp_path / "local.toml"
+    path.write_text('locale = "ja"\n')
+    result = read_local_toml(path)
+    assert result == {"locale": "ja"}
+    assert "hostname" not in result
+    assert "timezone" not in result
+
+
+def test_invalid_toml_returns_empty(tmp_path: Path, caplog: pytest.LogCaptureFixture):
+    path = tmp_path / "local.toml"
+    path.write_text("not = valid = toml = at all\n")
+    result = read_local_toml(path)
+    assert result == {}
+    assert any("could not be parsed" in r.message for r in caplog.records)
+
+
+def test_non_string_value_is_dropped(tmp_path: Path, caplog: pytest.LogCaptureFixture):
+    path = tmp_path / "local.toml"
+    path.write_text(
+        "hostname = 42\n"  # not a string
+        'locale = "de"\n'
+    )
+    result = read_local_toml(path)
+    assert result == {"locale": "de"}
+    assert any("expected str" in r.message for r in caplog.records)
+
+
+def test_unknown_keys_are_ignored(tmp_path: Path):
+    """A hand-edited config with extra keys must not leak them to the response."""
+    path = tmp_path / "local.toml"
+    path.write_text('locale = "de"\nunknown_key = "value"\nadmin_password = "should not surface"\n')
+    result = read_local_toml(path)
+    assert set(result.keys()) <= {"hostname", "timezone", "locale"}
+    assert "admin_password" not in result
+
+
+def test_escaped_characters_round_trip(tmp_path: Path):
+    """The wizard escapes backslash and quote when writing; the reader parses them back."""
+    path = tmp_path / "local.toml"
+    path.write_text('hostname = "with\\"quote"\n')
+    result = read_local_toml(path)
+    assert result == {"hostname": 'with"quote'}

+ 4 - 0
backend/tests/unit/test_route_auth_coverage.py

@@ -93,6 +93,10 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
         # UI bootstrap — defaults for sidebar order and ui-preferences are public defaults that ship with the app.
         ("GET", "/api/v1/settings/default-sidebar-order"),
         ("GET", "/api/v1/settings/ui-preferences"),
+        # Appliance locale defaults — read by the i18n bootstrap BEFORE auth might be set up.
+        # Contents are user-set hostname/timezone/locale from the firstboot wizard (no secrets);
+        # the file is absent on non-appliance installs, in which case every field is null.
+        ("GET", "/api/v1/system/appliance"),
         # Slicer printer-models — static catalog, no user data.
         ("GET", "/api/v1/slicer/printer-models"),
         # Current Bambuddy version — public info (already visible in HTTP response headers + Docker tags).

+ 36 - 1
frontend/src/i18n/index.ts

@@ -29,13 +29,16 @@ const resources = {
   tr: { translation: tr },
 };
 
+const SUPPORTED_LNGS = ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'tr', 'zh-CN', 'zh-TW'];
+const APPLIANCE_CONSUMED_KEY = 'bambuddy_appliance_locale_consumed';
+
 i18n
   .use(LanguageDetector)
   .use(initReactI18next)
   .init({
     resources,
     fallbackLng: 'en',
-    supportedLngs: ['en', 'de', 'es', 'fr', 'ja', 'it', 'ko', 'pt-BR', 'tr', 'zh-CN', 'zh-TW'],
+    supportedLngs: SUPPORTED_LNGS,
 
     detection: {
       // Order of detection methods
@@ -55,6 +58,38 @@ i18n
     },
   });
 
+/**
+ * Bambuddy Appliance hook: on the first SPA load after the firstboot wizard
+ * runs, /api/v1/system/appliance returns the locale the user picked. We
+ * apply it once (gated by a localStorage flag) and stop. On non-appliance
+ * installs the endpoint either 404s or returns nulls — silent no-op.
+ *
+ * This runs AFTER i18n.init so the LanguageDetector has already populated a
+ * default; we override that default exactly once for fresh appliances. The
+ * appliance is then "consumed" and the language picker is the only way to
+ * change locale going forward (the wizard ran once; future intent comes from
+ * the running UI).
+ */
+function applyApplianceLocale() {
+  if (typeof window === 'undefined' || !window.localStorage) return;
+  if (window.localStorage.getItem(APPLIANCE_CONSUMED_KEY)) return;
+
+  fetch('/api/v1/system/appliance')
+    .then((r) => (r.ok ? r.json() : null))
+    .then((data) => {
+      if (!data || typeof data.locale !== 'string') return;
+      if (!SUPPORTED_LNGS.includes(data.locale)) return;
+      i18n.changeLanguage(data.locale);
+      window.localStorage.setItem(APPLIANCE_CONSUMED_KEY, '1');
+    })
+    .catch(() => {
+      // Endpoint absent or unreachable — non-appliance install or dev environment.
+      // Leave the detector's choice in place.
+    });
+}
+
+applyApplianceLocale();
+
 export default i18n;
 
 // Helper to get available languages

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-CMgUP2QG.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-CTEw-X7C.js"></script>
+    <script type="module" crossorigin src="/assets/index-CMgUP2QG.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DSFMlFH_.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов