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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user