mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user