diff --git a/README.md b/README.md index 4c16494..5b99883 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Gateway routes: | GET | `/health` | Process liveness | | GET | `/printers` | Safe printer inventory (no addresses or credentials) | | GET | `/status` | Aggregate gateway envelope (one component per printer) | +| GET | `/ui` | Submission page for people (see below) | Per-printer STATUS_SPEC routes: @@ -246,8 +247,28 @@ dispatches, so a running print was started by some other route to the printer (Bambu Studio, the handset, the cloud) and the gateway reports only what it observes. +### The page + +`GET /ui` serves a submission page: pick a machine (its plate size, nozzle, +chamber and limits are shown so you know what you are targeting), upload a +file, and read the per-check verdict. It also lists that machine's queue with +finish times, and offers Approve / Cancel. + +It is one static file with **no build step and no external resources** — no +CDN, no npm, no bundler — served from the same origin as the API it calls, so +it needs no CORS exemption and works on an isolated lab network. It holds no +state of its own and calls only the public endpoints below, so it can do +nothing the API would refuse. It offers Approve only on a `queued` job, which +is the same rule the server enforces: never advertise an action that would be +refused. + +The page has no sign-in. The name you type is a label, not an identity — see +*Identity and approval* below. + ### Submitting +The page is the easy path. Directly: + ```bash curl -sS -X POST http://127.0.0.1:8012/submissions \ -F file=@plate.gcode.3mf \ diff --git a/deploy/bambu-server.local.service b/deploy/bambu-server.local.service index 75e4354..ffdb522 100644 --- a/deploy/bambu-server.local.service +++ b/deploy/bambu-server.local.service @@ -10,9 +10,21 @@ User=sdl2 Group=sdl2 WorkingDirectory=/home/sdl2/caoyang/bambu-server EnvironmentFile=/home/sdl2/caoyang/bambu-server/.env +# Bound to every interface, not loopback, for two readers that cannot share one +# address: the dashboard aggregator polls this service on 127.0.0.1:8012 (see +# ac-organic-lab/equipment.yaml), while a person opening /ui reaches it over the +# tailnet at 100.64.254.6:8012 -- loopback means the visitor's own machine in a +# browser, so a loopback-only bind serves the aggregator and nobody else. +# +# This matches the fleet's documented posture (DEVICE_PC_SETUP: device services +# bind 0.0.0.0 and access is gated by Tailscale ACLs, not by a local firewall). +# Note what it widens: port 8012 is now reachable on every interface this host +# has, and POST /submissions has no application-level auth. A tighter shape, if +# the exposure ever matters, is to keep loopback and front /ui through the Caddy +# edge like the camera gateway -- which would also put it behind ac_auth. ExecStart=/home/sdl2/caoyang/bambu-server/.venv/bin/uvicorn bambu_server.main:application_factory \ --factory \ - --host 127.0.0.1 \ + --host 0.0.0.0 \ --port 8012 \ --no-server-header \ --log-level info diff --git a/deploy/bambu-server.service b/deploy/bambu-server.service index 7c8c3e9..1d39568 100644 --- a/deploy/bambu-server.service +++ b/deploy/bambu-server.service @@ -10,6 +10,9 @@ User=ac Group=ac WorkingDirectory=/opt/bambu-server EnvironmentFile=/opt/bambu-server/.env +# Loopback by default: the conservative choice for a fresh host. Change --host +# to 0.0.0.0 if people need to reach /ui over the tailnet -- see the note in +# bambu-server.local.service for what that widens. ExecStart=/opt/bambu-server/.venv/bin/uvicorn bambu_server.main:application_factory \ --factory \ --host 127.0.0.1 \ diff --git a/docs/TODO.md b/docs/TODO.md index 6c23061..4b4f862 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -86,10 +86,28 @@ Open, from the design's §10 data gaps and what the build surfaced: stay on disk and in `GET /submissions` indefinitely. Fine at current volume, but it needs a sweep before this runs unattended for long. +## Submission page + +`GET /ui` — one static file (`src/bambu_server/static/index.html`), no build +step, no external resources, served from the same origin as the API. Added +because the pipeline shipped with no human-facing surface at all: the design +assumed the lab dashboard would render these endpoints, so a UI was never in +its scope, which left `curl` and Swagger as the only way in. + +Deliberate limits: it holds no state, calls only public endpoints, and offers +Approve only on a `queued` job so it can never advertise a refusal. It has no +sign-in, matching the rest of the service. + +Not visually verified — there is no browser on this host, so only the HTML +structure and the script's syntax were checked. Worth a look in a real browser +before pointing users at it. The durable home is probably the lab dashboard +(`ac-organic-lab/web`) once `ac_auth` makes `requested_by` a real identity; +this page is the interim surface. + ## Test suite - `uv run ruff check .` passes. -- `uv run pytest -q` passes all 126 tests, including the FastAPI API tests and +- `uv run pytest -q` passes all 130 tests, including the FastAPI API tests and the submission pipeline (artifact inspection, validation, store/state machine, queue ETA, HTTP surface). Tests build their own `.3mf` and `.gcode` fixtures and use fake backends; nothing touches hardware. diff --git a/pyproject.toml b/pyproject.toml index 258a630..35fdacf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,11 @@ bambu-server = "bambu_server.__main__:main" [tool.setuptools.packages.find] where = ["src"] +# The submission page ships with the package so a non-editable install serves +# /ui too, not just a checkout-based deploy. +[tool.setuptools.package-data] +bambu_server = ["static/*.html"] + [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/src/bambu_server/main.py b/src/bambu_server/main.py index 5374f8a..2334a60 100644 --- a/src/bambu_server/main.py +++ b/src/bambu_server/main.py @@ -18,11 +18,13 @@ from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from datetime import UTC, datetime +from pathlib import Path as PathLib from pathlib import PurePosixPath from typing import Annotated from fastapi import Depends, FastAPI, File, Form, HTTPException, Path, Query, UploadFile from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse from pydantic import BaseModel, Field from . import __version__ @@ -62,6 +64,11 @@ #: Read size for streaming an upload to disk. _UPLOAD_CHUNK_BYTES = 1 << 20 +#: The submission page. One self-contained file with no build step and no +#: external resources, served from the same origin as the API it calls, so a +#: browser needs neither a bundler nor a CORS exemption to use it. +_UI_PAGE = PathLib(__file__).parent / "static" / "index.html" + #: Leading bytes an artifact must start with, keyed by kind. A ``.3mf`` is a #: zip container; anything else under that name is a malformed submission and #: is refused at intake rather than carried through validation. @@ -169,6 +176,17 @@ async def gateway_info() -> GatewayInfo: printer_count=len(monitors), ) + @app.get("/ui", response_class=HTMLResponse, include_in_schema=False, tags=["gateway"]) + async def submission_ui() -> HTMLResponse: + """The operator/submitter page. + + Deliberately a single static file: it calls the same public endpoints + any other client would, holds no state of its own, and cannot do + anything the API would refuse. + """ + + return HTMLResponse(_UI_PAGE.read_text(encoding="utf-8")) + @app.get("/health", response_model=HealthResponse, tags=["gateway"]) async def gateway_health() -> HealthResponse: return HealthResponse() diff --git a/src/bambu_server/static/index.html b/src/bambu_server/static/index.html new file mode 100644 index 0000000..eaa6dc6 --- /dev/null +++ b/src/bambu_server/static/index.html @@ -0,0 +1,352 @@ + + + + + +Submit a print — AC Bambu Gateway + + + +
+

Submit a print

+

Upload a sliced .3mf or .gcode. It is checked against the machine you pick before it joins that machine's queue.

+ + + +
+

Machine

+
+
+ + +
+
+ + +
+
+
+
+ +
+

Artifact

+
+
+ + +
+
+ + +
+
+ + +
+ + + +
+

Queue

+
+
+
+ + + + diff --git a/tests/test_submission_api.py b/tests/test_submission_api.py index e505205..0e0596e 100644 --- a/tests/test_submission_api.py +++ b/tests/test_submission_api.py @@ -366,3 +366,37 @@ def test_a_cancelled_job_can_be_filtered_for(client: TestClient) -> None: assert len(client.get("/submissions", params={"state": "cancelled"}).json()) == 1 assert client.get("/submissions", params={"state": "queued"}).json() == [] + + +def test_the_ui_page_is_served(client: TestClient) -> None: + response = client.get("/ui") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/html") + assert "Submit a print" in response.text + + +def test_the_ui_page_loads_nothing_from_off_host(client: TestClient) -> None: + """No CDN, no build step: the page must work on an isolated lab network.""" + body = client.get("/ui").text + + assert "//cdn" not in body + for marker in ('src="http', "src='http", 'href="http', "href='http", "@import"): + assert marker not in body, marker + + +def test_the_ui_page_only_calls_public_endpoints(client: TestClient) -> None: + body = client.get("/ui").text + paths = client.get("/openapi.json").json()["paths"] + + # The approve/cancel URLs are built by concatenation, so match the verbs. + for called in ("/printers", "/submissions", "/queue", "/profile", '"approve"', '"cancel"'): + assert called in body, called + # It must not reach for anything that would actuate a printer. + assert "/control/" not in body + assert not any("/control/" in path for path in paths) + + +def test_the_ui_page_is_not_in_the_api_schema(client: TestClient) -> None: + """It is a page, not part of the contract a machine client reads.""" + assert "/ui" not in client.get("/openapi.json").json()["paths"]