Commit Graph
523 Commits
Author SHA1 Message Date
maziggy 6c8a821bca Add a wear cost per printing hour to printers (#694)
Print costs covered filament and energy but not the printer. Each printer
can now carry an optional wear cost per printing hour. Every run's wear is
its logged duration at that rate, stored on the print log entry; the
archive keeps the first run's wear, like energy (#1378). Failed and
cancelled runs count, reconciled runs (unknown duration) don't, and a rate
applies to new prints only.

Wear is shown on the archive card, as a hidden sortable Print Log column,
as a Quick Stats tile, in the most expensive record, in project totals and
in batch order cost, and is kept by GitHub backup and restore. Billing
still charges filament only.

New nullable columns: printers.wear_cost_per_hour, print_log_entries.wear_cost
and print_archives.wear_cost.
2026-10-05 09:29:12 +02:00
maziggy 175e683837 Give backfilled group permissions once, not on every start (#3238)
Four permission backfills in seed_default_groups ran on every start, so
removing MakerWorld, clear plate, forecasting or pipelines from a group
was undone by the next restart. Each now runs once behind a settings
flag. On the upgrade that adds the flag, a backfill whose permission the
Administrators group already held at the start of seeding is recorded as
done without running, so nothing an admin removed comes back one last
time. The Operators/Viewers block stays per-start: those system groups
can't be edited, so it only keeps them complete.
2026-10-05 08:11:55 +02:00
maziggy 6735e45563 Give library folders an owner and let admins share them (#3201)
Users with library:read_own saw every folder anyone had made, and the
folder counts and activity times included other users' files. Folders
now have an owner. A read_own user sees their own folders, shared ones,
folders holding their files and the parents leading there, and adds
files only to their own and shared folders. Admins can share a folder
with everyone. Owners can rename their folders and delete them when
everything inside is theirs.

On upgrade, a folder whose files all belong to one user becomes that
user's; every other folder is shared, so it stays visible as before.
2026-10-04 16:04:25 +02:00
maziggy b596b55456 Take the electricity price from Home Assistant and cost energy hour by hour (#1251)
Each hourly energy snapshot records the price, and the energy used until the
next one is costed at it. Prints and the Statistics energy cost follow the
price over time instead of applying one price after the fact. The price can
come from a Home Assistant sensor, read hourly and at print start and end.
Energy multipliers now accept any value above zero, for plugs that report
watt-seconds.
2026-10-04 15:09:33 +02:00
maziggy d336bb2ad2 Merge printer-scoped access (#1727) and the queue review gate (#1620) into dev
Groups can be limited to printers and whole locations, managed on the new
Printer access page, and jobs can wait for staff review before they print.

Merging onto the current dev:
- The Printer Locations page keeps to the same access rules: a rename keeps
  its groups' access, deleting a granted location or moving printers into or
  out of one needs an admin and drops the stale grant, and a user limited to
  some printers sees and changes only their own locations.
- The overlay logo checks its token without a printer, which the stricter
  overlay check now needs and the logo route doesn't have.
2026-10-04 13:29:57 +02:00
maziggy 3480d39434 Browse a Manyfold library and import its files (issue #1471)
The MakerWorld page becomes Model Sources, with a MakerWorld tab and a
Manyfold tab. Bambuddy signs in to a self-hosted Manyfold with an OAuth
application (public read scopes), lists and searches its models with their
previews, and imports 3MF, STL and STEP files into the library, from where
they are sliced and printed like any other file.

New permissions manyfold:view and manyfold:import are granted once to the
groups that hold the matching MakerWorld permissions. The client secret is
never returned and is left out of GitHub backups. The Manyfold URL and every
redirect pass the LAN-service URL check. Old /makerworld links open the
MakerWorld tab.

Several files import at once: tick files in a model, or Select all, then
Import selected or Import all; or tick models in the grid and import all
their printable files. Files import one after another, failures don't stop
the rest, and a summary reports the outcome.
2026-10-04 13:09:25 +02:00
maziggy 6954cff3ee Turn on the chamber light while the camera is in use
A printer kept dark between uses showed a black live view and sent black
notification photos (#1655). Settings → Camera can turn the light on for
all printers or for selected ones, for the live view, notification
photos, the finish photo and the snapshot API that Home Assistant uses,
and turns it off 15 seconds after the last use. It only turns off a
light it turned on, and switching the light by hand hands it back.
Snapshots wait an adjustable delay after the light comes on. Timelapse,
the Obico check and the in-print frame bank don't switch it, since they
would make it flash.

The camera card no longer lists every printer: external cameras are
added from a searchable printer picker and only those printers are
listed, so large farms get a short page.
2026-10-04 11:26:07 +02:00
WATARIE fa25956738 Easy management of printer locations (groups) (issue #2962)- #2970 (#2995) 2026-10-03 14:28:35 +02:00
M2ABRAMSTANK 3ce9b917cb fix(db): cap SQLite pool fd bill + raise container nofile — prevents fd-exhaustion DB corruption (#2884) 2026-10-03 13:44:50 +02:00
maziggy f9080d1130 Corrected version 2026-10-02 17:38:15 +02:00
maziggy 56e067753a fix(db): PostgreSQL pool default fits a stock server 2026-10-02 15:39:07 +02:00
maziggy 14a5e33e47 Configure Orca Cloud filaments with their own filament ID (#3216)
OrcaSlicer's Sync filaments finds a slot's preset by the slot's filament
    ID alone. The Configure dialog looked up an Orca profile's ID in the
    browser and quietly sent the generic for the material when that came back
    empty, so Orca custom filaments reached the slicer as Generic and the log
    showed nothing. configure now resolves the ID on the server from
    orca_profile_id, follows inherits to the parent profile or the Bambu
    filament it was copied from, logs the outcome and reports a fallback,
    which the dialog shows as a warning.

    Slot presets also store the filament ID they were written with, so the
    slot card and the Configure dialog stop showing a preset once the slot is
    changed from OrcaSlicer's Device tab or the printer.

    Re-configuring a slot for another filament no longer carries over the old
    filament's active K-profile, which switched the slot back to the old
    filament.
2026-10-02 14:21:59 +02:00
maziggy 24509b17e0 Let jobs wait for staff review before they print (#1620) 2026-10-02 09:32:54 +02:00
maziggy 02e39b18b9 Configure Orca Cloud filaments with their own filament ID (#3216)
OrcaSlicer's Sync filaments finds a slot's preset by the slot's filament
ID alone. The Configure dialog looked up an Orca profile's ID in the
browser and quietly sent the generic for the material when that came back
empty, so Orca custom filaments reached the slicer as Generic and the log
showed nothing. configure now resolves the ID on the server from
orca_profile_id, follows inherits to the parent profile or the Bambu
filament it was copied from, logs the outcome and reports a fallback,
which the dialog shows as a warning.

Slot presets also store the filament ID they were written with, so the
slot card and the Configure dialog stop showing a preset once the slot is
changed from OrcaSlicer's Device tab or the printer.

Re-configuring a slot for another filament no longer carries over the old
filament's active K-profile, which switched the slot back to the old
filament.
2026-10-02 08:32:57 +02:00
maziggy fe07fbd07b Give groups whole locations and a printer access page (issue #1727)
A group can now be given locations as well as single printers: it then
reaches every printer in them, including printers added there later.
Printer access moves out of the group editor to Settings ->
Authentication -> Printer access, built for large fleets: groups and
printers searchable and filtered by location, model and access, edited
by group or by printer, saved together. "Who has access" in the printer
card menu opens it on that printer.

- group_locations table; the scope is picked printers plus printers
  whose location matches.
- Moving a printer into or out of a location a limited group has is
  admin-only, and open connections are rescoped. The edit dialog names
  the groups a move affects. Locations are trimmed on save.
- Clearing a printer's location in the edit dialog now clears it.
2026-10-02 07:30:04 +02:00
maziggy 45921b7a56 Limit groups to selected printers (issue #1727)
A group can now be limited to a set of printers. Its members see and
control only those printers. Every other printer answers 404, as if it
didn't exist.

- Groups gain restrict_printers and a group_printers table (migration
  for SQLite and Postgres). A user's printers are the union of their
  limited groups. Groups without the flag don't limit anything, a user
  in no limited group keeps every printer, and admins see all of them.
- core/printer_scope.py holds the scope. RequestPrinterScope and
  RequirePrinterPermissionIfAuthEnabled apply it to routes: printer
  routes, camera, queue and batches, archives, projects, stats, print
  log, pipeline runs, inventory and Spoolman assignments, maintenance,
  smart plugs, scheduled drying, firmware and Obico status.
- API keys, camera stream, Cam Wall, overlay and WebSocket tokens carry
  the printers of whoever created them. WebSocket broadcasts are
  filtered per connection, and the filtering fails closed.
- Scheduler: "Any <model>" jobs stay on their owner's printers. A job
  pinned to a printer its owner lost waits with a reason. Callers with
  no user identity and limited printers must queue to a specific printer.
- Group editor: new Printer access section, translated into all 15
  locales. Saving a system group no longer resends unchanged
  permissions, which the backend refused.
2026-10-01 14:47:40 +02:00
maziggy 42d1083607 Show announcements from the Bambuddy maintainers
Fetch a signed feed.json from the public bambuddy-notifications repo on
    GitHub at startup and every 6 hours. Nothing about the install is sent;
    targeting (version, beta channel, install type) is decided locally.

    - Ed25519 against a key built into the app; an older serial is refused so a
      withdrawn message can't come back. The feed replaces the stored list, and
      a failed or rejected fetch keeps the last good one.
    - Sidebar entry above System with an unread count, a slide-over list, and a
      banner for unread important/critical messages. Read state per user.
    - Admins by default; Settings > General > Updates can show them to all
      users or switch them off, which also stops the fetch.
    - Plain text only; links to github.com and bambuddy.cool only.
2026-10-01 13:04:34 +02:00
maziggy 73d10ad65a Add feature: Attach camera snapshots to notifications (issue #3089) (#3199) 2026-10-01 13:01:57 +02:00
maziggy 1400e50a0d Give the two stock alerts something that fires them (issue #2955) (#3196) 2026-10-01 13:01:21 +02:00
maziggy 5eb37cd41c Show announcements from the Bambuddy maintainers
Fetch a signed feed.json from the public bambuddy-notifications repo on
GitHub at startup and every 6 hours. Nothing about the install is sent;
targeting (version, beta channel, install type) is decided locally.

- Ed25519 against a key built into the app; an older serial is refused so a
  withdrawn message can't come back. The feed replaces the stored list, and
  a failed or rejected fetch keeps the last good one.
- Sidebar entry above System with an unread count, a slide-over list, and a
  banner for unread important/critical messages. Read state per user.
- Admins by default; Settings > General > Updates can show them to all
  users or switch them off, which also stops the fetch.
- Plain text only; links to github.com and bambuddy.cool only.
2026-10-01 10:45:06 +02:00
Benji 7f64e2ba8b Add feature: Attach camera snapshots to notifications (issue #3089) (#3199) 2026-10-01 08:43:44 +02:00
Kouki Ojima 318430f0f4 Give the two stock alerts something that fires them (issue #2955) (#3196) 2026-10-01 08:28:12 +02:00
maziggy 0f32b71827 fix: display friendly legacy printer model names in stream overlay (#3134) 2026-09-30 16:24:23 +02:00
maziggy d09f9d8cc1 Sync OIDC provider groups to BamBuddy groups on every login (issue #3107) (#3122) 2026-09-30 16:23:47 +02:00
maziggy d30e0e685b Material number as a first-class spool field (#2994) 2026-09-30 16:23:17 +02:00
maziggy 80840ad6c5 Suppliers as a managed list with per-spool assignments (#2996) 2026-09-30 16:19:54 +02:00
maziggy 3b4b7439c6 (Post work): parked AMS drying timers and translated drying failures (issue #2896) 2026-09-30 16:18:06 +02:00
maziggy bc4084b2a9 Let other applications send messages through the notification channels
POST /notifications/app-message delivers an app's message to every channel
    with the new "Messages from connected apps" switch on (off by default),
    through quiet hours, the digest and the log. API keys need the new "Send
    notifications" permission, and their owner notifications:update; plain text,
    http(s) links, 20 messages a minute per key. The electricity-price door and
    this one now share one scoped-key check. /queue?batch=<id> opens and
    highlights one batch order.
2026-09-30 16:15:43 +02:00
maziggy a6635f6783 Fix external-spool usage charged to an AMS spool (#3166)
Print commands carry the external spool as -1 in the flat ams_mapping
    (the firmware rejects 254/255 there) and the real target only in
    ams_mapping2. We captured only the flat list, so external-spool prints
    looked unmapped and the usage tracker's position-based fallback
    charged them to the first loaded AMS tray.

    - Resolve external spools from ams_mapping2 when capturing a
      project_file (dual-nozzle keeps 254/255, single-nozzle -> 254)
    - Tracker: an explicit -1 no longer falls back to a positional tray;
      a mapping naming no tray for any used slot defers to tray_now
    - Keep the #1822 H2S tray_now override working with resolved mappings
2026-09-30 16:14:15 +02:00
maziggy 9fb878cd3e feat(auth): connected apps - sign in to external applications with Bambuddy
Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register
    an app with one exact callback URL (Settings > API Keys > Connected Apps);
    /connect/authorize asks for consent once and returns a single-use, 60 s code
    bound to app, callback and challenge; POST /api/v1/connect/token swaps it,
    with the client secret, for the user's identity and permissions. Codes and
    secrets stored hashed, exchanges rate-limited per client and IP, no redirect
    before the callback is validated, API keys cannot authorize, refused while
    auth is disabled. i18n for all 15 locales.

    -----

    fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup

    The #2974 failure-reason conversion ran before the #1378 migration that adds
    print_log_entries.failure_reason, so older databases stopped with "no such
    column: failure_reason". It now skips a table without the column, only runs
    where a legacy label exists, and on SQLite rebuilds archive_fts first, since
    archives created before that index existed trip "database disk image is
    malformed" when updated.
2026-09-30 16:13:18 +02:00
maziggy e65852f7ac feat(queue): let a batch record the external order it fulfils
POST /queue/batches accepts external_source + external_ref; both are
    returned on every batch and filterable on GET /queue/batches. The pair is
    unique (index uq_print_batches_external), so a retried create answers 409
    instead of queueing the same order twice. Migration covers SQLite and
    PostgreSQL.
2026-09-30 16:12:54 +02:00
maziggy 029093ed4e fix(diagnostics): name the stalled step when a bundle's connection check times out (issue #3164)
The support bundle gives each printer's connection diagnostic 15 s and
    discarded the whole result on overrun, recording only "timed_out". A
    14-printer farm's bundle carried that marker for every printer and
    nothing else, so it could not say which check was slow.

    run_connection_diagnostic now keeps an optional progress dict current
    (finished checks + the step in flight). On timeout the snapshot records
    stalled_in, elapsed_s and the checks that completed.
2026-09-30 16:12:35 +02:00
maziggy b60cee2c9f Bumped version 2026-09-30 16:08:49 +02:00
Thomansky 1c316e2ad6 Answer the outcome prompt by reacting to the Telegram message (#3129) 2026-09-29 16:06:51 +02:00
William Faircloth d04514c842 Sync OIDC provider groups to BamBuddy groups on every login (issue #3107) (#3122) 2026-09-29 15:49:44 +02:00
Thomansky 71d4b2f70d Material number as a first-class spool field (#2994) 2026-09-29 15:11:50 +02:00
Thomansky 053cfa73ad Suppliers as a managed list with per-spool assignments (#2996) 2026-09-28 15:14:07 +02:00
maziggy b04d753186 (Post work): parked AMS drying timers and translated drying failures (issue #2896) 2026-09-28 12:35:01 +02:00
maziggy 6855d65d12 Let other applications send messages through the notification channels
POST /notifications/app-message delivers an app's message to every channel
with the new "Messages from connected apps" switch on (off by default),
through quiet hours, the digest and the log. API keys need the new "Send
notifications" permission, and their owner notifications:update; plain text,
http(s) links, 20 messages a minute per key. The electricity-price door and
this one now share one scoped-key check. /queue?batch=<id> opens and
highlights one batch order.
2026-09-27 12:45:50 +02:00
Thomansky 44c7e6fb39 Post-print outcome confirmation: good/reject verdicts, one-tap links, yield stats (#3047) 2026-09-26 15:37:19 +02:00
maziggy d56b48c499 feat(auth): connected apps - sign in to external applications with Bambuddy
Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register
an app with one exact callback URL (Settings > API Keys > Connected Apps);
/connect/authorize asks for consent once and returns a single-use, 60 s code
bound to app, callback and challenge; POST /api/v1/connect/token swaps it,
with the client secret, for the user's identity and permissions. Codes and
secrets stored hashed, exchanges rate-limited per client and IP, no redirect
before the callback is validated, API keys cannot authorize, refused while
auth is disabled. i18n for all 15 locales.

-----

fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup

The #2974 failure-reason conversion ran before the #1378 migration that adds
print_log_entries.failure_reason, so older databases stopped with "no such
column: failure_reason". It now skips a table without the column, only runs
where a legacy label exists, and on SQLite rebuilds archive_fts first, since
archives created before that index existed trip "database disk image is
malformed" when updated.
2026-09-26 12:51:53 +02:00
maziggy 7c16079ffa feat(queue): let a batch record the external order it fulfils
POST /queue/batches accepts external_source + external_ref; both are
returned on every batch and filterable on GET /queue/batches. The pair is
unique (index uq_print_batches_external), so a retried create answers 409
instead of queueing the same order twice. Migration covers SQLite and
PostgreSQL.
2026-09-26 12:18:40 +02:00
Thomansky 12dddada0a File Manager: external link, notes and photos on library files (#3128) 2026-09-26 08:56:48 +02:00
maziggy 7a20e731b5 fix(finance): show the currency the install is configured for (issue #3123)
The Finance page was the only surface in Bambuddy that read its currency
    from a data row 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 wrong together on any install not set to euros. It now
    takes the configured currency from /settings/ui-flags, which is readable by
    anyone who can see Finance -- /settings needs SETTINGS_READ, which a
    cost_centers:read_own user does not have.

    The backend was the other half. 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. All four now go through one resolver,
    which lives beside the rest of the balance logic.

    The wallet's currency column is removed outright rather than merely ignored.
    An install has one currency and nothing here converts between them, so a
    per-wallet copy could only ever drift from the setting -- and a column
    nothing reads is a trap for whoever finds it next. A startup migration drops
    it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would
    otherwise re-add it on an install whose finance tables predate the ORM.
    SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly,
    since it has a default and no reader.

    Saving settings now invalidates the ui-flags query too. Nothing did, so a
    changed currency sat behind that query's staleTime before showing up. The
    sponsor prompt's own EUR fallback is now USD, matching AppSettings.
2026-09-20 13:40:10 +02:00
maziggy 417d03d174 fix(slicer): keep protocol-handler download tokens valid for their whole TTL (issue #3029)
The Slice and Open in Slicer actions 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. Nothing
    guarantees that: Bambu Studio's downloader retries three times after a
    failed attempt, transfers get resumed, on-access scanners fetch. The first
    request won and the slicer was handed a 403.

    verify_slicer_download_token takes a keyword-only single_use flag. The
    default still consumes via DELETE...RETURNING; single_use=False verifies
    with a SELECT and leaves the row for the rest of its five-minute TTL. The
    stored row is the same either way, so the endpoint decides, not the mint.

    The three protocol-handler downloads pass single_use=False: a library file,
    an archive's sliced 3MF, an archive's source 3MF. Resource binding and
    expiry are untouched. The two browser downloads keep consuming, because
    what they hand over is itself consumed -- the prepared printer bundle is
    deleted the moment it has been streamed.

    Also: add "/source-dl/" to PUBLIC_API_PATTERNS. Those patterns match by
    substring and the source 3MF route's segment is source-dl, which does not
    contain "/dl/", so with auth enabled the middleware rejected the slicer's
    header-less request before the route's token check ran. Open source 3MF in
    slicer could never work on an install with authentication on.
2026-09-20 13:29:25 +02:00
maziggy e1fad9d68f fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with 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 covers, print-log thumbnails, printer covers and
    external-link icons. A browser cannot put an Authorization header on an
    <img src>, so these need a credential that fits in the URL, and the
    camera token was the only one that existed. Minting one costs
    camera:view, so a user granted library access to their own files got a
    grid of broken images until they were also handed the live camera.

    Adds a media token: minted by POST /auth/media-token behind plain
    authentication, and identified -- it records the principal the way the
    websocket token does rather than being anonymous the way the camera
    token is. Each route now gates on the permission and ownership rules of
    the resource it serves, through the same _ensure_*_visible helpers its
    header-authenticated siblings already use. The three camera routes keep
    the camera token, and require_camera_stream_token_if_auth_enabled now
    documents that it is for those only.

    The media dependencies accept ordinary Authorization / X-API-Key headers
    as well as ?token=, delegating that path to the existing checkers, so
    API-key scope rules and the per-printer allowlist are unchanged.

    Long-lived camera_stream, camwall and overlay tokens are deliberately
    not accepted on the media routes -- those are handed to kiosks, walls
    and Home Assistant to display video. The cam wall, streaming overlay and
    kiosk views use only the three camera routes and are unaffected.

    Frontend: withMediaToken alongside withStreamToken, and
    useStreamTokenSync fetches a media token for every signed-in user while
    asking for a camera token only when the user can mint one, which also
    stops the 403 that fired on every page load for everyone else.

    Also fixed, same class:
    - /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
      had a header-only guard, so the file manager's plate thumbnails 401'd
      whenever auth was enabled. It now takes a media token too.
    - getProjectCoverImageUrl returned a URL ending in ?token=, and the
      project edit dialog appended its own ?v= cache-buster after it, so the
      second ? landed inside the token value. The version is now a parameter
      applied before the token.

    Tests: 15 integration tests for the token boundary, permission
    enforcement and per-row scoping; 10 frontend tests for the URL split and
    the two-query hook. test_cover_image_get_uses_stream_token_gate is
    renamed and repointed at the media gate -- what it pins, that the
    credential has to fit in a URL, is unchanged.
2026-09-20 13:28:50 +02:00
maziggy 5584dca898 Bumped version 2026-09-20 13:19:02 +02:00
maziggy 4a85e033c0 fix(finance): show the currency the install is configured for (issue #3123)
The Finance page was the only surface in Bambuddy that read its currency
from a data row 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 wrong together on any install not set to euros. It now
takes the configured currency from /settings/ui-flags, which is readable by
anyone who can see Finance -- /settings needs SETTINGS_READ, which a
cost_centers:read_own user does not have.

The backend was the other half. 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. All four now go through one resolver,
which lives beside the rest of the balance logic.

The wallet's currency column is removed outright rather than merely ignored.
An install has one currency and nothing here converts between them, so a
per-wallet copy could only ever drift from the setting -- and a column
nothing reads is a trap for whoever finds it next. A startup migration drops
it on both SQLite and PostgreSQL, after the raw CREATE TABLE that would
otherwise re-add it on an install whose finance tables predate the ORM.
SQLite builds older than 3.35 have no DROP COLUMN and keep it, harmlessly,
since it has a default and no reader.

Saving settings now invalidates the ui-flags query too. Nothing did, so a
changed currency sat behind that query's staleTime before showing up. The
sponsor prompt's own EUR fallback is now USD, matching AppSettings.
2026-09-20 10:06:47 +02:00
maziggy b9bd312826 fix(slicer): keep protocol-handler download tokens valid for their whole TTL (issue #3029)
The Slice and Open in Slicer actions 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. Nothing
guarantees that: Bambu Studio's downloader retries three times after a
failed attempt, transfers get resumed, on-access scanners fetch. The first
request won and the slicer was handed a 403.

verify_slicer_download_token takes a keyword-only single_use flag. The
default still consumes via DELETE...RETURNING; single_use=False verifies
with a SELECT and leaves the row for the rest of its five-minute TTL. The
stored row is the same either way, so the endpoint decides, not the mint.

The three protocol-handler downloads pass single_use=False: a library file,
an archive's sliced 3MF, an archive's source 3MF. Resource binding and
expiry are untouched. The two browser downloads keep consuming, because
what they hand over is itself consumed -- the prepared printer bundle is
deleted the moment it has been streamed.

Also: add "/source-dl/" to PUBLIC_API_PATTERNS. Those patterns match by
substring and the source 3MF route's segment is source-dl, which does not
contain "/dl/", so with auth enabled the middleware rejected the slicer's
header-less request before the route's token check ran. Open source 3MF in
slicer could never work on an install with authentication on.
2026-09-07 14:05:25 +02:00
maziggy 816f073a9e fix(auth): decouple media routes from the camera stream token (issue #3025)
Thirteen routes with 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 covers, print-log thumbnails, printer covers and
external-link icons. A browser cannot put an Authorization header on an
<img src>, so these need a credential that fits in the URL, and the
camera token was the only one that existed. Minting one costs
camera:view, so a user granted library access to their own files got a
grid of broken images until they were also handed the live camera.

Adds a media token: minted by POST /auth/media-token behind plain
authentication, and identified -- it records the principal the way the
websocket token does rather than being anonymous the way the camera
token is. Each route now gates on the permission and ownership rules of
the resource it serves, through the same _ensure_*_visible helpers its
header-authenticated siblings already use. The three camera routes keep
the camera token, and require_camera_stream_token_if_auth_enabled now
documents that it is for those only.

The media dependencies accept ordinary Authorization / X-API-Key headers
as well as ?token=, delegating that path to the existing checkers, so
API-key scope rules and the per-printer allowlist are unchanged.

Long-lived camera_stream, camwall and overlay tokens are deliberately
not accepted on the media routes -- those are handed to kiosks, walls
and Home Assistant to display video. The cam wall, streaming overlay and
kiosk views use only the three camera routes and are unaffected.

Frontend: withMediaToken alongside withStreamToken, and
useStreamTokenSync fetches a media token for every signed-in user while
asking for a camera token only when the user can mint one, which also
stops the 403 that fired on every page load for everyone else.

Also fixed, same class:
- /printers/{id}/files/plate-thumbnail/{i} is rendered in an <img> but
  had a header-only guard, so the file manager's plate thumbnails 401'd
  whenever auth was enabled. It now takes a media token too.
- getProjectCoverImageUrl returned a URL ending in ?token=, and the
  project edit dialog appended its own ?v= cache-buster after it, so the
  second ? landed inside the token value. The version is now a parameter
  applied before the token.

Tests: 15 integration tests for the token boundary, permission
enforcement and per-row scoping; 10 frontend tests for the URL split and
the two-query hook. test_cover_image_get_uses_stream_token_gate is
renamed and repointed at the media gate -- what it pins, that the
credential has to fit in a URL, is unchanged.
2026-09-07 13:38:15 +02:00