diff --git a/.gitignore b/.gitignore index bd8ef4707..fd36b15bc 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,8 @@ security/ test_pipeline_archive_source.3mf test_pipeline_run_1.3mf + +# Python coverage artifacts +.coverage +.coverage.* +htmlcov/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9422cf1ea..8c1e6340a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ All notable changes to Bambuddy will be documented in this file. +## [1.2.5.6] - 2026-09-24 + +### Added +- **Swedish (sv) is now a supported interface language (#3052, requested and contributed by @AntonPalmqvist in [PR #3062](https://github.com/maziggy/bambuddy/pull/3062))** — Adds `sv` as the fifteenth locale, listed as "Svenska" in the language picker. It arrives in parity with the reference locale rather than as a file to be finished afterwards: all 6302 leaves present, structure and key order matching `en.ts`, and every `{{placeholder}}` set intact, which is what the parity gate checks before a locale is allowed to ship. 156 of those leaves are identical to English, and each is enumerated by value in a 74-entry `SV_COGNATES` allow-list in `check-i18n-parity.mjs` — the same shape the other thirteen locales use, so the exemption is an enumerated translator decision rather than a blanket skip for the language. They are the strings with nothing in them to translate (`{{weight}}g`, `Hex: #{{hex}}`, `{{filament}} @ {{temp}}°C`), the product names Bambu and Orca ship untranslated, and the technical UI vocabulary Swedish takes verbatim from English — filament, status, extruder, timelapse, firmware, proxy, pipeline, diameter. Parity green at 6302 leaves across all 15 locales. + +### Changed +- **The schema no longer contains a cycle that `metadata.sorted_tables` cannot sort** — `print_archives.library_file_id` → `library_files.folder_id` → `library_folders.archive_id` → back to `print_archives`: three nullable `SET NULL` links, each reasonable alone, that together made a loop SQLAlchemy answered with a sort it could not make and a warning on every backup and every restore. The order it returned could place a child before its parent, which is what once imported `library_files` ahead of `library_folders` and killed a restore on a foreign key violation — the restore has since stopped depending on that order by stripping every foreign key before importing and adding them back afterwards, but the backup export sorts the same way, and the warning ends with "may raise an error in a future release", which would break backup and restore on the same upgrade. One edge of the loop is now marked `use_alter`, which removes it from the sort graph without removing the constraint from the database: PostgreSQL emits it as `ALTER TABLE ... ADD CONSTRAINT`, as it already did for every constraint on those three tables, and SQLite — which reports no ALTER support — inlines it into `CREATE TABLE` exactly as before, so `ON DELETE SET NULL` keeps working on both. `use_alter` requires a name, so new databases carry `fk_library_folders_archive_id` where existing ones keep PostgreSQL's generated `library_folders_archive_id_fkey`; nothing refers to either by name. Pinned by a test that fails if a new foreign key closes a loop again, and by one asserting the key still exists on SQLite — if that inlining ever stopped, deleting an archive would leave a dangling `library_folders.archive_id` instead of nulling it, silently. +- **The MakerWorld integration is now a model-provider package, so a second model site is an implementation rather than a second copy of the feature ([PR #2845](https://github.com/maziggy/bambuddy/pull/2845) by @pascalheidmann)** — `services/makerworld.py` was a single 635-line module that owned everything from URL parsing to the presigned S3 download, and the routes called it by name, so Thingiverse or Printables would have meant a second route file beside the first. It is now an interface: a `ModelProvider` descriptor carrying identity, host patterns, the credentials it needs, its library folder, its permissions and its SSRF allowlists, plus a per-request `ProviderService` that does the transport — with MakerWorld as the first implementation and a registry that hands a pasted URL to whichever provider claims it. Nothing about the MakerWorld flow changes: the endpoints and their request and response shapes are unchanged, the new `source_type` field defaults to `makerworld` so existing callers send exactly what they sent before, and the transport moved verbatim — thirteen of the eighteen functions are byte-identical by AST comparison, including the CDN allowlist, the 200 MB download cap and the certifi-pinned TLS context that keeps S3 downloads working on Windows. Four things did change. Bambu Cloud credential storage moved out of `routes/cloud.py` into `services/bambu_cloud_credentials.py`, because a feature package that has to import a route module to read a stored token is a dependency pointing the wrong way; the cloud endpoints themselves did not move. An import naming a provider that is not registered is now refused before anything happens, where it used to create the destination folder on its way to failing. "Your Bambu sign-in expired" is now an explicit flag the provider sets rather than something inferred from whether an error message happened to be present, so a network failure or a rate limit can no longer be reported to you as an expired sign-in. And each request is authorised against the permission of the provider it actually named instead of MakerWorld's — latent while MakerWorld is the only one registered, wrong on the day it is not. +- **Every FTP session Bambuddy opens now records how it closed (#3009, reported by @grengojbo)** — the report traced a print completion that opened two FTP connections to the printer, deleted one file and then, as far as the log showed, did nothing else until the printer was powered off 21 minutes later, and concluded the connections were being left open. They were not: the post-print SD-card cleanup opens one connection per candidate filename and closes each in a `finally`, which a run against a real FTPS server confirms at the server end for both the delete and the 550 not-here case. The trouble is that nothing in the log could have said so. Neither the clean close nor the hard socket drop logged anything at any level, so a session closed properly and a socket genuinely abandoned produced the same output — none — and the only way to tell them apart was to read the source. Both now log one DEBUG line naming the printer, whether QUIT was acknowledged or the socket had to be dropped without it, why, and how long the session was held. Every connect in a debug log is now paired with a close, so the next person suspecting a leaked FTP connection can settle it from a support bundle rather than by inference. Nothing about the connection handling itself changed, and at default log level nothing new is printed. This does not explain the SD-card read/write error in that report or in #645; it only removes one theory from the list by making it checkable. + +### Fixed +- **An AMS that reports no humidity percentage no longer shows the drop index as one (#3140, reported by @Sawtaytoes)** — Bambu sends two humidity fields that are not the same quantity: `humidity_raw` is relative humidity in percent, and `humidity` is the 1-5 drop index, which runs the other way — a high index means dry where a high percentage means wet. Bambuddy used the index whenever no percentage arrived, so a unit sending only the index rendered as "2%" in the green band while being the second-wettest of the five steps, charted an average of index values as a percentage, and could never cross a humidity alarm or auto-drying threshold, since no index reaches one. Such a unit now reports no humidity at all: the card hides the water-drop indicator, the history chart leaves a gap, and the alarm and auto-drying skip the unit instead of reading it as permanently bone dry. Temperature is recorded and alarmed on as before. No supported printer is known to be affected — the report came from an install running X1Plus, which Bambuddy does not support — and printers that send a percentage keep it, including two readings that previously fell through to the index because they would not parse as a whole number: one with a decimal point, and `38.0`. A unit that sends the index and no percentage now says so once in the log, with its firmware versions requested, so a supported printer that turns out to do this is visible rather than silently blank. Three smaller faults in the same paths went with it: a humidity of exactly 0% was stored as NULL when the firmware sent it as a number, a history window averaging exactly 0 reported no average at all while the minimum and maximum beside it reported 0, and a `humidity_raw` that was not a number at all aborted the whole recording pass for every printer, not just the one that sent it. +- **Two Bambuddy instances could not have their CA certificates trusted at the same time (#3014, reported by @Steven-Pierce)** — a slicer holding the CA of two Bambuddy installs failed to connect to one of them, with the same generic `code=-1` an install whose CA was never imported gives. Each CA worked on its own; together, one stopped. Every install signed its virtual printers with an authority named exactly `CN=Virtual Printer CA`, and a slicer's trust store is a flat list of certificates that OpenSSL resolves by subject name: it takes the first authority whose name matches the certificate in front of it and fails the chain when that authority turns out not to have signed it, rather than trying the next match. Whichever of the two landed second in the file therefore lost, decided by nothing more than the order they were appended in. A newly generated CA now carries a per-install suffix taken from its own key — `CN=Virtual Printer CA D55808BE` — and publishes that key identifier, which the printer certificate then points back at, so the two are told apart by name and each chain resolves to the authority that really signed it. Nothing has to be re-imported on upgrade: an install that already has a CA keeps it untouched, and the printer certificates signed by it keep exactly the shape they have today. The collision goes away as soon as one of the two CAs is newer than this release — a second install set up after upgrading, or an existing one whose `bbl_ca.crt` and `bbl_ca.key` are deleted from `virtual_printer/certs/` so a fresh CA is generated, which does mean importing that one into the slicer again. +- **ntfy per-event priorities were set, stored, and never sent (#3139, reported by @Thomansky)** — a per-event priority (#990) never reached ntfy: every notification arrived at the server's default, whatever the dialog showed. The two halves of the feature disagreed about the key. The dialog builds its rows from the provider's event toggles and stores the map under those names — `on_print_complete`, `on_print_failed` — while every sender is called with the bare event name, `print_complete`, so the lookup missed for all 18 events the dialog offers. It looked healthy from both ends: the stored config held exactly the priorities that were set, and the tests were green because they called the ntfy sender directly with the prefixed name, the one spelling the running system never produces. The lookup now accepts either spelling, so existing configurations keep working and nothing has to be migrated. The tests use the bare form, and one of them now runs from a finished print through to the outgoing request, which is the assertion that would have caught this. On upgrade, priorities that have been sitting in the database inertly start being honoured, so an event mapped to **Min** or **Low** will now arrive quieter than it did yesterday — that is the setting taking effect, not alerts going missing. The daily digest is unchanged and still goes out at the server default: it summarises several events at once, and the dialog offers no priority for it. +- **Restoring a backup into a different version of Bambuddy failed, and the failure took the live database with it** — A backup carries the schema of the install that made it, and the PostgreSQL restore path does not use that schema: it drops every table, recreates them from the running process's ORM, and inserts the backup's columns into the result. Any NOT NULL column the running version has and the backup does not then arrives with nothing to put in it, and a model-side `default=` cannot help, because SQLAlchemy applies one to ORM and Core inserts and never to the raw `text()` SQL this import builds — `create_all` emits no DDL default for it either. A backup made after `user_wallets.currency` was dropped in #3123, restored onto an install that still had the column, died on `null value in column "currency"` with the drop already committed: the restore's first transaction wipes the database, so the install was left with an empty schema, the previous data gone, and the MFA key file already replaced by the backup's — whatever had survived would have been encrypted under a key that no longer matched. The import now fills such a column from the model's default (evaluating a callable one), leaves a column with a server default to the database rather than second-guessing a timestamp it is better placed to produce, and ignores a nullable one exactly as before. A column that genuinely cannot be filled is refused **before anything is touched** — before services are paused, before the key file is written, before the first `DROP` — and the same applies to a `bambuddy.db` that is not a readable SQLite database at all, which previously was discovered only by the import that had already dropped the tables. Backups now carry a `manifest.json` naming the version that wrote them, so the refusal reads "this backup was made by X, this install runs Y" rather than a list of column names; a backup taken before the manifest existed restores exactly as it did, with the version simply unknown. The refusal is an HTTP 400, and the frontend was dropping its body on the floor — a refused restore is an `HTTPException`, so its `{detail}` is not the `{success, message}` a successful one returns, and the modal raised an empty error toast — so the reason now reaches the operator who has to act on it. A SQLite install is unaffected throughout: it restores by copying the backup's pages, schema included, and migrates forward afterwards. +- **Slicing a plate of many copies of one part could take the server down (#3135, reported by @TheUltimateC0der)** — the reporter sliced a plate of 25 small Gridfinity bins and Bambuddy was OOM-killed straight after the slice finished, taking the slice job with it. After every slice Bambuddy draws the plate thumbnail itself, because the slicer's command-line mode leaves it out, and it read the model with trimesh. trimesh mishandles the layout Bambu Studio and OrcaSlicer save projects in, where each copy is a reference into a shared model file: it read that file again for every reference and kept every copy of its triangles, so 25 copies of a part arrived as 625, and 25 bins of 10,000 triangles each became 6.4 million. Drawing that took 54 seconds and 8.4 GB, and it ran on the server's main loop, so the web UI and every printer connection stalled for as long. A project whose model file holds several parts had a second symptom: each placed part pulled in every other part from the same file, so its thumbnail drew parts in places they were never put. The plate is now read directly, each model file once and each part once however many times it is placed, and a large plate is simplified per part to a fixed triangle budget before its copies are laid out. The reporter's plate now renders in about 2.4 seconds at under 400 MB, and the heaviest real projects measured went from 48 seconds and 9.3 GB to 20 seconds and 0.8 GB. The thumbnail is drawn off the main loop, and a plate still too large after simplifying is left without one instead of risking the server. +- **Moving a queued job from "Any P2S" to one P2S threw away its filament colour (#3133, reported by @bgrr74)** — a job queued for any printer of a model can print in a different filament from the one it was sliced with: brown in the 3MF, Bone White on the spool. Editing that job and switching it to a specific printer of the same model reset the choice, and the job went back to brown. The print dialog cleared overrides whenever the target model changed, and "Specific Printer" empties the target model, so the switch counted as a change. Clearing would not have been the whole problem anyway: in specific-printer mode the dialog matched trays against the 3MF's colours, never sent the override, and left the old one on the queue item, where it contradicted the trays the job would actually use. Now the override is kept through the switch, and the chosen printer's trays are matched against the requested filament, so Bone White picks the Bone White spool, and a printer without one shows the usual colour-mismatch warning for Bone White instead of quietly settling on brown. The job keeps the override on the queue item, so if the dialog could not resolve every tray, the scheduler's match at dispatch looks for the same colour. Moving the job to a different printer model, or picking a different plate, still clears the override, as before. A printer's queue card and badge no longer hide a job assigned to that printer because its override colour is not loaded. That check is for picking among printers of a model, which a job for one printer has already done. The same goes for a virtual printer's force-colour jobs sent to a specific printer, which the card used to hide the same way even though the scheduler dispatched them. Saving a virtual-printer job in the print dialog now keeps the PLA-variant pin (Basic, Matte or Silk) its force-colour overrides carry. Editing one queued for any printer of a model used to strip it. +- **Asking for 25 copies across two printer models queued exactly one (#3101, reported by @Leander-Vh)** — the reporter sliced one project for two printer types, selected both files in the File Manager, set the count and pressed Print. One item printed, the queue was empty behind it, and the Batches tab held nothing. Two features had met badly. Cross-model alternatives (#671) put several sliced files behind one queue slot, and a multi-plate file (#342) moves the run count out of the modal's single Quantity field onto a stepper beside each plate. The cross-model submit reads the field, not the steppers — and on a multi-plate file that field is not rendered at all, so it stayed at its initial 1 with nothing on screen able to change it. The modal read "19 runs in total" directly above a button that queued one. Cross-model no longer borrows the per-plate steppers: such a job picks its plate per candidate, down in the alternatives list, so there is only ever one number to give and the ordinary Quantity field is back. The plate selector above it is gone in that mode for the same reason — its choice never reached the request. It only decided which plate the filament panel described, which is why picking plate 3 for a candidate while plate 1 stayed ticked above it produced overrides computed from a plate the job would not print; that panel now follows the first candidate's own plate. The backend needed nothing for the count itself — it has always created one item per copy and given each its own candidate rows, so the same request now produces 25 jobs, each free to land on whichever of the two models frees up first. It did need a name: a cross-model job carries neither an archive nor a library file, because the candidates are the files, so both branches that name a batch missed and the order the reporter went looking for would have appeared in the Batches tab as "Batch". It is now named after its first candidate, the same file the print dialog names the job after. +- **A print from Bambu Studio archived under the name `plate_1` instead of its own (#3126, reported by @alex-2000-ac-ghetto)** — the reporter's X2D archived Bambu Studio prints with no thumbnail, no filament total and no layer count, while the same project sent from OrcaSlicer archived in full. Two separate things were happening. The first is the known internal-storage limitation: Bambu Studio filed the sliced file on the printer's eMMC (`"url": "brtc://emmc/..."`), which FTPS on port 990 does not serve, and the X2D does this like the H2 series and the P2S. Bambuddy's bounded probe then found a file of exactly that name on the card and downloaded it — but it was an earlier slice of the same project, plate 4, while the running print was plate 1. The guard added in #1204 caught the contradiction and refused it, which is right: archiving plate 4's thumbnail, filament and cost against this print is the swap #2957 removed. What it did next was not. Having rejected the file, it asked `swap_plate_suffix` for a name with the correct plate number and blanked the print's name entirely whenever that came back `None` — but `None` covers a name that carries no `- Plate N` suffix at all, and such a name holds no stale plate number to be wrong about. So a single-plate project lost the name Bambuddy was holding and the row fell through to the gcode_file path, titled `plate_1`. #1204's own premise is consecutive plates *of the same model*, so the project part of a lagging name is right either way; it is now kept as the archive's title, and only a suffix that really does point at the wrong plate is rewritten. Kept as the title and nothing else, though: a name the guard has just watched fetch another plate's 3MF still gets disowned everywhere a file is looked up by name. That includes the `_active_prints` keys, and it matters — the cover endpoint downloads the running print's 3MF under that same name for a thumbnail and offers the bytes to the fallback-recovery path, which checks that a candidate is a readable 3MF but never which plate it holds. Registering the row under the rejected name would have let it be filled in with the exact file this branch had just discarded. The archive is still thumbnail-less — nothing can read that eMMC file — but it carries the print's name again. Separately, the Archives banner and the connection diagnostic named only "H2-series and P2S" as the models that do this, in all 14 languages; an X2D owner reading either was told the explanation did not apply to them. All three now name the X2D too. +- **Linking a tag another spool already carries gave an answer nothing could act on, and a duplicate broke the request outright (#3110, reported by @Niko11111)** — the built-in inventory refused with "Tag UID already linked to another active spool", naming nobody, although the route had just loaded the conflicting spool row and was holding it when it wrote that sentence. Spoolman mode did name the spool, but only inside a different English sentence. Same operation, two answers, neither machine-readable, so a client had to parse prose to learn which spool to look at — and could only do it in one of the two inventory modes. Both now raise one structured 409, `{"code": "tag_already_linked", "message": ..., "spool_id": N, "field": "tag_uid" | "tray_uuid"}`, the shape Bambuddy already answers `insufficient_filament` and `printer_connection_failed` with. The SpoolBuddy kiosk reads it: a refused link showed a flat "Failed to assign spool" and now names the spool holding the tag, so the operator can walk to that spool instead of rescanning one that cannot link. The other half was worse than untidy. Nothing stops two active spools from carrying one tag — neither column has a unique index, `PATCH /inventory/spools/{id}` writes both with no conflict check, and `POST /inventory/spools/bulk` copies a single payload, tag included, into every row it creates — and the conflict lookup read its result with `scalar_one_or_none()`, which raises when two rows come back. That exception escaped the route into the authentication middleware’s fail-closed handler, so the caller was told "Authentication service temporarily unavailable" for a request that had nothing to do with authentication. The lookup is now ordered and takes the first row, the way the by-tag lookup earlier in the same file always has, and a duplicate yields a 409 naming the lowest id. +- **"Clear RFID Tag" was permanently greyed out for a spool linked by its Bambu tray UUID (#3109, reported by @Niko11111)** — the button gated on `tag_uid` alone, and a spool linked by its 32-character tray UUID carries none: Bambuddy splits a stored tag by length, so a 32-char value becomes `tray_uuid` and `tag_uid` stays empty. In Spoolman mode that is every Bambu Lab spool synced from the AMS — on the reporter's instance, 35 of the 39 tagged spools, not one of which could have its tag cleared from the dialog. Everything around the button already treated those spools as tagged: the Tag ID column renders whichever identifier is present, and the payload the button sends clears both fields. Only the one condition disagreed. Either identifier now enables it, and clearing still removes both. The documented workaround — editing `extra.tag` in Spoolman's own interface — is no longer needed. +- **Assigning a spool to a slot holding a non-Bambu filament configured nothing, and could delete the assignment afterwards (#3084, reported by @anthonyma94; #3100, reported by @Sawtaytoes)** — swap a Bambu spool for one the AMS cannot read and Assign Spool published no `ams_filament_setting` at all: the printer kept showing `?` on its own screen and in the slicer, and only Configure, which publishes unconditionally, put anything there. Three backend decisions were reading the tray's `state` field to answer "is there a spool in this slot", and `state` cannot carry that. It is firmware-variant — the A1 Mini BMCU and the P1S Standard AMS report 3 for a loaded slot and never emit 11, and an AMS-HT reports a *loaded* tray as 9 because it does not feed into a shared buffer (#2594) — and it is also partly Bambuddy's own writing: the empty-slot cleanup stamps `state = 9` on every slot whose `tray_exist_bits` bit is 0, and when the bit comes back it refreshes only the `exists` annotation beside it. Either way a slot the firmware said was full sat in the cache reading `exists=True, state=9` until something configured it, and the deferred configuration that is supposed to rescue a pre-assigned slot could not fire either, because its own "has a spool appeared" test was the same 9. That is the deadlock removed from the assign path in #1322, still in place one step further along. The same 9 is what deleted the assignments in #3100: the replay never fired, so the row kept the empty fingerprint it was stored with, and the first tray report that named a filament was read as a spool swap and unlinked. Every one of the three now reads `tray_exist_bits` first, which is the bitmask firmware actually answers this question with and the one the printer card has drawn its `?` from since #2527; `state` remains the fallback for the external spool and anything else whose payload carries no presence bit. A blank tray report from a slot the bit calls occupied no longer unlinks anything either — off a print as well as during one — because a filament the AMS cannot identify is not a filament that was removed. +- **The Finance page showed euros whatever currency the install was set to (#3123)** — it was the only surface in Bambuddy that read its currency from a data row (`user_wallets.currency`) rather than the `currency` setting, and it fell back to EUR where every other page falls back to USD. One variable drives every amount on that page, so the personal balance, the cost-center budgets and the whole transaction list were all wrong together. The backend was the other half of it: of the four places that settle on a currency, three wrote a hardcoded `"EUR"` — the wallet the API mints on demand, the wallet a print charge mints when none exists, and the balance returned for a user with no wallet row at all. The page now takes the configured currency from `/settings/ui-flags`, which is readable by anyone who can see Finance, and the API reports the setting. The `user_wallets.currency` column is gone altogether — an install has one currency and nothing here converts between them, so a per-wallet copy could only ever drift from the setting; a startup migration drops it from existing databases on both SQLite and PostgreSQL. Saving settings now also refreshes those flags, which nothing invalidated before. +- **On Windows and macOS the Virtual Printer's bind dropdown offered one entry per network adapter, not one per IP address (#3121, reported by @SJB-OLVG)** — each enabled virtual printer needs its own address, and the documented way to get several is to add secondary IPs to the adapter you already have. Linux reads those back through `ip -j addr show`, which reports every address; Windows and macOS have no `ip` command and fell through to an enumeration that stopped at the first IPv4 of each adapter. A Windows host with three addresses on one NIC therefore offered exactly one bind target, and creating a second virtual printer could only fail with "Bind IP ... is already in use". Every address is now listed on those platforms too, with the ones after an adapter's first marked as aliases exactly as on Linux. A Linux host without `iproute2` installed gains the same thing, having previously been limited to one address per interface for the same reason. The wiki gained the Windows instructions for adding those addresses, which it had for every other platform but that one, and the creation steps it describes now match the dialog — bind interface, model and access code are set on the printer's card after **Create**, not in the dialog itself. +- **A macOS native install could silently lose all access to the printer after a system or Homebrew update (#3114, reported by @TheeBobbyDonuts)** — macOS grants Local Network permission to a code signature, and judges a launchd-spawned process on its own rather than letting it inherit the grant of the Terminal that started it. Homebrew ships Python unsigned on Intel, so there was no identity for that grant to attach to: every connection to the printer was dropped with no error Bambuddy could log and no permission prompt to answer. The web interface worked, the printer showed as disconnected, all three port checks reported unreachable while the subnet check passed, and the log recorded no MQTT attempt at all — not a failure, just silence. Toggling the Python entry in Privacy & Security did nothing, because the entry referred to an identity that no longer resolved. `install.sh` now ad-hoc signs the interpreter during a macOS install, and `install/update_macos.sh` re-checks it on every update, since `brew upgrade python` lays down a fresh unsigned binary under a new versioned path. Both sign only what is currently unsigned: on Apple Silicon the linker already ad-hoc signs every binary, and re-signing one would rotate the hash its identity is built from and revoke a working grant on each update. The connection diagnostic also gained a **macOS Local Network permission** check, which reports the missing signature where there is one, and otherwise points at System Settings — where an Apple Silicon machine, whose identity changes with every Python upgrade, can re-grant it. +- **The Library bulk add-to-queue answered 200 for a call that queued nothing, and queued items nothing could dispatch (#3112, reported by @toxxicpickles)** — `POST /api/v1/library/files/add-to-queue` reported every per-file rejection in an `errors` array and returned `200` regardless, so a caller that checked the status code saw a successful request, no visible failure, and no queue item. That is now a `400` when nothing at all was added, with the same per-file reasons in the body; a call that created some items still succeeds, because it did. The items it created were also aimed at nothing: the route always wrote them with no printer and no target model, and the scheduler dispatches on one or the other, so those rows could never be picked up by anything and sat in Unassigned until someone opened each one and assigned it by hand. The request now takes an optional `printer_id` or `target_model` for the batch, and with neither it aims each file at the model its own G-code declares — when a printer of that model is active, since aiming a job at hardware nobody owns would only look like progress. Three gates `POST /api/v1/queue/` has applied for a while now apply here too, because an item reaching the scheduler through this route has to be as printable as one reaching it through that one: the cross-model check that stops a file sliced for one printer being dispatched to another, the filename check that would otherwise surface as a failed upload hours later, and the filament requirements the scheduler matches before handing a model-based item to hardware. This endpoint has no caller inside Bambuddy — the Library's own Print action goes through the queue API with a printer already chosen — which is how it came to drift this far from it. +- **The connection diagnostic told anyone whose LAN was not a /24 that their printer was on a different network (#3092, reported by @cwawak)** — the reporter's LAN is `192.168.96.0/22`, his Bambuddy sits at `192.168.96.9` and his printer at `192.168.98.170`, four hundred addresses inside the same subnet. The *Network subnet* check warned that the two were on different networks and told him to go configure routing between two halves of one LAN. An IPv4 address does not carry its prefix, and the check supplied `/24` for both sides — the most common LAN, and not the only one. It now reads the prefix off the interface that owns the address, so a `/22`, a `/16` and a `/25` are each read as written, and when no local interface claims the address the check is skipped rather than asserting a split it cannot see. The same check also picked which of Bambuddy's own addresses to compare by probing a route toward `10.255.255.255`, which on any host with more than one network card is not the card the printer is on; it now asks for the route toward the printer itself. On a two-NIC host that alone was enough to warn about a printer sitting on the second card's own subnet. +- **Podman and LXC installs were told they were not running in a container at all (#3092, reported by @cwawak)** — the *Docker network mode* check said "Not running in Docker — not applicable" to a Bambuddy inside a Podman container, which reads as "you are on bare metal" and sends people looking for the problem somewhere else. Podman runs Bambuddy in exactly the same two shapes Docker does, and the shape is what breaks printer discovery and the Virtual Printer. The check is now **Container network mode**: it names the engine — Docker, Podman, Kubernetes, containerd — and reports host or bridge networking for each of them. Detection of the mode itself gained the general form of the signal it always used: a container that can see an interface created in its own network namespace, rather than only one end of a veth pair, is sharing the host's. A container whose mode genuinely cannot be read now says so and suggests host networking if discovery is failing, instead of guessing "bridge" and telling a perfectly healthy install to recreate itself. An LXC or LXD system container is named too, and told that the question does not apply to it — it sits on the LAN like a small virtual machine, so there is no network mode to recommend. The support bundle now carries the engine name beside the Docker flag for the same reason. Discovery's own Docker detection is deliberately untouched: it switches the Add Printer flow from SSDP to subnet scanning, and SSDP works for a host-networked Podman container. +- **SpoolBuddy said "Unknown color" for spools Bambuddy names perfectly well (#3090, reported by @Sawtaytoes)** — the reporter scanned a Bambu Lab PLA Silk+ spool that his inventory card calls Candy Red and the kiosk showed the right red swatch above the words "Unknown color". The name was never in the spool record: Bambu's RFID tags often carry none, so Bambuddy has always resolved the swatch's own hex against the colour catalog instead — and the kiosk was printing the empty column. Every screen in SpoolBuddy that shows a colour now resolves it the same way the rest of Bambuddy does, which also stops the ones that had a name on file from showing Bambu's internal code ("A06-D0") in place of it. Searching the inventory by a colour you can read on screen finds it now, in the kiosk and in Bambuddy, instead of matching only what was stored. On Spoolman-backed inventory the catalog also wins over the spool's subtype, which Spoolman installs were being shown as a colour name because Spoolman has no field for one — so those spools said "Silk+" where they now say Candy Red. The label the kiosk falls back to when nothing can name a colour is translated in all 14 languages rather than being English for everyone. +- **An external RTSP camera could pass the connection test and still show a black live view (#3082, reported by @M1XZG)** — the reporter's Wyze cam, republished through go2rtc, captured a frame for the test button and played in VLC, but the live view produced no image and ended after a few seconds. The two RTSP paths were not asking ffmpeg for the same thing: the one-shot capture used ffmpeg's own probe defaults, while the live stream hard-coded a 32-byte probe with no analysis. That is enough for a camera that describes itself in its SDP, and not enough for one that sends its H.264 parameters in-band a moment later — ffmpeg then never starts a decoder and yields nothing at all. The live stream now probes on the defaults like the capture beside it, so the test button means what it appears to mean. The low-latency settings are unchanged, and so is the camera handling for Bambu's own printers, which is tuned per model against a camera we know. +- **The jog API pushed an A1's nozzle at the plate when asked for clearance (#1334, reported by @AQU4R1U5)** — `POST /printers/{id}/bed-jog` takes a signed nozzle-bed gap: positive asks for more room between the nozzle and the plate. The reporter sent `distance=5` to his A1 and the toolhead came down instead. The endpoint had been flipping the sign on A1 models since the original report on this issue, where an A1 Mini owner clicked an arrow labelled "move the plate up" and watched the nozzle dive — but that flip was solving a labelling problem in the transport layer, and it turned a parameter documented as model-independent into one that meant the opposite thing on a quarter of the fleet. `Z` is the nozzle-to-bed distance on every Bambu model, whether the bed drops away from a fixed nozzle or the nozzle rises off a fixed bed, so the endpoint now sends `distance` through unchanged and one call means one physical outcome everywhere: positive is the safe direction on every printer. The arrows on the printer card are unchanged and still show you what you would expect to move — the card works out which gap change its own arrows stand for, which is where a question about the machine in front of you belongs. On an A1, A1 Mini or A2L those buttons now say *toolhead* rather than *plate*, since a bed-slinger's plate does not move in Z at all. **A2L owners had none of this**: the original fix listed the A1 models by name and the A2L, which slings its bed the same way, was never on the list — its up arrow has been sending the toolhead down since the machine was supported. +- **A queue item pinned to one printer never said why it was waiting (#3074, reported by @Sawtaytoes)** — the reporter watched a job pinned to a specific X1C sit at Pending for fourteen minutes, with nothing in the UI or the API to say why, while that printer ran a print he had started from its own screen. The same job queued as "Any X1C" explains itself, because the model-based half of the scheduler builds a sentence for every way a job could not start and puts it on the row. The pinned half never wrote that field at all: the Home Assistant sensor interlock was its only writer, and it actively cleared the field whenever no sensor was holding the printer. Pinned items now say what they are waiting on in the same words — **Busy: X1C-01** while it prints, while it dries, or while another job on that printer goes first; **Offline, no Auto On smart plug: X1C-01** when nothing can switch it back on — and the row shows the purple **Waiting** badge instead of a silent Pending. "Finished, waiting for you to confirm the plate is clear" is split out from plain busy and named as itself, because it is the one case on that list that does not resolve on its own. A printer that is simply printing still sends no notification, exactly as before, and neither does a sensor interlock; only the cases that need you to go and do something do. On the queue timeline, a job that is merely next in line still draws its forecast bar as before, while one that is waiting for you now drops off it rather than promising a start time nobody can keep. Nothing about which job goes out, or when, has changed. +- **A plate printed entirely from the external spool stalled at preheat and failed (#3087, reported by @Notaseraf)** — the reporter's P1S heated up, sat there for ten minutes and then paused with HMS 07FF_8012, "Failed to get AMS mapping table". Resuming just reheated it; prints that used the AMS were fine. The plate was a single filament out of a seven-filament MakerWorld project, mapped by hand to the external spool. A project's filaments are numbered across the whole project, so a plate that prints only the seventh one carries six placeholder entries in front of it — the same shape BambuStudio sends. Bambuddy read that as "nothing here is on the spool holder" and told the printer to use the AMS anyway, with a mapping that pointed at no tray at all, which is precisely the mapping table the firmware then could not find. A plate whose every printed filament sits on the external spool now dispatches with the AMS switched off, so it prints. The decision is made against the plate's own filament list rather than guessed from the mapping alone, because a placeholder entry and a filament that could not be matched to any tray look identical in the mapping, and sending the second one to the spool holder would silently print it in the wrong material (#2589). A plate that mixes the spool holder with an AMS tray, or that has a filament which matched nothing, is untouched and still behaves exactly as before, as are dual-nozzle printers — on those the same switch selects which nozzle to feed rather than whether to use the AMS. +- **A printer that had been offline for hours could stop the whole server (#3068, reported by @bazza2000)** — the reporter's A1 had been unreachable for 38 hours but still answered on its MQTT port, which is exactly the case the connection watchdog exists for: rebuild the session with a fresh client so nothing left over from the dead one can replay onto the new print. The rebuild ended in a call that waits for the old connection's network thread to finish, and that thread was stuck part-way through a TLS handshake the printer never completed, where nothing can interrupt it. The wait had no limit and it ran on the thread that serves every request, so Bambuddy stopped answering — the web UI, the API and the health check alike — while the process itself stayed up, which is why Docker's restart policy never kicked in. Retiring an old connection no longer waits for it: the replacement is built immediately and the old one is shut down in the background, cut off from the new session first so a printer that eventually answers cannot report itself connected again after being replaced. The same wait sat on three other printer paths — the queue's dispatch recovery, the staleness check behind an ordinary status poll, and editing, deleting or disconnecting a printer — and on the shutdown of the MQTT relay and smart-plug connections, where a wedged broker kept the process from exiting at all. All of them are fixed together. A connection that takes more than a few seconds to shut down now says so in the log instead of silently taking the server with it. +- **Auto-drying skipped every composite spool (#3067, reported by @TheUltimateC0der)** — the reporter's AMS-HT would not dry PA6-CF, and drying the same spool by hand worked. The scheduler reduced a tray to a preset key by splitting on spaces only, so "PA6-CF" stayed "PA6-CF", matched none of the eight rows the preset table has, and the tray was read as holding nothing worth drying. It was never only nylon: of the 41 types a printer can report, 33 had no row under that rule, and 20 of those — every -CF, -GF and -AERO variant of PLA, PETG, ABS, ASA, PC and PA — have a base material sitting right there. The drying popover has resolved these correctly since 1.2.5, which is exactly why doing it by hand worked; the scheduler now shares that rule, including the alias map that reads PA6, PA11, PA12, PAHT, PPA and Nylon as PA. A row you have added for the exact type still wins over the base material's, and a material whose base is not in the table either is still left alone rather than dried at a temperature nobody chose. The same lookup decides per-filament humidity thresholds and the chamber preheat target, so a composite now gets the override and the preheat that were set for its material too. +- **An archive gave up on its 3MF for good after one slow transfer (#3063, reported by @dfrysinger)** — the reporter's P1S had the file on its card and was serving it; the 19 MB transfer just did not finish inside the budget while the printer was also running its camera, its status messages and the job upload. Bambuddy wrote an empty archive and never looked again — and then downloaded that same file successfully three times in the next two minutes, throwing each copy away, because the only code that would have attached one had already run. Recovery for a fallback archive existed but was armed for exactly one give-up, the FTPS cool-off. It now also covers a transfer that ran out of time: Bambuddy comes back after one, four and ten minutes and fills the row in where an attempt lands. The two are told apart by what the printer said — a file that genuinely is not on the card is answered with 550, and that answer does not improve with waiting, so nothing is scheduled for it. Nor for a 3MF that downloaded fine but turned out to be another plate's, where retrying would put back exactly what was just discarded. The archives banner has wording for this case too, because the old text sent an owner whose card was working to switch on a setting that was already on; it names the Connection Timeout setting instead. +- **Items Printed could not be set to 0 after a total plate failure (#3051, reported by @tdavis75)** — a jam ruined everything on the plate while the printer reported the job a success, so the honest count of usable parts was zero; the field refused to go below one. The manual quantity override is what the documentation points at for exactly this correction, and a project's completed-items count sums that column, so there was no way to tell a project that a job produced nothing. The floor was in the edit dialog alone — the API had always stored whatever it was given, which also meant a negative count was accepted and would have subtracted from the project totals. Zero is now typeable and the column is bounded at zero, and the Filament Trends widget counts a zeroed archive as no prints rather than silently reading the 0 as "unset" and charging one. +- **Bulk edit could not turn G-code injection on or off (#3058)** — every other per-item print option in the queue can be changed for a whole selection at once, but **Inject G-code** was only ever settable one item at a time, in the item's own edit dialog. The bulk dialog simply had no control for it, which on a queue of a few dozen jobs meant opening every single one to arm the start and end snippets an auto-print system needs — the thing bulk edit exists to avoid. It is now a tri-state next to **Auto power off after print**, unchanged by default like the rest, and it appears only once a G-code snippet has actually been saved for some printer model, matching the checkbox in the print dialog. Nothing changed on the server: the bulk endpoint has always accepted the field, so this was a missing control rather than a missing capability. +- **AMS Filament Backup switched itself off with every print started from the queue (#3040, reported by @frnzzle)** — it never actually did: the printer had auto-refill on the whole time, and Bambuddy was reading its own request back as telemetry. Every `project_file` Bambuddy sent carried `"cfg": "0"`, a field Bambu Studio has never sent and the firmware ignores, but the printer echoes a command's fields back in its acknowledgement — and `cfg` is the device-config bitmask whose bit 18 is auto-refill. The acknowledgement was ingested as though it were a status frame, so 25 ms after every dispatch the badge flipped to off. On a P2S, H2C or X2D it flickered back a second later, because those repeat `cfg` in their periodic status; the P1S, A1, A1 Mini and A2L send it only in a full status dump, which arrives on connect and on Force Refresh and otherwise not at all, so there the wrong value stood until someone toggled it by hand. That mattered beyond the badge: the "prefer lowest remaining" sort is deliberately skipped while the printer reports backup off, since without a second spool to fall back on, feeding a print from the emptiest one risks running dry mid-job — which is why near-empty spools were being left untouched, the symptom that opened the report. On the A1 family it was worse still: they report no `cfg` at all, and the state is meant to stay "unknown" so that behaviour is left exactly as it was before the feature existed; the echo turned that into a definite "off" on the first queue print. Command acknowledgements are no longer read as status — for the backup bit or for the per-job timelapse flag they also echo — and the `cfg` field is gone from the print command, which is not a place to be writing printer settings from. +- **K values missing on a second AMS, and its slots unconfigurable (#3044, reported by @Zib-Astian)** — an X2D with two AMS 2 Pro, one per hotend, showed K on every slot of the first and nothing on any slot of the second. Configure Slot was worse than blank there: the picker offered no matching profile, the slot read as though it had none bound, and choosing one changed nothing visible. Both came from the same rule. A calibration index can mean two different profiles on a dual-nozzle printer — on the maintainer's H2C, index 16 is the left hotend's black PLA at K=0.018 and 15 is the right's at K=0.020 — so Bambuddy resolves it against the slot's own hotend and shows nothing rather than the wrong number. But a printer does not always file one profile per hotend. This one files one per filament, so the second AMS's slots point at the same entries as the first, all tagged with a single extruder, and requiring a match found nothing at all. The hotend now has to actually appear in the table the printer sent before it is used to narrow anything; where it does not, the index stands on its own, which is what BambuStudio does for the same card. Printers that do file per hotend are unaffected — the wrong-nozzle number still cannot be shown, and the other hotend's profiles stay under **Other K profiles**. Also fixed alongside it: on a dual-nozzle printer the K-profile dropdown named the hotend twice on every matching option and three times on every other one. +- **The Timeline ignored Shortest Job First (#3043)** — turning SJF on reordered the pending list and the scheduler itself, but the Timeline tab kept drawing the queue in its pre-SJF order, indefinitely. It chained each swimlane's bars by queue position alone and was never told the setting existed, so the one view whose entire job is to say *when* each print will run was the one view answering for an order the scheduler had no intention of using. Bars now chain in the scheduler's own order — jumped items first, then shortest print time, then position — and the starvation guard is visible there too, so a long print that has finally come up shows as such instead of staying buried. All three surfaces that claim to show queue order (the pending list, the "if started now" ETA, and the Timeline) now share one comparator rather than three hand-copied ones. Along the way the pending list stopped folding a model name down to its first letter when grouping lanes, which had been interleaving *Any X1C* with *Any X2D* (and *Any P1S* with *Any P1P*) into a single run of rows. +- **Preheat & Heat Soak delayed PLA prints by minutes with nothing to preheat for (#3041)** — with the feature on, a queued PLA print sat for five to seven minutes before the file was even uploaded, and turning the feature off started it immediately. The per-filament map does what it says and gives PLA a chamber target of 0, and the chamber phase duly skipped — but the stage then heated the bed to the print's own temperature, waited for it, and held the full soak anyway, because the soak had no idea it was holding for a chamber that was never asked for. That is pure delay: the print's G-code sets the bed the moment it starts, so preheating it here only moved the warm-up ahead of the upload instead of overlapping with it, and there was no chamber to condition. A chamber target of 0 derived from the filament map now skips the whole stage, so PLA, PETG, TPU and PVA prints dispatch straight away with the feature left on. The one thing the skip still does is put the airduct flap back to cooling, which is a single command and no wait — an H2D left in heating mode by the ABS job before it would otherwise cook the PLA that follows. Explicit instructions are untouched: a chamber target of **0** typed into a print's own override still heats the bed and runs the soak, which is what the queue documentation has always promised it does, as does forcing a print's Preheat override to **On**. Prints that genuinely want chamber heat are unaffected, including on the P1S/P1P/A1 tier where the bed and the soak timer are the whole mechanism. +- **Server-side slicing rejected MakerWorld 3MFs over filament-index fields, and the plate preview was never sanitised at all (#3030, reported by @kielsucks)** — Bambu Studio writes `0` into `wall_filament`, `sparse_infill_filament` and `solid_infill_filament` to mean "use whichever filament the object is set to". Bambu Studio and OrcaSlicer 2.4 both accept that; OrcaSlicer 2.3 and earlier counted these from 1 and reject it outright with `wall_filament: 0 not in range [1.000000,...]`, so a sidecar pinned to one of those images refused the file before it ever looked at the profiles Bambuddy supplied. This is the same shape as the `-1` inherit markers fixed in #1201, with a different marker value, so the sanitiser's allowlist is now a key-to-marker map rather than one global constant — a `-1` on a filament index is a real value and is left alone, as is a `0` on a raft field. The key is removed rather than rewritten, which is what makes it safe on every build: the slicer then falls back to its own default, which is the same `0` on the versions where `0` was legal and `1` on the older ones, which is what "the active filament" means there. Fixed alongside, and the more consequential half: **the plate preview never ran the sanitiser**, so a file that sliced fine could still fail the automatic preview and fall back to a guessed filament list — and it matters more there than in a real slice, because the preview deliberately runs on the file's own embedded settings and so has no `--load-settings` pass that could supply a replacement for a rejected field. Both paths now share one sanitiser. +- **The Slice action offered Bambu Studio files its URI handler cannot load (#3029)** — "Open in Slicer" was offered for `.stl`, `.step` and `.stp` as well as `.3mf`, on the reasonable assumption that a slicer which opens an STL from its own File menu will open one from a link. Bambu Studio does not. Every `bambustudio://` and `bambustudioopen://` URL goes through one import path that refuses any filename which is not `.3mf`, and it refuses it *before* fetching anything — the user gets "Download failed, unknown file format", which names the format and so reads as a broken model rather than an unsupported handoff. OrcaSlicer has no such limit: only MakerWorld links go down its equivalent path, and a link to your own Bambuddy goes to its general downloader, which does not look at the extension at all. So the action is now offered per slicer. With **Open in Slicer** set to Bambu Studio the File Manager offers Slice on 3MF only; set it to OrcaSlicer and STL, STEP and STP come back. In the 3D preview, where both slicers are reachable from one split button, a file the configured slicer cannot take is offered to the other one instead, with the button naming it rather than quietly sending the file somewhere the setting did not ask for. None of this touches the in-app slicer: with **Use Slicer API** on, Slice goes to the sidecar and still takes STL and 3MF whichever desktop slicer is configured. +- **"Slice" handed the slicer a download link that only worked once (#3029)** — the Slice action in the File Manager, and Open in Slicer on the Archives page, mint a short-lived token and put it in the URL, because a protocol handler cannot carry an `Authorization` header. That token was spent by the first request to reach the endpoint, which made the handoff depend on the slicer fetching the URL exactly once — and nothing guarantees that. Bambu Studio's own downloader retries three times after a failed attempt, transfers get resumed, on-access scanners fetch. Whichever request arrived first won and every later one got a 403, so the slicer opened on an error about the file instead of on the file, and the message it showed described the 403 body it had been handed rather than anything wrong with the model — which made this very hard to read from the user's side. The three protocol-handler downloads — a library file, an archive's sliced 3MF, an archive's source 3MF — now accept their token for the rest of its five-minute life. Nothing else about it changed: it is still bound to the single file it was minted for, still expires in five minutes, and is still refused everywhere else. The two downloads that are not handoffs stay single-use, because what they hand over is itself consumed — a prepared printer-file bundle is deleted the moment it has been streamed. Fixed on the way through: **Open source 3MF in slicer never worked at all with authentication enabled**. The gateway that lets these token-in-URL downloads past the login check matches the path fragment `/dl/`, and the source 3MF's route says `source-dl`, which does not contain it — so the slicer's header-less request was turned away as unauthenticated before the route's own token check ever ran. It failed on every install with auth on, and only there. +- **`camera:view` was required to see any image in the app (#3025, reported by @lonix)** — thirteen routes that have nothing to do with a camera took the camera stream token as their credential: library and archive thumbnails, plate previews and plate thumbnails, timelapses, print photos, archive QR codes, project cover images, print-log thumbnails, printer cover images and external-link icons. A browser cannot put an `Authorization` header on an ``, so those routes need a credential that fits in the URL, and the camera token was the only one there was. But the only way to mint one is `camera:view`, so a user granted library access to their own files got a grid of broken images until they were also granted the live camera — which on a home install points at the room the printer is in, and is not something you hand to a commission client so their own thumbnails will load. Those routes now take a new media token, minted by `POST /auth/media-token` behind plain authentication and carrying the identity of whoever asked, so each of them applies the permission its own resource is governed by: `library:read_own` for a library thumbnail, `projects:read` for a project cover, `external_links:read` for a sidebar icon. The camera stream token keeps the three routes it was named for. The frontend fetches a media token for every signed-in user and asks for a camera token only when the user can actually mint one, which also stops the 403 that used to fire on every page load for everyone else. Fixed on the way through: the printer file-browser's plate thumbnails, which had no query-token support at all and so 401'd in the file manager whenever auth was enabled; and the project cover image in the edit dialog, whose cache-busting `?v=` was appended after the token and corrupted it. + + **Upgrade note:** a long-lived `camera_stream`, `camwall` or `overlay` token is no longer accepted on those thirteen routes — the split is the point of the change, and those tokens are handed to kiosks, walls and Home Assistant to display video. The cam wall, the streaming overlay and the kiosk views use only the three camera routes and are unaffected. If you have an integration pulling a thumbnail or a cover image with a pasted camera token, it should authenticate with an API key instead: the media routes accept `X-API-Key` and `Authorization: Bearer` directly, which the camera-token-only versions did not. +- **The Finance sidebar entry was hidden from every non-admin, whatever their permissions (#3023, reported by @lonix)** — `cost_centers:read_own` exists so a user can see their own wallet, balance and cost-centre spend, and the page itself always worked if you typed the URL. The sidebar hid it anyway. It decides whether to offer Finance by reading `billing_enabled` from `GET /settings`, which requires `settings:read`; a non-admin gets 403 there, so the value arrived undefined and the entry was suppressed for exactly the people the permission was written for. The shell read three more fields from that same 403, and one of them failed the other way: the Notifications gate tests for `false`, which an undefined value never is, so an administrator who switched user notifications **off** still left the entry showing to the non-admins it governs — a fault no administrator could reproduce, because administrators can read `/settings`. The sponsor prompt also showed EUR regardless of the configured currency, and the update check ran on installs where it had been turned off. `settings:read` cannot be the price of any of this: it also grants sight of the SMTP, LDAP and MQTT credentials. These four now come from a new `GET /settings/ui-flags`, which asks only that the caller be signed in. It is deliberately not more fields on the existing public `/settings/ui-preferences`: that endpoint is served to anyone at all because its contents are defaults shipped with the app, while these describe how your particular install is configured, so they sit behind authentication instead. +- **A queued print showed "ASAP" in the queue even when Queue was the option chosen (#3018, reported by @kilrah; also #2557, reported by @ddavidebor)** — the Print dialog offers ASAP, Queue and Schedule, but ASAP and Queue differ only in where the item lands in the list, and neither is recorded on the item itself. The queue's time column had nothing to read but the scheduled time, so it labelled every unscheduled item "ASAP" — the name of the one mode the user may well not have picked. Someone who chose Queue, watched the row appear as ASAP and then watched it start, reasonably concluded Bambuddy had overridden them. That column answers when an item runs, so it now says that: **When a printer is free**, in all fourteen languages. The dispatch itself was correct and is unchanged — a print scheduled for later does not reserve the printer until then, so an unscheduled item behind it uses the printer in the meantime rather than leaving it idle until the scheduled time. The queue's own per-printer log line was no help in telling those apart either: it called a printer "not available" whether the queue could not use it or had just picked it for this pass, and printed printer state read at the moment of logging rather than the state the decision was made on — so a support bundle could show "printer 1 not available — state=IDLE" immediately above that printer receiving a job. Each printer now reports which of the two it was, and why. +- **An archive left empty by a printer refusing FTPS blamed the slicer (#2780, reported by @AntonPalmqvist)** — when a printer's file service answers port 990 with something that is not TLS, Bambuddy pauses transfers to it and the print in flight is archived with its name and timing only. That cause has been recorded on the archive since #2957, but the Archives banner was never taught about it: it ranked the three causes it knew, found none of them, and fell back to the original wording — the slicer did not leave the file on the card, go and switch on "Store sent files on external storage", here is a link to installation step 4. Every part of that is wrong for a refused handshake. The slicer did write the file, the reporter could see it on the stick from his own computer, the setting was already on, and there is nothing on his side to change. Worse, the banner dismisses one-shot into browser storage, so being shown the wrong explanation once meant never being shown the right one. It now has its own wording, in all fourteen languages, saying that the printer refused the connection, that this is not a slicer setting and not something the operator did, that Bambuddy comes back for the file after the five-minute pause so a brief episode fills itself in, and that a card still empty means the refusal outlasted the retry. It links to the troubleshooting entry for the handshake failure rather than to the installation guide. This cause now outranks the other three rather than falling to the bottom: the other three describe an install working as configured and each ends in something the operator can change, while this one reports a fault we cannot yet explain — and given the one-shot dismissal, ranking it below anything else would have hidden it permanently. It cannot mask a permanent cause in return, because the retry clears the marker when it succeeds, so a card still carrying this one is a printer whose file service is still refusing. The wiki page these reports are pointed at has also been corrected: it told people to power-cycle the printer, which the reporter who prompted that advice did on both of his printers to no effect, and which the code stopped saying for that reason. It now states what has been measured, says plainly that the trigger is unknown, and names the one log line worth collecting. +- **Custom filament profiles arrived in the slicer as Generic, or as the Bambu profile they were built on (#3003, reported by @marivo)** — the slot's filament id is the one field a custom profile travels in, and it holds eight characters on the printer. Bambuddy was putting an eighteen-character preset identifier in it whenever it could not find a real filament id. The printer stored the first eight and reported success, so the slot pointed at something that resolves nowhere: the slicer showed Generic and the printer's calibration table, keyed by the same field, no longer had a slot to key. Three printers in the support archive show it happening — an A1, a P1S and an H2D — so it was never specific to one model. Bambuddy now sends the slot's existing filament id, or the generic one for the material, both of which fit. Orca Cloud profiles were additionally never looked up at all, so theirs went in as a thirty-six-character identifier and fared worse still; they are now resolved like every other source. Also fixed on the way through: a failed Orca Cloud sign-in left its HTTP connection behind instead of closing it, which went unnoticed while only the settings page could trigger it and would have repeated on every spool assignment once the lookup above started using the same code. A profile that carries no filament id of its own — which is every profile created in OrcaSlicer, whose preset format has no such field — still cannot be told apart from the one it inherits from. +- **Hovering a muted control in the light theme made its label vanish (#1909, reported by @AntonPalmqvist)** — the tab strip on the Print Queue page was where it got noticed: point at **Batches** and the word turned white on a near-white background. It was never about that tab. The light theme keeps `text-white` readable by remapping it to the theme's foreground colour, but that remapping only ever matched the plain utility, not the `hover:` variant Tailwind compiles to a different selector — so roughly 400 controls across the app that dim their label at rest and brighten it on hover were brightening it to literal white, whatever the theme. They now follow the theme like everything else. Dark themes are unaffected, their foreground colour already being white, and the handful of buttons that turn a solid accent colour on hover read better for it rather than worse. + +### Security +- **Bumped the Tiptap editor stack to 3.31.1 for a prototype-manipulation advisory in `@tiptap/core` (GHSA-cp6q-959q-f8rh)** — `mergeAttributes()` copies keys straight out of `Object.entries()` with ordinary bracket assignment, so an own `__proto__` key coming from JSON invokes the legacy prototype setter instead of writing a property: the returned object carries an attacker-controlled prototype while `Object.keys()` and own-property checks show nothing. That matters because ProseMirror's `DOMSerializer.renderSpec()` enumerates attribute objects with `for...in`, which walks inherited keys — an inherited `src` and `onerror` become real attributes on a rendered `` and the handler runs in the app's origin. Medium severity, CVSS 4.0 6.4; the fix landed in 3.30.4 and the whole stack moves 3.19.0 → 3.31.1. **No running Bambuddy install was exposed.** The advisory needs either an untrusted object reaching `mergeAttributes()` or a custom/dynamic extension that preserves the attribute object, and Bambuddy has neither: nothing under `frontend/src/` calls `mergeAttributes` or defines an extension, and the one editor (`RichTextEditor`) builds a fixed schema from StarterKit plus six stock extensions whose `HTMLAttributes` are static literals. Content also crosses the boundary as an HTML **string**, never as JSON, so no own `__proto__` key can reach an attributes object in the first place — ProseMirror's DOM parser only fills in the attributes the schema declares — and every read-only render of that content is sanitized (`DOMPurify.sanitize` for project notes, `sanitizeHtml` in the project-page modal). This is a lockfile-only change: `frontend/package.json` already declared `^3.11.1`, so the patched line was inside the existing range and only the stale lock held it back; no `overrides` entry was needed. A side effect worth recording is that `@tiptap/pm` has narrowed what it pulls in, so `prosemirror-markdown`, `prosemirror-menu`, `prosemirror-collab`, `prosemirror-schema-basic`, `prosemirror-trailing-node`, `markdown-it` and `linkify-it` leave the tree entirely (16 packages) — which retires the reachability argument recorded for the `linkify-it` bump in 1.2.5, since that package is simply no longer there. Verified with eslint, the production build and its Safari 16 baseline check, and the full frontend suite (3514 tests across 256 files); `npm audit --omit=dev` reports zero vulnerabilities. +- **Bumped the build and lint toolchain for three development-dependency advisories in `browserslist` and `@humanfs/node` (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx, GHSA-p498-v437-472g)** — `browserslist` moves 4.28.1 → 4.28.8 for two high-severity issues: `normalizeStats()` walks an untrusted `browserslist-stats.json` with an unguarded `for...in` and uses the keys for plain bracket access and assignment, so a `__proto__` or `constructor` key either crashes the build or writes to the prototype (CVE-2026-73088), and the query-result cache has no eviction at all, so a long-lived process fed distinct queries grows without bound (CVE-2026-73089). `@humanfs/node` moves 0.16.7 → 0.16.8 for a medium-severity path-traversal issue where `copyAll()` ignores symlink state and `fs.copyFile()` dereferences the link, copying data from outside the source tree. **No running Bambuddy install was exposed, and neither issue was reachable even at build time.** Both packages are development-only — they are absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. `browserslist` is never called by our own code; it arrives under `autoprefixer` and `@babel/helper-compilation-targets`, there is no `browserslist-stats.json` anywhere in the repository or up the directory tree, no `browserslist` key in `package.json` and no `.browserslistrc`, and nothing passes `--stats` or `opts.stats`, so the untrusted input the first advisory needs has no way in; the unbounded cache needs a long-lived process taking attacker-chosen queries, where `vite build` is one-shot with a fixed query. `@humanfs/node` arrives under `eslint`, which calls only `isDirectory` and `walk` and never copies anything, so the copy path the advisory describes is never entered. Lockfile-only — every existing range already admitted the patched versions, so `frontend/package.json` is untouched. The bump carries `caniuse-lite` 1.0.30001769 → 1.0.30001810, `baseline-browser-mapping` 2.9.19 → 2.11.20, `electron-to-chromium` 1.5.286 → 1.5.420, `node-releases` 2.0.27 → 2.0.54 and `update-browserslist-db` 1.2.3 → 1.3.2, all of which feed autoprefixer's target data — the rebuilt bundle is byte-identical, same content hashes, so `static/` does not change. Verified with a clean build against the Safari 16.0 baseline check, eslint, 3514 frontend tests across 256 files, i18n parity in all 13 locales, and `npm audit` reporting zero findings with and without dev dependencies. +- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()` treats a central-directory entry whose compressed size is the Zip64 sentinel `0xFFFFFFFF` as a Zip64 entry and scans its extra field for the header id `0x0001`, with nothing telling the scan where that field ends: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, reference none of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reason a package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone here rather than moved in a security bump. +- **Bumped Vitest to 4.1.11 for a path-traversal advisory in `@vitest/mocker` (GHSA-82fw-gwwq-j7x9)** — the mocker registers a redirect mock's target path without checking it against Vite's file-serving allowlist, and the plugin's `load` hook then hands back `readFile(mock.redirect)` as the module source. Registration derives that path as `join(server.config.root, new URL(event.redirect).pathname)`, which does not confine anything: a non-special scheme leaves `..` segments in `pathname`, so the join resolves outside the project root, and even a path that stays inside it is read without the `server.fs.deny` check the dev server would otherwise apply to an in-root `.env`. Medium severity, CVSS 3.1 5.9, CWE-22; fixed in 4.1.11 and 5.0.0, with 2.1.x and 3.x unmaintained. **No running Bambuddy install was exposed, and the issue is not reachable in this project's test runs either.** The unauthenticated variant is specifically the public `mockerPlugin` and standalone `interceptorPlugin` exports, which attach the handler to Vite's HMR WebSocket — a socket with no token, Origin or same-origin check — and those exports exist for third-party dev servers embedding the mocker; nothing under `frontend/src/` imports either one. Vitest's own browser mode registers mocks over a token-authenticated RPC instead, and it is not installed here at all: `@vitest/browser` appears in the lockfile only as an unmet optional peer, and `vitest.config.ts` runs a plain jsdom environment, so no dev server is listening during a test run in the first place. Both packages are development-only — absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. Fifteen lockfile entries move, every one of them dev-scoped, with nothing added or removed: the eight `@vitest/*` packages and `vitest` itself go 4.1.8 → 4.1.11, carrying `es-module-lexer` 2.1.0 → 2.3.2, `expect-type` 1.3.0 → 1.4.0, `obug` 2.1.1 → 2.2.1, `std-env` 4.1.0 → 4.2.0, `tinyexec` 1.2.4 → 1.3.1 and `tinyrainbow` 3.1.0 → 3.1.1. Unlike the other entries here this one does touch `frontend/package.json`: the existing `^4.1.8` range already admitted the patched version, so the lock alone would have pinned it, but the declared floor is raised to `^4.1.11` so that a regenerated lockfile cannot resolve back below the fix. Nothing in `src/` changed, so the rebuilt bundle is byte-identical and `static/` does not move. Verified with the full frontend suite running on 4.1.11 (3562 tests across 259 files), eslint, the production build and its Safari 16.0 baseline check, i18n parity in all 13 locales, and `npm audit` reporting zero vulnerabilities with and without dev dependencies. + + + ## [1.2.5.5] - 2026-08-30 ### Changed @@ -11,12 +73,6 @@ All notable changes to Bambuddy will be documented in this file. ### Fixed - **Every camera stopped working on 1.2.5.4 (#3001, reported by @Jieper001, confirmed by @JmanB52D and @hikingthunder)** — live view, snapshots, timelapse frames and the camera diagnostic all failed at once on every RTSP model — X1, H2 and P2 — with the in-app diagnostic reporting `capture_exception` at 0 ms while network reachability passed at 1 ms. That 0 ms is the whole story: the failure happened before a socket was opened. The RTSPS proxy added in 1.2.5.4 finished by hanging its set of in-flight connection handlers on the server object as an attribute. `asyncio.start_server` returns an `asyncio.base_events.Server`, which has a `__dict__` and accepts that; under uvloop it returns a `uvloop.loop.Server`, a Cython cdef class with no `__dict__`, which raises `AttributeError` outright. Every launch path this repo ships — the Dockerfile, `install/install.sh`, `deploy/bambuddy.service`, the Windows service and the SpoolBuddy installer — pins `--loop asyncio`, added for #1896, so none of them selects uvloop and none of them could hit this. What broke is the installs running a unit file we did not write. The Proxmox VE Helper-Scripts LXC composes its own `ExecStart` with no loop pinned, and `requirements.txt` pins `uvicorn[standard]`, which installs uvloop on Linux, so uvicorn's default `--loop auto` selects it — that is the reporter's install and the two that confirmed it. Native installs created before the #1896 pin landed on 2026-07-05 are in the same position for a different reason: `install/update.sh` never rewrites the unit file, so a service written before that date has never been given the flag by any update since. Those installs are also still exposed to #1896 itself, where a truncated Virtual Printer FTP upload corrupts a `.gcode.3mf` silently; the camera outage is simply the visible half. Hence a fix in the code rather than another flag in a unit file: the proxy now works on either loop instead of depending on the launch command to steer around it. The handler set now lives in a module-level registry keyed weakly by server, which both loops accept; keying it weakly rather than by `id(server)` means a proxy abandoned without a close takes its entry with it, instead of leaking one forever and eventually handing a new server a dead one's handlers once CPython recycles the address. A1 and P1 use the chamber-image protocol and return before the proxy is built, so they were never affected, and external RTSPS cameras caught the error and fell back to a direct connection, so they kept working without the TLS workaround. The reason the test suite could not see any of this is that `conftest` builds its event loop from the default policy, so every async test in the repo runs on the selector loop — the one loop where the assignment was legal. The regression is now pinned twice: once by a test that drives the real function on a real uvloop loop, and once by a test that gives it a `__slots__` server, so the contract holds even where uvloop is not installed. The two external-camera teardowns were also switched to `close_tls_proxy`, which #2968 introduced and left them out of, so they no longer leave handlers running past the server that owned them. -### Security -- **Bumped the Tiptap editor stack to 3.31.1 for a prototype-manipulation advisory in `@tiptap/core` (GHSA-cp6q-959q-f8rh)** — `mergeAttributes()` copies keys straight out of `Object.entries()` with ordinary bracket assignment, so an own `__proto__` key coming from JSON invokes the legacy prototype setter instead of writing a property: the returned object carries an attacker-controlled prototype while `Object.keys()` and own-property checks show nothing. That matters because ProseMirror's `DOMSerializer.renderSpec()` enumerates attribute objects with `for...in`, which walks inherited keys — an inherited `src` and `onerror` become real attributes on a rendered `` and the handler runs in the app's origin. Medium severity, CVSS 4.0 6.4; the fix landed in 3.30.4 and the whole stack moves 3.19.0 → 3.31.1. **No running Bambuddy install was exposed.** The advisory needs either an untrusted object reaching `mergeAttributes()` or a custom/dynamic extension that preserves the attribute object, and Bambuddy has neither: nothing under `frontend/src/` calls `mergeAttributes` or defines an extension, and the one editor (`RichTextEditor`) builds a fixed schema from StarterKit plus six stock extensions whose `HTMLAttributes` are static literals. Content also crosses the boundary as an HTML **string**, never as JSON, so no own `__proto__` key can reach an attributes object in the first place — ProseMirror's DOM parser only fills in the attributes the schema declares — and every read-only render of that content is sanitized (`DOMPurify.sanitize` for project notes, `sanitizeHtml` in the project-page modal). This is a lockfile-only change: `frontend/package.json` already declared `^3.11.1`, so the patched line was inside the existing range and only the stale lock held it back; no `overrides` entry was needed. A side effect worth recording is that `@tiptap/pm` has narrowed what it pulls in, so `prosemirror-markdown`, `prosemirror-menu`, `prosemirror-collab`, `prosemirror-schema-basic`, `prosemirror-trailing-node`, `markdown-it` and `linkify-it` leave the tree entirely (16 packages) — which retires the reachability argument recorded for the `linkify-it` bump in 1.2.5, since that package is simply no longer there. Verified with eslint, the production build and its Safari 16 baseline check, and the full frontend suite (3514 tests across 256 files); `npm audit --omit=dev` reports zero vulnerabilities. -- **Bumped the build and lint toolchain for three development-dependency advisories in `browserslist` and `@humanfs/node` (GHSA-73wf-gq98-2v4g, GHSA-c83g-rgw3-j3cx, GHSA-p498-v437-472g)** — `browserslist` moves 4.28.1 → 4.28.8 for two high-severity issues: `normalizeStats()` walks an untrusted `browserslist-stats.json` with an unguarded `for...in` and uses the keys for plain bracket access and assignment, so a `__proto__` or `constructor` key either crashes the build or writes to the prototype (CVE-2026-73088), and the query-result cache has no eviction at all, so a long-lived process fed distinct queries grows without bound (CVE-2026-73089). `@humanfs/node` moves 0.16.7 → 0.16.8 for a medium-severity path-traversal issue where `copyAll()` ignores symlink state and `fs.copyFile()` dereferences the link, copying data from outside the source tree. **No running Bambuddy install was exposed, and neither issue was reachable even at build time.** Both packages are development-only — they are absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. `browserslist` is never called by our own code; it arrives under `autoprefixer` and `@babel/helper-compilation-targets`, there is no `browserslist-stats.json` anywhere in the repository or up the directory tree, no `browserslist` key in `package.json` and no `.browserslistrc`, and nothing passes `--stats` or `opts.stats`, so the untrusted input the first advisory needs has no way in; the unbounded cache needs a long-lived process taking attacker-chosen queries, where `vite build` is one-shot with a fixed query. `@humanfs/node` arrives under `eslint`, which calls only `isDirectory` and `walk` and never copies anything, so the copy path the advisory describes is never entered. Lockfile-only — every existing range already admitted the patched versions, so `frontend/package.json` is untouched. The bump carries `caniuse-lite` 1.0.30001769 → 1.0.30001810, `baseline-browser-mapping` 2.9.19 → 2.11.20, `electron-to-chromium` 1.5.286 → 1.5.420, `node-releases` 2.0.27 → 2.0.54 and `update-browserslist-db` 1.2.3 → 1.3.2, all of which feed autoprefixer's target data — the rebuilt bundle is byte-identical, same content hashes, so `static/` does not change. Verified with a clean build against the Safari 16.0 baseline check, eslint, 3514 frontend tests across 256 files, i18n parity in all 13 locales, and `npm audit` reporting zero findings with and without dev dependencies. -- **Bumped `fflate` to 0.8.3 for a denial-of-service advisory reachable through three's compressed-format loaders (GHSA-px8p-9vwx-vf98, #3034)** — `unzipSync()` never returns on a crafted ZIP whose central-directory entry declares the ZIP64 sentinel `compressed_size=0xFFFFFFFF` but omits the required ZIP64 extra field, tag `0x0001`: `z64e()` then reads past the end of the buffer, the `undefined` that comes back coerces to 0, and the loop condition can never go false, so the tab spins at 100% CPU until it is killed (CVE-2026-45820, medium, CWE-400). **No running Bambuddy install was exposed.** Unlike the other two entries here this one is classified runtime rather than development scope, so it is worth saying exactly why it cannot fire: `fflate` reaches the tree only as a dependency of `@types/three`, which is a types-only package whose imports TypeScript erases at compile time, so it never becomes a runtime import at all. The ten three.js addons that genuinely call it — `3MFLoader`, `FBXLoader`, `KMZLoader`, `AMFLoader`, `EXRLoader`, `USDLoader`, `VTKLoader`, `NRRDLoader`, `USDZExporter` and `EXRExporter` — are imported nowhere in the frontend or the backend; the four this project does use, `OrbitControls`, `BufferGeometryUtils`, `STLLoader` and `RoomEnvironment`, reference none of it. `3MFLoader` is the one worth checking twice given what Bambuddy spends its time reading, and it is genuinely absent: 3MF files are parsed on the backend, not in the model viewer. The shipped bundle confirms it, carrying no fflate signature whatsoever — `unzipSync`, `z64e`, `invalid zip data`, `no stream handler` and `extra field too long` are all absent, and the one inflate error string that does appear belongs to pako, which the surrounding `e.msg=` / `n.mode=30` zlib-port idiom identifies unambiguously. Lockfile-only: three lines, no `frontend/package.json` change, nothing added or removed, and no transitive churn. The reason a package that never ships shows up in runtime scope at all is that `@types/three` sits in `dependencies` rather than `devDependencies`, which is left alone here rather than moved in a security bump. -- **Bumped Vitest to 4.1.11 for a path-traversal advisory in `@vitest/mocker` (GHSA-82fw-gwwq-j7x9)** — the mocker registers a redirect mock's target path without checking it against Vite's file-serving allowlist, and the plugin's `load` hook then hands back `readFile(mock.redirect)` as the module source. Registration derives that path as `join(server.config.root, new URL(event.redirect).pathname)`, which does not confine anything: a non-special scheme leaves `..` segments in `pathname`, so the join resolves outside the project root, and even a path that stays inside it is read without the `server.fs.deny` check the dev server would otherwise apply to an in-root `.env`. Medium severity, CVSS 3.1 5.9, CWE-22; fixed in 4.1.11 and 5.0.0, with 2.1.x and 3.x unmaintained. **No running Bambuddy install was exposed, and the issue is not reachable in this project's test runs either.** The unauthenticated variant is specifically the public `mockerPlugin` and standalone `interceptorPlugin` exports, which attach the handler to Vite's HMR WebSocket — a socket with no token, Origin or same-origin check — and those exports exist for third-party dev servers embedding the mocker; nothing under `frontend/src/` imports either one. Vitest's own browser mode registers mocks over a token-authenticated RPC instead, and it is not installed here at all: `@vitest/browser` appears in the lockfile only as an unmet optional peer, and `vitest.config.ts` runs a plain jsdom environment, so no dev server is listening during a test run in the first place. Both packages are development-only — absent from the shipped image, and `npm audit --omit=dev`, which is what CI gates on, reported zero findings before and after. Fifteen lockfile entries move, every one of them dev-scoped, with nothing added or removed: the eight `@vitest/*` packages and `vitest` itself go 4.1.8 → 4.1.11, carrying `es-module-lexer` 2.1.0 → 2.3.2, `expect-type` 1.3.0 → 1.4.0, `obug` 2.1.1 → 2.2.1, `std-env` 4.1.0 → 4.2.0, `tinyexec` 1.2.4 → 1.3.1 and `tinyrainbow` 3.1.0 → 3.1.1. Unlike the other entries here this one does touch `frontend/package.json`: the existing `^4.1.8` range already admitted the patched version, so the lock alone would have pinned it, but the declared floor is raised to `^4.1.11` so that a regenerated lockfile cannot resolve back below the fix. Nothing in `src/` changed, so the rebuilt bundle is byte-identical and `static/` does not move. Verified with the full frontend suite running on 4.1.11 (3514 tests across 256 files), eslint, the production build and its Safari 16.0 baseline check, i18n parity in all 13 locales, and `npm audit` reporting zero vulnerabilities with and without dev dependencies. - ## [1.2.5.4] - 2026-08-29 diff --git a/backend/app/api/routes/ams_history.py b/backend/app/api/routes/ams_history.py index a781a7dd8..d4bb0fc13 100644 --- a/backend/app/api/routes/ams_history.py +++ b/backend/app/api/routes/ams_history.py @@ -93,10 +93,14 @@ async def get_ams_history( ], min_humidity=stats.min_humidity, max_humidity=stats.max_humidity, - avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity else None, + # ``is not None``, not truthiness: an average of exactly 0 is a + # reading, and the min/max beside it would report it while the average + # showed an em dash. AVG over an empty or all-NULL window is the only + # case that has no answer (#3140). + avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity is not None else None, min_temperature=stats.min_temp, max_temperature=stats.max_temp, - avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp else None, + avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp is not None else None, ) diff --git a/backend/app/api/routes/archives.py b/backend/app/api/routes/archives.py index a9887b33f..6b2dcb956 100644 --- a/backend/app/api/routes/archives.py +++ b/backend/app/api/routes/archives.py @@ -16,11 +16,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core import database from backend.app.core.auth import ( - RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled, check_printer_access, current_api_key_if_present, probe_permissions_if_auth_enabled, + require_media_token_ownership, require_ownership_permission, ) from backend.app.core.config import settings @@ -40,6 +40,8 @@ from backend.app.services.bambu_ftp import ftps_handshake_blocked, list_files_re from backend.app.services.design_settings import overrides_from_config from backend.app.services.filament_requirements import annotate_rack_groups from backend.app.services.print_storage import ( + REASON_FTP_TRANSFER_FAILED, + REASON_FTPS_COOLOFF, REASON_INTERNAL_HISTORY, REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE, @@ -537,8 +539,8 @@ async def no_3mf_warning( single-cause wording sent people the wrong way. Historically the only known cause was install step 4 ("Store sent files on external storage") being off in the slicer, so the banner said so unconditionally. On - H2-series and P2S that advice is actively wrong: the setting is already on - and turning it on again changes nothing, because the printer keeps the + H2-series, P2S and X2D that advice is actively wrong: the setting is already + on and turning it on again changes nothing, because the printer keeps the sliced file on internal storage that FTPS does not serve at all (#2780). ``reason`` is the slug from :mod:`print_storage` when we recorded one, @@ -581,12 +583,41 @@ async def no_3mf_warning( # all, so an install with one H2C and three older printers still gets the # H2C explanation rather than the generic one. # + # REASON_FTPS_COOLOFF leads, and it is the only one of these that reports a + # fault rather than a choice: the printer's file service refused a TLS + # handshake, so the sweep never ran and nothing about where the file went + # was ever tested. The other three describe an install working as + # configured, and each ends in something the operator can change. This one + # ends in "your printer is doing something we cannot yet explain", which is + # both the more urgent thing to say and the thing that produces a useful + # report. It also has to outrank them because the banner dismisses one-shot + # into localStorage: a reason ranked below another is not merely deferred, + # it is never shown to that user again (#2780). + # + # Ranking it first cannot mask a permanent cause, because a cool-off row is + # not permanent. The retry #2957 schedules clears the row's markers when it + # lands, so a row still carrying this slug is one where the retry failed too + # -- a printer whose file service is still refusing, days later. + # + # REASON_FTP_TRANSFER_FAILED sits second for the same reasons and one more: + # it is the only slug here whose remedy is a Bambuddy setting rather than a + # slicer one or a card. It ranks below the cool-off because a printer that + # will not complete a TLS handshake is the worse fault of the two, and its + # own retry (#3063) clears the row the same way, so a row still carrying + # this slug is one where three later attempts also ran out of time. + # # REASON_INTERNAL_HISTORY comes last on purpose, even though it is the # narrowest: it is the one cause with no remedy at all -- the file was # already on the printer, in an area port 990 does not serve. The two ahead # of it each end in something the operator can do, so when an install has # both, the actionable explanation is the one worth the banner (#1820). - for candidate in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE, REASON_INTERNAL_HISTORY): + for candidate in ( + REASON_FTPS_COOLOFF, + REASON_FTP_TRANSFER_FAILED, + REASON_INTERNAL_STORAGE, + REASON_NO_EXTERNAL_STORAGE, + REASON_INTERNAL_HISTORY, + ): if candidate in reasons: return {"has_fallback": True, "reason": candidate} return {"has_fallback": True, "reason": None} @@ -2291,13 +2322,15 @@ async def download_archive_for_slicer( ): """Download 3MF file using a slicer download token. - Token-authenticated (no auth headers needed). The token is short-lived - and single-use, created by POST /{archive_id}/slicer-token. + Token-authenticated (no auth headers needed). The token is short-lived and + archive-bound, created by POST /{archive_id}/slicer-token, and redeemable + for the rest of its TTL rather than exactly once -- the slicer is a separate + process that may fetch the URL more than once (#3029). Filename is at the end of the URL so slicers can detect the file format. """ from backend.app.core.auth import verify_slicer_download_token - if not await verify_slicer_download_token(token, "archive", archive_id): + if not await verify_slicer_download_token(token, "archive", archive_id, single_use=False): raise HTTPException(403, "Invalid or expired download token") service = ArchiveService(db) @@ -2320,15 +2353,22 @@ async def download_archive_for_slicer( async def get_thumbnail( archive_id: int, db: AsyncSession = Depends(get_db), - _: None = RequireCameraStreamTokenIfAuthEnabled, + auth_result: tuple[User | None, bool] = Depends( + require_media_token_ownership( + Permission.ARCHIVES_READ_ALL, + Permission.ARCHIVES_READ_OWN, + ) + ), ): """Get the thumbnail image. - Requires a stream token query param (?token=xxx) when auth is enabled. + Requires a media token query param (?token=xxx) when auth is enabled, and + returns 404 for an archive the caller may not read (#3025). """ + user, can_read_all = auth_result service = ArchiveService(db) - archive = await service.get_archive(archive_id) - if not archive or not archive.thumbnail_path: + archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all) + if not archive.thumbnail_path: raise HTTPException(404, "Thumbnail not found") thumb_path = settings.base_dir / archive.thumbnail_path @@ -2549,15 +2589,22 @@ async def download_archive_media_with_token( async def get_timelapse( archive_id: int, db: AsyncSession = Depends(get_db), - _: None = RequireCameraStreamTokenIfAuthEnabled, + auth_result: tuple[User | None, bool] = Depends( + require_media_token_ownership( + Permission.ARCHIVES_READ_ALL, + Permission.ARCHIVES_READ_OWN, + ) + ), ): """Get the timelapse video. - Requires a stream token query param (?token=xxx) when auth is enabled. + Requires a media token query param (?token=xxx) when auth is enabled, and + returns 404 for an archive the caller may not read (#3025). """ + user, can_read_all = auth_result service = ArchiveService(db) - archive = await service.get_archive(archive_id) - if not archive or not archive.timelapse_path: + archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all) + if not archive.timelapse_path: raise HTTPException(404, "Timelapse not found") timelapse_path = settings.base_dir / archive.timelapse_path @@ -3265,16 +3312,21 @@ async def get_photo( archive_id: int, filename: str, db: AsyncSession = Depends(get_db), - _: None = RequireCameraStreamTokenIfAuthEnabled, + auth_result: tuple[User | None, bool] = Depends( + require_media_token_ownership( + Permission.ARCHIVES_READ_ALL, + Permission.ARCHIVES_READ_OWN, + ) + ), ): """Get a specific photo. - Requires a stream token query param (?token=xxx) when auth is enabled. + Requires a media token query param (?token=xxx) when auth is enabled, and + returns 404 for an archive the caller may not read (#3025). """ + user, can_read_all = auth_result result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) - archive = result.scalar_one_or_none() - if not archive: - raise HTTPException(404, "Archive not found") + archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all) # Membership check first — UUID-generated names on upload mean any URL # filename that doesn't appear here is by definition not a real photo. @@ -3353,12 +3405,19 @@ async def get_qrcode( request: Request, size: int = 200, db: AsyncSession = Depends(get_db), - _: None = RequireCameraStreamTokenIfAuthEnabled, + auth_result: tuple[User | None, bool] = Depends( + require_media_token_ownership( + Permission.ARCHIVES_READ_ALL, + Permission.ARCHIVES_READ_OWN, + ) + ), ): """Generate a QR code that links to this archive. - Requires a stream token query param (?token=xxx) when auth is enabled. + Requires a media token query param (?token=xxx) when auth is enabled, and + returns 404 for an archive the caller may not read (#3025). """ + user, can_read_all = auth_result try: import qrcode from PIL import Image as PILImage @@ -3366,9 +3425,7 @@ async def get_qrcode( raise HTTPException(500, "QR code generation not available - qrcode package not installed") result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) - archive = result.scalar_one_or_none() - if not archive: - raise HTTPException(404, "Archive not found") + archive = _ensure_archive_visible(result.scalar_one_or_none(), user, can_read_all) # Build URL to archive download base_url = str(request.base_url).rstrip("/") @@ -3694,19 +3751,24 @@ async def get_gcode( async def get_plate_preview( archive_id: int, db: AsyncSession = Depends(get_db), - _: None = RequireCameraStreamTokenIfAuthEnabled, + auth_result: tuple[User | None, bool] = Depends( + require_media_token_ownership( + Permission.ARCHIVES_READ_ALL, + Permission.ARCHIVES_READ_OWN, + ) + ), ): """Get the plate preview image from the 3MF file. Returns the slicer-generated plate thumbnail which shows the model with correct colors and positioning. - Requires a stream token query param (?token=xxx) when auth is enabled. + Requires a media token query param (?token=xxx) when auth is enabled, and + returns 404 for an archive the caller may not read (#3025). """ + user, can_read_all = auth_result service = ArchiveService(db) - archive = await service.get_archive(archive_id) - if not archive: - raise HTTPException(404, "Archive not found") + archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all) file_path = settings.base_dir / archive.file_path if not file_path.is_file(): @@ -4227,16 +4289,21 @@ async def get_plate_thumbnail( archive_id: int, plate_index: int, db: AsyncSession = Depends(get_db), - _: None = RequireCameraStreamTokenIfAuthEnabled, + auth_result: tuple[User | None, bool] = Depends( + require_media_token_ownership( + Permission.ARCHIVES_READ_ALL, + Permission.ARCHIVES_READ_OWN, + ) + ), ): """Get the thumbnail image for a specific plate. - Requires a stream token query param (?token=xxx) when auth is enabled. + Requires a media token query param (?token=xxx) when auth is enabled, and + returns 404 for an archive the caller may not read (#3025). """ + user, can_read_all = auth_result service = ArchiveService(db) - archive = await service.get_archive(archive_id) - if not archive: - raise HTTPException(404, "Archive not found") + archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all) file_path = settings.base_dir / archive.file_path if not file_path.is_file(): @@ -4678,18 +4745,23 @@ async def get_project_image( archive_id: int, image_path: str, db: AsyncSession = Depends(get_db), - _: None = RequireCameraStreamTokenIfAuthEnabled, + auth_result: tuple[User | None, bool] = Depends( + require_media_token_ownership( + Permission.ARCHIVES_READ_ALL, + Permission.ARCHIVES_READ_OWN, + ) + ), ): """Get an image from the 3MF project page. - Requires a stream token query param (?token=xxx) when auth is enabled. + Requires a media token query param (?token=xxx) when auth is enabled, and + returns 404 for an archive the caller may not read (#3025). """ + user, can_read_all = auth_result from backend.app.services.archive import ProjectPageParser service = ArchiveService(db) - archive = await service.get_archive(archive_id) - if not archive: - raise HTTPException(404, "Archive not found") + archive = _ensure_archive_visible(await service.get_archive(archive_id), user, can_read_all) file_path = settings.base_dir / archive.file_path if not file_path.is_file(): @@ -4910,12 +4982,13 @@ async def download_source_3mf_for_slicer_with_token( ): """Download source 3MF using a slicer download token. - Token-authenticated (no auth headers needed). The token is short-lived - and single-use, created by POST /{archive_id}/source-slicer-token. + Token-authenticated (no auth headers needed). The token is short-lived and + archive-bound, created by POST /{archive_id}/source-slicer-token, and + redeemable for the rest of its TTL rather than exactly once (#3029). """ from backend.app.core.auth import verify_slicer_download_token - if not await verify_slicer_download_token(token, "source", archive_id): + if not await verify_slicer_download_token(token, "source", archive_id, single_use=False): raise HTTPException(403, "Invalid or expired download token") result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id)) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index f894db556..4ffbf59a3 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -25,12 +25,14 @@ from backend.app.core.auth import ( authenticate_user, authenticate_user_by_email, create_access_token, + create_media_token, create_websocket_token, get_current_active_user, get_password_hash, get_user_by_email, get_user_by_username, is_jti_revoked, + require_auth_if_enabled, resolve_apikey_owner, resolve_session_max_minutes, revoke_jti, @@ -346,10 +348,8 @@ async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)): # (#2530). Only migrate when there is exactly one obvious owner: # handing another admin's session a Bambu credential is not a # guess worth making. - from backend.app.api.routes.cloud import ( - get_stored_token, - migrate_global_cloud_token_to_user, - ) + from backend.app.api.routes.cloud import migrate_global_cloud_token_to_user + from backend.app.services.bambu_cloud_credentials import get_stored_token if admin_created: cloud_owner = admin_user @@ -659,6 +659,30 @@ async def mint_websocket_token( return {"token": await create_websocket_token(username)} +@router.post("/media-token") +async def mint_media_token( + current_user: User | None = Depends(require_auth_if_enabled), +): + """Mint a short-lived token for ```` / ``