Camera permission and review fixes (openpanel C3, C4)

C4: a device's `viewers` (superusers by default, or members) covers the
image and video messages its ports publish. Anyone else gets none of their
values on the socket, in the catalogue or a flow's state, 403 for their
history, for the frames they hold (the last 8 included) and for TURN
credentials, no bytes when asking for them, and `bye: forbidden` for a live
view. A device credential stays bounded by its dashboards. The device page
sets it in its Camera section, and a tile the viewer may not see says
"No access to this camera" and asks the camera for nothing.

C3:
- A volatile upload is held to MAX_FRAME_BYTES (4 MiB, 413 above it, read
  without extra copies), and so is a frame on a device's socket.
- A frame the engine holds in memory is written to the ring directory
  before a local worker is handed it, so load_artifact finds it.
- A socket the bus dropped events for catches up with a snapshot, demand
  included, and a device's credential is checked again per frame, cached by
  the flow store revision and its nonce, so a lost event never keeps an
  unpaired device's frames coming.
- A report is at most 64 KB once its named config_schemas are put in place.
- A device away for 60 s has its true bool messages set to false.
- Refused frames are logged by fixed keys, a frame is named after its
  message, `fluksio serve` waits 5 s for open connections, a tile with a
  stream URL creates no demand, the 8-name cap keeps the newest names.
- Every container's log rotates (json-file, 3 x 10 MB).
This commit is contained in:
Melvin Strobl
2026-10-17 09:20:04 +02:00
parent 6a98dcd23f
commit 72c1d73e96
36 changed files with 1268 additions and 152 deletions
+23
View File
@@ -933,6 +933,11 @@ export const DeviceBindingSchema = {
type: 'string',
enum: ['in', 'out'],
title: 'Direction'
},
dtype: {
type: 'string',
title: 'Dtype',
default: ''
}
},
type: 'object',
@@ -976,6 +981,12 @@ export const DeviceDefSchema = {
title: 'Touch',
default: false
},
viewers: {
type: 'string',
enum: ['superusers', 'members'],
title: 'Viewers',
default: 'superusers'
},
version: {
type: 'integer',
title: 'Version',
@@ -1011,6 +1022,12 @@ export const DeviceDocumentSchema = {
title: 'Touch',
default: false
},
viewers: {
type: 'string',
enum: ['superusers', 'members'],
title: 'Viewers',
default: 'superusers'
},
version: {
type: 'integer',
title: 'Version',
@@ -1090,6 +1107,12 @@ export const DevicePublicSchema = {
title: 'Touch',
default: false
},
viewers: {
type: 'string',
enum: ['superusers', 'members'],
title: 'Viewers',
default: 'superusers'
},
version: {
type: 'integer',
title: 'Version',
+5 -1
View File
@@ -271,7 +271,7 @@ export class ArtifactsService {
* watching now, which the oldest of falls out of memory rather than being
* kept. A worker on another host or a device publishing a camera sends this.
* It is read into memory, where the ring holds it, and never touches the
* disk.
* disk, so it is held to ``MAX_FRAME_BYTES`` rather than the store's cap.
* @param data The data for the request.
* @param data.name
* @param data.mediaType
@@ -1015,6 +1015,7 @@ export class DevicesService {
*
* STUN and TURN servers, the TURN ones with credentials of the caller's own
* that expire. Empty when none are set, which is enough on one network.
* Only for whoever may see the device's camera (``DeviceDef.viewers``).
* @param data The data for the request.
* @param data.deviceId
* @returns IceServers Successful Response
@@ -1958,6 +1959,9 @@ export class MessagesService {
/**
* Read Messages
* Every message any published flow declares, with its last value.
*
* A camera the caller may not see is listed without its value, so a tile
* can still be bound to it.
* @returns MessagesPublic Successful Response
* @throws ApiError
*/
+6
View File
@@ -238,6 +238,7 @@ export type DeviceBinding = {
port: string;
message: string;
direction: 'in' | 'out';
dtype?: string;
};
export type direction = 'in' | 'out';
@@ -254,9 +255,12 @@ export type DeviceDef = {
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
viewers?: 'superusers' | 'members';
version?: number;
};
export type viewers = 'superusers' | 'members';
/**
* A device as it reads itself: what it shows, and what its ports carry.
*
@@ -268,6 +272,7 @@ export type DeviceDocument = {
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
viewers?: 'superusers' | 'members';
version?: number;
io?: Array<DeviceIo>;
bindings?: Array<DeviceBinding>;
@@ -293,6 +298,7 @@ export type DevicePublic = {
title?: string;
dashboards?: Array<(string)>;
touch?: boolean;
viewers?: 'superusers' | 'members';
version?: number;
io?: Array<DeviceIo>;
bindings?: Array<DeviceBinding>;
@@ -6,7 +6,7 @@ import { isRef, useArtifactUrl } from "@/lib/media"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { config, text } from "./ui/core/config"
import { useLiveDevice, useLiveView } from "./useLiveView"
import { useCameraHidden, useLiveDevice, useLiveView } from "./useLiveView"
import type { WidgetProps } from "./widgets"
/**
@@ -66,10 +66,15 @@ export function MediaView({
const ref = isRef(value) ? value : null
const kind = (ref?.media_type ?? dtype).split("/")[0]
const playing = kind === "audio" || kind === "video"
const camera = useLiveDevice(stream ? "" : message)
// Only superusers may see this camera: nothing is asked of it.
const hidden = useCameraHidden(message)
const camera = useLiveDevice(stream || hidden ? "" : message)
const live = useLiveView(camera)
const onAir = live.phase === "playing"
const active = (shown || playing) && !onAir
// A stream URL draws the picture itself, all but sound: the message's
// bytes would only be demand on a camera nothing shows.
const draws = (!stream || kind === "audio") && !hidden
const active = (shown || playing) && !onAir && draws
const url = useArtifactUrl(
active ? ref : null,
active ? message || undefined : undefined,
@@ -116,6 +121,13 @@ export function MediaView({
if (!message && !stream) {
return <p className="text-muted-foreground">Pick a message.</p>
}
if (hidden) {
return (
<p className="text-muted-foreground" data-testid="media-no-access">
No access to this camera
</p>
)
}
if (!ref) {
return <p className="text-muted-foreground">Nothing published yet.</p>
}
@@ -1,13 +1,18 @@
import { useQuery } from "@tanstack/react-query"
import { useCallback, useEffect, useRef, useState } from "react"
import { LIVE_PORT, reportedPorts } from "@/components/Devices/ports"
import {
cameraMessages,
LIVE_PORT,
reportedPorts,
} from "@/components/Devices/ports"
import { devicesQueryOptions } from "@/components/Devices/queries"
import {
type LiveSignal,
listenLive,
sendLive,
} from "@/components/Flow/useFlowSocket"
import useAuth from "@/hooks/useAuth"
import { appRoute } from "@/lib/portal"
/** How long a live view gets from the tap to its first frame. */
@@ -45,6 +50,29 @@ export function useLiveDevice(message: string): string {
return device?.id ?? ""
}
/**
* Whether this viewer may not see the camera publishing `message`: a
* device's image or video while its `viewers` are superusers, for anyone
* else. The server holds the frames back too, this only says why the tile
* stays empty.
*
* A wall panel shows what its dashboards carry, which a superuser assigned,
* and cannot read the device list anyway.
*/
export function useCameraHidden(message: string): boolean {
const { user } = useAuth()
const enabled =
Boolean(message) &&
!appRoute().startsWith("/panel") &&
user?.is_superuser === false
const { data } = useQuery({ ...devicesQueryOptions(), enabled })
if (!enabled) return false
return (data?.data ?? []).some(
(device) =>
device.viewers !== "members" && cameraMessages(device).includes(message),
)
}
/**
* A live view of one device's camera: the browser's own `RTCPeerConnection`
* answering the device's offer, signalled over the page's socket.
@@ -44,6 +44,7 @@ import {
type DeviceReport,
deviceNodeOf,
freeNodeId,
hasCamera,
LIVE_PORT,
type ReportedPort,
reportedPorts,
@@ -141,7 +142,7 @@ export function DeviceDetail({ id }: { id: string }) {
<section className={cn(CARD, "grid gap-4")}>
<Basics device={device} canEdit={canEdit} onChange={write} />
</section>
<Camera device={device} />
<Camera device={device} canEdit={canEdit} onChange={write} />
<section className={CARD}>
<Dashboards device={device} canEdit={canEdit} onChange={write} />
</section>
@@ -219,33 +220,81 @@ function Basics({
)
}
/** Who may see a device's camera, as the setting reads. */
const VIEWERS = {
superusers: "Superusers",
members: "All members",
} as const
/**
* What the device's camera sees, once a flow wires its `camera` port.
* Who may watch the device's camera, and what it sees once a flow wires its
* `camera` port.
*
* Watching is what turns the camera on: it sends frames only while this
* preview, a tile or a flow reading them wants them.
*/
function Camera({ device }: { device: DevicePublic }) {
function Camera({
device,
canEdit,
onChange,
}: {
device: DevicePublic
canEdit: boolean
onChange: (changes: Partial<DeviceDef>) => void
}) {
if (!hasCamera(device)) return null
const bound = (device.bindings ?? []).find(
(binding) => binding.port === "camera" && binding.direction === "in",
)
if (!bound) return null
return (
<section className={CARD}>
<PanelSection
title="Camera"
help="Frames while this preview is on screen, which the camera sends only while someone watches them here or on a dashboard, or a running flow reads them. Tap it for live video when the device streams live."
>
<div
className="flex aspect-video w-full overflow-hidden rounded-md bg-muted text-center text-sm"
data-testid="device-camera"
>
<MediaView
message={bound.message}
fit="contain"
label={`${device.title || device.id} camera`}
/>
<div className="flex items-center gap-2 text-sm">
<span className="flex-1">Who may watch</span>
<InfoTip label="Who may watch">
Its pictures, their history and its live view: superusers only, or
every member of this instance. A wall panel shows the camera when
one of its dashboards carries it. Whoever edits flows can still pass
the pictures on through a node.
</InfoTip>
<Select
value={device.viewers ?? "superusers"}
disabled={!canEdit}
onValueChange={(viewers) =>
onChange({ viewers: viewers as DeviceDef["viewers"] })
}
>
<SelectTrigger
className="w-40"
aria-label="Who may watch"
data-testid="device-viewers"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(VIEWERS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{bound ? (
<div
className="flex aspect-video w-full overflow-hidden rounded-md bg-muted text-center text-sm"
data-testid="device-camera"
>
<MediaView
message={bound.message}
fit="contain"
label={`${device.title || device.id} camera`}
/>
</div>
) : null}
</PanelSection>
</section>
)
+24
View File
@@ -63,6 +63,30 @@ export function screensOf(devices: DevicePublic[] = []) {
})
}
/** What a device's `viewers` setting covers: what a camera sends. */
const CAMERA_DTYPES: readonly string[] = ["image", "video"]
/** The messages a device's camera publishes, its image and video `in` ports. */
export const cameraMessages = (device: DevicePublic) =>
(device.bindings ?? [])
.filter(
(binding) =>
binding.direction === "in" &&
CAMERA_DTYPES.includes(binding.dtype ?? ""),
)
.map((binding) => binding.message)
/**
* Whether a device has a camera to say who may watch: one it reports, or an
* image or video port wired in a flow.
*/
export const hasCamera = (device: DevicePublic) =>
cameraMessages(device).length > 0 ||
(reportedPorts(device) ?? []).some(
(port) =>
port.direction === "in" && CAMERA_DTYPES.includes(port.dtype ?? ""),
)
/** Whether a reported port may be wired in `direction`. `live` never is. */
export const wiresAs = (port: ReportedPort, direction: "in" | "out") =>
port.port !== LIVE_PORT &&