mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-01 11:47:46 +02:00
d82f4e032fefdb9269fe906ce50c6311cc3cd784
166
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
19073f5c84 |
fix(vp): auto-derive access code from target printer in non-proxy modes
Non-proxy VPs (Archive / Review / Queue) with a target printer set up a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth bytes through to the real printer. The slicer holds one code in its profile (the one it bound the VP with), and that code has to satisfy both the VP listener and the real printer at the far end of the bridge. If the codes diverge the bridge silently fails at the second hop — slicer reaches .49:8883, FINs before sending a ClientHello, retries identically. The wiki framed the code-match requirement as a camera-only concern; it isn't, all bridged protocols inherit. Fix removes the foot-gun instead of re-documenting it. When a target is selected on a non-proxy VP the access-code field switches to a read-only display showing the target's code with an Eye-toggle reveal; the backend auto-inherits on every create / update (any explicit access_code submitted alongside a target is silently overridden as belt-and-braces for non-UI clients). The required-when- enabling check now treats target-set as satisfying the access-code requirement. Standalone (no-target) non-proxy VPs still get the editable input + Save button. One-shot startup migration corrects any pre-existing mismatched rows: SELECTs diverged VPs and logs one INFO line per row for the audit trail, then UPDATEs via correlated subquery. Idempotent and portable between SQLite and Postgres. |
||
|
|
12d17bfbe7 |
fix(photo): source finish photo from forced timelapse + cleanup (#1397)
Bambu's end-gcode lowers the bed at gcode_state=FINISH. Bambuddy's live-camera grab captured the bed already dropped, ruining the photo framing. Source the photo from a brief Bambu timelapse instead — firmware stops timelapse recording AFTER toolhead parks but BEFORE bed-drop runs, so the last frame frames the finished print correctly. When capture_finish_photo is on AND the user did not opt in to timelapse for this print, force timelapse=True at dispatch + mark the new PrintArchive.bambuddy_forced_timelapse column. After extraction (success or failure), cleanup deletes the locally-attached file, clears archive.timelapse_path, and walks the four scanner directories (/timelapse, /timelapse/video, /record, /recording) trying FTP DELE against the original filename. User-opted-in timelapses pass through unchanged. Resolver lives at services/background_dispatch.py::resolve_effective_timelapse (module-level so the print queue can reuse it). Both dispatch paths wired: background_dispatch.py (Print Now / Reprint) AND print_scheduler.py:_start_print (the queue). Field testing caught the scheduler gap on the first round — AST regression test now asserts start_print(timelapse=...) references effective_timelapse, not the raw item.timelapse, so a future refactor can't silently drop it. Extractor: ffmpeg -i input.mp4 -update 1 -q:v 2 out.jpg. Decoded frames overwrite the same output file, so the file left on disk is the literal last frame regardless of duration. Bambu records one frame per layer-change, so a 16-layer cube produces a 0.6 s timelapse — the original -sseof -1.0 approach seeked before the start of the file and returned frame 0 (empty bed). Decoding every frame is fine; Bambu timelapses are short by construction even on hours-long prints. Migration adds bambuddy_forced_timelapse branched on is_sqlite() (DEFAULT 0 / DEFAULT FALSE — PG rejects DEFAULT 0 for BOOLEAN). Verified live on postgres:16-alpine. Photo-task wait_for budget extends 45s -> 75s when timelapse_was_active so the notification carries the bed-up photo instead of falling back to the live-cam grab on slow links. Scope limit, documented in the camera wiki: prints started directly on the printer touchscreen / Bambu Handy / Bambu Studio Send bypass both dispatch paths, so the override doesn't fire there. Future option: mid-print M981 S1 P20000 MQTT toggle in on_print_start. Setting description rewritten in all 11 locales to drop the "only works when timelapse enabled" caveat (Bambuddy now forces it) and explain the kept-or-deleted behaviour. |
||
|
|
18d534c945 |
feat(orca-cloud): integrate Orca Cloud profile sync across UI, slicer and SpoolBuddy
Reads, lists, and slices with profiles from a user's Orca Cloud account (OrcaSlicer 2.4.0-alpha's Supabase-backed sync) alongside the existing Bambu Cloud integration. Four sign-in providers (Google / Apple / GitHub / email+password); password defaults. Paste-flow PKCE because Orca's Supabase project only allowlists localhost redirect_to — open feature request at OrcaSlicer/OrcaSlicer#14028. Surfaces: - Profiles tab: new "Orca Cloud" tab next to "Bambu Cloud" with the same rich layout (search + 5 filter dropdowns + 3-column grouped grid + read-only detail modal) - SliceModal: 4-tier preset picker (orca_cloud > local > bambu cloud > standard); separate status banner per cloud; metadata-aware pre-pick scores Orca filaments above local (Orca's sync_pull returns full content inline so filament_type / filament_colour come for free, no per-setting fetch rate-limit dance) - ConfigureAmsSlotModal: orca_cloud as a new preset source (prefixed orca_<UUID> to match local_/builtin_); generic Bambu filament-ID derivation from parsed material (printer firmware can't grok Orca UUIDs); slot mapping persists preset_source='orca_cloud' - SpoolForm / SpoolBuddyWriteTagPage: Orca filaments merge into the cloud preset list via Promise.allSettled (OrcaProfileMeta is structurally identical to SlicerSetting) Backend: - services/orca_cloud.py: OrcaCloudService with PKCE / token exchange / single-use refresh rotation / get_user_info / list_profiles via the bare /sync/pull bootstrap path - routes/orca_cloud.py: 7 endpoints (auth/start, auth/finish, auth/password, status, logout, profiles, profiles/{id}); router-level _cloud_api_key_gate + per-route cloud_caller() so API-keyed callers (SpoolBuddy kiosk) properly resolve their owner User; just-in-time refresh with atomic persist-before-API-call - routes/slicer_presets.py: _fetch_orca_cloud_presets mirrors the Bambu Cloud fetcher (status vocabulary, 5min cache, permission shortcut); _dedupe_by_name extended to 4 tiers; UnifiedPresetsResponse gains orca_cloud + orca_cloud_status - services/preset_resolver.py: PresetRef.source extended with "orca_cloud"; _resolve_orca_cloud walks list + filters - 8 columns on users table for tokens (5 persistent) + transient PKCE handshake state with 10-min TTL (3); dialect-branched DATETIME / TIMESTAMP; auth-disabled mode falls back to Settings table - orca_cloud:auth permission folded into can_access_cloud API-key scope (same trust dimension) |
||
|
|
a837a3acbd |
● fix(library): #1600 thumbnail extraction for external-folder .gcode.3mf
files + unify file_type classification across ingest paths #1600: external-folder sliced outputs landed with no thumbnail. Cause: four backend ingest paths classified LibraryFile.file_type differently for the same .gcode.3mf family. upload / ZIP-extract / in-process used os.path.splitext()[1] which returns .3mf for foo.gcode.3mf and stored file_type="3mf", matching the thumbnail-extraction gate at library.py:1467 (file_type == "3mf"). External-folder scan explicitly detected the compound and stored file_type="gcode.3mf" — preserving "sliced output" identity — but then skipped both the "3mf" gate and the "gcode" gate, so the file landed with thumbnail_path = None. Same compound-extension drift that bit #1543 in the 3D preview, in a surface that audit didn't trace back to. Unified fix: - New classify_file_type(filename) helper in routes/library.py is the single source of truth. Returns "gcode.3mf" for sliced outputs and ext[1:] otherwise. - Applied to every ingest path: upload (line 1704), ZIP-extract (1998), external-folder scan (the bug site — the manual compound check is replaced), and in-process save_3mf_from_bytes (471, used by MakerWorld import). - External-scan thumbnail gate widened to `if file_type in ("3mf", "gcode.3mf"):` — a .gcode.3mf IS a 3MF zip with Metadata/plate_1.png; ThreeMFParser doesn't care about the trailing extension. - gcode-download endpoint at GET /library/files/{id}/gcode had the same drift in reverse: gate was `elif file.file_type == "3mf":` so a row stored with file_type="gcode.3mf" (the external-scan path's pre-unification behaviour, and the canonical going forward) got rejected with HTTP 400. Widened to the same compound-aware tuple. One-shot DB migration in core/database.py::run_migrations backfills existing legacy rows: UPDATE library_files SET file_type = 'gcode.3mf' WHERE file_type = '3mf' AND LOWER(filename) LIKE '%.gcode.3mf' Idempotent (post-update rows no longer match the file_type='3mf' predicate, so re-runs at every boot are no-ops) and dialect-neutral (LOWER + LIKE are identical under SQLite and Postgres). Without the backfill, users would have a permanent split state: old uploads at '3mf', new uploads at 'gcode.3mf' — which would double-bucket sliced outputs in the dashboard stats query at line 4615 and show two entries in the file-manager filter dropdown for the same conceptual type. Frontend untouched. FileManagerPage.tsx and ProjectDetailPage.tsx already accept both '3mf' and 'gcode.3mf' per the #1543 fix. After the migration the DB only contains canonical values, so the legacy '3mf' branches in the frontend become dead code for sliced files — they stay as defence-in-depth in case any future ingest path I missed reverts to the legacy classifier. |
||
|
|
5d6d928b3f |
fix(virtual-printer): #1429 net.info[*].ip cache leak + mode wire-value rename
#1429 (reported by @TrickShotMLG02, confirmed by @Mape6 on a flat single-LAN that rules out subnet / mDNS-reflector theories): with the physical printer off the slicer's "Send" landed in Bambuddy's archive; once the printer powered on every subsequent "Send" went straight to the printer's SD card and bypassed Bambuddy. Bundle analysis: mape6-before showed clean FTP receive + archive lines, mape6-after had zero FTP attempts to Bambuddy once the printer was online. Cause: mqtt_bridge.py::_resolve_client encoded _target_ip_uint32_le / _vp_ip_uint32_le ONLY on client-identity change and early-returned on every refresh tick when the same client object was still bound. If target_client.ip_address was empty at first bind (DB row stale, or client constructed before SSDP refresh filled it in), the encoding stayed None, the net.info[*].ip rewrite block was skipped, the cache filled with the real printer IP, sticky-key preservation kept the poisoned net value alive across every subsequent incremental push, and the slicer followed the leaked IP. Only Bambuddy-restart-with-printer-off cleared it — the workaround both reporters independently arrived at. Same shape on multi-NIC printers (X1C, H2D Pro): the rewrite only matched entries whose ip equalled _target_ip_uint32_le, so a secondary interface IP Bambuddy never saw would leak through unchanged. Bridge fix: - _resolve_client calls a new _refresh_ip_encoding() on every refresh tick, even when client identity is unchanged; self-heals once ip_address becomes valid. - _refresh_ip_encoding() sweeps the existing _latest_print_state when encoding becomes valid for the first time. Without the sweep, sticky-key preservation keeps the pre-arm poisoned cache alive forever — incremental pushes that don't include net carry the bad value forward. - _rewrite_net_info_ips() rewrites EVERY non-zero net.info[].ip entry that doesn't already equal the VP IP, not just entries matching _target_ip_uint32_le. Multi-NIC printers stop leaking secondary interfaces. Zero-IP placeholders are left alone so "active interface" detection still works. - INFO logging on encoding arm/update and on cache sweep so future bundles directly answer "did the rewrite fire?". Mode wire-value rename (#1429 follow-up, separate confusion source): - Both reporters' support bundles showed mode: immediate while the UI said "Archive"; @TrickShotMLG02 quoted: "I have no idea why it says immediate in the support-info.json file. In the webui the printer is set to archive". UI button "Archive" had always saved immediate, and "Queue" had always saved print_queue. Canonical wire values are now archive / review / queue / proxy, matching the button labels 1:1. - New normalize_vp_mode() + VP_MODE_* constants in models/virtual_printer.py; manager.py normalises on construction so a legacy row read pre-migration still dispatches correctly. - core/database.py::run_migrations rewrites existing virtual_printers and settings rows; idempotent (re-runs are no-ops); identical SQL under SQLite and Postgres. - API routes accept both legacy and canonical on input, normalise before storage. GET /settings/virtual-printer normalises on read so the frontend's mode-button highlight works for stale legacy values. - Three frontend VP components (VirtualPrinterSettings, VirtualPrinterCard, VirtualPrinterAddDialog) switched click handlers and type aliases to canonical; each got its own normalizeMode() helper so a stale-cached settings payload still highlights the right button. Two pre-existing `printer.mode === 'queue' ? 'review'` legacy mappings in VirtualPrinterCard were the source of a test failure caught mid-implementation where the new canonical 'queue' was being mis-aliased back to 'review' and hiding the auto-dispatch + force-color-match toggles. mode handler is NOT the dispatch bug: manager.py::_archive_file (the handler for archive mode) doesn't dispatch to the physical printer. The "files end up on the printer's SD card" symptom was the IP-leak from the bridge cache. The mode rename is purely clarity / support- bundle accuracy. |
||
|
|
ec51394196 |
fix(security): GHSA-r2qv-8222-hqg3 — allowlist API-key permissions (CVSS 9.9)
API-key permission gates went from a 17-entry admin denylist with the three
documented scope flags (can_read_status / can_queue / can_control_printer)
enforced only inside /api/v1/webhook/* to an explicit per-Permission
allowlist consulted by every dependency:
- core/auth.py: _APIKEY_SCOPE_BY_PERMISSION maps every non-admin
Permission to one scope flag on APIKey; unmapped = 403.
_check_apikey_permissions now takes the api_key and checks the flag.
- require_any_permission_if_auth_enabled + require_ownership_permission
were returning None for any valid key with zero scope check; both now
invoke _check_apikey_permissions and fail closed.
- Two new scope flags on api_keys: can_manage_library (LIBRARY_UPLOAD /
UPDATE_OWN / DELETE_OWN / MAKERWORLD_IMPORT) and can_manage_inventory
(INVENTORY_CREATE / UPDATE / DELETE / FORECAST_WRITE — required by
SpoolBuddy kiosks). Default TRUE, backfilled from can_queue so existing
"queue-only" keys keep working and hardened "read-only" keys do not
silently gain writes.
- CLOUD_AUTH now routed through can_access_cloud for defence-in-depth
alongside the existing _cloud_api_key_gate.
- Migration column-existence check (_api_keys_column_exists) gates the
backfill so user-edited values are never overwritten on restart.
Structural drift backstop: test_every_permission_has_a_classification fails
CI on any new Permission added without an explicit scope mapping —
prevents the denylist-shape regression that grew the prior surface.
Backend 5469 tests green; ruff clean. Frontend build green; i18n parity
green across 9 locales (5005 leaves each, +6 new keys). Wiki permissions
table + allowlist callout + upgrade notes updated.
|
||
|
|
7ea4410b21 |
fix(queue): insufficient-filament warning now fires on every dispatch path (#1496)
The pre-print deficit warning from #720 only ran inside the PrintModal submit flow. Both the green ▶ button on a staged queue row (POST /queue/{id}/start) and the Virtual Printer queue-mode intake bypassed it — auto_dispatch=True VP intakes would dispatch unsupervised onto spools that physically can't complete the print. Extracted the deficit check into backend/app/services/filament_deficit.py (single source of truth, both internal inventory and Spoolman modes). POST /queue/{id}/start returns 409 with a structured deficit payload unless ?skip_filament_check=true. The dispatch scheduler runs the same check before each _start_print; a deficit promotes the item to manual_start + sets a new filament_short flag (idempotent migration on print_queue). The flag clears automatically on the next tick when the operator swaps a spool to one with enough material. Frontend ▶ catches the 409 and opens a confirm modal showing each shorted slot's required vs remaining grams; the row now renders a yellow "Insufficient filament" badge when filament_short is set. Translated across all 9 locales. |
||
|
|
71e58e6cf1 |
fix(library): show the filename, not the embedded 3MF Title (#1489)
File Manager cards, search and sort keyed off file_metadata.print_name, which ThreeMFParser lifts from the 3MF's <metadata name="Title">. That title is the in-app project title — generic "Exported 3D Model" for any Bambu Studio "Save As", a marketing title for a MakerWorld download — and almost never the filename the user saved as. A card for Whatever.3mf showed "Exported 3D Model"; correcting it needed a rename round-trip, since the Rename dialog disables Save while the name is unchanged. The slicer-output write path already dropped print_name for this exact reason; the four other paths that store parsed 3MF metadata onto a LibraryFile did not — external-folder scan, managed multipart upload, the multi-file ZIP-upload branch, and MakerWorld import. Add a shared _without_print_name() helper and apply it at all four import paths; switch the slicer path to it so there is one rule. A LibraryFile's display name is its filename — only PrintArchive carries a real print_name, which is untouched. Remove the now-redundant filename->print_name mirroring in the rename route. Add a one-time idempotent data migration (_migrate_drop_library_print_name, SQLite json_remove / PostgreSQL jsonb key-removal branched on is_sqlite()) so libraries imported before the fix correct themselves without the rename workaround. No frontend change: print_name || filename yields the filename once print_name is gone. Tests: 6 new in test_library_print_name.py cover _without_print_name and the migration (incl. idempotency, siblings preserved, null metadata). SQLite migration branch verified by test; PostgreSQL branch verified against a real Postgres instance. |
||
|
|
e61a454a0f |
fix(inventory): "Reset usage to 0" preserves remaining in both modes (#1390)
Reporter saw a 544 g spool jump to 1000 g after pressing the eraser.
"Spools and remaining weights are not changed" - the dialog promised
this; the implementation did the opposite. Root cause was an
architectural conflation: `weight_used` did double duty as the
resettable "consumed since tracking started" counter AND as the basis
for the displayed remaining (`label_weight - weight_used`), so zeroing
it correctly cleared the stat but unavoidably reset remaining to full.
Spoolman has separate `used_weight` and `remaining_weight` fields, so
the API call there was correct - but Bambuddy's frontend was also
computing remaining as `label_weight - weight_used` for Spoolman
spools (ignoring Spoolman's real `remaining_weight` field), so the
same visual bug bit there too. Inventory-mode parity required fixing
both halves in one drop.
Internal mode
- New `weight_used_baseline` column (Float DEFAULT 0) on `spool`.
- Reset stamps `baseline = weight_used` and leaves `weight_used` alone.
- Displayed consumed = `weight_used - baseline`; remaining =
`label_weight - weight_used` (unchanged).
- Subsequent prints continue to grow `weight_used`, so the resettable
counter naturally tracks post-reset delta and remaining keeps
decrementing across the reset.
Spoolman mode
- `_map_spoolman_spool` now reads Spoolman's `remaining_weight` field
and returns a synthetic `weight_used = label - remaining` so the
frontend's remaining calc matches Spoolman's real stored value;
`weight_used_baseline = synthetic - real_used_weight` so the consumed
counter (`weight_used - baseline`) matches Spoolman's `used_weight`.
- Fallback path (no `remaining_weight` set) preserves the old behavior.
- Related fix: `update_spool` (Spoolman PATCH) was deriving the default
`weight_used` from `used_weight`, so editing unrelated fields AFTER
a reset would patch Spoolman with `remaining_weight = label - 0 =
label`, trampling the real value. Now derives from
`remaining_weight` so non-weight edits preserve physical state.
Frontend
- `InventoryPage` `totalConsumed` aggregate switched to
`Math.max(0, weight_used - (weight_used_baseline ?? 0))`.
- `ForecastPanel` `computeDeltaRate`, `totalUsedG`, and the per-spool
"consumed" table cell got the same treatment so forecast and
inventory aggregates stay coherent across a reset.
- `?? 0` keeps pre-migration installs rendering correctly until
`init_db()` runs the idempotent ALTER TABLE.
Migration
- `ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0`
via `_safe_execute` - SQLite and Postgres both accept it; verified
end-to-end on Postgres 16.
|
||
|
|
6f2cec5eb3 |
feat(smart-plugs): auto-off after AMS drying completes (#1349)
Reporter Kyobinoyo asked for the equivalent of the existing print-finish auto-off but triggered when AMS drying ends. Two new SmartPlug columns: auto_off_after_drying (default false), off_delay_after_drying_minutes (default 10 — AMS chamber is hot post-cycle so longer cooldown than the print-finish default of 5). SQLite + Postgres migrations both idempotent. Trigger lives in BambuMQTTClient — per-AMS _previous_dry_times tracks the dry_time > 0 → 0 falling edge and fires a new on_drying_complete(ams_id) callback. Plumbed through PrinterManager.set_drying_complete_callback to SmartPlugManager.on_drying_complete(printer_id, db), which walks linked plugs and respects the per-plug toggle. Catches queue, ambient and manual drying identically because it observes firmware state, not scheduler intent. Frontend: single "Auto Off After Drying" toggle + delay input on the smart plug card, next to the existing print-finish auto-off section. Per-AMS plug routing (separate plug for AMS only, per-AMS targeting on dual-AMS printers) deferred — Bambuddy's plug model is plug→printer, so the trigger fires whenever any AMS on the linked printer finishes a cycle. |
||
|
|
8e4f815b37 |
fix(stats): backfill PrintLogEntry.cost/energy/archive_id for pre-#1378 rows (#1390)
Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the per-event aggregation rewrite from #1378) and saw Quick Stats split between consistent values (Total Prints, Print Time, Filament Used, Energy, Success Rate matched the archive list) and zero-or-empty ones (Filament Cost, Time Accuracy). Root cause: #1378's migration added six columns to print_log_entries - archive_id, cost, energy_kwh, energy_cost, failure_reason, created_by_id - but never backfilled them. Pre-upgrade rows kept NULL on all six. The new Quick Stats query sums PrintLogEntry.cost (gets 0 on legacy data); the time-accuracy query JOINs PrintArchive ON archive_id (drops every legacy run from the average). Counts and the pre-existing per-row fields (status, duration_seconds, filament_used_grams) kept working - which is why some panels looked right and others didn't. Two-step backfill added inside run_migrations next to the existing column-add block, as DML inside begin_nested() (not _safe_execute, which is documented DDL-only): Step 1: link each orphan log entry to its archive via print_name + printer_id (highest archive id wins on tiebreak - newest matches the overwrite-then-stop shape pre-#1378 reprints left behind). Step 2: copy archive.cost / energy_kwh / energy_cost onto the latest matching log entry per archive, BUT only for archives where no log entry yet carries a cost. That second clause is the idempotency anchor and the double-count guard for users running this after #1378 has already written cost-bearing rows for new runs - those archives are left untouched. Earlier reprints stay NULL, matching the "first/latest writes, rest stay NULL" convention #1378 introduced for new prints. Sum across the legacy reprint chain reproduces sum-of-archive-cost exactly, so Quick Stats Filament Cost matches the pre-upgrade total instead of dropping to zero. SQL is plain ANSI - correlated UPDATE with LIMIT 1 in the SET subquery, WHERE id IN (SELECT MAX(id) ... GROUP BY archive_id HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0). Verified end-to-end on SQLite (4 new unit tests in test_print_log_backfill_migration.py: link-via-name, latest-run-gets- cost, idempotent, skip-archives-with-any-costed-run) and against a live postgres:16-alpine + asyncpg container (first-pass and second- pass produce identical state). The other widgets the reporter listed (Printer Stats, Filament Trends, By Material, Success by Material, Color Distribution) iterate the archives list on the frontend rather than calling /stats - they read consistent pre-upgrade data and aren't part of this fix; the inconsistency between them and Quick Stats resolves once the backfill brings Quick Stats in line. |
||
|
|
856b849ffa |
fix(stats): per-event aggregation so reprints add to Quick Stats instead of overwriting (#1378)
Statistics now aggregate over PrintLogEntry (one row per print event,
the same table backing the global Print Log) rather than PrintArchive
(one row per file). A reprint creates a new PrintLogEntry instead of
overwriting the source archive's runtime fields, so:
- a 100 g successful print + a 10 g failed reprint correctly sums to
110 g / 2 prints / 1 successful / 1 failed in Quick Stats and the
Prometheus /metrics endpoint (previously the failed reprint silently
replaced the source archive's data; totals dropped from 100 g to 10 g)
- the archive's card cost/energy_kwh are preserved on reprints (only
the first run writes them); per-run actuals live on PrintLogEntry
- failed/cancelled/stopped reprints record partial-aware filament: sum
of tracked spool deltas when inventory is set up, else estimate
scaled to progress%, else None — prevents the full slicer estimate
from inflating totals on a print that stopped at 10 % progress
PrintLogEntry gains six columns: archive_id (nullable FK, ON DELETE
SET NULL so log entries survive archive deletion preserving #1343
soft-delete-vs-stats decoupling), cost, energy_kwh, energy_cost,
failure_reason, created_by_id. Idempotent SQLite + Postgres migrations.
New per-archive surface:
- archive list response carries run_count / last_run_at /
total_filament_actual_grams / successful_run_count / failed_run_count
via a single batch JOIN, no N+1
- new GET /archives/{id}/runs endpoint returns every PrintLogEntry for
the archive (ARCHIVES_READ permission, newest-first ordering)
- archive cards render an orange "N prints" badge for archives with
more than one run; clicking the badge opens a dedicated PrintLogModal
with date/status/duration/filament/cost columns plus failure_reason
under failed runs. Also reachable via the context menu's new "Print
Log" entry (works for single-run archives too), and embedded at the
top of the Edit Archive modal for context.
The purge_stats=true delete path now hard-deletes linked PrintLogEntry
rows up front so the archive's contribution truly leaves the totals;
without it, ON DELETE SET NULL would orphan the runs and leave them
counting toward stats.
|
||
|
|
ae29a7dcd3 |
fix(api-keys): expose narrowly-scoped "Update electricity price" toggle (#1356)
Reporter @maziggy followed the Energy Tracking wiki literally - "create a
key with Write Settings permission, PATCH /api/v1/settings with
{energy_cost_per_kwh: ...}" - and hit:
{"detail":"API keys cannot be used for administrative operations"}.
Triage showed three independent drifts:
1. Wiki listed nine fictional API-key permissions (Read Printers / Write
Settings / Admin / ...) but the UI only ever exposed four toggles
(Read Status, Manage Queue, Control Printer, Allow Cloud Access).
There was no Write Settings toggle to tick.
2. Even if it had existed, the backend hard-denies SETTINGS_UPDATE for
every API key via _APIKEY_DENIED_PERMISSIONS - intentional protection
because PATCH /settings can rewrite SMTP/LDAP/MQTT credentials and the
HA access token. Wider surface than any documented use case needs.
3. So the wiki had been promising a workflow that was never deliverable.
Fix: introduce a narrowly-scoped door rather than relax the deny list.
- New column can_update_energy_cost (default FALSE - existing keys
never silently gain settings-write capability on upgrade).
- New route POST /api/v1/settings/electricity-price accepting
{"energy_cost_per_kwh": <float >= 0>}. Field name matches what the
wiki already documented so the HA rest_command example needs only a
URL+method change, not a payload change.
- Custom dependency require_energy_cost_update() bypasses
_APIKEY_DENIED_PERMISSIONS for this one route for API keys with the
flag set. JWT users still go through standard SETTINGS_UPDATE.
- General PATCH /settings remains denied for API keys - flipping the
narrow flag does NOT widen general settings-write access. Pinned by
test_patch_settings_still_denied_with_energy_flag.
Frontend: fifth "Update electricity price" toggle on the create-API-key
card + amber "Energy" badge on existing keys with the flag set. Three
new i18n keys across all 8 locales (German translated, English fallbacks
elsewhere).
|
||
|
|
9ba1e34729 |
fix(archives): keep Quick Stats contribution when deleting a print (#1343)
Reported by @IndividualGhost1905: printing the same model ten times and
then deleting nine archive entries (to keep the file list tidy) silently
rewound the totals on the Statistics page — total prints, filament,
cost, and per-print energy all dropped back to whatever the surviving
row contributed, as if the other nine prints had never happened.
Root cause: every metric in get_archive_stats is recomputed live from
PrintArchive rows via COUNT / SUM, so removing a row removes its
contribution. Energy in the default "Total" mode already survived
deletion because it reads the smart-plug lifetime counters — that's
the architectural shape we now generalise to the rest.
Fix: soft delete with opt-in hard purge.
Backend:
- New nullable, indexed deleted_at column on print_archives, dialect-
conditional migration (DATETIME on SQLite, TIMESTAMP on PostgreSQL).
- ArchiveService.soft_delete_archive flips deleted_at and removes the
files from disk (still reclaims storage); the path-safety checks were
extracted into _resolve_archive_dir_for_delete so soft and hard delete
share the rules.
- DELETE /archives/{id} accepts ?purge_stats=true; default is soft.
- Listings filter deleted_at IS NULL: list_archives, search FTS + LIKE
fallback, GET /{id} (404 on soft-deleted), tag listing, duplicate
detection (so a 1-live + 9-soft-deleted group no longer marks the
survivor as a duplicate), and ArchiveComparisonService's "similar"
suggestions. GET /stats and GET /slim deliberately do NOT filter so
Quick Stats and the dashboard widgets keep counting deleted prints.
Frontend:
- ConfirmModal gained an optional children slot.
- ArchivesPage (both card and detail views) own a per-instance
deletePurgeStats boolean and render an opt-in checkbox in the delete
dialog; resets to off on every close so the destructive option is
never sticky.
- api.deleteArchive(id, purgeStats?) appends ?purge_stats=true only
when the box is ticked.
- One new i18n key archives.modal.deletePurgeStats added across all 8
locales (full German, English fallbacks elsewhere).
|
||
|
|
8a7598f6b5 |
feat(auth): proxy OIDC provider icons server-side (#1333) (#1342)
* feat(auth): proxy OIDC provider icons server-side (#1333) Strict img-src CSP blocked external OIDC icon hosts on the login page. Loosening CSP was rejected via the MakerWorld precedent, so icons are proxied: admin sets icon_url, backend fetches and caches the bytes in a deferred BLOB column, the SPA renders from a same-origin /api/v1/auth/oidc/providers/{id}/icon endpoint. |
||
|
|
4d8dbc8336 |
fix(auth): cleanup orphan OIDC/MFA rows when user is deleted (#1285) (#1295)
fix(auth): cleanup orphan OIDC/MFA rows on user delete (#1285) Three User-FK tables (user_oidc_links, user_totp, user_otp_codes) declare ON DELETE CASCADE in their models, but SQLite ships with PRAGMA foreign_keys=OFF (the project's existing pattern, mirrored for APIKey in PR #1182). Without explicit DELETEs, deleting a user on SQLite leaves orphan rows behind: |
||
|
|
af52c4f2ff |
fix(spoolman): allow AMS-HT ams_id range in slot-assignment table (#1274)
H2C / H2D AMS-HT units report ams_id 128+ (one ams_id per unit, single tray), but spoolman_slot_assignments.ck_ams_id_range only admitted 0-7 and 255. Every attempt to link a Spoolman spool to an AMS-HT slot died with `CHECK constraint failed: ck_ams_id_range`. The internal spool_assignment table has no such constraint and works fine. Widen the formula to (0-7) OR (128-191) OR 255 in the model, the CREATE TABLE DDL, and an idempotent in-place migration for existing installs (Postgres: DROP/ADD CONSTRAINT; SQLite: detect stale formula in sqlite_master, rebuild via _v2 rename pattern). |
||
|
|
79d54a8d53 |
feat(archives): build-plate icon on cards + uniform printer/model line (#1253)
Show an OrcaSlicer-style bed icon in the archive card's printer-name row indicating which build plate the print was sliced for (Cool / Cool SuperTack / Engineering / High Temp / Textured PEI / Smooth PEI), with the full plate name in the hover tooltip. Closes the gap where users had to remember which plate matched a re-print or open the source 3MF in a slicer just to read the bed setting. Card row also unified: archives with a real Bambuddy-printer association used to render "H2D-1 GCODE ..." while slicer-only uploads rendered "Sliced for X1C GCODE ..." -- same line, two different shapes. Drop the "Sliced for " prefix so both render as a uniform "<name-or-model> [bed-icon] GCODE <hash>" row, scanning identically regardless of provenance. Backend: new bed_type column on print_archives (idempotent ALTER TABLE migration; SQLite + Postgres safe). Populated from curr_bed_type in Metadata/slice_info.config (per-plate, authoritative -- that's what got sent to the printer for the exported plate) with a fallback to project_settings.config for older 3MF shapes. Wired through both archive_to_response() (the hand-rolled dict converter that bypasses from_attributes -- easy to miss) and the /rescan endpoint, so old archives can be re-parsed via the existing per-archive Rescan button. Backfill script (scripts/backfill_archive_bed_type.py, --dry-run supported) re-opens every NULL archive's 3MF on disk to populate the column. Auto-loads .env from project root before importing backend modules (config.py reads DATABASE_URL from os.environ at import time, not from pydantic-settings at Settings() time) and prints the resolved DB URL with credentials redacted, so operators can confirm they're hitting the intended database -- Postgres or SQLite. Frontend: 6 OrcaSlicer-style PNGs ship in frontend/public/img/bed/ -- under /img/ because that path is already statically mounted; a toplevel /bed-icons/ tried first hit the SPA catch-all and returned index.html as text/html. New utils/bedType.ts maps slicer strings case-insensitively, covering both Bambu Studio and OrcaSlicer naming variants for the same physical plate. Unmapped or NULL bed_type simply omits the icon, so cards stay clean for pre-feature archives. |
||
|
|
f5ecc61cda |
fix(spoolbuddy): lower /update permission to INVENTORY_UPDATE so kiosk's own Settings -> Update button works
The kiosk's Settings -> Update Daemon button returned "API keys cannot
be used for administrative operations" because POST /spoolbuddy/devices/
{id}/update was gated on Permission.SETTINGS_UPDATE, and SETTINGS_UPDATE
is in the _APIKEY_DENIED_PERMISSIONS deny-list introduced by PR #1241.
Every kiosk-side request tripped the deny-list before the API key's
scope set (Read / Print Queue / Control / Legacy) was even consulted.
Same root cause as the four QuickMenu System buttons fixed in 0.2.4b3
(Restart Daemon / Restart Browser / Reboot / Shutdown). Missed /update
in that audit on the reasoning "replaces the daemon binary, different
threat surface" — but that's wrong: restart_daemon already replaces
the running daemon process, so daemon-replacement is not a step up in
blast radius. The SSH update is also strictly scoped to the one device
the operator physically controls (git fetch + pip install + systemctl
restart on that host) — same threat profile as the system commands
already running on INVENTORY_UPDATE.
Lower /spoolbuddy/devices/{id}/update from SETTINGS_UPDATE to
INVENTORY_UPDATE so it aligns with the rest of the kiosk-scoped routes
(calibration/tare, display, cancel-write, system/command,
system/command-result, update-status). The main Bambuddy in-app updater
at POST /api/v1/updates/apply keeps SETTINGS_UPDATE — that one runs on
the Bambuddy host and is correctly fenced behind the deny-list.
|
||
|
|
b30a283184 |
Feature/spoolman inventory UI (#1241)
feat(spoolman-inventory): squashed feature work for rebase onto dev Squashed all commits from feature/spoolman-inventory-ui onto a single commit to enable a clean rebase onto dev. Original per-commit history preserved at backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721. |
||
|
|
90743cfa39 |
feat(encryption): MFA at-rest encryption auto-bootstrap with status UI (#1219) (#1231)
chore(i18n): extend parity gate to all locales with strict/info tiers Previously the script only inspected en/zh-CN/zh-TW, leaving de/fr/it/ja/pt-BR drift invisible. Now locales are auto-discovered from src/i18n/locales/, and a STRICT list (de, zh-CN, zh-TW — currently in parity) gates CI while the rest report informationally until their drift is caught up. ja notably has 27 real placeholder bugs worth fixing before promotion to strict. |
||
|
|
dac2a31192 |
Revert "feat(inventory): unified Spoolman inventory UI + AMS slot assignments…" (#1232)
This reverts commit
|
||
|
|
55d71498e9 |
feat(inventory): unified Spoolman inventory UI + AMS slot assignments + Storage Location + NFC write support + Spoolman Filament Catalog Picker (#1114)
feat(spoolman-inventory): squashed feature work for rebase onto dev Squashed all commits from feature/spoolman-inventory-ui onto a single commit to enable a clean rebase onto dev. Original per-commit history preserved at backup tag backup/spoolman-inventory-ui-prerebase-20260507-105721. |
||
|
|
37c9d5f26d | [Feature] Add Stock forecasting and Logistics view (#1184) | ||
|
|
e45f967616 | feat(backup): Extend Backup to other Git providers (#1160) | ||
|
|
abc8e97050 |
feat(camera): optional snapshot URL override for external cameras (#1177)
go2rtc and several IP cameras still emit a warm-up / black frame on every fresh MJPEG connection — even with the v0.2.4b2 warm-up-skip fix it slipped through intermittently for @nkm8's setup. His own bisect named the clean solution: go2rtc exposes /api/frame.jpeg as a dedicated single-frame endpoint that never returns the encoder's stale keyframe. Adds an optional external_camera_snapshot_url column on printers. When set, every single-frame capture path (snapshot endpoint, [SNAPSHOT] notification thumbnails, [PHOTO-BG] finish photo, layer timelapse, Obico ML, plate-detect / calibrate-plate) routes through _capture_snapshot on the override URL via plain HTTP GET, bypassing the warm-up dance. Live view stays on the configured stream URL — only single-frame captures use the override. Override is camera-type-agnostic. SSRF guard applies (existing _sanitize_camera_url allowlist). Empty string treated as unset. Settings UI: new "Snapshot URL (optional)" input + Test button under External Cameras, hidden for camera_type=snapshot since the live URL is already a single-frame source. en + de fully translated; 6 other locales seeded with English copy. 5 backend tests pin the routing contract; 3 frontend tests pin the input + debounced PATCH. Documented in bambuddy-wiki/docs/features/camera.md with the go2rtc example. |
||
|
|
d0d0be89ea |
fix(oidc): use preferred_username/name claim for auto-created username (#1173) (#1176)
fix(oidc): use preferred_username/name claim for auto-created username When auto-creating an OIDC user without a valid email claim, derive the username from preferred_username or name IdP claims instead of falling back to the opaque provider_sub[:30]. |
||
|
|
459cfdc51f |
fix(virtual-printer): queue mode pins per-slot type+color so scheduler can match colour (#1188)
Edward's diagnosis was exact: the manual /print-queue/ POST extracts filament requirements from the 3MF and writes required_filament_types + filament_overrides + ams_mapping onto the queue item, but the VP queue-mode write path skipped all of that. Net effect: scheduler reached its model-only-matching fallback and auto-dispatched onto whatever printer was free regardless of loaded colour. Extract the scheduler's existing _get_filament_requirements 3MF parser into a shared helper so the VP path can reuse it. VP's _add_to_print_queue now populates required_filament_types unconditionally (cheap; helps the scheduler reject obvious type mismatches) and writes filament_overrides with force_color_match: true per consumed slot when a new per-VP queue_force_color_match toggle is on. Default off to preserve current behaviour for upgraders. UI: new toggle on VirtualPrinterCard, mode-gated to print_queue, mirroring the existing auto-dispatch toggle. i18n: en + de translated, other 6 locales seeded with English copy. Schema: one nullable column on virtual_printers (queue_force_color_match BOOLEAN, default 0/FALSE). 11 new backend tests (8 for the extracted parser, 3 for the VP write path) + 6 new frontend tests (toggle render gating, default state, click posts queue_force_color_match in update body). Existing scheduler tests pass against the refactored helper. README, CHANGELOG, website features page, and wiki virtual-printer page all updated. |
||
|
|
01a7e6ee93 |
fix(archive,vp): strip .gcode.3mf properly + sync review/archive name (#1152)
@smandon retested the original #1152 fix on the latest daily and surfaced two distinct holes: 1. ``Path(name).stem`` only strips the *last* suffix, so Bambu Studio's default ``Plate_1.gcode.3mf`` exports landed in the archive UI as ``Plate_1.gcode`` — never the bare ``Plate_1`` the user expected. 2. The pending-uploads review card always showed the raw FTP filename, while the eventual ``PrintArchive.print_name`` resolved from the 3MF's embedded title (or, with the toggle on ``filename``, the stripped stem). Net effect: same upload showed two different names depending on which view you were looking at, with no way for the toggle to flip both views in lockstep. Three changes: - ``resolve_display_stem`` helper in ``services/archive.py`` strips ``.gcode.3mf`` / ``.3mf`` / ``.gcode`` (case-insensitive). Applied at the archive-creation site so ``Plate_1.gcode.3mf`` → ``Plate_1`` for every flow that produces a ``PrintArchive`` row. - ``PendingUpload.metadata_print_name`` (new nullable column) is populated at FTP-receive time by peeking at the 3MF's embedded title via the existing ``ThreeMFParser``. Read happens once per upload — the list endpoint then doesn't have to reopen each 3MF on every render. Parser failures are swallowed and the column stays NULL; the response model gracefully falls back to the stripped filename. - ``PendingUploadResponse.display_name`` is a computed field that mirrors ``archive_print``'s exact precedence — ``filename`` toggle → stripped stem; ``metadata`` toggle (default) → cached title or stripped stem. The frontend's review card reads it (with ``upload.filename`` as a defensive fallback) and surfaces the raw FTP filename via tooltip so users can still inspect what arrived. Migration is one idempotent ``ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)`` (Postgres/SQLite-safe). Pre-migration rows have NULL and degrade to filename-stem behaviour without any operator action. Tests: 14 unit tests in ``test_archive_display_stem.py`` covering the canonical normalisation rules (Bambu Studio default name, mixed case, dots-in-the-middle, edge cases like ``.gcode.3mf``-only, full-path inputs); 6 integration tests in ``test_pending_upload_display_name.py`` pinning the response contract (default toggle uses metadata title when present, falls back to stripped stem when absent, ``filename`` toggle overrides metadata, ``filename`` toggle still strips the double suffix, ``GET /{id}`` exposes the same field, whitespace-only metadata behaves like absent); 3 frontend tests in ``PendingUploadsPanel.test.tsx`` pinning the review card's render path (resolved name shown, fallback to filename when display_name is empty, raw filename available via tooltip). Full backend suite: 3598 passed; frontend build clean; no regressions in any flow that previously processed ``.3mf`` / ``.gcode`` / non-3D filenames. |
||
|
|
133ec72527 |
feat(api-keys): per-user ownership + opt-in cloud access scope (#1182)
Tim (@turulix) is building a fully automated headless slicing pipeline against Bambuddy's API and hit the wall flagged in #665: /cloud/* routes resolve cloud_token per-user from User.cloud_token, but the auth gate returned None for API-keyed requests, so the route fell back to the global Settings-table token, which only carries a value in auth-disabled deployments. Net effect on auth-enabled deployments: API keys reached the gate just fine, then /cloud/filaments always saw user=None and returned 401 / empty results — no path to read slicer presets or the filament catalogue that a CLI workflow needs. Make API keys carry an owner and route /cloud/* lookups through that owner; gate the new capability behind an explicit opt-in scope so existing automation doesn't gain cloud-read access on upgrade. - APIKey gains user_id (FK to users.id, ON DELETE CASCADE) and can_access_cloud (BOOLEAN DEFAULT 0). User-delete route also runs an explicit DELETE FROM api_keys WHERE user_id = ? since SQLite ships FK enforcement off — same pattern as the existing created_by_id cleanup blocks. - New cloud_caller dep on /cloud/* routes resolves to the JWT user OR the API-key owner stashed by a router-level gate. The auth gate itself continues to return None for API keys so #1182's surface stays bounded to /cloud/* — without that bound, any route that fences API keys via `if current_user is None: raise 403` (e.g. long-lived-token management) would silently start accepting them. - The /cloud/* router-level dep enforces three independent fences for API-keyed callers: user_id IS NOT NULL (legacy keys → 401 with recreate copy), can_access_cloud=True (otherwise 403), and owner has cloud_token (existing fence, unchanged). Two extra one-shot fence errors at create/update time refuse can_access_cloud=True when auth is disabled or the key is ownerless. - Frontend: APIKey list shows "Cloud" badge on cloud-enabled keys and "Legacy" badge on ownerless rows; create form gains an "Allow cloud access" toggle, default off. New i18n keys in all 8 locales (en + de fully translated, others seeded with English fallbacks pending native translation — matches the project's flow for newly-added features). Migration: two idempotent ALTER TABLE statements + an index on user_id for the auth gate's owner→keys lookup. Postgres-safe. Tests: 9 backend integration tests in test_api_key_cloud_access.py covering creation flags, the three /cloud/* fences, JWT no-op, and deletion CASCADE; 2 frontend SettingsPage tests pinning the badge matrix and the create-form contract; 5 daemon unit tests for the related SpoolBuddy ssh-key sync work that landed in the same branch. Full backend suite: 3578 passed; full frontend suite: 1597 passed; no regressions. Permission semantics for existing keys: keys created before this release become "legacy" and are rejected at /cloud/* with the recreate message. Every other endpoint they were used against — queue, status, control — is untouched. |
||
|
|
a34beaa599 |
feat(inventory): multi-colour gradients, transparency, visual effects (#1154)
Spool and color_catalog rows carry extra_colors (comma-separated hex stops) and effect_type (14 visual variants: surface effects, sheen, structural). The shared FilamentSwatch component renders gradient, conic, effect overlay, and alpha-checkerboard consistently across the inventory grid, table, group banner, card, ColorSection preview, and catalog editor. Catalog hex_color accepts #RRGGBBAA so catalog entries can carry transparency too. The paste field accepts the exact format 3dfilamentprofiles.com puts on its filament details pages, so users can copy a multi-colour combo directly. The effect dropdown spans the full filament-variant vocabulary -- surface effects (sparkle/wood/marble/glow/matte), sheen variants (silk/galaxy/rainbow/metal/translucent), and structural variants (gradient/dual-color/tri-color/multicolor). None of these fields touch MQTT/firmware -- pure visual hint. Spool group-key extended to include extra_colors + effect_type so "Group similar" no longer collapses visually distinct spools. Migrations: 4 idempotent ALTER TABLE ADD COLUMN (Postgres-safe), plus ALTER COLUMN hex_color TYPE VARCHAR(9) on Postgres only (SQLite ignores VARCHAR length). Tests: 42 new backend (35 unit + 7 integration), 20 new frontend (14 FilamentSwatch + 3 ColorCatalogSettings + 3 InventoryPageGrouping regression). 3522 backend + 1582 frontend tests pass; ruff clean. Localised across all 8 UI locales. |
||
|
|
57af8a1c19 |
feat(projects): URL field + cover photo on project cards (#1155)
Two new project fields: a free-text URL rendered as a one-click
external-link button beside the project name on every card (opens in a
new tab, click is e.stopPropagation()-guarded so it doesn't enter the
project), and a cover photo that replaces the status-icon box with a
square thumbnail.
URL is plumbed through ProjectCreate/Update/Response/ListResponse,
including from-template + create-template flows so it inherits between
a project and its template. Cover photo is not inherited because the
file would be shared on disk between source and copy.
Schema validator rejects anything other than http:// or https://
prefixes -- <a href> rendering would otherwise execute javascript:
/ data: / file: URLs even with React's default escaping. PATCH uses
model_fields_set for the URL field so users can clear it by sending
{"url": null}.
Cover image storage: Project.cover_image_filename references a file
Cover image storage: Project.cover_image_filename references a file
inside the existing archives/projects/{id}/attachments/ dir, but it's
tracked separately from the attachments JSON list so swap/delete on
the cover doesn't perturb the user's other attachments. Three routes
(POST/GET/DELETE /projects/{id}/cover-image) accept only .jpg/.jpeg/
.png/.gif/.webp (no SVG -- SVG can carry script payloads), replace in
place (prior file deleted before the new one lands so repeat uploads
can't accumulate orphans), and self-heal when a DB reference points at
a vanished disk file by clearing the column and 404'ing.
GET cover-image is gated by RequireCameraStreamTokenIfAuthEnabled
(accepts ?token=... query string) -- not the bearer-token gate -- so
<img src> requests work in both auth-on and auth-off configurations.
The frontend wraps getProjectCoverImageUrl with withStreamToken(),
matching the existing pattern from getArchiveThumbnail.
Permissions: PROJECTS_UPDATE for upload/delete/PATCH, PROJECTS_READ
gate is implicit via the stream-token credential. Migration: 2
idempotent ALTER TABLE projects ADD COLUMN. Localised across all 8
UI languages.
|
||
|
|
78408856cd |
fix(oidc): Allow auto_link_existing_accounts with custom email claims (Azure Entra ID) (#1142)
chore(i18n): extend parity gate to all locales with strict/info tiers |
||
|
|
9884018497 |
fix: cancel-safe get_db + drop sqlalchemy.pool cancellation noise
@Carter3DP's support package showed bambuddy.log filling with two
distinct cascades on long uploads:
ERROR sqlalchemy.pool Exception terminating connection ...
CancelledError: Cancelled via cancel scope
... by starlette.middleware.base
.BaseHTTPMiddleware.__call__.call_next
ERROR sqlalchemy.pool The garbage collector is trying to clean up
non-checked-in connection ... will be
terminated.
WARN backend.app.main Runtime tracking commit failed:
(sqlite3.OperationalError) database is locked
Single root cause. Starlette's BaseHTTPMiddleware (used under the hood
by every @app.middleware("http") decorator) cancels the inner task
scope when a client disconnects mid-request — common on long
multipart uploads where the client times out before the server's
response. Pre-fix get_db only caught Exception, but CancelledError
is BaseException, so cancellation skipped the rollback path entirely.
The SQLite write lock stayed held until GC reclaimed the connection
ages later, blocking every other writer in the meantime. On Postgres
the leak shape is identical; the symptom would be "QueuePool limit
... overflow" instead of "database is locked".
(1) get_db now catches BaseException so CancelledError triggers
rollback. Both rollback() and close() are wrapped in
asyncio.shield so the cleanup completes even when the await
itself is being cancelled by the same cancel scope. SQLite write
lock is released promptly; connection returns to the pool instead
of leaking until GC.
(2) CancelledPoolNoiseFilter (new filter on sqlalchemy.pool) drops
the residual records that pre-existing pools still emit during
their own cleanup. Two patterns suppressed:
- "Exception terminating connection ..." with a CancelledError
anywhere in the exc_info chain (walks __cause__/__context__
with a seen-set guard against pathological cycles)
- "The garbage collector is trying to clean up non-checked-in
connection ..." (always symptomatic of cancellation; never
independently actionable)
Real pool problems — broken connections, OSError on terminate,
pool exhaustion — keep flowing because they carry a different
exception chain or a different message prefix.
13 regression tests across test_get_db_cancel_safety.py (commit on
clean exit, rollback on regular Exception, rollback on CancelledError,
close runs even if rollback raises, close failure on clean exit
doesn't propagate, rollback + close both go through asyncio.shield)
and test_cancelled_pool_filter.py (drops cancellation-driven
terminate, drops GC-cleanup, keeps real OSError terminate, keeps
terminate without exc_info, keeps unrelated pool messages, drops
chained-cause CancelledError, defensive guard against self-referential
cause chains).
Applies to SQLite and PostgreSQL — get_db is dialect-agnostic and
the filtered messages come from base sqlalchemy.pool not from any
specific dialect.
|
||
|
|
fdaec47378 |
feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1126)
feat(oidc): add Azure Entra ID support with configurable email claim resolution Adds two new OIDC provider fields: email_claim and require_email_verified. |
||
|
|
4304a42542 |
feat(#729): per-spool category + low-stock threshold override
Two new optional fields on Spool: free-text `category` (max 50) and `low_stock_threshold_pct` (1-99). Powers the "differentiate critical spools from prototype spools and alert at different thresholds" use case from #729 without taking on the full multi-tag taxonomy + auto- apply rules + per-tag alert system the ticket originally proposed. Form gains: - Category input with datalist autocomplete sourced from categories already in use, so casing/spelling stays consistent. - Per-spool low-stock threshold input. Empty = global default; the global value renders as the placeholder. Inventory page: - New category filter chip (hidden until at least one spool carries a category — keeps the chip row uncluttered). - Stat-card "Low Stock" count and the "Low Stock" filter both honour the per-spool override. Plus: rename "Delete Tag" button to "Clear RFID Tag" (the original ticket reporter mistook it for a taxonomy-tag delete; the button actually clears the RFID UID/UUID off the spool record). Toast key renamed from `tagDeleted` to `rfidCleared`. i18n: full translations across all 8 locales. Tests: 9 new backend schema tests (defaults, partial-update, range rejection, max-length); 2 new frontend tests (per-spool threshold pulls extra spools into low-stock count, filter chip hidden when no categories exist). |
||
|
|
12c01f029d |
Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)"
This reverts commit
|
||
|
|
50382006b3 |
feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1118)
feat(oidc): add Azure Entra ID support with configurable email claim resolution |
||
|
|
fcda728af4 |
feat(#1108): long-lived camera-stream tokens + fix(#1089) audit-pass tweaks
#1108 — Long-lived camera-stream tokens for HA / Frigate / kiosks. Camera-only V1, hard 365-day cap (no infinite tokens), pbkdf2 hashed at rest, plaintext shown to user exactly once on creation. New "Camera API Tokens" panel under Settings → API Keys with self-service create/revoke, styled confirm modal, admin "All users" view for leak triage. Auth path: /camera/stream tries the existing 60-min ephemeral table first, falls through to the long-lived path. Indexed lookup_prefix keeps verify O(1) per token. Permission audit: gated the existing API-keys-CRUD + Webhook docs + API Browser content behind api_keys:read so non-admins with camera:view land on the API Keys tab and see only the Camera Tokens panel they actually have permission to use. Grid layout collapses to single column for non-admins. Tests: 29 new backend (15 service + 14 integration covering create/list/ revoke ownership rules, the auth fall-through, scope enforcement, prefix collisions) + 6 new frontend tests for the section UI including the new modal flow. All 77 backend tests + 21 frontend camera tests pass. Ruff clean (lint + format). Docs: README updated with fan-out + long-lived-token bullets. Wiki gets a new "Long-Lived Camera Tokens" section under features/camera.md (HA YAML example, security model, permission requirements, revoke flow). Website features.html gets the bullet under Camera Streaming. Also includes #1089 follow-up tweaks already merged in this branch: _stream_start_times.setdefault for accurate stream_uptime, subscribe() RuntimeError retry to close the grace-vs-subscribe race, atomic unsubscribe count via the iter_subscriber on_unsubscribe callback. |
||
|
|
7f11618e1e |
Revert "feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)"
This reverts commit
|
||
|
|
365c38483b |
feat(oidc): Azure Entra ID support — configurable email claim & verification + Remember Me persistent login (#1103)
feat(oidc): add Azure Entra ID support with configurable email claim resolution fix(oidc): harden email claim resolution, guards, and test coverage |
||
|
|
9e938cbc8c |
Revert "feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)"
This reverts commit
|
||
|
|
89f14c57ad |
feat(inventory): unified Spoolman inventory UI + Storage Location + AMS deep-link + SpoolBuddy NFC write support (#1063)
feat(inventory): replace Spoolman iframe with internal inventory UI When Spoolman is enabled, the Inventory page now uses the same internal UI (spool list, create/edit modal, archive, delete, weight sync) backed by a new proxy layer instead of opening an iframe. |
||
|
|
b99ceb26ed |
fix(db): dedupe legacy settings rows and add missing UNIQUE(key) index
Legacy SQLite installs created the `settings` table without a UNIQUE constraint on `key`. The seed loop's `INSERT OR IGNORE` silently degraded to a plain INSERT, so every `systemctl restart` added another row of `advanced_auth_enabled` / `smtp_auth_enabled`. After a handful of restarts, `scalar_one_or_none()` in is_advanced_auth_enabled() and similar sites blew up with `MultipleResultsFound`, 500'ing the login flow. Run-migrations now deletes dup rows (keeping MIN(id) per key) and creates the missing `ix_settings_key` unique index before the seed loop. Both ops are idempotent — fresh installs and Postgres already have the index, so they no-op. |
||
|
|
e8f252d2b8 | Post work PR #701 | ||
|
|
91a3d391ff |
feat(virtual-printer): add Tailscale opt-out toggle (closes #701 point 3) (#1070)
* feat(virtual-printer): add Tailscale certificate provisioning |
||
|
|
bf511c54cd |
feat(#1008): archive auto-purge + dedicated archives:purge permission
Adds an archive counterpart to the library trash sweeper shipped in the
previous commit. Unlike the library flow, archives are hard-deleted —
print history is a decaying timeline, so there is no trash intermediate;
download or favourite anything you want to keep first.
Backend
- New ArchivePurgeService (backend/app/services/archive_purge.py) with
its own 15-minute scheduler loop and a 24h throttle on actual purge
runs. Delegates every delete to the existing safety-checked
ArchiveService.delete_archive so the 3MF, thumbnail, timelapse, source
3MF, F3D, and photo folder all get cleaned up together with the DB
row. Per-row session via async_session() avoids commit-per-row churn
on any caller-passed session.
- New /archives/purge/{preview,settings} + POST /archives/purge routes
gated on a dedicated archives:purge permission (not archives:delete_all)
so admins can delegate bulk-delete to a role without granting
per-archive delete on other users' rows.
- seed_default_groups() now backfills both library:purge and
archives:purge on the Administrators group for upgraded installs —
the original library:purge was added after Administrators was first
seeded so the "create if not exists" path skipped existing DBs and
left admins without the permission.
- 8 new integration tests (defaults, settings roundtrip, bound
validation, preview, manual purge, auto-purge enabled path, 24h
throttle, disabled skip).
Frontend
- Settings → Archives card gains an auto-purge toggle + age input (7d
floor, 10y ceiling, 365d default), with a save-toast on every change.
The bulk "Purge old" button lives on the Archives page header
(rightmost, after Upload 3MF) to match the File Manager pattern —
configuration in Settings, one-shot action on the page.
- New PurgeArchivesModal mirrors PurgeOldFilesModal: live preview (count
+ total size freed + sample filenames) debounced at 300ms, amber
"hard-delete, no undo" warning.
- Admin-only UI gates on archives:purge via the standard hasPermission
hook; Permission TS union updated.
- i18n blocks across all 8 locales (en/de full, other 6 English
fallback per project convention).
Docs
- CHANGELOG entry under 0.2.4b1 following the existing library-trash
entry.
- bambuddy-wiki archiving.md gains a new "Auto-Purge" section.
- bambuddy-website features.html gets a matching bullet.
Verification: python -m ruff check backend/app/ clean; 25 integration
tests pass (8 archive_purge + 17 library_trash regression); npm run
build clean.
|
||
|
|
e0e597271e |
● feat(#1008): library trash bin, admin bulk purge, auto-purge setting
Library files now move to a configurable-retention trash bin on delete
instead of being hard-deleted from disk (default 30 days). Admins get a
"Purge old" bulk action on the File Manager with a live preview, plus an
optional auto-purge setting in Settings → File Manager that runs the same
operation once per 24h when enabled (default off). Regular users see and
manage their own trashed files; admins see everyone's. External (linked)
files bypass trash since their bytes aren't under Bambuddy's control.
- New `library:purge` permission (admin-only by default)
- Nullable indexed `deleted_at` column on library_files; dialect-aware
ALTER TABLE so the column actually gets added on PostgreSQL (raw
DATETIME is SQLite-only syntax)
- New `LibraryFile.active()` classmethod; every query site routed through
it so trashed rows don't leak into listings, print dispatch, MakerWorld
dedupe, or stats
- Trash page: select-all + bulk restore/delete, per-row checkboxes, wider
layout so datetime columns don't clip
- Auto-purge: 24h throttle via `library_auto_purge_last_run` setting so
the 15-minute sweeper cadence still runs the purge at most once per day
- Save toast wired into every trash/auto-purge setting change
- 17 new backend integration tests (service + routes + auto-purge throttle),
8 new frontend tests, localised across all 8 UI languages
- Wiki + website feature entries updated
|
||
|
|
5da403ba0c |
Feature/makerworld (#1099)
* feat(makerworld): URL-paste import and print for MakerWorld models
Add a dedicated /makerworld sidebar page where users paste a MakerWorld
model URL and get the full plate list + one-click "Import to Library" or
"Print Now". Closes the workflow gap that kept LAN-only users on the
Bambu Handy app solely for MakerWorld download-and-send.
The authenticated tier reuses the existing Bambu Cloud token that
Bambuddy already stores for firmware checks and slicer settings --
MakerWorld shares the same auth backend, so the same JWT works there.
No separate OAuth flow, no companion browser extension, no credential
hijack. Anonymous users can still paste a URL and see model metadata;
the 3MF download itself requires the Cloud login.
Print Now hands off to the existing PrintModal (plate picker + AMS
mapping + dispatch) so multi-filament models work via the same code
path as library-file prints. Imported 3MFs are stored through a new
shared save_3mf_bytes_to_library() helper so the multipart upload
route and the MakerWorld import route don't duplicate 3MF parsing +
thumbnail extraction logic.
LibraryFile gains indexed source_type + source_url columns. Re-pasting
a URL for a model already in the library returns the existing row
instead of re-downloading -- dedupe is by canonicalised URL, not SHA256,
because MakerWorld's download URLs are signed and change per request.
Thumbnail proxy (/makerworld/thumbnail) hot-links through the backend
instead of directly to makerworld.bblmw.com -- the SPA's img-src CSP
stays strict and users' IPs don't hit MakerWorld's CDN logs. The
endpoint is intentionally unauthenticated since <img> tags can't carry
a Bearer token; SSRF-guarded by a CDN host allowlist so it can't be
used as a generic proxy.
Search and browse-catalogue are explicitly out of scope. The public
design/search endpoint returns empty results from server-originated
requests (likely needs csrf/session state reproducible only from a
real browser), and the __NEXT_DATA__ HTML fallback is blocked by
Cloudflare. URL-paste covers the realistic discovery pattern (Reddit /
YouTube / shared links).
Headers match kloshi-io/makerworld-api-reverse's production-tested set
(User-Agent: 3d-printing-service/1.0, x-bbl-* client identifiers,
Referer). The /instance/{id}/f3mf call includes ?type=download which
community userscripts use to signal legitimate download intent. 418
responses (MakerWorld's CAPTCHA gate) retry once with backoff and then
surface a clear actionable error with an "Open on MakerWorld" fallback
link; we never try to evade bot detection.
Permissions: new makerworld:view (browse metadata, view thumbnails) and
makerworld:import (save 3MFs to library). Administrators and Operators
get both; Viewers get view-only. Migration grants these to existing
groups based on whether they already have library:upload / library:read.
Disclaimer in the UI and wiki page mirrors kloshi's framing: not
affiliated with or endorsed by MakerWorld or Bambu Lab, interoperability
only, not intended to circumvent access controls.
Tests: 30 backend (service + routes) + 4 frontend. Full backend suite
(1931 tests) clean. Frontend build clean.
* feat(makerworld): ship working URL-paste import via api.bambulab.com iot-service
The MakerWorld integration shipped in 0.2.4b1 dev was broken for most
public models: the makerworld.com/design-service path returns "Please
log in to download models" even with a valid Bambu Cloud bearer,
because it's cookie-gated behind Cloudflare. Published reverse-
engineering projects work around this by pasting browser cookies; we
route around it entirely by using the api.bambulab.com/iot-service
endpoint (documented by Pr0zak/YASTL#51), which accepts the same
bearer Bambuddy already has and returns a presigned S3 URL.
Working flow:
GET api.bambulab.com/v1/design-service/design/{id} → metadata
GET api.bambulab.com/v1/iot-service/api/user/profile/{pid}?model_id=<str>
Authorization: Bearer {cloud_token} → signed S3 URL
urllib.request (no redirects, no query re-encoding) → bytes
Notes on each step:
- The model_id query param is the alphanumeric string from the
design response (e.g. US2bb73b106683e5), NOT the integer designId
from the /models/{N} URL. The import route fetches design metadata
first to get it.
- S3 presigned URLs MUST be fetched with urllib (not httpx/curl_cffi)
because the signature is computed over exact query-string bytes;
any normalising encoder breaks it with SignatureDoesNotMatch 400s
(YASTL#52 hit the same issue). Wrapped in a no-redirect opener so
the .amazonaws.com host allowlist guarantee isn't bypassed by a
302 elsewhere.
- The canonical source_url now includes profile_id so different
plates of the same model get distinct library entries. Older rows
from dev builds keep the model-level URL; the resolve endpoint's
"already imported" check LIKEs both shapes.
UI rebuild:
- Per-plate Save + Save & Slice in Bambu Studio / OrcaSlicer (the
plate is unsliced source, so "Print Now" was misleading and is
replaced by an explicit slicer hand-off).
- Import all plates with sequential progress.
- Folder picker (default: auto-created top-level "MakerWorld"
folder, created on first import, folder tree invalidated so
File Manager shows it immediately).
- Image gallery per plate with keyboard-navigable lightbox.
- Recent imports sidebar (sticky on lg+, vertical list with
jump-to-library / slicer / open-on-makerworld icons).
- Inline follow-up actions on imported plate rows so the user
doesn't scroll back to a top-of-page card.
- Per-plate delete via the standard ConfirmModal (no window.confirm).
- Elapsed-time + phase label during import so the 10-30s synchronous
POST doesn't feel frozen.
- URL-change detection drops the preview when the pasted URL
diverges from the resolved one.
Security hardening (found in review):
- DOMPurify.sanitize on the MakerWorld HTML summary before
dangerouslySetInnerHTML (user-authored content).
- <img> tags in that HTML routed through the thumbnail proxy so
the SPA's img-src 'self' data: blob: CSP isn't widened.
- /makerworld/thumbnail uses follow_redirects=False (the host
allowlist only covers the initial URL).
- 3MF CDN fetch strips the bearer (signed URL is the credential).
- S3 fetch uses a no-op HTTPRedirectHandler for the same reason.
- Upstream filename is os.path.basename'd before persisting.
Tests: 46 backend service unit tests, 19 route tests, 12 frontend
tests — all passing. All user-facing strings localised across the
8 UI languages.
* - frontend/src/App.tsx — removed the 3 stale <AdminRoute> lines (kept the 3 <PermissionRoute> equivalents). TSC + Vite both clean.
- backend/tests/integration/test_auth_api.py — added # pragma: allowlist secret + # noqa: S106 on the test fixture line that GitGuardian flagged.
|
||
|
|
baf0716a9a |
feat(cloud): support China region for token-based login (#1013)
feat(cloud): support China region for token-based login The /cloud/token endpoint always used the global Bambu API endpoint, so users with China-region access tokens could not validate their token. The password login flow already exposes a region selector; this brings the token flow to parity. |