diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index c31ec47c..5e5cfd5a 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -109,7 +109,7 @@ carries value or liability of its own: | Endpoint | Requires | Why | |---|---|---| -| `GET /api/applications/package/` | `applications.view` | Licensed vendor installers. An anonymous URL is a redistribution channel. | +| `GET /api/applications/package/` | `applications.download` | Licensed vendor installers. A separate permission from `applications.view` on purpose: seeing that an application exists is not the same as being handed the vendor's installer, and an anonymous URL is a redistribution channel. The script-body download is gated the same way. | | `GET /api/warranty/proof/` | `warranty.view` | Invoices and certificates carry pricing and a service tag. | Their sibling upload/delete routes are `applications.edit` / `warranty.edit` @@ -135,14 +135,23 @@ like any other mutation. | `GET /api/plugins/enabled` | List enabled plugins (no claims used). | | `GET /api/dashboarddefaults/display-role` | Display role resolution (see section 1). | | `GET /api/dashboarddefaults/visitor-location` | Lobby business-unit resolution (see section 1). | -| `GET /api/employees/search`, `/lookup/`, `/lookup` | Employee directory lookups (kiosk / sign-in flows). | +| ~~`GET /api/employees/search`, `/lookup/`, `/lookup`~~ | NOT public. These need `employees.view` and answer 401 anonymously. A kiosk resolves a badge through its own gated endpoint, not through these. | | `GET /api/employees/photo/` | Serve an employee photo. | -| `GET /api/notifications` (and `/types`, `/`, `/active`, `/calendar`, `/dashboard/summary`, `/employee/`, `/shopfloor`) | Read shop-floor notifications for the public display and kiosks. | +| `GET /api/notifications/shopfloor`, `GET /api/notifications/employee/` | The two the public display and kiosks actually read. The rest of `/api/notifications` (the list, `/types`, `/`, `/active`, `/calendar`, `/dashboard/summary`) needs `notifications.view` and answers 401 anonymously. | | `GET /api/slides/feed` | Slide feed for the lobby display and screensaver. | | `GET /api/slides/img//` | Serve a slide image. | | `GET /api/printedparts/image/` | Serve a printed-part image. | -| `GET /api/printedparts/kiosk/item/` | Kiosk part lookup (deliberately open; a kiosk carries no JWT). | -| `POST /api/printedparts/kiosk/take` | Kiosk part checkout (deliberately open, per decision record). | +| `GET /api/printedparts/kiosk/item/` | Kiosk part lookup. Carries no JWT, but is NOT open: the routes 404 unless `printedparts_kiosk_enabled` is on AND the request carries the site's `X-Kiosk-Token`. | +| `POST /api/printedparts/kiosk/take` | Kiosk part checkout, behind the same flag and device token. | +| `GET /api/usb/kiosk/device/` | USB kiosk device lookup, behind `usb_kiosk_enabled` plus `X-Kiosk-Token`. | +| `POST /api/usb/kiosk/checkout`, `/checkin` | USB kiosk custody moves, same gate. | + +**"No authentication" is not "open."** Both kiosks are off by default and refuse +every request without the site's device token; with the flag off they answer 404 +rather than 403, so a kiosk a site has not turned on does not announce itself. +The token is a credential for a screen, not authorization for a person: it says +which cabinet is asking, and the badge on the request says who is standing at +it. --- diff --git a/docs/api-inventory.json b/docs/api-inventory.json index b836db25..a85441ed 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -3235,8 +3235,8 @@ { "method": "GET", "path": "/api/printers/lookup", - "auth": "jwt-optional", - "params": "ip or fqdn (one required; value matched against communication ipaddress)", + "auth": "printers.view", + "params": "ip or fqdn (one required; ip matches a communication address, fqdn matches the printer's hostname)", "purpose": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).", "example": "curl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'" }, @@ -4215,4 +4215,4 @@ } ] } -] +] \ No newline at end of file diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 858814e5..89029cc0 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -1306,6 +1306,11 @@ export const slidesApi = { }, remove(surface, files) { return api.post(`/slides/${surface}/delete`, { files }) + }, + // Per-slide hold time. The route has always existed; nothing called it, so + // the documented per-slide seconds could only be set with curl. + update(surface, slideid, data) { + return api.patch(`/slides/${surface}/${slideid}`, data) } } diff --git a/plugins/printedparts/services/badges.py b/plugins/printedparts/services/badges.py index 13ea6462..4da5f53f 100644 --- a/plugins/printedparts/services/badges.py +++ b/plugins/printedparts/services/badges.py @@ -36,16 +36,24 @@ class BadgeError(ValueError): def _parse_badge(badge): - """Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.""" - badge = (badge or '').strip() - if not badge: - raise BadgeError('Scan or enter a badge') - if badge.isdigit(): - return 'sso', badge - match = _PAYNO_BADGE.match(badge) - if match: - return 'payno', match.group(1) - raise BadgeError('Unrecognized badge format') + """Return ('sso'|'payno', digits) or raise BadgeError on unknown shape. + + The shapes themselves live in shopdb.api (normalise_badge), because the USB + kiosk reads the same physical badges and used to disagree: it took the + string raw, so the PayNo wrapper resolved to nobody and an operator whose + scanner emits it could take a part and not check out a drive. + + That also closed a subtler hole. This used str.isdigit(), which is true for + Arabic-Indic and other decimal forms, so a badge written in them was + accepted, resolved against int() to a REAL employee, and then stored as an + SSO string that joins to nothing. + """ + from shopdb.api import BadgeShapeError, badge_kind, normalise_badge + + try: + return badge_kind(badge), normalise_badge(badge) + except BadgeShapeError as bad: + raise BadgeError(str(bad)) def _selfhosted_lookup(digits): diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index 5a1f0027..e626b95e 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -2329,15 +2329,28 @@ def printer_lookup(): 'Provide ip or fqdn' ) - # match the IP against any active printer communication - row = ( + # An IP matches a communication row; an FQDN matches the printer's + # hostname. Both were compared against Communication.ipaddress, so + # ?fqdn= never found anything - it 404d on printers whose + # hostname was right there on the record. Zabbix uses this to jump to a + # printer, and it knows names as often as addresses. + query = ( db.session.query(Printer, Asset) .join(Asset, Asset.assetid == Printer.assetid) - .join(Communication, Communication.assetid == Asset.assetid) .filter(Asset.isactive == True) - .filter(Communication.ipaddress == lookup_value) - .first() ) + if ip: + row = (query + .join(Communication, Communication.assetid == Asset.assetid) + .filter(Communication.ipaddress == ip) + .first()) + else: + row = query.filter(Printer.hostname == fqdn).first() + if row is None: + # A caller holding a fully-qualified name against a site that + # stores the short one, or the reverse. + shortname = fqdn.split('.', 1)[0] + row = query.filter(Printer.hostname == shortname).first() if not row: return error_response( diff --git a/plugins/slides/frontend/views/SlideManager.vue b/plugins/slides/frontend/views/SlideManager.vue index e6a3903c..51200681 100644 --- a/plugins/slides/frontend/views/SlideManager.vue +++ b/plugins/slides/frontend/views/SlideManager.vue @@ -47,6 +47,15 @@ {{ slide.filename }} + +
@@ -73,6 +82,8 @@ import { ref, computed, onMounted } from 'vue' import { slidesApi } from '@/api' import { withBase } from '@/utils/basePath' +import { useToast } from '@/composables/toast' +import { apiError } from '@/utils/apiError' // Slide currently hovered, shown enlarged. Null when the cursor is elsewhere. const previewSlide = ref(null) @@ -133,6 +144,7 @@ const surfaces = [ { key: 'shopfloor', label: 'Shopfloor Screensaver' } ] const surface = ref('lobby') +const toast = useToast() const slides = ref([]) const selected = ref([]) const loading = ref(true) @@ -176,6 +188,22 @@ async function onUpload(event) { } } +async function setSeconds(slide, raw) { + const seconds = Math.max(0, Math.min(600, Number(raw) || 0)) + if (seconds === slide.seconds) return + try { + await slidesApi.update(surface.value, slide.slideid, { seconds }) + slide.seconds = seconds + toast.success(seconds + ? `${slide.filename} holds for ${seconds}s` + : `${slide.filename} uses the default interval`) + } catch (err) { + console.error('Error setting slide seconds:', err) + toast.error(apiError(err, 'Could not set the hold time')) + await load() + } +} + async function move(idx, delta) { const target = idx + delta if (target < 0 || target >= slides.value.length) return @@ -197,6 +225,17 @@ onMounted(load)