Commit graph

3705 commits

Author SHA1 Message Date
maziggy
f421bb8160 Show the picked preset's real values in the process-settings panel
The panel baselined every field on the option schema's compiled-in
defaults, so a preset setting a 0.42mm line width displayed 0 -- the C++
default meaning "derive from the nozzle". Every field was affected; the
Line width group just made it obvious.

Bambuddy cannot answer this itself. A standard-tier pick is only an
{inherits: ...} stub on our side, and local/cloud presets are deltas whose
remainder lives in the profile tree bundled inside the running sidecar.
The values now come from the sidecar's POST /profiles/resolve, which runs
the same resolver /slice does against the same profiles, so what the panel
shows cannot disagree with what a slice produces. Deliberately not the
local orca_profiles resolver: it walks OrcaSlicer's published tree, which
can differ from the image actually installed.

An untouched field shows the preset's value and reverting returns to it.
isModified compares against that baseline too, so fields the preset moved
off the C++ default are no longer flagged as user edits, and values nobody
typed are no longer sent. When the values can't be read -- sidecar offline
or older than the endpoint -- the panel falls back to schema defaults and
says so rather than presenting them as the preset's.

Row layout, from screenshots:

- The control column is anchored to the right edge at a fixed width. It
  had been packed left after a fixed label column, leaving the values
  stranded mid-container with dead space beside them.
- Units are no longer truncated to "mm o...". The cap fitted the common
  "mm" but not "mm or %" or "mm/s² or %".
- The "from file" tick moved ahead of the control it qualifies; it used to
  sit past the unit at the row's right edge, reading as unrelated.

Both the unit and the control keep fixed widths, and the tick's slot is
reserved on rows without one -- sizing any of them to content makes each
row's input land at a different x and the column comes out ragged.

Also fixes a field that could not be cleared: emptying a free-text input
dropped the key, so it snapped back to the baseline and retyping appended
to it ("0.42" + "0.5" = "0.420.5"). The number branch was fixed earlier;
the text branch -- coFloatOrPercent, coString, the vector types -- was
not, and the regression test used a number input so it never caught it.

Requires a sidecar built from orca-slicer-api 4b664b7 or later. Older
images 404 the endpoint, which is handled as the fallback above.
2026-08-09 11:02:03 +02:00
maziggy
c384911f7c Sync 2026-08-09 10:00:53 +02:00
maziggy
2368445378 Changed layout 2026-08-08 16:51:19 +02:00
maziggy
f0500578bd Edit the full print-parameter set from the slice dialog
Slicing from Bambuddy meant taking a process preset as-is; any change
meant a round trip through Bambu Studio. The slice dialog now carries
OrcaSlicer's full process tree -- pages, groups, labels, tooltips,
ranges and defaults extracted from the slicer's own sources.

Enable/disable rules are evaluated from the slicer's own enable_if
expressions via a recursive-descent interpreter (no eval, CSP), with
enum comparisons validated against each option's declared values.
Anything undecidable leaves the field editable rather than greyed.

Overrides apply after the source's support config (#1881) and the
designer's carried tweaks (#2622), so an explicit choice always wins;
an untouched panel sends the same request as before.

Adds slice_engine as a separate setting from preferred_slicer -- where
slicing runs is a different axis from which binary the sidecar drives.
Only the sidecar engine is registered, so no picker renders yet.
2026-08-08 16:33:41 +02:00
maziggy
1748d7cefa Updated .github/workflows/windows-installer.yml 2026-08-08 14:12:13 +02:00
maziggy
287ee05596 Removed unused test backend/tests/unit/test_finance_table_migration.py 2026-08-08 13:37:52 +02:00
maziggy
c8e5ecc23a chore(deps): clear every npm audit and pip-audit finding
Frontend:
- react-router/-dom 7.18.1 -> 7.18.2. The RSC-mode CSRF advisory was carried
  as a documented exception in the audit gate because its only fix was the
  8.3.0 major; upstream backported it, so the exemption lapsed on its own --
  an entry only holds while fixAvailable.isSemVerMajor is true. The allowlist
  is now empty; the machinery stays for the next one.
- dompurify 3.4.12 -> 3.4.13. Ships in the app, but the path is unreachable:
  no hooks registered, IN_PLACE never used.
- js-yaml override ^4.3.0 -> ^5.2.3 (fix not backported below 5.x, so a
  major) and nanoid override ^3.3.18. Both dev-only, via eslint and postcss.
  eslintrc calls only load(), on the legacy .eslintrc.yml path this repo does
  not use; eslint, vite build and 2861 frontend tests pass on it.

Backend:
- cryptography >=48.0.1 -> >=50.0.0, aiohttp >=3.14.0 -> >=3.14.3, pyopenssl
  >=26.3.0 -> >=26.4.0. CI resolves from scratch and was already installing
  the fixed releases; the floors cover the case CI does not, an existing venv
  where >= is satisfied and `pip install -r` upgrades nothing. pyOpenSSL has
  to move with cryptography -- each release caps it to a narrow window, so a
  stale pyOpenSSL pins cryptography below its own fix line.
2026-08-08 13:20:18 +02:00
maziggy
73eec29358 Updated BACKERS 2026-08-08 12:45:07 +02:00
maziggy
328bac450a Stop auto-drying re-arming into a threshold it can never reach (#2770)
An H2D armed five 12-hour drying cycles inside four hours, one of them six
seconds after the previous one ended, and none ran more than a couple of
hours.

Two things combine. The firmware ends a cycle when it decides the filament
is dry rather than when the clock runs out, and reports no fault doing it --
across this printer's history the run length tracks how wet the spools were,
from nearly the full 12 hours starting at 32% down to minutes once the unit
sat at 10-13%. That part is the AMS doing its job.

The loop is ours. An AMS reports higher relative humidity while it is warm
than once it has cooled: the same unit read 10-13% cold and 15-20% through
every cycle. With the threshold at 14% the reading at the moment a cycle
ended was always still above it, so the next 30-second pass armed another
12-hour cycle. Nothing counted, nothing waited, and it only stopped when the
box finally cooled enough to read 13%.

Auto-drying now waits 30 minutes after a cycle ends before arming another on
the same unit, and gives up on a unit after two consecutive cycles that
bring the reading no lower -- logging why and sending a new notification,
on by default because it reports that Bambuddy has stopped acting. Progress
is judged against the lowest reading any cycle on that unit has ended at,
not against the threshold, so a genuinely wet spool in a humid room coming
down 40-37-35 keeps drying however far it still is from the target;
comparing against the best so far rather than the previous end stops a
sensor wobbling by one point reading as progress every other cycle. The
suspension lifts by itself once the reading falls below the threshold.

Neither guard can stop a running cycle, and a cycle Bambuddy cut short for a
print, or that the user stopped by hand, is not counted against the unit --
so a farm that dries between queue jobs is unaffected. The threshold field
now warns below 20%, and every cycle end logs the unit's temperature and
humidity, which is what made this diagnosable.

The same bundle showed unrelated tasks failing with "database is locked",
each inside a 30.000-second Discord connect timeout. Alarms are raised from
inside the loop that records sensor history, at a point where the new rows
are added but not committed; the first read in the notification path flushed
them to satisfy itself, opening a write transaction, and the provider was
then contacted over the network with that transaction still open. SQLite
allows one writer and 30 seconds outlives the 15-second busy timeout, so
every other write in that window failed. The two reads that run before a
provider is contacted no longer flush the caller's pending work, and the
connect timeout is 5 seconds rather than 30 -- the body keeps the full 30,
so image uploads on a slow uplink are unaffected. SQLite only; Postgres has
no single-writer limit.
2026-08-08 12:40:17 +02:00
maziggy
04009a5c6a Post work PR #1448 2026-08-08 11:42:44 +02:00
MartinNYHC
3cd538acb9
Merge pull request #1448 from behrinml/feature/billing
[Feature] Cost Centers and Billing Functionality
2026-08-08 11:20:44 +02:00
maziggy
bf525661d3 Three fixes on top of the billing branch, all found by running the suite against
both dialects rather than one.

Postgres upgrades never got as far as the finance schema.

  database.py added on_billing_charge_failed with BOOLEAN DEFAULT 1. The 1 is a
  SQLite-ism; Postgres answers DatatypeMismatchError, and _safe_execute
  deliberately re-raises anything that is not an idempotency error, so
  run_migrations died there and rolled the whole transaction back. No finance
  tables, no columns, and the app does not start. Six lines above, the same
  change gets is_voided right with an is_sqlite() branch, so this was an
  oversight rather than a decision. Now branched the same way.

  This also explains the four test_security.py::TestBackupKeyFiles failures
  reporting "column print_archives.cost_center_id does not exist". That column's
  migration exists and works -- it simply never ran, because every startup
  aborted before committing. Reproduced against Postgres 16 by building a
  pre-billing schema from dev and upgrading over it: fails without this,
  completes with it, and re-running the migrations or starting from an empty
  database are both clean.

test_billing_run_id_migration.py failed on any Postgres-configured checkout.

  It builds its own SQLite engine, but run_migrations branches on the global
  dialect rather than the connection in hand, so on a box whose DATABASE_URL
  points at Postgres it emitted md5(random()::text) and btrim() into SQLite.
  Given the same fixture test_ldap_migration.py already carries for exactly this
  reason. The suite now agrees across dialects -- 9190 passed either way, where
  it used to be 9184 on one and 9183 on the other.

The kill switch could not tell a print Bambuddy started from one it merely
watched.

  Authorization fell back to a print_archives row in status="printing" matched on
  subtask_id. But on_print_start archives every print it observes, including ones
  started from Bambu Studio or Handy, and stamps them with the same status and
  subtask_id -- the code says as much where it notes "a print Bambuddy didn't
  dispatch". So a foreign print became authorized the moment its 3MF finished
  downloading, and _active_prints was rehydrated from it, making that permanent.
  The switch fired only inside the download race, and never afterwards. Neither
  test caught it: one stubs the authorization call to False, the other stubs the
  query to return an archive, so the real lookup was never exercised against a
  foreign print.

  Authorization now requires a marker Bambuddy writes itself: billing_run_id,
  minted per dispatch in the scheduler, or created_by_id carried over from the
  queue item. Failing that, it looks for a queue row in status="printing" on that
  printer -- committed before the MQTT send, and the only durable trace a
  library-file dispatch leaves, since those have no archive at send time and the
  row created for them moments later carries neither marker. That row cannot be
  tied to a subtask_id, so it defers rather than authorizes.

  Deferring also closes a false positive the previous version shared: a restart
  in the window between the send and the download left no archive at all, and a
  Bambuddy print was stopped as unauthorized. Stopping a print is irreversible
  and declining to act costs a log line, so ambiguity resolves that way.

  Tests cover an unmarked archive not being authorization and not entering
  _active_prints, either marker alone authorizing and rehydrating the fast path
  without touching the queue, an unmarked archive with a live dispatch deferring,
  a dispatch not yet archived deferring, and nothing at all being unauthorized.
2026-08-08 11:19:33 +02:00
MartinNYHC
406cf71149
Merge branch 'dev' into feature/billing 2026-08-08 10:56:09 +02:00
maziggy
595dc5844a Say which header blocked the 3D preview, instead of leaving the browser's page (#2787)
A reporter uploaded an STL, sliced it in Bambuddy, and got a frowny icon and
"<hostname> refused to connect" when previewing the sliced file -- while the
STL's own preview worked. That is Chrome's ERR_BLOCKED_BY_RESPONSE page, drawn
inside our layout shell, and the split between the two previews is where the
cause is: an STL or source 3MF renders in the page, a sliced file opens the
embedded G-code viewer, which is the only thing in Bambuddy that frames a
Bambuddy page (FileManagerPage.tsx:2472, GCodeViewerPage.tsx:47).

Our headers permit that frame -- frame-ancestors 'self' plus SAMEORIGIN on
everything under /gcode-viewer (main.py:7709) -- and the frame is same-origin,
so a refusal means a stricter header was added after we replied: a reverse
proxy, a security add-on, an auth gateway. None of which the user could see.
The browser drew its own page and nothing said what was refused, by whom, or
that the viewer opens perfectly well in a tab.

The frame cannot report this itself. A frame blocked by X-Frame-Options or
frame-ancestors still fires onLoad -- the browser commits an error document --
so there is no failure to catch. The page now asks for the same URL directly:
same-origin, so every response header is readable, and it goes through whatever
proxy the browser reaches Bambuddy by.

findFramingRefusal reads the verdict the way a browser does. frame-ancestors
wins outright when present, because CSP requires X-Frame-Options to be ignored
in that case -- reading both would blame a proxy-added DENY the browser never
consulted. Multiple CSP headers are intersected and fetch joins them into one
comma-separated string, so every frame-ancestors occurrence has to permit us,
not just the first; that is the shape a proxy appending its own policy to ours
actually takes. Failing that, a legacy header that is anything other than a
single SAMEORIGIN refuses us, including the conflicting "SAMEORIGIN, DENY" that
appears when a second copy is appended.

On refusal the frame is replaced with the header named verbatim, so an operator
can go and find the rule in their proxy config, and a link that opens the viewer
in its own tab -- a top-level page, which no framing header applies to. A
non-200 is reported the same way rather than as raw {"detail":"Not Found"}
inside the frame, which the startup-time warning at main.py:8120 already calls
out as easy to miss. A probe that cannot reach a verdict changes nothing: the
iframe stays, because guessing at a cause we cannot see is worse than the
browser's own page.

The working case is unaffected -- the iframe renders immediately as before and
the probe only ever replaces it.
2026-08-08 10:53:54 +02:00
maziggy
f5fbae45a3 Housekeeping 2026-08-08 10:20:57 +02:00
maziggy
bbcb7e54db Merge branch 'worktree-fix-2791-cursor-pointer' into dev 2026-08-08 10:16:15 +02:00
maziggy
3f218729b9 . 2026-08-08 10:14:37 +02:00
maziggy
82d6d98266 Restore the pointer cursor on interactive controls (#2791)
Hovering most of Bambuddy gave an arrow rather than a hand. Not everywhere,
which is what made it read as sloppiness rather than a bug: the update pill was
inert while the buttons beside it were fine, a bed or nozzle tile responded but
the history-graph button in its corner did not, and dropdowns went either way
with no pattern behind it.

The pattern was there. Tailwind v3's Preflight set `button { cursor: pointer }`.
v4 dropped it to match the browser default, which for a button is `default`.
Bambuddy has been on v4 since the frontend was built, and `src/index.css` never
had a base layer restoring it, so a button only looked clickable where someone
had written `cursor-pointer` by hand. 15 of 934 had. 0 of 149 selects, and 19
of 130 checkbox/radio inputs. The 233 ad-hoc `cursor-pointer` usages are why it
looked arbitrary instead of uniformly broken.

One `@layer base` rule now covers button, select, checkbox, radio, summary and
[role=button]. base sits below utilities, so `cursor-not-allowed` and the
`disabled:cursor-*` variants still win; the `:not(:disabled)` guard catches the
disabled controls that carry no such utility. Verified against the built bundle
rather than the source -- the rule lands inside @layer base, and
`.cursor-not-allowed` is emitted after it.

Click-outside backdrops are deliberately excluded. 90 of the 96 remaining
onClick divs are `fixed inset-0` overlays; a full-screen sheet advertising
itself as a button is worse than one that says nothing. Of the rest, 50 are
stopPropagation wrappers and 3 are the temperature tiles, which already set the
cursor through `statusControlClass` -- which is exactly why those tiles worked
while the button nested inside them did not. That left two real ones: Card, now
conditional on an onClick actually being passed, and the queue card, whose
existing `sm:cursor-default` kept the desktop intent.

Separately, from the same report. FilamentHoverCard draws the slot menu twice,
and the two paths had drifted into opposite orders: Configure above Assign Spool
on an empty slot, the reverse on a filled one, so the menu reshuffled itself
depending on whether the slot held filament. Both now lead with the spool
action. Tests assert the order on each path, so one can no longer move without
the other -- checked by reinstating the old order and confirming the empty-slot
test fails.

Those buttons also used justify-center, which centred each label independently
and left the icons in a ragged column; they are justify-start now. Their hover
was a 10% opacity step that was very hard to see, now 20%. And the favourites
star previews yellow on hover, suppressed when the user lacks archives:update.
2026-08-08 10:06:45 +02:00
maziggy
604fa44593 Explain Bambu Cloud's CAPTCHA challenge instead of repeating it (#2790)
A reporter tried to connect to Bambu Cloud and got "We need you to confirm you
are not a robot" as an error toast, with no CAPTCHA anywhere to answer and
nothing to click. That sentence is Bambu's, not ours. Their anti-abuse layer had
flagged the network and was answering the sign-in with HTTP 418 and a challenge
body: {"captchaId": "...", "error": "We need you to confirm you are not a
robot"}.

Bambuddy had no idea what that was. The reply is well-formed JSON, so
_detect_cloudflare_challenge -- which triggers on an unparseable body, CF
markers, 403+cf-mitigated or 503+cf-ray -- never fired on it, and login_request
fell through to its generic error path, which lifts data["message"] or
data["error"] out and hands it to the UI verbatim. The user was left to conclude
their password was wrong or that Bambuddy was broken. Four sign-in attempts
inside eighteen seconds appear in their log, each one more evidence for the
thing that had flagged them.

is_captcha_challenge matches on the 418 status plus a challenge marker in the
body -- captchaId is the reliable one, the wording is matched too because Bambu
has shipped it under more than one phrasing. A bare 418 with no marker is
has shipped it under more than one phrasing. A bare 418 with no marker is
deliberately NOT reported as a CAPTCHA: telling someone to solve a challenge
that was never offered is the exact confusion this issue is about.

login_request, verify_code and verify_totp now return reason="captcha" with an
explanation covering the three things the reporter had no way to find out: the
credentials are not the problem, the block is keyed to the public IP address
rather than the account, and it clears by itself within a few hours.

Sign-in requests are then held back for 300s so Bambuddy stops deepening the
block. Keyed per origin, not per service: TOTP verification posts to
bambulab.com while everything else posts to api.bambulab.com, and a challenge
seen on one must not strand somebody halfway through a two-factor sign-in on the
other. Entries expire on read, so the map cannot grow past one per region. The
token endpoint is deliberately left ungated -- it is the way out.

The UI shows a persistent panel rather than a toast. A toast names a problem the
user cannot act on and then vanishes; this one stays put and carries a one-click
route to "Use access token instead", which is the only thing that works while
the challenge lasts, since that path does not touch the challenged endpoint.

MakerWorld meets the same challenge from the same edge and now shares the
detection. It used to require the literal word "robot" in the error text and
reported any other wording as an unexplained block.

The System Health scanner gets a bambu-cloud-captcha signature. The reporter's
bundle came back with zero findings while their log was full of the failure.

Its advice for a failed FTPS handshake was corrected at the same time: it still
blamed firewalls and outdated firmware, which the #2780 investigation ruled out
last release -- it is the printer's own file service wedging, and the fix is to
restart the printer. The wiki said so already; the health panel did not.
2026-08-08 10:04:55 +02:00
maziggy
14d0d14365 Power on a printer for jobs queued to a printer class (#2786)
Queue a print against a printer class -- "Any X1C", or a Slicer Pipeline whose
target type is Printer class -- with every printer of that class switched off,
and nothing happened. The job sat pending and no smart plug was touched, while
the same file pinned to a specific printer powered that printer on within one
queue check. The reporter's log holds both halves: thirteen minutes of the item
being polled as (133, None, ...) and passed over, then a PATCH onto printer 2,
then "Printer 2 offline, attempting to power on via smart plug(s)" on the very
next tick. Same item, same plug, same Auto On setting.

Powering a printer on had only ever been written inside `if item.printer_id:`.
The model-based branch below it walks the same queue but its matcher classes an
offline printer as a reason to keep waiting -- printers_offline collects the
*name*, for the waiting reason -- and nothing on that path ever looks at plugs.

_wake_printer_for_model adds it. The model query moves into _printers_for_model
so the matcher and the wake step answer "which printers can this job run on"
from one place: a job can only be woken onto a printer the matcher would also
have considered. Candidates that failed the cross-model gate are excluded --
switching a printer on for a file that can never legally run on it leaves the
job just as stuck, with the printer now drawing power.

Two things it does that the fixed-printer branch does not:

A printer awaiting plate-clear acknowledgment is skipped. Waking it buys
nothing; it boots into IDLE and is held by the gate. That is what the reporter's
log shows for the eighty minutes after their manual edit -- "printer 2 not
available -- connected=True, state=IDLE, awaiting_plate_clear=True" every thirty
seconds to the end of the capture. The flag is Bambuddy-side and persisted, so
it is readable while the printer is still off.

At most one printer per pass, because each wake blocks the queue loop for the
boot wait. Several queued jobs bring several printers up over the following
minutes rather than a whole shelf at once.

A failed power-on opens a 600s per-printer cool-off. Without it the walk is by
id, the pass spends its single attempt on the same broken printer every time,
and a healthy sibling two slots down is never reached -- one unreachable plug
starves its whole model, and costs a 180s boot timeout out of every 30s pass.
Entries expire on read: a printer inside its cool-off is skipped before the
power-on is reached, so a live entry can never be overwritten by a success.

The failed printer is deliberately NOT added to busy_printers. It is off, not
busy; labelling it busy would misdescribe it in every later item's waiting
reason and, because an all-busy reason is treated as needing no user action,
suppress the notification too.

Assignment is left to the next pass. AMS trays arrive with the first status push
after connect, so matching filament against a printer that booted five seconds
ago can reject the printer we just woke.

Finally, the waiting reason separates "Offline: X1C-1" from "Offline, no Auto On
smart plug: X1C-2". Those are different problems and only the second is one the
user has to go and fix -- it was also the first question the reporter had to be
asked, and the queue could not answer it.

Tests cover the wake, the plate-clear skip in both gate states, all-candidates-
awaiting-plate-clear waking nothing, one wake per pass, the starvation case over
two passes, cool-off expiry, no-Auto-On-plug being left alone and named, an
incompatible sliced model waking nothing, connected printers being left alone,
scheduled-for-later and manual-start jobs switching nothing on, and a regression
pin on the fixed-printer branch.
2026-08-08 09:35:43 +02:00
maziggy
91acac2b35 Stop retrying a printer whose FTPS handshake fails, and name the cause (#2780)
Two printers went on printing while every archive they produced held nothing
but a filename. Bambuddy opened port 990, the printer accepted the connection
and answered with something that was not TLS, and connect() logged a warning
and returned False -- indistinguishable, to every caller, from "the file is
not at this path". So the 3MF lookup walked all six filename variants across
five directories with four retries each, the cover endpoint ran its own
sixteen-path sweep, and the timelapse scan added four more, all against a
sixteen-path sweep, and the timelapse scan added four more, all against a
printer that could not have answered any of them. One reporter's log carried
1813 identical handshake failures, another's 3511.

The evidence says this is the printer's own file service getting stuck, not a
model, firmware or TLS-configuration problem. In #2780's bundle the same two
printers ran clean from 22 July to 4 August and failed again from the 5th; a
second bundle shows an X2D serving files for five days, flipping on 19 July,
then failing every connection for eight days with zero successes. The same
models and firmware appear in roughly twenty other bundles with no occurrences
at all. Both bundles show it happening with cap_tls_v1_2 in effect -- the X2D
and H2C entries in ftp_profiles were added on analogy with P2S to fix exactly
this symptom, and the reporter's own debug line proves they do not.

An ssl.SSLError from connect() now opens a five-minute cool-off for that
printer. Subsequent connects return False without touching the network, so a
wedged printer is contacted twice an hour instead of hundreds of times a
minute, and the single warning that is logged names the remedy. The cool-off
is dropped on expiry rather than kept, so the map holds one key per currently
wedged printer. ftps_handshake_blocked() lets the sweeps stop: the 3MF lookup
abandons the remaining paths and skips the directory-walk fallback, the cover
endpoint returns 503 naming the file service instead of a 404 that reads as
"this print has no thumbnail", and the timelapse scan separates 503 (cannot
reach the printer) from 404 (no timelapse directory) -- one 500 used to cover
both, which is what the reporter hit when reproducing.

The Connection Diagnostic completed a bare TCP connect to 990, which is why it
reported the port green throughout: the port is open, it is what is behind it
that is broken. It now completes a real implicit-TLS handshake using the
model's own ftp_profiles cap, so a pass means the FTP client would also get
through. An open port that cannot negotiate reports warn with reason no_tls,
selecting a new message in all 13 locales that points at a printer restart
rather than at the firewall. No login is attempted, so this stays valid in the
pre-save Add Printer flow.

The cool-off tests run against a real socket that accepts on 990 and replies
with a plaintext FTP banner, reproducing WRONG_VERSION_NUMBER rather than
mocking ssl. The autouse fixture clearing _mode_cache now clears the cool-off
map too -- every test here talks to 127.0.0.1, so one left behind would make
the next test's connect() a no-op.
2026-08-08 09:00:03 +02:00
behrinml
a9e23910fc Merge branch 'dev' into feature/billing 2026-08-06 20:32:59 +02:00
behrinml
2e5d36d680 implemented pr (minor) feedback 2026-08-06 20:28:16 +02:00
behrinml
dd1d40b0d4 implemented pr (worth fixing) feedback
update commit
2026-08-06 20:27:25 +02:00
maziggy
9c86a05657 Check filament deficit for Library-backed queue items (#2779)
A job needing 20.5 g was dispatched onto a spool holding 9 g and the printer
started. _resolve_source_3mf returned LibraryFile.file_path verbatim, but that
column stores a path relative to base_dir -- so it resolved against the process
working directory, found nothing, and compute_deficit_for_queue_item treated a
missing source as "nothing to verify" and returned no deficit.

Every library-backed queue item was affected: Slicer Pipeline jobs, which are
always library-backed, and everything added through the Library's bulk Add to
queue. Both callers share the resolver, so the Play button on the queue was as
blind as the auto-dispatcher. Archive-backed items (print history, VP intake)
resolved correctly and were never affected, and neither was PrintModal, which
resolves the file on its own path.

The library branch now uses the same idiom as the eleven other readers of
file_path -- absolute stays, relative joins base_dir. The join carries a
SEC-PATH-OK marker: the value is DB-stored and generated by the Library ingest,
and it is already what resolves the file for upload, so the check has to
resolve it identically or it is not checking what gets printed.

A source that is configured but absent now logs a warning naming the item and
the resolved path. It still dispatches, because the upload needs the same file
seconds later and fails there, where blocking would strand a queue on a moved
file -- but a safety check that skips itself must not do so in silence, which
is what hid this for every library-backed item.

Tests cover the relative path (the reporter's 20.5 g against 9 g), the absolute
path against a base_dir the file is not under, and the missing-source warning.
The existing cases all used archives with absolute paths, which is the gap the
bug lived in.
2026-08-06 12:21:50 +02:00
maziggy
306b9ba7fd Accept Forgejo tokens scoped to a single repository (#2775)
ForgejoBackend.test_connection asked GET /user who the token belonged to
before asking whether the token could reach the repository, and treated a 403
there as fatal. A Forgejo v15 repository-scoped token may only carry
read/write on issues and repositories, so it 403s on /user -- and was rejected
despite reaching its own repository fine, which is all a backup needs: the push
path uses the Contents API and restore reads commits, trees and blobs, all
under /repos/{owner}/{repo}. That /user call was the only one in the whole
provider layer.

The probe stays, because a 401 from it is genuinely conclusive and names a bad
token before the repo call has to guess -- Forgejo v15+ hides a private repo
behind 404 rather than 403, so the repo call cannot always tell those apart.
Every other status now falls through to the repo check.

Two additions keep the messages as sharp as before: the repo call's own 401 is
mapped to "Invalid access token" instead of a generic API error, and the 404
names write:repository and the scoped-to-another-repository case, mentioning a
possibly-invalid token only when /user did not confirm the identity.

The token hint under the field was one shared string reading "fine-grained
token with Contents read/write" -- GitHub's advice, shown to Gitea, Forgejo and
GitLab users too. It is now per provider via PROVIDER_TOKEN_HINT_I18N_KEY,
following the existing repo-URL placeholder map, translated in all 13 locales.

Tests pin the repository-scoped token connecting, a transient /user status not
blocking the repo call, both 404 wordings, and the repo-call 401; a frontend
test switches providers and asserts the hint follows.
2026-08-06 12:08:36 +02:00
maziggy
9beb001a17 Record who queued a file from the Library and the webhook API
PrintQueueItem.created_by_id is what the queue:read_own / queue:update_own /
queue:delete_own permissions filter on, but only three of the paths that create
queue items were setting it.

The Library's bulk "Add to queue" required Permission.QUEUE_CREATE and then
bound the dependency to `_`, discarding the user, so every item it created was
ownerless -- and invisible to the person who added it if their permissions are
scoped to their own work. That is the one path built for adding many files at
once, which is where it was hardest to notice.

The webhook queue endpoint has no request user, but APIKey.user_id records the
key's owner, which is the acting identity everywhere else the key is used, so
its items are credited to that owner. Keys minted before per-user ownership
have no user_id and their items stay ownerless.

The virtual-printer path is left as-is on purpose. VirtualPrinter carries no
owner, and the obvious substitute is wrong rather than incomplete: one admin
typically configures the VP while everyone sends prints through it, so
crediting those to the admin would make the "added by" column lie and put other
people's jobs in the admin's own queue. Existing NULL rows are not backfilled
-- there is no record of who created them, and the ownerless case is already
handled throughout.

Tests pin both fixed paths and the two cases that must stay ownerless (auth
disabled, legacy key).
2026-08-06 11:06:15 +02:00
maziggy
afa0ba0dc0 Nest projects under a master project and roll their figures up (#1264)
Projects were flat. The parent_id column and the sub-project list existed
but nothing could set a parent outside the API, and a master project's
stats only ever covered its own prints.

The project dialog gets a parent picker, and a project with sub-projects
gets a second card covering the whole tree -- jobs, parts, time, filament,
cost, and progress against every target in the tree added together. That
card is separate from the project's own stats, which keep their existing
meaning; widening them would have restated the figures of anyone who had
already nested projects over the API. Each listed sub-project carries its
own branch's roll-up, so the rows add up to the card above them.

On the Projects page a sub-project is drawn inside its parent's group
rather than as another card in the grid -- two cards columns apart cannot
show that they belong together, whatever the caption says. A sub-project
whose parent the status filter has hidden stays put and names its parent
instead.

compute_project_stats now goes through the same grouped aggregation as the
roll-up rather than its own copy of the SQL, since the two must agree.

Three things the interface made reachable:

- PATCH refused only a project as its own direct parent, so A -> B -> A
  was two calls away. A cycle has no root to roll up to, and the walk
  keeps its seen-set for databases that already contain one.
- A sub-project's percentage was completed quantities against the plate
  target, disagreeing with the page it linked to.
- Deleting a mid-tree project orphaned its children at top level; they
  now move up to its own parent.
2026-08-06 10:00:45 +02:00
maziggy
312f09a4ac Updated CHANGELOG 2026-08-06 08:50:01 +02:00
MartinNYHC
22683c058d
Merge pull request #2714 from jmoore-skild/feature/2656-restore-from-github
feat(backup): restore selected categories from a Git backup commit
2026-08-06 08:46:34 +02:00
maziggy
b5163b94f8 fix(backup): report the categories a failed restore already committed (#2656)
The service reports what landed on a part-way failure -- categories commit as
they finish, so results names the ones on disk -- and the modal gated the
whole result panel on success, so it showed the failure message and dropped
them.

The cache invalidation was inside that same branch, which is the half that
mattered: a run that committed the settings category and then failed left the
app rendering pre-restore settings, with no reload and no re-read, which is
the failure the modal's own reload-on-close exists to prevent.

Gate on what was written instead. A refusal that never reached a category
still carries an empty results and still keeps the form, so the mutex and
backup-in-flight cases are unchanged. A partial does not read as a success:
the tick becomes a warning and a line says the listed categories are the ones
on disk.

---

fix(backup): keep the local owner when the backup names one we cannot resolve (#2656)

An owner the backup names but this instance has no user for was written as
NULL, and overwrite is a blanket setattr -- so restoring over a local archive
that had a perfectly good owner took it away, which is the 404-for-its-own-
owner failure this column is carried across to fix. Resolving by username
widened the trigger from a stale id to any user renamed since the backup.

It is the same state as an absent key: the backup has not told us who owns
this. So it takes the same action -- the column is not written at all.
Overwrite keeps the local owner, insert lands ownerless with the note, and an
explicit null still writes, so overwrite still means "match the backup".

The notes move to the insert path with it. On overwrite nothing was taken
away, so there is nothing to warn about, which is the rule the absent-key
case already follows.
2026-08-06 08:46:02 +02:00
MartinNYHC
6cd81fcd85
Merge branch 'dev' into feature/2656-restore-from-github 2026-08-06 08:23:37 +02:00
maziggy
1eea194953 Resolve a spool's material to a known drying preset before starting a cycle (#2774)
The drying popover prefilled its material from the loaded spool without
checking the preset table had that material. An AMS-HT holding Support for
PLA/PETG (tray_type PLA-S) fell back to PLA's temperature but kept PLA-S as
the material, and the dropdown displays its first option when handed a value
outside its list -- so it read PLA while PLA-S was sent. Same gap for every
composite: PETG-CF prefilled at PLA's 45C.

Resolve the tray_type to a key the table has before setting either value.
Support materials and composites resolve to their base, nylon is aliased
under its several spellings, and anything unrecognised falls back to PLA --
the coolest row, so an unknown material under-dries rather than deforming a
PLA spool.

Also record request-topic messages in the MQTT debug log. That topic carries
every command a printer is given, including Bambu Studio's, and returned
before the logging block -- so a capture could show only what the printer
said, never what it was told.
2026-08-06 08:19:01 +02:00
jmoore-skild
b2e34dd00e docs(changelog): note that the LDAP family is refused alongside auth policy (#2656) 2026-08-05 20:15:57 -04:00
jmoore-skild
f62e907e9f fix(backup): refuse the whole LDAP family on restore, not just its password (#2656)
A settings restore could substitute the instance'"'"'s authentication source.
auth.py reads the LDAP config live from the settings table on every
login, and none of ldap_server_url, ldap_user_filter, ldap_auto_provision
or ldap_default_group is credential-shaped, so the secret-key hints never
saw them and only the four auth-policy keys were protected.

ldap_enabled was covered by the companion-credential rule instead, and
that rule asks the wrong question. It judges availability - "will the
integration still work?" - and an anonymous bind works, so a payload that
simply OMITS ldap_bind_password skips the refusal and has its toggle
written. Omitting the credential is exactly what an attacker authoring
the file would do: they own the directory being pointed at, so they need
no bind credential from us.

Left unrefused, a backup repository anyone can write to yields admin:
point ldap_server_url at your own directory, set ldap_auto_provision and
ldap_default_group=Administrators, and the next login on a fresh username
is provisioned into the admin group. Overwrite-off is enough on an
instance that never configured LDAP - there are no rows to skip.

Refused by prefix so a key added to the LDAP schema later is refused by
default, and matched case-insensitively because the key comes from the
backup JSON rather than from our own writer. ldap_enabled leaves
_COMPANION_CREDENTIALS rather than sitting there as dead code, since
_is_protected_setting_key runs first.

The two tests asserting an anonymous bind was a false positive are
inverted - they encoded the hole - and the refusal reuses the existing
settingsAuthSkipped note, which already points at Settings >
Authentication.
2026-08-05 20:05:23 -04:00
jmoore-skild
0bd0fb206d docs(changelog): add the restore-from-Git entry (#2656)
The branch had never added one, though the convention here is one bullet
per PR in the same commit as the code.

One entry for the whole feature rather than one per commit - it has not
shipped yet, so the review rounds are refinements of an unreleased thing
rather than fixes to a released one. For the same reason the per-category
permissions are written as what to grant, not as something that changed.
2026-08-05 19:39:41 -04:00
jmoore-skild
a71b30f1fc build(frontend): rebuild static/ on the merged tip (#2656)
One build on the tip after merging dev, per the branch'"'"'s standing rule
that intermediate commits carry a stale bundle and only the tip has to be
right.

dev'"'"'s CSS moved to index-Db2rfQf-.css while this branch was out; the
rebuild lands on the same hash, so static/index.html differs from dev by
the script line alone again.
2026-08-05 17:51:19 -04:00
jmoore-skild
bb25e36510 Merge branch 'dev' into feature/2656-restore-from-github 2026-08-05 16:47:49 -04:00
jmoore-skild
b34ea64417 build(frontend): rebuild static/ so the restore UI actually ships (#2656)
The dev merge took dev's static/index.html, which loads the pre-restore
bundle, and left both bundles tracked. Merged as-is none of the frontend
shipped: no Restore button, no modal, no Type column.

Rebuild drops the superseded bundle and points index.html at a single one
that carries restoreFromGit and this round's new note leaf. The CSS
hashes identically to dev'"'"'s, so index.html differs by the script line
alone.
2026-08-05 16:23:33 -04:00
jmoore-skild
f6fca4b927 fix(backup): keep both restore tallies equal to the number the preview showed (#2656)
Two ways the K-profile and spool categories broke the
restored + skipped + failed == item_count invariant the settings count
holds:

* The spools preview counted only the spools and put the usage records
  in the detail, but _restore_spool_usage increments the same tally, so
  any backup with usage history reported a total larger than the number
  the user was shown. The preview now counts both and the detail breaks
  the total down instead of adding to it.
* A K-profile entry that is not a dict was dropped silently on the
  connected path. _kprofile_profile_count includes it, so the offline,
  printer-missing and step-failed paths all account for it; only the one
  path that talks to a printer let it leave the tally. It now counts
  failed.
2026-08-05 16:17:42 -04:00
jmoore-skild
fc2dcf76b5 fix(backup): commit each database category so SQLite's writer is not held (#2656)
The database phase had the same shape the K-profile phase did: _find_
archive, _find_spool, the usage dedupe and _restore_settings are all one
SELECT per row or per key, interleaved with autoflushed INSERTs, inside a
single open write transaction. A few thousand archives plus a full usage
history plausibly passes the 15 s busy_timeout, and every concurrent
writer in the app fails with "database is locked" until it finishes.

Each category now commits before the next starts. The id maps are plain
dicts in memory and the session is expire_on_commit=False, so the
ordering tolerates it.

The cost is that a later failure no longer rolls back an earlier
category, so a tally is recorded only after its category commits and
run_restore reports the categories already on disk instead of an empty
result - the same correction the K-profile split needed.
2026-08-05 16:14:23 -04:00
jmoore-skild
9284240279 fix(backup): gate every restore category on the permission owning its rows (#2656)
settings was gated on settings:update because a restore rewrites rows
PUT /api/v1/settings/ owns. The same argument applies to the other three
categories, and gating one but not the rest is the only state that is
not defensible: a role holding Backup alone could still write spools,
archives and K-profiles through a restore that it cannot write through
the endpoints that own them.

Each category now also requires that endpoint's write permission -
inventory:update, archives:update_all and kprofiles:update. archives
takes update_all rather than create because a restore writes rows owned
by other users, which is exactly what update_all means.

All missing permissions are reported in one refusal: a restore is a
multi-select, so naming them one at a time turns picking four categories
into four round trips.
2026-08-05 16:10:04 -04:00
jmoore-skild
8602c54c1f docs(backup): say what the secret-key hints actually refuse (#2656)
The comment called the hint list belt-and-braces over keys the collector
already refuses to write. It is not: _collect_settings filters exactly
bambu_cloud_token and auth_secret_key, so a current backup really does
carry mqtt_password, ldap_bind_password, ha_token and prometheus_token,
and the hints are the only thing that refuses them. The companion-
credential rule sits downstream of that, so reading the list as redundant
and shortening it would write a stale credential and make that rule inert
at the same time.

Comment and test docstring only - no behaviour change.
2026-08-05 16:09:50 -04:00
jmoore-skild
4ec0f3f9f2 fix(backup): resolve a restored archive's owner by username, not by id (#2656)
created_by_id is only meaningful on the instance that wrote it. Restoring
onto a rebuilt instance - this feature's main use case - renumbers the
users table, so a live id can land on a different person and hand one
user's print history to another under archives:read_own. The id path
cannot even detect that: archivesOwnerCleared fires only for an id that
is absent, so a valid-but-wrong id produced no note at all.

The collector now records created_by_username alongside the id, and the
restore prefers it. username is unique on users, so a match is the same
person; the one case it cannot resolve - a user renamed since the backup
- falls through to ownerless with a note rather than guessing from the
id. The id stays as the fallback for backups taken before this change.
2026-08-05 16:09:20 -04:00
behrinml
9a397f46e9 implemented pr feedback #2 2026-08-05 21:25:46 +02:00
behrinml
43bf854bd5 Merge remote-tracking branch 'upstream/dev' into feature/billing 2026-08-05 20:19:32 +02:00
maziggy
16c8c6f2ea Security hardening (maziggy/bambuddy-security #9) 2026-08-05 15:21:42 +02:00
maziggy
684a328d6f Broadcast AMS slot changes that keep the same material
Configuring a slot from the printer card left the card showing the old
filament until a reload or the 30s fallback poll. The command reached the
printer and the printer applied it; the update just never got broadcast.

on_printer_status_change deduplicates WebSocket pushes against a status_key
whose AMS part carried id, tray_type and state. Configure Slot writes none of
those -- it writes tray_info_idx, tray_color, tray_sub_brands and cali_idx. So
PLA to another brand or colour of PLA produced an identical key and was
dropped, while PLA to PETG came through. Reset always worked because it clears
tray_type.

Those four fields only move when someone configures a slot or swaps a spool,
so this costs no broadcasts mid-print. remain stays out of the key for the
opposite reason.
2026-08-05 14:53:05 +02:00
maziggy
3bbe00784f Write printer status straight through while the tab is hidden (#2754)
Removing the requestAnimationFrame wrapper fixed the total stall but left the
100ms coalescing timer in the path, and a hidden page's timers are clamped to
once a second at best -- once a minute past five minutes hidden. The reporter
still saw a tab title at 2% beside a page at 40%.

The coalescing guards against a render cascade, which a hidden tab cannot
have, so it is skipped there and kept while visible.

The existing hidden-tab tests advanced fake timers, which simulates the timer
the browser was throttling; the new one never advances the clock.
2026-08-05 14:40:05 +02:00
maziggy
cd004df817 Show Home Assistant sensors on the printer card (#1148, #448)
Binds binary_sensor and reading-carrying sensor entities to a printer and
renders their state on its card, worded by Home Assistant's device_class.
Optional per-sensor alert condition drives a notification on the transition
into the alert state and an opt-in interlock that holds queued prints while
alerting -- a hold with a readable waiting_reason, never a failure, and only
ever on a sensor that was read successfully.

Sensors get their own table rather than a wider entity pattern on SmartPlug:
get_smart_plug_by_printer would otherwise hand the card's power button a door
contact to switch.

The hold is passed to the model matcher directly rather than merged into
busy_printers: _check_auto_drying reads that set as "is currently printing"
and would put an idle-but-held printer down the mid-print drying path.

The notification_providers migration spells its default FALSE, not 0 --
Postgres rejects an integer default for a boolean and _safe_execute swallows
the error.
2026-08-05 14:26:38 +02:00