mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Merge branch 'dev' into fix/2799-ams-mapping-per-printer
This commit is contained in:
commit
89df94e9be
31 changed files with 1589 additions and 204 deletions
|
|
@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
## [1.2.6b1] - Unreleased
|
||||
|
||||
### Added
|
||||
- **A slim user listing, so an API client can put names to the ids it already gets back (#1894, reported by @MorganMLGman)** — Archives, the queue and the statistics endpoints all report ownership as a numeric `created_by_id`, and statistics accept it as a filter, but there was no way for an API key to find out whose id was whose: the only user listing returns emails, roles, group membership and every account's full permission set, so it is administrative and rejects keys outright. Anyone building against the API was left parsing ids out of somewhere else, or doing without. `GET /users/slim` now answers with `id` and `username` and nothing else. It is covered by the **Read Status** scope, because a key holding that scope could already filter statistics by any `created_by_id` it cared to guess — what was missing was only the ability to address the filter, not permission to use it. The full listing stays admin-only, and for groups there is a matching **List User Names** permission that grants the narrow read without the broad one. If all you need is your own id, `GET /auth/me` now answers that on its own and no user listing is involved.
|
||||
- **Cost centers, budgets and a print ledger, for installs where somebody has to be billed (#1448, contributor @behrinml, requested in #1065)** — A print farm shared by a lab, a makerspace or a department has always been able to see what a print cost, but not to say whose budget it came out of. Bambuddy now has an optional billing layer that answers that. **Settings → Workflow → Billing** turns it on; it is off by default and nothing about an existing install changes until it is switched on. A **cost center** is a budget somebody can print against — a team, a course, a customer, a grant. Every user gets a private one automatically, so a personal install is usable the moment billing is enabled, and shared centers are created and staffed by an administrator with per-member permission to print against them. Each center carries an optional total or monthly budget; a center with no budget is explicitly unlimited rather than blocked. When a print is queued against a budgeted center, its cost is **reserved** rather than merely predicted, so a queue of ten jobs cannot each pass a check the tenth would fail — the reservation is released if the print never happens. The monthly window is configurable: pick the day it resets and the timezone that day is measured in, since a team spread across timezones otherwise disagrees about which month a print landed in. The estimate itself is computed on the server. The figure shown in the print dialog is a display hint, and the browser cannot lower it: budget enforcement uses Bambuddy's own calculation from the file's filament and the printer's rates, whatever the client sends. Completed prints are charged from the archive's measured cost, and a print that is aborted part-way is charged for the filament it actually used rather than the whole job. Every charge, deposit, withdrawal and administrative adjustment lands in a ledger on the new **Finance** page, which appears in the sidebar only while billing is on. Each charge carries a per-dispatch identity that survives restarts and reprints, so a job cannot be billed twice, and a charge that fails is reported through the UI and any configured notification provider rather than quietly not happening. Balances and budgets deliberately mean different things: a balance records what somebody has spent, and only a cost center's budget can stop a print. Personal balances count unassigned charges and the user's own private center; a print billed to a shared center does not touch the personal one. Four permissions gate the whole thing — `cost_centers:read_own`, `read_all`, `modify` and `create` — with the default Administrators group holding all four. Separately switchable, and off by default, is a **printer kill switch** that stops any print that starts on a printer without going through Bambuddy, so a farm that bills its users cannot be bypassed by sending a job straight from Bambu Studio. It stops the print, says so on screen and notifies. It also declines to act whenever it cannot prove ownership — a restart mid-print, a job Bambuddy dispatched but has not finished recording — because stopping a print is irreversible and refusing to act costs only a log line.
|
||||
- **Nest projects under a master project and see the whole programme in one place (#1264, reporter @enjoylifenow)** — A project has always been a flat thing: a build with fifty parts and a build with two got the same single row, and the only way to keep a large job legible was to split it into separate projects that then knew nothing about each other. Projects can now be nested. The project dialog has a **Parent project** picker, so an assembly can sit under the build it belongs to, at whatever depth suits the work; the picker leaves out the project itself and anything already beneath it, because nesting a project inside its own branch is a loop rather than a hierarchy. A project that has sub-projects gains a second card reporting the whole tree at once — print jobs, parts, time, filament and total cost, with progress measured against every target in the tree added together. That card is deliberately separate from the project's own figures, which keep meaning exactly what they meant before: what this project printed, not what its sub-projects did. Each sub-project listed underneath now carries its own branch's totals rather than only a percentage, so the rows add up to the card above them instead of contradicting it. On the Projects page a sub-project is drawn inside its parent's group rather than as another card somewhere in the grid, because two cards that belong together cannot show it while they sit columns apart, however they are captioned; the group is ruled in the parent's own colour and nests as deep as the projects do. A sub-project whose parent is hidden by the status filter stays where it is and says which project it belongs to instead. Two things that only became reachable once the interface could reach them were fixed along the way: a project could be moved under its own sub-project, which the API refused only when a project was made its own direct parent, and a percentage shown against a sub-project was measured differently from the same percentage on the page it linked to. Deleting a project in the middle of a tree now lifts its sub-projects up to its own parent instead of cutting them loose at the top level.
|
||||
- **Restore selected categories from a Git backup commit (#2714, contributor @jmoore-skild, requested in #2656)** — Bambuddy has pushed backups to GitHub, GitLab, Gitea and Forgejo for a long time, and every one of those commits was a restore point that nothing could read back. Recovering from a bad settings change, a rebuilt instance or a lost database meant opening the repository by hand and copying JSON into the right places, if you knew which places those were. **Settings → Backup & Restore → Restore from Git** now picks any of the twenty most recent commits and pulls back the categories you tick — K-profiles, app settings, spool inventory and print history — without touching anything you did not select. The modal previews the commit before anything is written: it shows how many items each category holds and greys out the ones that commit does not contain, so a category you only enabled last week is visibly absent from older commits rather than silently restoring nothing. **Overwrite existing entries** decides what happens when something already exists locally — off, it fills in what is missing and leaves the rest alone; on, it makes the local row match the backup. The result panel reports what actually happened per category as restored, skipped and failed, and those three always add up to the number the preview showed you, so a count that does not match the preview is a bug rather than something to interpret. Restoring never resurrects a credential: the backup carries MQTT, LDAP, Home Assistant and Prometheus secrets so that a repository is a complete record, but the restore refuses every one of them, and refuses along with them any switch that would be left pointing at a service it can no longer authenticate to — an exposed Prometheus endpoint with no token is worse than one that stays off. The keys that decide who can reach the instance at all are refused outright for the same reason — the four authentication-policy switches, and the whole LDAP family alongside them, since those name *which directory server decides who you are* rather than how the instance behaves. Authentication is reconfigured through the auth UI, which has the guards that a JSON file does not. Print archives come back as history only, since a Git backup holds metadata and never the 3MF or thumbnail bytes, and each one is returned to its owner by username rather than by user id — an id means nothing on a rebuilt instance, where it would hand one person's print history to whoever now holds that number. An archive whose owner this instance does not have lands unowned with a note saying so, rather than being attributed to a stranger; one that already exists locally keeps the owner it already has, because an owner the backup cannot name is not an instruction to take one away. K-profiles are the one category that leaves the database: they are sent to the printer over MQTT, which means the printer must be online, and writing a slot is always an overwrite there regardless of the toggle — the modal says so before you click rather than in the summary afterwards. Cloud profiles are backed up but deliberately not restorable, as writing them means writing to a Bambu or Orca account rather than to this instance. **Restoring is permissioned per category**: `github:restore` opens the dialog, and each category additionally requires the permission that owns its rows — `settings:update`, `inventory:update`, `archives:update_all` and `kprofiles:update` — so a role cannot write through a restore what it cannot write through the page that owns it. Administrators hold all of them already; a custom role built around the Backup permissions alone can open the dialog and preview a commit, but needs the owning permission for each category you want it to be able to write. Each category is committed as it completes rather than at the end, so a large restore does not hold the database against the rest of Bambuddy for the length of the run; the trade is that a failure part-way through leaves the categories that already finished in place, which the result panel reports rather than claiming nothing was restored. Translated in all locales; wiki updated. Covered by backend and frontend tests.
|
||||
|
|
@ -24,6 +25,9 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
- **Error and warning toasts now stay up twice as long** — Every pop-up notification disappeared after three seconds regardless of what it said. That is about right for "Settings saved", which confirms something you just did and is skimmed rather than read, but errors and warnings are a different kind of message: they carry a reason, often one relayed from the printer or the backend, and they run to a couple of lines. Three seconds was not long enough to finish reading one, and a missed error message is gone for good — there is no notification history to go back to. Errors and warnings now hold for six seconds. Success and informational toasts keep the three-second default, so the common case of clicking something and seeing it confirmed is unchanged, and the close button and the manual dismiss work exactly as before on all of them. The background print-dispatch toast is unaffected: it stays up while it has work in progress and clears itself shortly after the last job settles. Covered by frontend tests.
|
||||
|
||||
### Fixed
|
||||
- **STEP files were offered for server-side slicing, which cannot work** — The **Slice** action appeared on `.step` / `.stp` files and the backend accepted the job, but neither slicer can load one from its command line: OrcaSlicer 2.4.2 and Bambu Studio 02.07.01.62 both answer `Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension.` So the file was read, converted and uploaded, and the failure came back as "The input model file to the slicer can not be parsed" — which reads as a corrupt model rather than an unsupported format. The Slice button and the pipeline action no longer appear on STEP files, and the endpoint refuses one up front with a message that says to export it as STL or 3MF first. **Open in Slicer** is unchanged and still hands STEP to the desktop application, which opens it perfectly well — that was always the working path for these files.
|
||||
- **A large model was refused with "Slicer CLI failed (500): File too large" and no way to find out what was too large (#2802, reported by @zevulos)** — Server-side slicing of a big multi-colour project failed on every attempt, and the message pointed at nothing. The slicer sidecar caps the size of the model it will accept; that cap was fixed at 100 MB, which real MakerWorld projects exceed. Worse than the limit was how it arrived: the sidecar's upload layer reports a size rejection as a kind of error its own handler does not recognise, so it fell through to a generic **HTTP 500** carrying the bare words "File too large". A 500 reads as a crash inside the slicer, and Bambuddy's one good explanation about request size was written for the HTTP 413 that a reverse proxy sends, so it never appeared. The reporter did the only reasonable thing with what they were shown: set `MAX_FILE_SIZE`, `BODY_PARSER_LIMIT` and `EXPRESS_PAYLOAD_LIMIT`, restart everything, stop nginx in case it was interfering, and move the whole installation from Windows to Docker — none of which the sidecar reads, on a proxy that was never in the path. The cap is now **512 MB** by default and settable with `MAX_MODEL_UPLOAD_MB` on the slicer-api service, and the sidecar answers an oversized upload with a 413 that names the limit and where it lives. Bambuddy recognises the rejection by what it says rather than by its status code, so an installation still running an older sidecar image gets the same explanation — including that the fix there is to update the image, since those have no setting to change. Two things followed from the same misreading: the failure was classed as a slicer crash, so every attempt retried the identical oversized upload "with embedded settings", spending a second 25-second conversion on a guaranteed-identical answer; and nothing anywhere recorded the size of what was being sent, so the support package from a slice that died on an upload cap looked exactly like one that died on a bad profile. Both are fixed — the retry is skipped, and each slice logs the model's size. Raising the cap also changed how the sidecar handles the upload: the model is streamed to disk instead of being held whole in memory, so a 512 MB project no longer costs half a gigabyte of RAM per concurrent slice on the small machines most likely to be running it. **This needs a sidecar update to take effect** — `cd slicer-api/ && docker compose pull && docker compose up -d`.
|
||||
- **`/auth/me` described API keys as administrators they were never allowed to be (#1894, reported by @MorganMLGman)** — Asked to identify an API key, Bambuddy answered with a synthetic administrator: user id `0`, role `admin`, `is_admin: true`, and every permission in the system. None of that was true. An API key cannot reach an administrative route at all, whatever its scopes and whoever owns it, so a client that built its interface from this answer — which is exactly what a native app does — offered buttons that failed with a permission error the moment anyone pressed one, and still had no way to learn which user id its own prints were filed under. The endpoint now reports the key's **owner** as its identity, `is_admin: false`, and a permission list containing precisely what the key's scopes admit, so what a client is told matches what it will be allowed to do. Keys created before keys had owners have no identity to report and keep the old `id: 0` placeholder, but they no longer claim to be administrators either. Clients that branched on `is_admin` or `role` should branch on `permissions` instead.
|
||||
- **An unacknowledged plate no longer stops AMS drying once a minute, for ever (#2801, reported by @superflyer11)** — With "require plate clear" on, a finished print left unacknowledged and something pending in that printer's queue put the scheduler into a loop: it stopped drying, auto-drying re-armed on the next tick, and it stopped it again — around 2000 state changes over ten days on the reporter's P2S, with no cycle ever running long enough to remove any moisture. Cycles the user had started by hand on other AMS units of the same printer were torn down with it. Two ideas had become tangled. Plate-clear answers "is the bed ready for the next job", which says nothing about whether the AMS may heat — and the gap between a finished print and the acknowledgment is exactly when drying is most useful, since the printer is free and nobody is waiting on it. Leaving the plate unacknowledged is also how people hold the queue by hand, so the hold was costing them the drying it should have enabled. On top of that, the "print takes priority" stop was reached only on the passes where the print was *not* going to start: drying is not one of the things the idle check looks at, so stopping a cycle could never make a blocked printer dispatchable, and the cycle was spent for nothing. Auto-drying no longer consults plate-clear at all; the stop now happens on dispatches that are actually going to proceed, and only where the model cannot dry through a print — hardware that can, and has been allowed to, keeps drying as #2758 established it should. A stop is also confined to cycles Bambuddy itself started, matching a contract the code already documented but did not honour, so a manual dry on another unit is left alone. Two smaller faults went with it: a printer merely waiting on the plate was being classed as mid-print, which silently applied the mid-print spool-protection cap to a printer that was not printing and logged the cycle as `(mid-print)` in `FINISH`; and a humidity reading that dipped to the threshold as the AMS cooled discarded the unit's whole history, including the 30-minute re-arm cooldown added in #2770 — so a reading oscillating a point either side of the threshold reset the very guard meant to ride it out. **One behaviour change to be aware of:** "Block queue while drying" previously had no effect on dispatch at all, and now does what it says — with it on, a queued print waits for a running cycle to finish. It is off by default.
|
||||
- **An H2C could clean and level with one hotend and then print with another, several millimetres above the plate (#2800, reported by @tru3l3gend)** — The reporter's H2C ran its startup clean and bed levelling on the wrong nozzle, switched hotends, and then printed in mid-air; the same job sent from Bambu Studio was fine. The H2C is the only printer that mounts its nozzle from a rack of six, and a print command names that nozzle by its *physical* rack position — the firmware reports those as IDs 16 to 21 — rather than by the extruder index, 0 or 1, that every other dual-nozzle printer uses. Bambuddy only ever had a rack position when a job arrived through the Virtual Printer, which captures Bambu Studio's own pick and replays it untouched (#1780). Anything queued from the library, from an archive, through the webhook or from a slicer pipeline carried none, so the field was left off the command entirely and the firmware chose a nozzle for itself — and its choice does not have to agree with the one the file was sliced for. Bambuddy now reads the per-slot extruder assignment out of the file it is about to dispatch and resolves it against the rack position the printer is reporting at that moment, which is the only place it can be known: the mounted hotend can be swapped from the touchscreen between queueing a job and printing it. Nothing about this is guessed. When the rack position cannot be established — mid-swap, or a connection that has not yet reported one — the field is left off and the firmware picks exactly as it did before, because a wrong physical ID is what puts a print in the air and is far worse than no ID at all. For the same reason a job that prints only from the fixed hotend is still left to the firmware: that nozzle's own physical ID has not yet been confirmed against a known-good Bambu Studio capture, and it will not be invented. Confined to the H2C throughout — the dispatch for every other printer, including the H2D and X2D, is unchanged. Diagnosed on real hardware by the reporter, who compared Bambuddy's dispatch against a working Bambu Studio one, established the rack ID range, and supplied a patch.
|
||||
- **Automatic drying no longer loops when the humidity threshold is set below what a warm AMS reports (#2770, reported by @tchavei)** — A reporter's H2D armed five separate 12-hour drying cycles inside four hours, one of them six seconds after the previous ended, and none of them ran for more than a couple of hours. Two things combine to produce that. The firmware ends a cycle whenever it decides the filament is dry, without reporting a fault: across this printer's history the run length tracks how wet the spools were, from nearly the full 12 hours when the AMS started at 32% down to minutes once it sat at 10-13%. That is the AMS doing its job. The loop is Bambuddy's. An AMS reports *higher* relative humidity while it is warm than once it has cooled — the same unit read 10-13% cold and 15-20% throughout every cycle — so with a threshold of 14% the reading at the moment a cycle ended was always still above it, and the next 30-second pass started another 12-hour cycle. Nothing counted, nothing waited, and it only stopped when the box finally cooled enough to read 13%. Auto-drying now waits half an hour after a cycle ends before it will arm another on the same unit, because the humidity reading means nothing until the AMS has cooled; and after two cycles in a row that bring the reading no lower it stops arming that unit altogether, says so in the log, and sends a notification — a new **Auto-drying suspended** event, on by default, since it reports that Bambuddy has *stopped* doing something and silence there reads as "still drying". Progress is judged against the lowest reading any cycle on that unit has ended at, so a genuinely wet spool in a humid room that is coming down slowly -- 40%, 37%, 35% -- keeps drying however far it still is from the threshold, and the suspension lifts by itself the moment the reading falls below it. Neither guard can ever stop a cycle that is running, and a cycle Bambuddy itself cut short for a print, or that you stopped by hand, is not counted against the unit -- so a farm that dries between queue jobs is unaffected. The threshold field in **Settings → Filament → AMS Display Thresholds** now warns when it is set below 20%, and every drying cycle end — early or normal — logs the unit's temperature and humidity, which is what made this diagnosable at all.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from backend.app.core.auth import (
|
|||
RequirePermissionIfAuthEnabled,
|
||||
_is_token_fresh,
|
||||
_validate_api_key,
|
||||
apikey_effective_permissions,
|
||||
authenticate_user,
|
||||
authenticate_user_by_email,
|
||||
create_access_token,
|
||||
|
|
@ -30,13 +31,13 @@ from backend.app.core.auth import (
|
|||
get_user_by_email,
|
||||
get_user_by_username,
|
||||
is_jti_revoked,
|
||||
resolve_apikey_owner,
|
||||
resolve_session_max_minutes,
|
||||
revoke_jti,
|
||||
security,
|
||||
)
|
||||
from backend.app.core.database import async_session, get_db
|
||||
from backend.app.core.oidc_env import env_bool
|
||||
from backend.app.core.permissions import ALL_PERMISSIONS
|
||||
from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.settings import Settings
|
||||
|
|
@ -89,17 +90,47 @@ def _user_to_response(user: User) -> UserResponse:
|
|||
)
|
||||
|
||||
|
||||
def _api_key_to_user_response(api_key) -> UserResponse:
|
||||
"""Create a synthetic admin UserResponse for a valid API key."""
|
||||
async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse:
|
||||
"""Describe a valid API key as the identity it actually carries (#1894).
|
||||
|
||||
Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``,
|
||||
``is_admin=True`` and every permission in the enum. That was wrong in both
|
||||
directions. A key cannot perform administrative operations at all --
|
||||
``_check_apikey_permissions`` denies every permission that is not in the
|
||||
scope allowlist -- so a client that builds its UI from this response (which
|
||||
is exactly what a native client does) rendered admin actions that 403 on
|
||||
use, and had no way to learn the id its own prints are filed under.
|
||||
|
||||
Now: identity comes from the key's owner, and ``permissions`` is the set the
|
||||
key can genuinely exercise. ``is_admin`` is always False because no key can
|
||||
reach an administrative route regardless of who owns it.
|
||||
|
||||
Legacy keys predating per-user ownership (``user_id IS NULL``) have no
|
||||
identity to report, so they keep ``id=0`` and the ``api-key:`` username --
|
||||
but they stop claiming admin. ``created_at`` describes the credential in
|
||||
both branches, unchanged.
|
||||
"""
|
||||
# Same resolution the permission gate uses, so what is reported here and
|
||||
# what is enforced there cannot drift -- including the 403 when the owner
|
||||
# has been deactivated, which makes the key dead rather than anonymous.
|
||||
owner = await resolve_apikey_owner(db, api_key)
|
||||
return UserResponse(
|
||||
id=0,
|
||||
username=f"api-key:{api_key.key_prefix}",
|
||||
id=owner.id if owner else 0,
|
||||
username=owner.username if owner else f"api-key:{api_key.key_prefix}",
|
||||
# Withheld on purpose: the owner's email is not needed to resolve
|
||||
# identity, and this response is reachable by anyone holding the key.
|
||||
email=None,
|
||||
role="admin",
|
||||
# Deprecated free-text field; "user" is the existing value meaning
|
||||
# "not an admin". Inventing an "api_key" role here would put a third
|
||||
# value into a field callers compare against string literals.
|
||||
role="user",
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
is_admin=False,
|
||||
auth_source=getattr(owner, "auth_source", "local") if owner else "local",
|
||||
# The key is not a group member -- listing the owner's groups would
|
||||
# imply capabilities the key does not inherit.
|
||||
groups=[],
|
||||
permissions=sorted(ALL_PERMISSIONS),
|
||||
permissions=apikey_effective_permissions(api_key, owner),
|
||||
created_at=api_key.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
|
@ -637,8 +668,9 @@ async def get_current_user_info(
|
|||
"""Get current user information.
|
||||
|
||||
Accepts JWT tokens (via Authorization: Bearer header) and API keys
|
||||
(via X-API-Key header or Authorization: Bearer bb_xxx).
|
||||
API keys return a synthetic admin user with all permissions.
|
||||
(via X-API-Key header or Authorization: Bearer bb_xxx). API keys report
|
||||
their owner's identity and the permissions the key can actually exercise
|
||||
-- see ``_api_key_to_user_response``.
|
||||
"""
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError as JWTError
|
||||
|
|
@ -647,7 +679,7 @@ async def get_current_user_info(
|
|||
if x_api_key:
|
||||
api_key = await _validate_api_key(db, x_api_key)
|
||||
if api_key:
|
||||
return _api_key_to_user_response(api_key)
|
||||
return await _api_key_to_user_response(db, api_key)
|
||||
|
||||
# Check for Bearer token (could be JWT or API key)
|
||||
if credentials is not None:
|
||||
|
|
@ -656,7 +688,7 @@ async def get_current_user_info(
|
|||
if token.startswith("bb_"):
|
||||
api_key = await _validate_api_key(db, token)
|
||||
if api_key:
|
||||
return _api_key_to_user_response(api_key)
|
||||
return await _api_key_to_user_response(db, api_key)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key",
|
||||
|
|
|
|||
|
|
@ -28,8 +28,19 @@ from backend.app.schemas.group import (
|
|||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
|
||||
|
||||
# Permissions whose derived label would misdescribe what is being granted.
|
||||
# The derived form for USERS_READ_SLIM is "Read Slim Users", which reads as a
|
||||
# property of the users rather than of the response -- and an admin ticking a
|
||||
# box in the group editor has nothing else to go on (#1894).
|
||||
_PERMISSION_LABEL_OVERRIDES: dict[Permission, str] = {
|
||||
Permission.USERS_READ_SLIM: "List User Names (id + username only)",
|
||||
}
|
||||
|
||||
|
||||
def _permission_label(perm: Permission) -> str:
|
||||
"""Convert permission enum to human-readable label."""
|
||||
if perm in _PERMISSION_LABEL_OVERRIDES:
|
||||
return _PERMISSION_LABEL_OVERRIDES[perm]
|
||||
# e.g., "printers:read" -> "Read Printers"
|
||||
parts = perm.value.split(":")
|
||||
if len(parts) == 2:
|
||||
|
|
|
|||
|
|
@ -4464,13 +4464,23 @@ async def slice_library_file(
|
|||
lib_file = _ensure_library_file_visible(lib_file, current_user, can_read_all)
|
||||
|
||||
src_lower = (lib_file.filename or "").lower()
|
||||
if not (
|
||||
src_lower.endswith(".stl")
|
||||
or src_lower.endswith(".3mf")
|
||||
or src_lower.endswith(".step")
|
||||
or src_lower.endswith(".stp")
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Source file must be STL, 3MF, or STEP")
|
||||
if src_lower.endswith(".step") or src_lower.endswith(".stp"):
|
||||
# Neither slicer's CLI can load STEP: OrcaSlicer 2.4.2 and BambuStudio
|
||||
# 02.07.01.62 both answer "Unknown file format. Input file must have
|
||||
# .stl, .obj, .amf(.xml) extension." Accepting the job here meant
|
||||
# reading the file, converting it and uploading it before the sidecar
|
||||
# rejected it as unparseable -- which reads as a corrupt model rather
|
||||
# than an unsupported format. Say so before any of that happens.
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"STEP files cannot be sliced. The OrcaSlicer and Bambu Studio command-line "
|
||||
"slicers load only STL and 3MF -- open the STEP in your slicer and export it "
|
||||
"as one of those first."
|
||||
),
|
||||
)
|
||||
if not (src_lower.endswith(".stl") or src_lower.endswith(".3mf")):
|
||||
raise HTTPException(status_code=400, detail="Source file must be STL or 3MF")
|
||||
|
||||
src_path = Path(app_settings.base_dir) / lib_file.file_path
|
||||
if not src_path.exists():
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from backend.app.core.auth import (
|
|||
ALGORITHM,
|
||||
SECRET_KEY,
|
||||
RequireAdminIfAuthEnabled,
|
||||
RequireAnyPermissionIfAuthEnabled,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
get_current_user_optional,
|
||||
get_password_hash,
|
||||
|
|
@ -34,7 +35,14 @@ from backend.app.models.settings import Settings
|
|||
from backend.app.models.user import User
|
||||
from backend.app.models.user_otp_code import UserOTPCode
|
||||
from backend.app.models.user_totp import UserTOTP
|
||||
from backend.app.schemas.auth import ChangePasswordRequest, GroupBrief, UserCreate, UserResponse, UserUpdate
|
||||
from backend.app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
GroupBrief,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
UserSlim,
|
||||
UserUpdate,
|
||||
)
|
||||
from backend.app.services.email_service import (
|
||||
create_welcome_email_from_template,
|
||||
generate_secure_password,
|
||||
|
|
@ -190,6 +198,31 @@ async def create_user(
|
|||
return _user_to_response(new_user)
|
||||
|
||||
|
||||
@router.get("/slim", response_model=list[UserSlim])
|
||||
async def list_users_slim(
|
||||
_: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List users as ``{id, username}`` only (#1894).
|
||||
|
||||
Exists so an API key -- or a group that should not see emails, roles and
|
||||
permission sets -- can turn the ``created_by_id`` values it already gets
|
||||
back from archives, stats and the queue into names.
|
||||
|
||||
``USERS_READ`` is accepted alongside ``USERS_READ_SLIM`` because it is
|
||||
strictly broader; groups that already hold it keep working without a
|
||||
permission backfill. For API keys only the slim permission resolves (the
|
||||
full one is unmapped = administrative), so a key reaches this and not the
|
||||
listing above.
|
||||
|
||||
Declared before ``/{user_id}`` on purpose: FastAPI matches in declaration
|
||||
order, and the reverse order would parse "slim" as the int path parameter
|
||||
and answer 422.
|
||||
"""
|
||||
result = await db.execute(select(User.id, User.username).order_by(User.username))
|
||||
return [UserSlim(id=row.id, username=row.username) for row in result.all()]
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import check_permission, check_printer_access, get_api_key
|
||||
from backend.app.core.auth import check_printer_access, check_webhook_permission, get_api_key
|
||||
from backend.app.core.database import get_db
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.archive import PrintArchive
|
||||
|
|
@ -68,7 +68,7 @@ async def webhook_add_to_queue(
|
|||
|
||||
Requires 'can_queue' permission.
|
||||
"""
|
||||
check_permission(api_key, "queue")
|
||||
await check_webhook_permission(db, api_key, "queue")
|
||||
check_printer_access(api_key, data.printer_id)
|
||||
|
||||
# Verify archive exists
|
||||
|
|
@ -153,7 +153,7 @@ async def webhook_start_print(
|
|||
|
||||
Requires 'can_control_printer' permission.
|
||||
"""
|
||||
check_permission(api_key, "control_printer")
|
||||
await check_webhook_permission(db, api_key, "control_printer")
|
||||
check_printer_access(api_key, printer_id)
|
||||
|
||||
# Get printer
|
||||
|
|
@ -191,12 +191,13 @@ async def webhook_start_print(
|
|||
async def webhook_stop_print(
|
||||
printer_id: int,
|
||||
api_key: APIKey = Depends(get_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Stop the current print on a printer.
|
||||
|
||||
Requires 'can_control_printer' permission.
|
||||
"""
|
||||
check_permission(api_key, "control_printer")
|
||||
await check_webhook_permission(db, api_key, "control_printer")
|
||||
check_printer_access(api_key, printer_id)
|
||||
|
||||
status = printer_manager.get_status(printer_id)
|
||||
|
|
@ -222,12 +223,13 @@ async def webhook_stop_print(
|
|||
async def webhook_cancel_print(
|
||||
printer_id: int,
|
||||
api_key: APIKey = Depends(get_api_key),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Cancel the current print on a printer.
|
||||
|
||||
Requires 'can_control_printer' permission.
|
||||
"""
|
||||
check_permission(api_key, "control_printer")
|
||||
await check_webhook_permission(db, api_key, "control_printer")
|
||||
check_printer_access(api_key, printer_id)
|
||||
|
||||
status = printer_manager.get_status(printer_id)
|
||||
|
|
@ -257,7 +259,7 @@ async def webhook_get_printer_status(
|
|||
|
||||
Requires 'can_read_status' permission.
|
||||
"""
|
||||
check_permission(api_key, "read_status")
|
||||
await check_webhook_permission(db, api_key, "read_status")
|
||||
check_printer_access(api_key, printer_id)
|
||||
|
||||
# Get printer
|
||||
|
|
@ -293,7 +295,7 @@ async def webhook_get_queue_status(
|
|||
|
||||
Requires 'can_read_status' permission.
|
||||
"""
|
||||
check_permission(api_key, "read_status")
|
||||
await check_webhook_permission(db, api_key, "read_status")
|
||||
|
||||
# Get printers
|
||||
if printer_id:
|
||||
|
|
|
|||
|
|
@ -49,8 +49,15 @@ logger = logging.getLogger(__name__)
|
|||
# The denylist is retained for documentation / drift-detection only — its
|
||||
# entries also satisfy "not in the allowlist", so they fail closed regardless.
|
||||
#
|
||||
# #1894 follow-on: the allowlist is a ceiling, not a grant. A key is also
|
||||
# narrowed to what its owner may do, so a user who can create keys cannot mint
|
||||
# themselves authority they do not have, and deactivating a user disables their
|
||||
# keys. Legacy ownerless keys (``user_id IS NULL``) have no owner to narrow
|
||||
# against and remain governed by the scope flags alone.
|
||||
#
|
||||
# Mapping rationale (see wiki/features/api-keys.md):
|
||||
# can_read_status → every ``*_READ`` + camera + stats + system + websocket
|
||||
# + the slim id/username user listing (NOT ``users:read``)
|
||||
# can_queue → queue write ops + archive reprint
|
||||
# can_control_printer → physical printer + smart-plug control
|
||||
# can_manage_library → library upload/own + MakerWorld import (separate
|
||||
|
|
@ -94,6 +101,14 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
|
|||
Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status",
|
||||
Permission.STATS_READ: "can_read_status",
|
||||
Permission.STATS_FILTER_BY_USER: "can_read_status",
|
||||
# USERS_READ_SLIM grants no data an API key could not already reach (#1894):
|
||||
# for API-keyed requests the permission deps return None as ``current_user``,
|
||||
# so ``_validate_user_filter_permission`` in routes/archives.py short-circuits
|
||||
# and ``?created_by_id=N`` is already honoured for every N. Without a way to
|
||||
# discover the ids, that filter is only addressable by brute force. The slim
|
||||
# listing makes it usable; the full USERS_READ listing (emails, roles, group
|
||||
# membership, permission sets) stays unmapped = admin-only.
|
||||
Permission.USERS_READ_SLIM: "can_read_status",
|
||||
Permission.SYSTEM_READ: "can_read_status",
|
||||
# SETTINGS_READ stays allowed via read-status so SpoolBuddy kiosks keep
|
||||
# working (they need the UI-language setting via API key).
|
||||
|
|
@ -293,13 +308,89 @@ def _resolve_apikey_scope(perm_string: str) -> str | None:
|
|||
return _APIKEY_SCOPE_BY_PERMISSION.get(perm)
|
||||
|
||||
|
||||
def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, require_any: bool = False) -> None:
|
||||
def apikey_effective_permissions(api_key: APIKey, owner: User | None = None) -> list[str]:
|
||||
"""Return the permissions ``api_key`` can actually exercise, sorted.
|
||||
|
||||
This is the exact set ``_check_apikey_permissions`` will let through: every
|
||||
mapped permission whose scope flag is True on the key, further narrowed to
|
||||
what ``owner`` may do. Unmapped permissions are administrative and never
|
||||
resolve for a key, so they are absent.
|
||||
|
||||
``owner=None`` means a legacy ownerless key, where the scope flags are the
|
||||
whole of the key's authority -- not "skip the owner check". Callers holding
|
||||
an owned key must pass the owner, or ``/auth/me`` will over-report and drift
|
||||
from the gate, which is the defect #1894 was about.
|
||||
"""
|
||||
return sorted(
|
||||
perm.value
|
||||
for perm, scope_attr in _APIKEY_SCOPE_BY_PERMISSION.items()
|
||||
if getattr(api_key, scope_attr, False) and (owner is None or owner.has_permission(perm.value))
|
||||
)
|
||||
|
||||
|
||||
async def resolve_apikey_owner(db: AsyncSession, api_key: APIKey) -> User | None:
|
||||
"""Load the owner of ``api_key`` for an authorization decision.
|
||||
|
||||
Distinct from ``_user_from_api_key``, which answers "who is this, if
|
||||
anyone" and returns None for both the legacy and the broken case. Here
|
||||
those two must not be conflated:
|
||||
|
||||
- ``user_id IS NULL`` -- a key predating per-user ownership. There is no
|
||||
owner to narrow against, so the scope flags stand alone. Returns None.
|
||||
- ``user_id`` set but the row is missing or deactivated -- the key's
|
||||
authority came from a user who no longer has any. Raises 403 rather than
|
||||
returning None, because returning None here would fail open: deactivating
|
||||
a user would leave their keys working with full scope authority.
|
||||
|
||||
Groups are eager-loaded because ``has_permission`` walks them, and a lazy
|
||||
load inside the permission check would raise MissingGreenlet.
|
||||
"""
|
||||
if api_key.user_id is None:
|
||||
return None
|
||||
result = await db.execute(select(User).where(User.id == api_key.user_id).options(selectinload(User.groups)))
|
||||
owner = result.scalar_one_or_none()
|
||||
if owner is None or not owner.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="API key owner is deactivated or no longer exists",
|
||||
)
|
||||
return owner
|
||||
|
||||
|
||||
async def authorize_api_key(
|
||||
db: AsyncSession,
|
||||
api_key: APIKey,
|
||||
perm_strings: list[str],
|
||||
*,
|
||||
require_any: bool = False,
|
||||
) -> None:
|
||||
"""Resolve the key's owner and run the full permission gate. Raises 403."""
|
||||
owner = await resolve_apikey_owner(db, api_key)
|
||||
_check_apikey_permissions(api_key, perm_strings, owner=owner, require_any=require_any)
|
||||
|
||||
|
||||
def _check_apikey_permissions(
|
||||
api_key: APIKey,
|
||||
perm_strings: list[str],
|
||||
*,
|
||||
owner: User | None = None,
|
||||
require_any: bool = False,
|
||||
) -> None:
|
||||
"""Raise 403 unless ``api_key`` is allowed to use ``perm_strings``.
|
||||
|
||||
Allowlist semantics: every requested permission MUST be present in
|
||||
``_APIKEY_SCOPE_BY_PERMISSION`` AND its scope flag must be True on
|
||||
``api_key``. Unmapped permissions = administrative = 403.
|
||||
|
||||
A key must not out-rank the user it belongs to, so when ``owner`` is given
|
||||
the permission must additionally be one the owner holds. Scope flags are
|
||||
chosen at creation time by whoever holds ``api_keys:create``; that is
|
||||
admin-only in the default groups, but a custom group can grant it, and
|
||||
without this check such a user could mint themselves a key with
|
||||
``can_control_printer`` and act through it beyond their own permissions.
|
||||
``owner=None`` is only correct for legacy ownerless keys -- see
|
||||
``resolve_apikey_owner``.
|
||||
|
||||
By default ALL requested permissions must pass (mirrors
|
||||
``require_permission`` / ``require_permission_if_auth_enabled``).
|
||||
When ``require_any=True``, only one needs to pass (mirrors
|
||||
|
|
@ -327,6 +418,11 @@ def _check_apikey_permissions(api_key: APIKey, perm_strings: list[str], *, requi
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"API key does not have '{scope_attr}' permission",
|
||||
)
|
||||
elif owner is not None and not owner.has_permission(perm_str):
|
||||
failure = HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"API key owner does not have '{perm_str}' permission",
|
||||
)
|
||||
else:
|
||||
failure = None
|
||||
|
||||
|
|
@ -384,6 +480,12 @@ def require_energy_cost_update():
|
|||
detail="Invalid API key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
# Fails closed if the owner has been deactivated. The scope
|
||||
# flag itself is not narrowed against the owner's permissions
|
||||
# the way the general gate is: this door exists precisely
|
||||
# because no user permission maps to it (SETTINGS_UPDATE stays
|
||||
# denied for keys even when the owner is an administrator).
|
||||
await resolve_apikey_owner(db, api_key)
|
||||
if not api_key.can_update_energy_cost:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
|
|
@ -1133,10 +1235,14 @@ async def require_auth_if_enabled(
|
|||
if not auth_enabled:
|
||||
return None
|
||||
|
||||
# Check for API key first (X-API-Key header)
|
||||
# Check for API key first (X-API-Key header). The owner is resolved
|
||||
# purely for its side effect: a key whose owner has been deactivated
|
||||
# must be dead everywhere, not just on the permission-gated routes.
|
||||
# There is no permission to check here -- this dep is auth-only.
|
||||
if x_api_key:
|
||||
api_key = await _validate_api_key(db, x_api_key)
|
||||
if api_key:
|
||||
await resolve_apikey_owner(db, api_key)
|
||||
return None # API key valid, allow access
|
||||
|
||||
# Check for Bearer token (could be JWT or API key)
|
||||
|
|
@ -1146,6 +1252,7 @@ async def require_auth_if_enabled(
|
|||
if token.startswith("bb_"):
|
||||
api_key = await _validate_api_key(db, token)
|
||||
if api_key:
|
||||
await resolve_apikey_owner(db, api_key)
|
||||
return None # API key valid, allow access
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -1424,6 +1531,35 @@ def check_permission(api_key: APIKey, permission: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
# The coarse webhook permission names predate the Permission enum. Each maps to
|
||||
# the enum member that best represents it, so the owner can be held to the same
|
||||
# standard here as on the modern routes.
|
||||
_WEBHOOK_PERMISSION_EQUIVALENT: dict[str, Permission] = {
|
||||
"queue": Permission.QUEUE_CREATE,
|
||||
"control_printer": Permission.PRINTERS_CONTROL,
|
||||
"read_status": Permission.PRINTERS_READ,
|
||||
}
|
||||
|
||||
|
||||
async def check_webhook_permission(db: AsyncSession, api_key: APIKey, permission: str) -> None:
|
||||
"""``check_permission`` plus the owner checks the modern routes apply.
|
||||
|
||||
``/webhook/*`` reaches its scope flags through ``check_permission`` rather
|
||||
than ``_check_apikey_permissions``, so it does not pick up the owner
|
||||
narrowing automatically. Without this it would be the way around the gate:
|
||||
the same key that is refused printer control on ``/printers/{id}/print/stop``
|
||||
could stop the print through ``/webhook/printer/{id}/stop``.
|
||||
"""
|
||||
check_permission(api_key, permission)
|
||||
owner = await resolve_apikey_owner(db, api_key)
|
||||
equivalent = _WEBHOOK_PERMISSION_EQUIVALENT.get(permission)
|
||||
if owner is not None and equivalent is not None and not owner.has_permission(equivalent.value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"API key owner does not have '{equivalent.value}' permission",
|
||||
)
|
||||
|
||||
|
||||
def check_printer_access(api_key: APIKey, printer_id: int) -> None:
|
||||
"""Check if API key has access to the specified printer.
|
||||
|
||||
|
|
@ -1481,7 +1617,7 @@ def require_permission(*permissions: str | Permission):
|
|||
if x_api_key:
|
||||
api_key = await _validate_api_key(db, x_api_key)
|
||||
if api_key:
|
||||
_check_apikey_permissions(api_key, perm_strings)
|
||||
await authorize_api_key(db, api_key, perm_strings)
|
||||
return None # API key valid, allow access
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
|
|
@ -1498,7 +1634,7 @@ def require_permission(*permissions: str | Permission):
|
|||
if token.startswith("bb_"):
|
||||
api_key = await _validate_api_key(db, token)
|
||||
if api_key:
|
||||
_check_apikey_permissions(api_key, perm_strings)
|
||||
await authorize_api_key(db, api_key, perm_strings)
|
||||
return None # API key valid, allow access
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -1571,7 +1707,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
if x_api_key:
|
||||
api_key = await _validate_api_key(db, x_api_key)
|
||||
if api_key:
|
||||
_check_apikey_permissions(api_key, perm_strings)
|
||||
await authorize_api_key(db, api_key, perm_strings)
|
||||
return None # API key valid, allow access
|
||||
|
||||
# Check for Bearer token (could be JWT or API key)
|
||||
|
|
@ -1581,7 +1717,7 @@ def require_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
if token.startswith("bb_"):
|
||||
api_key = await _validate_api_key(db, token)
|
||||
if api_key:
|
||||
_check_apikey_permissions(api_key, perm_strings)
|
||||
await authorize_api_key(db, api_key, perm_strings)
|
||||
return None # API key valid, allow access
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -1674,7 +1810,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
# GHSA-r2qv-8222-hqg3: previously returned None unconditionally,
|
||||
# letting any valid API key satisfy admin "any-of" route
|
||||
# dependencies. require_any → at-least-one must pass the scope check.
|
||||
_check_apikey_permissions(api_key, perm_strings, require_any=True)
|
||||
await authorize_api_key(db, api_key, perm_strings, require_any=True)
|
||||
return None
|
||||
|
||||
if credentials is not None:
|
||||
|
|
@ -1682,7 +1818,7 @@ def require_any_permission_if_auth_enabled(*permissions: str | Permission):
|
|||
if token.startswith("bb_"):
|
||||
api_key = await _validate_api_key(db, token)
|
||||
if api_key:
|
||||
_check_apikey_permissions(api_key, perm_strings, require_any=True)
|
||||
await authorize_api_key(db, api_key, perm_strings, require_any=True)
|
||||
return None
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -1873,7 +2009,7 @@ def require_ownership_permission(
|
|||
if x_api_key:
|
||||
api_key = await _validate_api_key(db, x_api_key)
|
||||
if api_key:
|
||||
_check_apikey_permissions(api_key, [all_perm])
|
||||
await authorize_api_key(db, api_key, [all_perm])
|
||||
return None, True
|
||||
|
||||
# Check for Bearer token (could be JWT or API key)
|
||||
|
|
@ -1883,7 +2019,7 @@ def require_ownership_permission(
|
|||
if token.startswith("bb_"):
|
||||
api_key = await _validate_api_key(db, token)
|
||||
if api_key:
|
||||
_check_apikey_permissions(api_key, [all_perm])
|
||||
await authorize_api_key(db, api_key, [all_perm])
|
||||
return None, True
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -174,6 +174,11 @@ class Permission(StrEnum):
|
|||
|
||||
# Users (admin-level)
|
||||
USERS_READ = "users:read"
|
||||
# Narrow read: id + username only, no emails/roles/groups/permissions (#1894).
|
||||
# Exists so an id -> name mapping can be resolved without handing out the
|
||||
# full user objects. Pairs with STATS_FILTER_BY_USER, which is useless
|
||||
# without a way to discover the ids it filters on.
|
||||
USERS_READ_SLIM = "users:read_slim"
|
||||
USERS_CREATE = "users:create"
|
||||
USERS_UPDATE = "users:update"
|
||||
USERS_DELETE = "users:delete"
|
||||
|
|
@ -346,6 +351,7 @@ PERMISSION_CATEGORIES = {
|
|||
],
|
||||
"User Management": [
|
||||
Permission.USERS_READ,
|
||||
Permission.USERS_READ_SLIM,
|
||||
Permission.USERS_CREATE,
|
||||
Permission.USERS_UPDATE,
|
||||
Permission.USERS_DELETE,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,22 @@ class UserResponse(BaseModel):
|
|||
from_attributes = True
|
||||
|
||||
|
||||
class UserSlim(BaseModel):
|
||||
"""Just enough to resolve a user id to a display name (#1894).
|
||||
|
||||
Deliberately narrower than ``UserResponse``: no email, role, auth source,
|
||||
group membership or permission set. Adding a field here widens what every
|
||||
``can_read_status`` API key can read about every account, so treat this
|
||||
shape as the contract rather than a starting point.
|
||||
"""
|
||||
|
||||
id: int
|
||||
username: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LDAPSearchResultResponse(BaseModel):
|
||||
"""One match from GET /auth/ldap/search — surfaced in the admin UI."""
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,84 @@ def _format_sidecar_error(response: httpx.Response) -> str:
|
|||
return (message or details or response.text)[:500]
|
||||
|
||||
|
||||
def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> SliceResult:
|
||||
def _transport_error_reason(exc: httpx.RequestError) -> str:
|
||||
"""Describe a transport failure, even when the exception carries no message.
|
||||
|
||||
Several ``httpx.RequestError`` subclasses are raised with no args, so
|
||||
``str(exc)`` is the empty string — which is how three lines of the #2802
|
||||
reporter's support package came to read ``Slicer sidecar unreachable:``
|
||||
with nothing after the colon. The class name is not much, but it
|
||||
distinguishes a refused connection from a protocol error, and a log line
|
||||
that names nothing is worth less than one that names the exception type.
|
||||
"""
|
||||
return str(exc) or type(exc).__name__
|
||||
|
||||
|
||||
# How the sidecar says "your model is bigger than my cap", across versions.
|
||||
# Images built before the cap became configurable answer with multer's raw
|
||||
# ``LIMIT_FILE_SIZE`` text under a **500** — ``MulterError`` is not the
|
||||
# sidecar's ``AppError``, so its handler falls through to the default status —
|
||||
# while current ones send a 413 naming the limit and the env var that raises
|
||||
# it. Matching on text rather than status covers both, and matters because a
|
||||
# 500 otherwise reads as a slicer crash and sends people off tuning reverse
|
||||
# proxies that were never in the path (#2802).
|
||||
#
|
||||
# Deliberately specific: a proxy's own "413 Request Entity Too Large" must NOT
|
||||
# match, because that one really is fixed at the proxy and gets its own advice.
|
||||
_UPLOAD_TOO_LARGE_MARKERS = (
|
||||
"file too large",
|
||||
"upload limit",
|
||||
"max_model_upload_mb",
|
||||
)
|
||||
|
||||
# A sidecar that says which knob raises the cap is new enough to have one.
|
||||
# Older ones only ever emit multer's bare "File too large", and for those the
|
||||
# advice has to be "update the image" — there is no env var to set.
|
||||
_CONFIGURABLE_CAP_MARKERS = ("upload limit", "max_model_upload_mb")
|
||||
|
||||
|
||||
def _upload_size_rejection(response: httpx.Response, model_size_bytes: int | None) -> str | None:
|
||||
"""Return an explanation if the sidecar refused the upload as oversized.
|
||||
|
||||
The 500 case is matched strictly — the body has to be *only* multer's
|
||||
message — because a 500 is also how a genuine CLI failure arrives, and
|
||||
those must keep reaching the embedded-settings fallback. A CLI failure
|
||||
always carries the slicer's stderr in ``details``, so it never reduces to
|
||||
the bare string on its own.
|
||||
"""
|
||||
detail = _format_sidecar_error(response)
|
||||
lowered = detail.lower()
|
||||
if response.status_code >= 500:
|
||||
if lowered.strip() != "file too large":
|
||||
return None
|
||||
elif not any(marker in lowered for marker in _UPLOAD_TOO_LARGE_MARKERS):
|
||||
return None
|
||||
|
||||
size = f"{model_size_bytes / (1024 * 1024):.0f} MB " if model_size_bytes else ""
|
||||
# Shared preamble: both variants must rule out the layers people reach for
|
||||
# first, because those are the ones that look like they should apply.
|
||||
common = (
|
||||
f"The slicer sidecar refused the {size}model file as too large. The limit lives inside "
|
||||
"the sidecar container, so it is neither a Bambuddy setting nor a reverse-proxy one — "
|
||||
"raising 'client_max_body_size' or a proxy body limit will not change it."
|
||||
)
|
||||
|
||||
if any(marker in lowered for marker in _CONFIGURABLE_CAP_MARKERS):
|
||||
return (
|
||||
f"{common} Raise it by setting MAX_MODEL_UPLOAD_MB on the slicer-api service and "
|
||||
f"restarting it. Sidecar said: {detail}"
|
||||
)
|
||||
return (
|
||||
f"{common} This sidecar image predates the configurable cap and is fixed at 100 MB — "
|
||||
"update it with 'cd slicer-api/ && docker compose pull && docker compose up -d', which "
|
||||
"raises the default and adds MAX_MODEL_UPLOAD_MB for going higher still. "
|
||||
f"Sidecar said: {detail}"
|
||||
)
|
||||
|
||||
|
||||
def _handle_slice_response(
|
||||
response: httpx.Response, *, export_3mf: bool, model_size_bytes: int | None = None
|
||||
) -> SliceResult:
|
||||
"""Turn a sidecar ``/slice`` HTTP response into a validated ``SliceResult``.
|
||||
|
||||
Shared by ``slice_with_profiles`` / ``slice_without_profiles`` so the status
|
||||
|
|
@ -151,6 +228,14 @@ def _handle_slice_response(response: httpx.Response, *, export_3mf: bool) -> Sli
|
|||
SlicerInputError: 4xx from the sidecar (bad input / proxy body limit).
|
||||
SlicerApiServerError: 5xx, or a 2xx whose body is not a valid 3MF.
|
||||
"""
|
||||
# Checked ahead of the status branches because the same rejection arrives
|
||||
# as a 500 from older sidecars and a 413 from newer ones, and because
|
||||
# raising SlicerInputError (rather than SlicerApiServerError) is what stops
|
||||
# the library route retrying the identical oversized upload with embedded
|
||||
# settings — a second 25-second conversion for a guaranteed same answer.
|
||||
oversized = _upload_size_rejection(response, model_size_bytes)
|
||||
if oversized:
|
||||
raise SlicerInputError(oversized)
|
||||
if response.status_code == 413:
|
||||
# A 413 almost never comes from the slicer itself — it's a reverse proxy
|
||||
# (nginx/SWAG/Traefik) or a CDN capping the multipart upload (model +
|
||||
|
|
@ -317,7 +402,7 @@ class SlicerApiService:
|
|||
try:
|
||||
response = await self._client.get(f"{self.base_url}/health", timeout=10.0)
|
||||
except httpx.RequestError as exc:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar /health returned {response.status_code}")
|
||||
return response.json()
|
||||
|
|
@ -358,7 +443,7 @@ class SlicerApiService:
|
|||
timeout=15.0,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
||||
|
||||
if response.status_code == 404:
|
||||
# Sidecar predates the endpoint. Not an error, and specifically not
|
||||
|
|
@ -397,7 +482,7 @@ class SlicerApiService:
|
|||
try:
|
||||
response = await self._client.get(f"{self.base_url}/profiles/bundled", timeout=10.0)
|
||||
except httpx.RequestError as exc:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar /profiles/bundled returned {response.status_code}")
|
||||
return response.json()
|
||||
|
|
@ -530,7 +615,7 @@ class SlicerApiService:
|
|||
try:
|
||||
return post_task.result()
|
||||
except httpx.RequestError as exc:
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {exc}") from exc
|
||||
raise SlicerApiUnavailableError(f"Slicer sidecar unreachable: {_transport_error_reason(exc)}") from exc
|
||||
|
||||
async def slice_with_profiles(
|
||||
self,
|
||||
|
|
@ -612,8 +697,9 @@ class SlicerApiService:
|
|||
# and surfaces structured updates via on_progress. Uses a
|
||||
# short-tick poll (1s) since the slicer emits stage changes
|
||||
# several times per minute on complex models.
|
||||
_log_slice_request(model_filename, model_bytes, plate=plate, profiles=len(filament_profile_jsons) + 2)
|
||||
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
||||
return _handle_slice_response(response, export_3mf=export_3mf)
|
||||
return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
|
||||
|
||||
async def slice_without_profiles(
|
||||
self,
|
||||
|
|
@ -668,8 +754,26 @@ class SlicerApiService:
|
|||
# embedded-settings fallback path triggered by an Orca/Bambu CLI
|
||||
# segfault on complex H2D models — both want to keep updating
|
||||
# the user's toast through the slow operation.
|
||||
_log_slice_request(model_filename, model_bytes, plate=plate, profiles=0)
|
||||
response = await self._post_slice(files=files, data=data, request_id=request_id, on_progress=on_progress)
|
||||
return _handle_slice_response(response, export_3mf=export_3mf)
|
||||
return _handle_slice_response(response, export_3mf=export_3mf, model_size_bytes=len(model_bytes))
|
||||
|
||||
|
||||
def _log_slice_request(filename: str, model_bytes: bytes, *, plate: int | None, profiles: int) -> None:
|
||||
"""Record what is being sent to the sidecar, size included.
|
||||
|
||||
Nothing used to log the payload size, so a support package from a slice
|
||||
that failed on an upload cap looked identical to one that failed on a bad
|
||||
profile — #2802 had to be sized by probing a sidecar by hand. One line per
|
||||
slice is cheap next to the operation it describes.
|
||||
"""
|
||||
logger.info(
|
||||
"Slicing %s (%.1f MB) plate=%s with %d profile(s)",
|
||||
filename,
|
||||
len(model_bytes) / (1024 * 1024),
|
||||
"all" if plate is None else plate,
|
||||
profiles,
|
||||
)
|
||||
|
||||
|
||||
def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:
|
||||
|
|
|
|||
240
backend/tests/integration/test_api_key_owner_authority_1894.py
Normal file
240
backend/tests/integration/test_api_key_owner_authority_1894.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"""An API key must not out-rank the user it belongs to (#1894 follow-on).
|
||||
|
||||
``_check_apikey_permissions`` gated purely on the scope flags stored on the key
|
||||
row and never looked at the owner. Scope flags are chosen at creation time by
|
||||
whoever holds ``api_keys:create`` -- admin-only in the default groups, but a
|
||||
custom group can grant it -- so a user with, say, queue permissions could mint
|
||||
themselves a key with ``can_control_printer`` and stop other people's prints
|
||||
through it. Deactivating that user did not help either: their keys kept working
|
||||
with full scope authority, because nothing re-checked the owner.
|
||||
|
||||
The gate now narrows the scope flags to what the owner may do. Two cases must
|
||||
NOT be conflated, and each has a test below:
|
||||
|
||||
- ``user_id IS NULL`` -- legacy key from before per-user ownership. No owner
|
||||
exists to narrow against, so the flags stand alone and the key keeps working.
|
||||
- ``user_id`` set but the row is gone or deactivated -- the key's authority came
|
||||
from a user who has none. Fails closed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import generate_api_key, get_password_hash
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.user import User
|
||||
|
||||
# A route gated on PRINTERS_READ (can_read_status) and one gated on
|
||||
# PRINTERS_CONTROL (can_control_printer). Both scope flags are set on every key
|
||||
# built below, so any denial comes from the owner check rather than the flags.
|
||||
READ_ROUTE = "/api/v1/printers/"
|
||||
CONTROL_ROUTE = "/api/v1/printers/1/print/stop"
|
||||
|
||||
|
||||
async def _setup(async_client: AsyncClient) -> None:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": "owneradmin", "admin_password": "OwnerPass1!"},
|
||||
)
|
||||
|
||||
|
||||
async def _key_for(db_session, owner: User | None, **scopes) -> str:
|
||||
defaults = {"can_read_status": True, "can_control_printer": True, "can_queue": True}
|
||||
defaults.update(scopes)
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
db_session.add(
|
||||
APIKey(
|
||||
name=f"key-{owner.username if owner else 'legacy'}",
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
enabled=True,
|
||||
user_id=owner.id if owner else None,
|
||||
**defaults,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return full_key
|
||||
|
||||
|
||||
async def _user(db_session, username: str, permissions: list[str], *, is_active: bool = True) -> User:
|
||||
group = Group(name=f"grp-{username}", description="t", permissions=permissions, is_system=False)
|
||||
db_session.add(group)
|
||||
await db_session.flush()
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=get_password_hash("Whatever1!"), # noqa: S106
|
||||
role="user",
|
||||
is_active=is_active,
|
||||
groups=[group],
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_admin_owned_key_keeps_full_scope_authority(async_client: AsyncClient, db_session):
|
||||
"""The common case must not regress -- almost every key is admin-owned."""
|
||||
await _setup(async_client)
|
||||
admin = (await db_session.execute(select(User).where(User.username == "owneradmin"))).scalar_one()
|
||||
key = await _key_for(db_session, admin)
|
||||
|
||||
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_legacy_ownerless_key_still_works(async_client: AsyncClient, db_session):
|
||||
"""No owner to narrow against is not the same as a failed owner lookup."""
|
||||
await _setup(async_client)
|
||||
key = await _key_for(db_session, None)
|
||||
|
||||
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_key_cannot_exceed_its_owners_permissions(async_client: AsyncClient, db_session):
|
||||
"""The escalation: control flags ticked, owner who may not control."""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "readonly", ["printers:read"])
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
allowed = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
|
||||
denied = await async_client.post(CONTROL_ROUTE, headers={"X-API-Key": key})
|
||||
|
||||
assert allowed.status_code == 200
|
||||
assert denied.status_code == 403
|
||||
assert "owner does not have" in denied.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deactivating_the_owner_disables_the_key(async_client: AsyncClient, db_session):
|
||||
"""Previously the key kept working -- nothing re-checked the owner."""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "gone", ["printers:read"], is_active=False)
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "deactivated" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_deleted_owner_does_not_fall_back_to_anonymous(async_client: AsyncClient, db_session):
|
||||
"""The dangling-row case fails closed rather than reverting to flags-only.
|
||||
|
||||
CASCADE should prevent this, but "should" is not a gate -- if the row is
|
||||
ever orphaned the key must not silently regain full scope authority.
|
||||
"""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "doomed", ["printers:read"])
|
||||
key = await _key_for(db_session, owner)
|
||||
api_key = (await db_session.execute(select(APIKey).where(APIKey.user_id == owner.id))).scalar_one()
|
||||
api_key.user_id = 999999 # owner row that does not exist
|
||||
await db_session.commit()
|
||||
|
||||
response = await async_client.get(READ_ROUTE, headers={"X-API-Key": key})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bearer_path_is_gated_the_same_as_the_header(async_client: AsyncClient, db_session):
|
||||
"""Both credential paths run the same gate; only one was ever tested."""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "bearer-readonly", ["printers:read"])
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
denied = await async_client.post(CONTROL_ROUTE, headers={"Authorization": f"Bearer {key}"})
|
||||
|
||||
assert denied.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_webhook_routes_are_not_a_way_around_the_owner_check(async_client: AsyncClient, db_session):
|
||||
"""/webhook/* reaches its scope flags by a different route than the rest.
|
||||
|
||||
It gates on ``check_permission``, not ``_check_apikey_permissions``, so it
|
||||
does not inherit the owner narrowing for free. If it is missed, the same key
|
||||
that is refused on /printers/{id}/print/stop simply stops the print here
|
||||
instead, and the whole gate is decorative.
|
||||
"""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "webhook-readonly", ["printers:read"])
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
denied = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
|
||||
|
||||
assert denied.status_code == 403
|
||||
assert "owner does not have" in denied.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_webhook_still_works_for_a_permitted_owner(async_client: AsyncClient, db_session):
|
||||
"""The narrowing must not simply break every webhook caller."""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "webhook-operator", ["printers:read", "printers:control"])
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
|
||||
|
||||
# There is no connected printer 1, so the handler itself fails. What
|
||||
# matters is that the request got that far: neither gate rejected it.
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_webhook_rejects_a_deactivated_owner(async_client: AsyncClient, db_session):
|
||||
"""Fail-closed applies on this path too."""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "webhook-gone", ["printers:read", "printers:control"], is_active=False)
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
response = await async_client.post("/api/v1/webhook/printer/1/stop", headers={"X-API-Key": key})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "deactivated" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_reports_the_narrowed_set(async_client: AsyncClient, db_session):
|
||||
"""/auth/me and the gate must agree, including about the owner."""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "narrow", ["printers:read"])
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})).json()
|
||||
|
||||
assert result["id"] == owner.id
|
||||
assert "printers:read" in result["permissions"]
|
||||
# can_control_printer is ticked on the key, but the owner cannot control.
|
||||
assert "printers:control" not in result["permissions"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_is_rejected_once_the_owner_is_deactivated(async_client: AsyncClient, db_session):
|
||||
"""A dead key identifies as nothing, rather than as an anonymous key."""
|
||||
await _setup(async_client)
|
||||
owner = await _user(db_session, "me-gone", ["printers:read"], is_active=False)
|
||||
key = await _key_for(db_session, owner)
|
||||
|
||||
response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": key})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
|
@ -232,8 +232,8 @@ class TestAuthMeAPI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_with_api_key_bearer(self, async_client: AsyncClient, db_session):
|
||||
"""Verify /me returns synthetic admin user when using API key via Bearer token."""
|
||||
async def test_me_with_ownerless_api_key_bearer(self, async_client: AsyncClient, db_session):
|
||||
"""A legacy key has no identity to report, but no longer claims admin (#1894)."""
|
||||
from backend.app.core.auth import generate_api_key
|
||||
from backend.app.models.api_key import APIKey
|
||||
|
||||
|
|
@ -253,15 +253,18 @@ class TestAuthMeAPI:
|
|||
result = response.json()
|
||||
assert result["id"] == 0
|
||||
assert result["username"].startswith("api-key:")
|
||||
assert result["role"] == "admin"
|
||||
assert result["is_admin"] is True
|
||||
assert result["role"] != "admin"
|
||||
assert result["is_admin"] is False
|
||||
assert result["is_active"] is True
|
||||
# can_read_status defaults True, so the scope-derived set is non-empty
|
||||
# -- but it is a set, not "every permission there is".
|
||||
assert len(result["permissions"]) > 0
|
||||
assert "users:create" not in result["permissions"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_with_api_key_header(self, async_client: AsyncClient, db_session):
|
||||
"""Verify /me returns synthetic admin user when using X-API-Key header."""
|
||||
async def test_me_with_ownerless_api_key_header(self, async_client: AsyncClient, db_session):
|
||||
"""Same as above via the X-API-Key header rather than Bearer."""
|
||||
from backend.app.core.auth import generate_api_key
|
||||
from backend.app.models.api_key import APIKey
|
||||
|
||||
|
|
@ -279,7 +282,7 @@ class TestAuthMeAPI:
|
|||
result = response.json()
|
||||
assert result["id"] == 0
|
||||
assert result["username"].startswith("api-key:")
|
||||
assert result["is_admin"] is True
|
||||
assert result["is_admin"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
|
|
@ -292,6 +295,127 @@ class TestAuthMeAPI:
|
|||
|
||||
assert response.status_code == 401
|
||||
|
||||
async def _owned_key(self, async_client: AsyncClient, db_session, **scopes):
|
||||
"""Set up auth and return (owner, full_key) for a key with ``scopes``.
|
||||
|
||||
The owner is given an email and a group explicitly rather than relying
|
||||
on what /auth/setup happens to seed, so the assertions about what /me
|
||||
withholds cannot pass vacuously.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from backend.app.core.auth import generate_api_key
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.user import User
|
||||
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "keyowner",
|
||||
"admin_password": "KeyPass1!",
|
||||
},
|
||||
)
|
||||
owner = (
|
||||
await db_session.execute(select(User).where(User.username == "keyowner").options(selectinload(User.groups)))
|
||||
).scalar_one()
|
||||
owner.email = "keyowner@example.invalid"
|
||||
group = Group(name="key-owner-group", description="t", permissions=["printers:read"], is_system=False)
|
||||
db_session.add(group)
|
||||
await db_session.flush()
|
||||
owner.groups.append(group)
|
||||
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
db_session.add(
|
||||
APIKey(name="owned", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=owner.id, **scopes)
|
||||
)
|
||||
await db_session.commit()
|
||||
return owner, full_key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_reports_the_key_owner_not_a_synthetic_admin(self, async_client: AsyncClient, db_session):
|
||||
"""The id is the point of #1894 -- it is what created_by_id filters on."""
|
||||
owner, full_key = await self._owned_key(async_client, db_session)
|
||||
|
||||
response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["id"] == owner.id
|
||||
assert result["username"] == "keyowner"
|
||||
# The owner is an admin; the key still is not, because no key reaches
|
||||
# an administrative route regardless of who owns it.
|
||||
assert result["is_admin"] is False
|
||||
assert result["role"] != "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_withholds_owner_email_and_groups(self, async_client: AsyncClient, db_session):
|
||||
"""Identity, not the owner's profile -- anyone holding the key sees this."""
|
||||
owner, full_key = await self._owned_key(async_client, db_session)
|
||||
assert owner.email is not None and owner.groups # the helper made both non-empty
|
||||
|
||||
result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()
|
||||
|
||||
assert result["email"] is None
|
||||
assert result["groups"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_permissions_track_the_key_scopes_not_the_owner(self, async_client: AsyncClient, db_session):
|
||||
"""A key owned by an admin still reports only what its flags allow."""
|
||||
_, full_key = await self._owned_key(
|
||||
async_client,
|
||||
db_session,
|
||||
can_read_status=True,
|
||||
can_control_printer=False,
|
||||
can_queue=False,
|
||||
)
|
||||
|
||||
perms = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()["permissions"]
|
||||
|
||||
assert "printers:read" in perms # can_read_status
|
||||
assert "printers:control" not in perms # can_control_printer is off
|
||||
assert "queue:create" not in perms # can_queue is off
|
||||
assert "users:create" not in perms # administrative: unmapped for keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_permissions_are_exactly_what_the_gate_admits(self, async_client: AsyncClient, db_session):
|
||||
"""/me must not drift from _check_apikey_permissions.
|
||||
|
||||
The whole defect in #1894 was a /me response that described a different
|
||||
credential than the one the gate enforces, so pin them to each other
|
||||
rather than to a hand-written list that can rot. The owner is threaded
|
||||
through both sides for the same reason -- the gate narrows to the
|
||||
owner's permissions, so a check that skipped the owner would stop
|
||||
catching drift the moment the owner is not an administrator.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import _check_apikey_permissions, resolve_apikey_owner
|
||||
from backend.app.core.permissions import ALL_PERMISSIONS
|
||||
from backend.app.models.api_key import APIKey
|
||||
|
||||
_, full_key = await self._owned_key(async_client, db_session, can_read_status=True, can_control_printer=False)
|
||||
|
||||
response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
|
||||
reported = set(response.json()["permissions"])
|
||||
|
||||
key = (await db_session.execute(select(APIKey).where(APIKey.name == "owned"))).scalar_one()
|
||||
owner = await resolve_apikey_owner(db_session, key)
|
||||
for perm in ALL_PERMISSIONS:
|
||||
try:
|
||||
_check_apikey_permissions(key, [perm], owner=owner)
|
||||
except HTTPException:
|
||||
assert perm not in reported, f"/me reports '{perm}' but the gate denies it"
|
||||
else:
|
||||
assert perm in reported, f"the gate admits '{perm}' but /me omits it"
|
||||
|
||||
|
||||
class TestUsersAPI:
|
||||
"""Integration tests for /api/v1/users/ endpoints."""
|
||||
|
|
|
|||
|
|
@ -205,7 +205,48 @@ class TestSliceValidation:
|
|||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "STL, 3MF, or STEP" in response.json()["detail"]
|
||||
assert "STL or 3MF" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_step_source_is_refused_with_an_explanation(
|
||||
self, async_client: AsyncClient, db_session, slice_test_setup
|
||||
):
|
||||
"""STEP was accepted here and then failed at the sidecar.
|
||||
|
||||
Neither slicer's CLI can load STEP -- it answers "Unknown file format"
|
||||
and exits 250 -- so the job was read, converted and uploaded only to
|
||||
come back as "The input model file to the slicer can not be parsed",
|
||||
which reads as a corrupt model rather than an unsupported format.
|
||||
"""
|
||||
from backend.app.models.library import LibraryFile
|
||||
|
||||
step_path = slice_test_setup["tmp_path"] / "part.step"
|
||||
step_path.write_bytes(b"ISO-10303-21;\n")
|
||||
sfile = LibraryFile(
|
||||
filename="part.step",
|
||||
file_path=str(step_path.relative_to(slice_test_setup["tmp_path"])),
|
||||
file_type="step",
|
||||
file_size=14,
|
||||
)
|
||||
db_session.add(sfile)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(sfile)
|
||||
|
||||
response = await async_client.post(
|
||||
f"/api/v1/library/files/{sfile.id}/slice",
|
||||
json={
|
||||
"printer_preset_id": slice_test_setup["printer_id"],
|
||||
"process_preset_id": slice_test_setup["process_id"],
|
||||
"filament_preset_id": slice_test_setup["filament_id"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
detail = response.json()["detail"]
|
||||
assert "STEP" in detail
|
||||
# Naming the way out matters more than the refusal.
|
||||
assert "export" in detail.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|||
from backend.app.core.auth import generate_api_key
|
||||
from backend.app.core.config import settings as app_settings
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.user import User
|
||||
|
||||
|
|
@ -173,7 +174,14 @@ class TestWebhookQueueAddAttribution:
|
|||
async def test_credits_the_key_owner(self, async_client: AsyncClient, db_session, test_engine, printer_and_archive):
|
||||
printer, archive = printer_and_archive
|
||||
|
||||
owner = User(username="keyowner", password_hash="x", is_active=True)
|
||||
# The owner needs queue:create in their own right: a key is capped by
|
||||
# its owner's permissions (#1894), so a bare account with no groups
|
||||
# cannot queue through a key however its scope flags are set. This test
|
||||
# is about who the row is credited to, not about the gate.
|
||||
group = Group(name="queue-writers", description="t", permissions=["queue:create"], is_system=False)
|
||||
db_session.add(group)
|
||||
await db_session.flush()
|
||||
owner = User(username="keyowner", password_hash="x", is_active=True, groups=[group])
|
||||
db_session.add(owner)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(owner)
|
||||
|
|
|
|||
180
backend/tests/integration/test_users_slim_1894.py
Normal file
180
backend/tests/integration/test_users_slim_1894.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""GET /api/v1/users/slim -- the id -> username mapping for API clients (#1894).
|
||||
|
||||
An API key could already read global archive stats and filter them by
|
||||
``created_by_id`` (for API-keyed requests the permission deps return None as
|
||||
``current_user``, so the ``stats:filter_by_user`` guard short-circuits), but
|
||||
had no way to discover which id belonged to whom: the full listing is gated on
|
||||
``users:read``, which is unmapped in the API-key scope allowlist and therefore
|
||||
administrative.
|
||||
|
||||
The slim listing closes that gap without handing keys the full user objects.
|
||||
These tests pin both halves: that it answers for a key, and that it stays
|
||||
narrow while the full listing stays admin-only.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from backend.app.core.auth import generate_api_key
|
||||
from backend.app.models.api_key import APIKey
|
||||
from backend.app.models.group import Group
|
||||
from backend.app.models.user import User
|
||||
|
||||
|
||||
async def _setup_and_login(async_client: AsyncClient) -> str:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True, "admin_username": "slimadmin", "admin_password": "SlimPass1!"},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "slimadmin", "password": "SlimPass1!"},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _add_key(db_session, *, user_id: int | None = None, **scopes) -> str:
|
||||
full_key, key_hash, key_prefix = generate_api_key()
|
||||
db_session.add(
|
||||
APIKey(name="slim-test", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=user_id, **scopes)
|
||||
)
|
||||
await db_session.commit()
|
||||
return full_key
|
||||
|
||||
|
||||
async def _add_user(db_session, username: str, **kwargs) -> User:
|
||||
from backend.app.core.auth import get_password_hash
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=get_password_hash("Whatever1!"),
|
||||
email=f"{username}@example.invalid",
|
||||
role="user",
|
||||
is_active=True,
|
||||
**kwargs,
|
||||
)
|
||||
db_session.add(user)
|
||||
await db_session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_returns_only_id_and_username(async_client: AsyncClient, db_session):
|
||||
"""The response shape is the contract -- no emails, roles, or permissions."""
|
||||
token = await _setup_and_login(async_client)
|
||||
await _add_user(db_session, "bob")
|
||||
|
||||
response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code == 200
|
||||
rows = response.json()
|
||||
assert rows, "expected at least the admin created by setup"
|
||||
for row in rows:
|
||||
assert set(row) == {"id", "username"}
|
||||
assert "bob" in [row["username"] for row in rows]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_is_reachable_with_an_api_key(async_client: AsyncClient, db_session):
|
||||
"""The point of the issue: a key can resolve the ids it already filters on."""
|
||||
await _setup_and_login(async_client)
|
||||
owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
|
||||
full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True)
|
||||
|
||||
header = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key})
|
||||
bearer = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {full_key}"})
|
||||
|
||||
assert header.status_code == 200
|
||||
assert bearer.status_code == 200
|
||||
assert {row["username"] for row in header.json()} == {"slimadmin"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_needs_can_read_status(async_client: AsyncClient, db_session):
|
||||
"""A key without the read scope gets nothing, same as any other read route."""
|
||||
await _setup_and_login(async_client)
|
||||
owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
|
||||
full_key = await _add_key(db_session, user_id=owner.id, can_read_status=False)
|
||||
|
||||
response = await async_client.get("/api/v1/users/slim", headers={"X-API-Key": full_key})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_full_listing_stays_admin_only_for_api_keys(async_client: AsyncClient, db_session):
|
||||
"""Regression guard: widening the slim route must not widen the full one.
|
||||
|
||||
``users:read`` returns emails, group membership and the complete permission
|
||||
set for every account. It has to stay unmapped in the scope allowlist.
|
||||
"""
|
||||
await _setup_and_login(async_client)
|
||||
owner = (await db_session.execute(select(User).where(User.username == "slimadmin"))).scalar_one()
|
||||
full_key = await _add_key(db_session, user_id=owner.id, can_read_status=True)
|
||||
|
||||
response = await async_client.get("/api/v1/users", headers={"X-API-Key": full_key})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "administrative" in response.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_slim_is_not_parsed_as_a_user_id(async_client: AsyncClient, db_session):
|
||||
"""Route ordering. Declared after /{user_id}, "slim" would 422 as an int."""
|
||||
token = await _setup_and_login(async_client)
|
||||
|
||||
response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code != 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_group_with_only_users_read_still_reaches_slim(async_client: AsyncClient, db_session):
|
||||
"""``users:read`` is strictly broader, so it must pass the any-of gate.
|
||||
|
||||
Without this, every existing custom group holding ``users:read`` would need
|
||||
a permission backfill before the frontend could ever move to this route.
|
||||
"""
|
||||
await _setup_and_login(async_client)
|
||||
group = Group(name="readers", description="t", permissions=["users:read"], is_system=False)
|
||||
db_session.add(group)
|
||||
await db_session.flush()
|
||||
await _add_user(db_session, "reader", groups=[group])
|
||||
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "reader", "password": "Whatever1!"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
|
||||
response = await async_client.get("/api/v1/users/slim", headers={"Authorization": f"Bearer {token}"})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_a_group_with_only_slim_cannot_read_the_full_listing(async_client: AsyncClient, db_session):
|
||||
"""The narrow grant has to actually be narrower for JWT users too."""
|
||||
await _setup_and_login(async_client)
|
||||
group = Group(name="slim-only", description="t", permissions=["users:read_slim"], is_system=False)
|
||||
db_session.add(group)
|
||||
await db_session.flush()
|
||||
await _add_user(db_session, "slimonly", groups=[group])
|
||||
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "slimonly", "password": "Whatever1!"},
|
||||
)
|
||||
token = login.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
assert (await async_client.get("/api/v1/users/slim", headers=headers)).status_code == 200
|
||||
assert (await async_client.get("/api/v1/users", headers=headers)).status_code == 403
|
||||
245
backend/tests/unit/test_slicer_upload_size_rejection.py
Normal file
245
backend/tests/unit/test_slicer_upload_size_rejection.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"""The sidecar's upload cap, reported as something the user can act on (#2802).
|
||||
|
||||
The slicer sidecar bounds the size of the model it will accept. multer raises
|
||||
that rejection as a ``MulterError``, which is not the sidecar's ``AppError`` —
|
||||
so on every image built before the cap became configurable, the sidecar's error
|
||||
handler fell through to its default status and answered:
|
||||
|
||||
HTTP 500 {"message": "File too large"}
|
||||
|
||||
A 500 reads as "the slicer crashed". Bambuddy's one good message about request
|
||||
size lived behind ``if response.status_code == 413``, so it never fired, and the
|
||||
reporter of #2802 spent an evening setting ``MAX_FILE_SIZE``,
|
||||
``BODY_PARSER_LIMIT`` and ``EXPRESS_PAYLOAD_LIMIT`` and stopping nginx — none of
|
||||
which the sidecar reads, on a proxy that was never in the path.
|
||||
|
||||
Two things follow, and both are pinned here:
|
||||
|
||||
- The rejection is recognised by its *text*, not its status, so it is handled
|
||||
the same whether the sidecar is old (500) or current (413).
|
||||
- It raises ``SlicerInputError`` rather than ``SlicerApiServerError``. That is
|
||||
what stops ``POST /library/files/{id}/slice`` retrying the identical
|
||||
oversized upload "with embedded settings" — a second 25-second 3MF
|
||||
conversion for a guaranteed-identical answer, which the reporter's log shows
|
||||
happening on every attempt.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from backend.app.services.slicer_api import (
|
||||
SlicerApiServerError,
|
||||
SlicerApiService,
|
||||
SlicerApiUnavailableError,
|
||||
SlicerInputError,
|
||||
_transport_error_reason,
|
||||
)
|
||||
|
||||
SLICE_ARGS = {
|
||||
"model_bytes": b"x" * (3 * 1024 * 1024),
|
||||
"model_filename": "0399 Bidoof.3mf",
|
||||
"printer_profile_json": "{}",
|
||||
"process_profile_json": "{}",
|
||||
"filament_profile_jsons": ["{}"],
|
||||
}
|
||||
|
||||
|
||||
def _service(handler) -> SlicerApiService:
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
return SlicerApiService("http://sidecar:3001", client=client)
|
||||
|
||||
|
||||
def _responder(status_code: int, payload: dict):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code, json=payload)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
class TestOversizeUploadIsRecognised:
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_500_file_too_large_is_treated_as_bad_input(self):
|
||||
"""The exact shape an un-updated sidecar returns."""
|
||||
svc = _service(_responder(500, {"message": "File too large"}))
|
||||
|
||||
with pytest.raises(SlicerInputError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
assert "too large" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_413_from_a_current_sidecar_is_handled_the_same(self):
|
||||
"""Once the sidecar maps MulterError properly it sends 413 instead."""
|
||||
svc = _service(
|
||||
_responder(
|
||||
413,
|
||||
{
|
||||
"message": "The model file exceeds this slicer's 512 MB upload limit.",
|
||||
"details": "Raise it by setting MAX_MODEL_UPLOAD_MB.",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SlicerInputError):
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_it_is_not_a_server_error(self):
|
||||
"""The distinction the retry logic in library.py branches on.
|
||||
|
||||
``SlicerApiServerError`` is the "the CLI fell over, try the other
|
||||
request shape" signal. An upload the sidecar never accepted is not
|
||||
that, and retrying it uploads the same too-big file again.
|
||||
"""
|
||||
svc = _service(_responder(500, {"message": "File too large"}))
|
||||
|
||||
with pytest.raises(SlicerInputError):
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
# Belt and braces: SlicerInputError must not be a subclass of the type
|
||||
# the fallback catches, or the branch above is decorative.
|
||||
assert not issubclass(SlicerInputError, SlicerApiServerError)
|
||||
|
||||
|
||||
class TestTheMessageIsActionable:
|
||||
@pytest.mark.asyncio
|
||||
async def test_it_names_the_model_size(self):
|
||||
"""Support packages carried no size at all; #2802 had to be probed."""
|
||||
svc = _service(_responder(500, {"message": "File too large"}))
|
||||
|
||||
with pytest.raises(SlicerInputError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
assert "3 MB" in str(excinfo.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_it_rules_out_the_layers_the_reporter_tried(self):
|
||||
"""Naming the wrong knobs is the point: they were tried first."""
|
||||
svc = _service(_responder(500, {"message": "File too large"}))
|
||||
|
||||
with pytest.raises(SlicerInputError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "reverse-proxy" in message
|
||||
assert "client_max_body_size" in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_old_sidecar_is_told_to_update_not_to_set_a_variable(self):
|
||||
"""There is no env var to set on an image that predates the cap.
|
||||
|
||||
Telling that user to set MAX_MODEL_UPLOAD_MB would send them round the
|
||||
loop the reporter already did: change a setting, restart, no effect.
|
||||
"""
|
||||
svc = _service(_responder(500, {"message": "File too large"}))
|
||||
|
||||
with pytest.raises(SlicerInputError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "docker compose pull" in message
|
||||
assert "100 MB" in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_current_sidecar_is_told_which_variable_to_set(self):
|
||||
"""Once the image is current, the fix is one env var, not another pull."""
|
||||
svc = _service(
|
||||
_responder(
|
||||
413,
|
||||
{"message": "The model file exceeds this slicer's 512 MB upload limit."},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SlicerInputError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "MAX_MODEL_UPLOAD_MB" in message
|
||||
assert "docker compose pull" not in message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_it_keeps_what_the_sidecar_said(self):
|
||||
"""Never swallow the upstream text — it identifies the sidecar version."""
|
||||
svc = _service(_responder(413, {"message": "The model file exceeds this slicer's 256 MB upload limit."}))
|
||||
|
||||
with pytest.raises(SlicerInputError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
assert "256 MB" in str(excinfo.value)
|
||||
|
||||
|
||||
class TestOtherFailuresAreUnaffected:
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_ordinary_cli_failure_is_still_a_server_error(self):
|
||||
"""The embedded-settings fallback must keep working for real crashes."""
|
||||
svc = _service(
|
||||
_responder(
|
||||
500,
|
||||
{
|
||||
"message": "Slicing failed with error from slicer",
|
||||
"details": "Slicer process failed (exit code 250)",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SlicerApiServerError):
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_cli_error_that_merely_mentions_a_large_file_is_not_hijacked(self):
|
||||
"""A 500 only counts as an upload rejection if that is all it says.
|
||||
|
||||
The slicer's own diagnostics land in ``details``, and treating one of
|
||||
those as a size rejection would rob it of the embedded-settings retry
|
||||
that exists to recover from CLI failures.
|
||||
"""
|
||||
svc = _service(
|
||||
_responder(
|
||||
500,
|
||||
{
|
||||
"message": "Slicing failed with error from slicer",
|
||||
"details": "stderr: output file too large to write",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(SlicerApiServerError):
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_proxy_413_still_names_the_proxy(self):
|
||||
"""A 413 that is *not* the sidecar's own cap is a proxy body limit.
|
||||
|
||||
Those really are fixed with ``client_max_body_size``, so that advice
|
||||
has to survive — the new branch must not swallow every 413.
|
||||
"""
|
||||
svc = _service(_responder(413, {"message": "<html>413 Request Entity Too Large</html>"}))
|
||||
|
||||
with pytest.raises(SlicerInputError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
assert "client_max_body_size" in str(excinfo.value)
|
||||
|
||||
|
||||
class TestTransportErrorsAlwaysNameSomething:
|
||||
"""Three lines of the #2802 support package read "unreachable: " and stop."""
|
||||
|
||||
def test_an_exception_with_no_message_falls_back_to_its_type(self):
|
||||
assert _transport_error_reason(httpx.ConnectError("")) == "ConnectError"
|
||||
|
||||
def test_a_real_message_is_preferred(self):
|
||||
assert _transport_error_reason(httpx.ConnectError("All connection attempts failed")) == (
|
||||
"All connection attempts failed"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_slice_path_never_reports_an_empty_reason(self):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise httpx.ReadError("")
|
||||
|
||||
svc = _service(handler)
|
||||
|
||||
with pytest.raises(SlicerApiUnavailableError) as excinfo:
|
||||
await svc.slice_with_profiles(**SLICE_ARGS)
|
||||
|
||||
assert str(excinfo.value).strip().endswith("ReadError")
|
||||
|
|
@ -484,6 +484,7 @@ export const handlers = [
|
|||
http.get('/api/v1/spoolman/spools/linked', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/spoolman/spools/unlinked', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/users/', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/users/slim', () => HttpResponse.json([])),
|
||||
|
||||
// Status / object endpoints → minimal disabled-state responses
|
||||
http.get('/api/v1/archives/purge/settings', () =>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* The Stats filter-by-user dropdown sources names from the slim listing (#1894).
|
||||
*
|
||||
* `stats:filter_by_user` is a permission an operator can be granted on its own,
|
||||
* but the dropdown used to be populated from the admin-level `users:read`
|
||||
* listing. An operator who had been granted the filter therefore saw an empty
|
||||
* control -- the filter renders only when the user list is non-empty -- and had
|
||||
* no way to tell whether that meant "no users" or "not allowed to look".
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { StatsPage } from '../../pages/StatsPage';
|
||||
import { setAuthToken } from '../../api/client';
|
||||
|
||||
function signInAs(permissions: string[]) {
|
||||
setAuthToken('test-token', 'session');
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/status', () =>
|
||||
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
|
||||
),
|
||||
http.get('*/api/v1/auth/me', () =>
|
||||
HttpResponse.json({ id: 1, username: 'operator', is_admin: false, permissions }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setAuthToken(null);
|
||||
});
|
||||
|
||||
describe('stats filter-by-user (#1894)', () => {
|
||||
it('populates from /users/slim without the admin-level users:read', async () => {
|
||||
signInAs(['stats:read', 'stats:filter_by_user']);
|
||||
server.use(
|
||||
// The admin listing is exactly what such an operator cannot call.
|
||||
http.get('*/api/v1/users/', () => new HttpResponse(null, { status: 403 })),
|
||||
http.get('*/api/v1/users/slim', () =>
|
||||
HttpResponse.json([
|
||||
{ id: 1, username: 'operator' },
|
||||
{ id: 2, username: 'colleague' },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
render(<StatsPage />);
|
||||
|
||||
// The control only renders once names have arrived, so its presence is
|
||||
// the assertion -- an empty list leaves it out of the tree entirely.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Users')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('stays hidden when the user has no filter permission', async () => {
|
||||
signInAs(['stats:read']);
|
||||
server.use(
|
||||
http.get('*/api/v1/users/slim', () =>
|
||||
HttpResponse.json([{ id: 1, username: 'operator' }]),
|
||||
),
|
||||
);
|
||||
|
||||
render(<StatsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Quick Stats')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('All Users')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
frontend/src/__tests__/utils/slicerStepGating.test.ts
Normal file
53
frontend/src/__tests__/utils/slicerStepGating.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isApiSliceableFileType,
|
||||
isApiSliceableFilename,
|
||||
isSliceableFileType,
|
||||
isSliceableFilename,
|
||||
} from '../../utils/slicer';
|
||||
|
||||
/**
|
||||
* STEP splits the two slice paths.
|
||||
*
|
||||
* The desktop slicers open a STEP fine, so "Open in Slicer" must keep offering
|
||||
* it. Their command-line interfaces cannot load one -- OrcaSlicer 2.4.2 and
|
||||
* Bambu Studio 02.07.01.62 both answer "Unknown file format. Input file must
|
||||
* have .stl, .obj, .amf(.xml) extension." -- so the in-app "Slice" button and
|
||||
* the pipeline action, which both post to the sidecar, must not.
|
||||
*
|
||||
* One predicate used to serve both, which is why a STEP got a Slice button
|
||||
* that could only ever fail, several seconds and one upload later.
|
||||
*/
|
||||
describe('STEP is offered to the desktop slicer but not the sidecar', () => {
|
||||
it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is a desktop handoff', (name) => {
|
||||
expect(isSliceableFilename(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['part.step', 'part.stp', 'PART.STEP'])('%s is not sidecar-sliceable', (name) => {
|
||||
expect(isApiSliceableFilename(name)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['cube.stl', 'project.3mf'])('%s stays sliceable both ways', (name) => {
|
||||
expect(isSliceableFilename(name)).toBe(true);
|
||||
expect(isApiSliceableFilename(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['out.gcode', 'out.gcode.3mf'])('%s is slicer output, not input', (name) => {
|
||||
expect(isSliceableFilename(name)).toBe(false);
|
||||
expect(isApiSliceableFilename(name)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies the same split to stored file types', () => {
|
||||
expect(isSliceableFileType('step')).toBe(true);
|
||||
expect(isApiSliceableFileType('step')).toBe(false);
|
||||
expect(isApiSliceableFileType('stl')).toBe(true);
|
||||
expect(isApiSliceableFileType('3mf')).toBe(true);
|
||||
expect(isApiSliceableFileType('gcode.3mf')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing type as not sliceable', () => {
|
||||
expect(isApiSliceableFileType(undefined)).toBe(false);
|
||||
expect(isApiSliceableFileType(null)).toBe(false);
|
||||
expect(isApiSliceableFileType('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -3783,7 +3783,7 @@ export type Permission =
|
|||
| 'cloud:auth' | 'orca_cloud:auth'
|
||||
| 'makerworld:view' | 'makerworld:import'
|
||||
| 'api_keys:read' | 'api_keys:create' | 'api_keys:update' | 'api_keys:delete'
|
||||
| 'users:read' | 'users:create' | 'users:update' | 'users:delete'
|
||||
| 'users:read' | 'users:read_slim' | 'users:create' | 'users:update' | 'users:delete'
|
||||
| 'groups:read' | 'groups:create' | 'groups:update' | 'groups:delete'
|
||||
| 'pipelines:read' | 'pipelines:write' | 'pipelines:run'
|
||||
| 'websocket:connect';
|
||||
|
|
@ -3873,6 +3873,17 @@ export interface UserResponse {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Just enough to label an owner id (#1894). Backed by GET /users/slim, which
|
||||
* is readable with `users:read_slim` as well as the admin-level `users:read`
|
||||
* -- use it anywhere a screen only needs to turn a `created_by_id` into a
|
||||
* name, so operators are not forced into the full listing to get one.
|
||||
*/
|
||||
export interface UserSlim {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
username: string;
|
||||
password?: string; // Optional when advanced auth is enabled
|
||||
|
|
@ -4264,6 +4275,7 @@ export const api = {
|
|||
|
||||
// Users
|
||||
getUsers: () => request<UserResponse[]>('/users/'),
|
||||
getUsersSlim: () => request<UserSlim[]>('/users/slim'),
|
||||
getUser: (id: number) => request<UserResponse>(`/users/${id}`),
|
||||
createUser: (data: UserCreate) =>
|
||||
request<UserResponse>('/users/', {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { ModelViewer } from './ModelViewer';
|
|||
import { Button } from './Button';
|
||||
import { api, withStreamToken } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
|
||||
import { isApiSliceableFileType, isSliceableFileType, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
|
||||
import type { ArchivePlatesResponse, LibraryFilePlatesResponse, PlateMetadata } from '../types/plates';
|
||||
|
||||
// The modal shows the model only; G-code has its own full-page viewer.
|
||||
|
|
@ -368,12 +368,14 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
|||
}, [isDraggingDivider, dividerHeight, minPlateHeight, minViewerPx, minViewerRatio]);
|
||||
|
||||
// Which file types can be handed to a desktop slicer via the URL protocol
|
||||
// handler — and sliced in-app via the sidecar. Shares its list with
|
||||
// `isSliceableFilename()`, which the File Manager's card menu and list row
|
||||
// use, so a file's "Slice" action and its 3D-preview slicer button can no
|
||||
// longer disagree about the same file.
|
||||
// handler. Shares its list with `isSliceableFilename()`, which the File
|
||||
// Manager's card menu and list row use, so a file's "Slice" action and its
|
||||
// 3D-preview slicer button can no longer disagree about the same file.
|
||||
const slicerReadyType = isSliceableFileType(fileType);
|
||||
const canOpenInSlicer = isLibrary ? slicerReadyType : true;
|
||||
// The sidecar's list is narrower: its CLI cannot load STEP even though the
|
||||
// desktop GUI opens one fine, so in-app slicing is gated separately.
|
||||
const apiSlicerReadyType = isApiSliceableFileType(fileType);
|
||||
|
||||
// When the user has the in-app Slicer API enabled (Settings → Workflow →
|
||||
// Slicer → Use Slicer API), library-mode previews route the header's slicer
|
||||
|
|
@ -382,7 +384,7 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
|
|||
// the API is off, when no in-app handler is wired (e.g. archive preview),
|
||||
// or when the file type can't be sliced (.gcode / .gcode.3mf, etc.).
|
||||
const useBambuddySlicer = Boolean(
|
||||
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && slicerReadyType,
|
||||
isLibrary && settings?.use_slicer_api && onSliceWithBambuddy && apiSlicerReadyType,
|
||||
);
|
||||
|
||||
const handleOpenInSlicer = async (slicer: SlicerType) => {
|
||||
|
|
|
|||
|
|
@ -2930,9 +2930,10 @@ export function ArchivesPage() {
|
|||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
// Print Log user filter -- names only, so the slim listing is enough (#1894).
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: api.getUsers,
|
||||
queryKey: ['users', 'slim'],
|
||||
queryFn: api.getUsersSlim,
|
||||
enabled: viewMode === 'log',
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -472,8 +472,8 @@ export function CameraTokensSection() {
|
|||
// (e.g. permission missing for some reason), the table still renders
|
||||
// with the numeric user_id as fallback.
|
||||
try {
|
||||
const users = await api.getUsers();
|
||||
setUserIdToName(new Map(users.map((u: { id: number; username: string }) => [u.id, u.username])));
|
||||
const users = await api.getUsersSlim();
|
||||
setUserIdToName(new Map(users.map((u) => [u.id, u.username])));
|
||||
} catch {
|
||||
setUserIdToName(new Map());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ import { usePageFileDrop } from '../hooks/usePageFileDrop';
|
|||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { formatDuration, parseUTCDate, formatDate } from '../utils/date';
|
||||
import { formatFileSize } from '../utils/file';
|
||||
import { isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
|
||||
import { isApiSliceableFilename, isSliceableFilename, openInSlicer, resolveDesktopSlicer, type SlicerType } from '../utils/slicer';
|
||||
|
||||
type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
|
@ -895,7 +895,8 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
|
|||
{t('common.print')}
|
||||
</button>
|
||||
)}
|
||||
{isSliceableFilename(file.filename) && (useSlicerApi ? onSlice : onOpenInSlicer) && (
|
||||
{(useSlicerApi ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) &&
|
||||
(useSlicerApi ? onSlice : onOpenInSlicer) && (
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
|
||||
canSlice ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
|
||||
|
|
@ -913,7 +914,7 @@ function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload,
|
|||
{t('slice.action')}
|
||||
</button>
|
||||
)}
|
||||
{onRunPipeline && useSlicerApi && isSliceableFilename(file.filename) && (
|
||||
{onRunPipeline && useSlicerApi && isApiSliceableFilename(file.filename) && (
|
||||
<button
|
||||
className={`w-full px-3 py-1.5 text-left text-sm flex items-center gap-2 ${
|
||||
hasPermission('pipelines:run') ? 'text-white hover:bg-bambu-dark' : 'text-bambu-gray cursor-not-allowed'
|
||||
|
|
@ -1305,10 +1306,10 @@ export function FileManagerPage() {
|
|||
queryFn: () => api.getLibraryStats(),
|
||||
});
|
||||
|
||||
// Get users for the username filter autocomplete
|
||||
// Get users for the username filter autocomplete -- names only (#1894)
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => api.getUsers(),
|
||||
queryKey: ['users', 'slim'],
|
||||
queryFn: () => api.getUsersSlim(),
|
||||
});
|
||||
|
||||
// Get unique file types for filter dropdown
|
||||
|
|
@ -2639,7 +2640,7 @@ export function FileManagerPage() {
|
|||
</button>
|
||||
</>
|
||||
)}
|
||||
{isSliceableFilename(file.filename) && (
|
||||
{(settings?.use_slicer_api ? isApiSliceableFilename(file.filename) : isSliceableFilename(file.filename)) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canSlice()) return;
|
||||
|
|
@ -2656,7 +2657,7 @@ export function FileManagerPage() {
|
|||
{settings?.use_slicer_api ? <Cog className="w-4 h-4" /> : <ExternalLink className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{(settings?.use_slicer_api ?? false) && isSliceableFilename(file.filename) && (
|
||||
{(settings?.use_slicer_api ?? false) && isApiSliceableFilename(file.filename) && (
|
||||
<button
|
||||
onClick={() => hasPermission('pipelines:run') && setRunPipelineFile(file)}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
|
|
@ -2902,7 +2903,7 @@ export function FileManagerPage() {
|
|||
onSliceWithBambuddy={
|
||||
// Only offer in-app slicing on files the SliceModal can actually
|
||||
// handle (matches the file-row Cog visibility check at :2127).
|
||||
isSliceableFilename(viewerFile.filename) && hasPermission('library:upload')
|
||||
isApiSliceableFilename(viewerFile.filename) && hasPermission('library:upload')
|
||||
? () => {
|
||||
const f = viewerFile;
|
||||
setViewerFile(null);
|
||||
|
|
|
|||
|
|
@ -105,7 +105,10 @@ export function FinancePage() {
|
|||
const canUpdateBudgets = hasPermission('cost_centers:modify');
|
||||
const canAssignCostCenterUsers = hasPermission('cost_centers:modify');
|
||||
const canAdjustWallet = hasPermission('cost_centers:modify');
|
||||
const canReadUsers = hasPermission('users:read');
|
||||
// Finance only ever labels a user or picks one to assign, so the slim
|
||||
// listing suffices -- and a billing operator should not need the admin-level
|
||||
// users:read (emails, roles, permission sets) to staff a cost center (#1894).
|
||||
const canReadUsers = hasPermission('users:read_slim') || hasPermission('users:read');
|
||||
|
||||
const canAccessAllCostCenters =
|
||||
canReadAllFinance ||
|
||||
|
|
@ -202,8 +205,8 @@ export function FinancePage() {
|
|||
});
|
||||
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: api.getUsers,
|
||||
queryKey: ['users', 'slim'],
|
||||
queryFn: api.getUsersSlim,
|
||||
enabled: canReadUsers && (canViewMyCostCenters || canAdjustWallet || canAssignCostCenterUsers),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1053,9 +1053,12 @@ export function StatsPage() {
|
|||
queryFn: api.getSettings,
|
||||
});
|
||||
|
||||
// Slim listing (#1894): the filter only needs id + username, and gating it
|
||||
// on the admin-level users:read left the dropdown empty for exactly the
|
||||
// operators who were granted stats:filter_by_user.
|
||||
const { data: users } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: api.getUsers,
|
||||
queryKey: ['users', 'slim'],
|
||||
queryFn: api.getUsersSlim,
|
||||
enabled: canFilterByUser,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,17 @@ export function resolveDesktopSlicer(
|
|||
*/
|
||||
export const SLICEABLE_FILE_TYPES = ['3mf', 'stl', 'step', 'stp'] as const;
|
||||
|
||||
/**
|
||||
* The subset the *sidecar* can slice.
|
||||
*
|
||||
* The desktop slicers open a STEP happily; their command-line interfaces do
|
||||
* not. OrcaSlicer 2.4.2 and Bambu Studio 02.07.01.62 both answer one with
|
||||
* "Unknown file format. Input file must have .stl, .obj, .amf(.xml) extension."
|
||||
* So a STEP still gets an "Open in Slicer" handoff, and no longer gets a
|
||||
* "Slice" button that could only ever fail.
|
||||
*/
|
||||
export const API_SLICEABLE_FILE_TYPES = ['3mf', 'stl'] as const;
|
||||
|
||||
/**
|
||||
* Does a `LibraryFile.file_type` name a sliceable source file?
|
||||
*
|
||||
|
|
@ -78,6 +89,19 @@ export function isSliceableFilename(filename: string): boolean {
|
|||
return SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a filename name something the slicer *sidecar* can slice?
|
||||
*
|
||||
* Narrower than `isSliceableFilename` by exactly STEP — see
|
||||
* `API_SLICEABLE_FILE_TYPES`. Use this wherever the action posts to
|
||||
* `/library/files/{id}/slice`; use the wider one for the desktop handoff.
|
||||
*/
|
||||
export function isApiSliceableFilename(filename: string): boolean {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.gcode') || lower.endsWith('.gcode.3mf')) return false;
|
||||
return API_SLICEABLE_FILE_TYPES.some((ext) => lower.endsWith(`.${ext}`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the user's operating system
|
||||
*/
|
||||
|
|
@ -148,3 +172,13 @@ export function openArchiveInSlicer(path: string, slicer: SlicerType = 'bambu_st
|
|||
const downloadUrl = buildDownloadUrl(path);
|
||||
openInSlicer(downloadUrl, slicer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a `LibraryFile.file_type` name something the sidecar can slice?
|
||||
*
|
||||
* The `isSliceableFileType` counterpart, narrowed to the sidecar's formats.
|
||||
*/
|
||||
export function isApiSliceableFileType(fileType?: string | null): boolean {
|
||||
const normalized = (fileType || '').toLowerCase();
|
||||
return (API_SLICEABLE_FILE_TYPES as readonly string[]).includes(normalized);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,3 +12,9 @@ BAMBU_API_PORT=3001
|
|||
# (e.g. SIDECAR_TAG=bambuddy-0.2.5)
|
||||
# daily track the bambuddy:daily channel
|
||||
SIDECAR_TAG=latest
|
||||
|
||||
# Largest model file the sidecars will accept for a slice, in megabytes.
|
||||
# Raise it if a big multi-colour project is rejected as "too large" -- the
|
||||
# cap is enforced inside the sidecar, so no reverse-proxy body limit affects
|
||||
# it. Leave it unset for the 512 MB default.
|
||||
#MAX_MODEL_UPLOAD_MB=512
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ services:
|
|||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
# Largest model accepted for a slice, in MB. See .env.example.
|
||||
MAX_MODEL_UPLOAD_MB: "${MAX_MODEL_UPLOAD_MB:-512}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
|
|
@ -55,6 +57,8 @@ services:
|
|||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
# Largest model accepted for a slice, in MB. See .env.example.
|
||||
MAX_MODEL_UPLOAD_MB: "${MAX_MODEL_UPLOAD_MB:-512}"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||
interval: 30s
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -26,7 +26,7 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Bh7umAlT.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-CK67RtNz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-jOkuIvep.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue