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).
This commit is contained in:
maziggy
2026-06-22 14:13:44 +02:00
parent bb42b423af
commit f4a4d6dceb
9 changed files with 443 additions and 152 deletions
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
View File
@@ -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'}
@@ -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
View File
@@ -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
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>