Sync whiteboard images and add board ownership
CI / server (push) Successful in 33s
CI / frontend (push) Successful in 28s
CI / kiosk (push) Failing after 7m17s
Build and Publish Docker Images / build-and-push (apps/web-client/Dockerfile, pistation-web) (push) Successful in 2m17s
Build and Publish Docker Images / build-and-push (server/Dockerfile, pistation-server) (push) Successful in 2m32s

This commit is contained in:
2026-08-09 21:06:31 -04:00
parent 07508504ac
commit 404a35e1b1
13 changed files with 355 additions and 14 deletions
+14 -1
View File
@@ -2,12 +2,14 @@ import type {
DataEnvelope, DataEnvelope,
KioskLayout, KioskLayout,
RoomMode, RoomMode,
WhiteboardElement WhiteboardElement,
WhiteboardFile
} from "@pistation/shared-types"; } from "@pistation/shared-types";
import { import {
isNightModeActive, isNightModeActive,
isTopic, isTopic,
mergeWhiteboardElements, mergeWhiteboardElements,
mergeWhiteboardFiles,
PIN_GRACE_SECONDS PIN_GRACE_SECONDS
} from "@pistation/shared-types"; } from "@pistation/shared-types";
import type { ConnectionStatus } from "@pistation/client-core"; import type { ConnectionStatus } from "@pistation/client-core";
@@ -52,6 +54,7 @@ export class KioskController {
mode = $state<RoomMode>("idle"); mode = $state<RoomMode>("idle");
annotations = $state<AnnotationState>(createAnnotationState()); annotations = $state<AnnotationState>(createAnnotationState());
whiteboardElements = $state<WhiteboardElement[]>([]); whiteboardElements = $state<WhiteboardElement[]>([]);
whiteboardFiles = $state<WhiteboardFile[]>([]);
participants = $state<RoomParticipant[]>([]); participants = $state<RoomParticipant[]>([]);
isScreenActive = $state(false); isScreenActive = $state(false);
screenWidth = $state(0); screenWidth = $state(0);
@@ -61,6 +64,13 @@ export class KioskController {
isNight = $state(false); isNight = $state(false);
connectionError = $state<string | null>(null); connectionError = $state<string | null>(null);
get resolvedWhiteboardFiles(): WhiteboardFile[] {
return this.whiteboardFiles.map((file) => ({
...file,
url: file.url.startsWith("http") ? file.url : `${this.mediaBaseUrl}${file.url}`
}));
}
private config: KioskConfig | null = null; private config: KioskConfig | null = null;
private connection: NativeRoom | null = null; private connection: NativeRoom | null = null;
private timers: ReturnType<typeof setInterval>[] = []; private timers: ReturnType<typeof setInterval>[] = [];
@@ -281,12 +291,15 @@ export class KioskController {
const event = envelope.payload; const event = envelope.payload;
if (event.type === "whiteboard.patch") { if (event.type === "whiteboard.patch") {
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements); this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
} }
if (event.type === "whiteboard.snapshot") { if (event.type === "whiteboard.snapshot") {
this.whiteboardElements = event.elements; this.whiteboardElements = event.elements;
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
} }
if (event.type === "whiteboard.clear") { if (event.type === "whiteboard.clear") {
this.whiteboardElements = []; this.whiteboardElements = [];
this.whiteboardFiles = [];
} }
} }
} }
+5 -1
View File
@@ -61,7 +61,11 @@
</p> </p>
</div> </div>
{:else if kiosk.mode === "whiteboard"} {:else if kiosk.mode === "whiteboard"}
<WhiteboardCanvas elements={kiosk.whiteboardElements} readOnly={true} /> <WhiteboardCanvas
elements={kiosk.whiteboardElements}
files={kiosk.resolvedWhiteboardFiles}
readOnly={true}
/>
{:else if isPresenting} {:else if isPresenting}
<AnnotationOverlay annotations={kiosk.annotations} labels={participantLabels} /> <AnnotationOverlay annotations={kiosk.annotations} labels={participantLabels} />
{:else if kiosk.layout && kiosk.isNight} {:else if kiosk.layout && kiosk.isNight}
+26
View File
@@ -73,6 +73,32 @@ export function refreshSession(sessionId: string): Promise<SessionRefreshRespons
}); });
} }
export async function uploadWhiteboardImage(
sessionId: string,
blob: Blob
): Promise<{ imageUrl: string }> {
let response: Response;
try {
response = await fetch(`${apiBaseUrl}/api/whiteboard/image`, {
method: "PUT",
headers: { "content-type": blob.type, authorization: `Bearer ${sessionId}` },
body: blob
});
} catch {
throw new ApiError(0, "network", "Could not reach the PiStation server.");
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = (payload as { message?: string })?.message ?? "Upload failed.";
throw new ApiError(response.status, "upload", message);
}
return payload as { imageUrl: string };
}
export function adminLogin(email: string, password: string): Promise<AdminLoginResponse> { export function adminLogin(email: string, password: string): Promise<AdminLoginResponse> {
return request<AdminLoginResponse>("/api/admin/login", { return request<AdminLoginResponse>("/api/admin/login", {
method: "POST", method: "POST",
+86 -6
View File
@@ -7,9 +7,15 @@ import type {
StrokeStyle, StrokeStyle,
TopicPayloadMap, TopicPayloadMap,
WhiteboardElement, WhiteboardElement,
WhiteboardEvent WhiteboardEvent,
WhiteboardFile
} from "@pistation/shared-types";
import {
DEFAULT_STROKE_STYLE,
isTopic,
mergeWhiteboardElements,
mergeWhiteboardFiles
} from "@pistation/shared-types"; } from "@pistation/shared-types";
import { DEFAULT_STROKE_STYLE, isTopic, mergeWhiteboardElements } from "@pistation/shared-types";
import type { AnnotationState, ConnectionStatus } from "@pistation/client-core"; import type { AnnotationState, ConnectionStatus } from "@pistation/client-core";
import { import {
applyAnnotationEvent, applyAnnotationEvent,
@@ -30,7 +36,7 @@ import {
Track Track
} from "livekit-client"; } from "livekit-client";
import { refreshSession } from "./api"; import { apiBaseUrl, refreshSession, uploadWhiteboardImage } from "./api";
import type { StoredSession } from "./session"; import type { StoredSession } from "./session";
export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser"; export type AnnotationTool = "pen" | "highlighter" | "arrow" | "rectangle" | "ellipse" | "laser";
@@ -54,6 +60,8 @@ export class RoomController {
mode = $state<RoomMode>("idle"); mode = $state<RoomMode>("idle");
annotations = $state<AnnotationState>(createAnnotationState()); annotations = $state<AnnotationState>(createAnnotationState());
whiteboardElements = $state<WhiteboardElement[]>([]); whiteboardElements = $state<WhiteboardElement[]>([]);
whiteboardFiles = $state<WhiteboardFile[]>([]);
whiteboardOwnerId = $state<string | null>(null);
screenTrack = $state<RemoteTrack | null>(null); screenTrack = $state<RemoteTrack | null>(null);
localScreenTrack = $state<LocalVideoTrack | null>(null); localScreenTrack = $state<LocalVideoTrack | null>(null);
localCameraTrack = $state<LocalVideoTrack | null>(null); localCameraTrack = $state<LocalVideoTrack | null>(null);
@@ -229,6 +237,17 @@ export class RoomController {
this.participants = [self, ...others]; this.participants = [self, ...others];
this.sharingParticipantName = this.broadcaster?.displayName ?? null; this.sharingParticipantName = this.broadcaster?.displayName ?? null;
// Whoever opened the whiteboard has gone, so it would otherwise be stuck open for
// everyone still in the room.
if (
this.whiteboardOwnerId !== null &&
!this.participants.some(
(participant) => participant.participantId === this.whiteboardOwnerId
)
) {
this.whiteboardOwnerId = null;
}
} }
/// The room has a single broadcast slot. Whoever holds it, with either a screen or a /// The room has a single broadcast slot. Whoever holds it, with either a screen or a
@@ -343,8 +362,27 @@ export class RoomController {
setMode(mode: RoomMode): void { setMode(mode: RoomMode): void {
if (!this.canPresent) return; if (!this.canPresent) return;
if (this.mode === "whiteboard" && mode !== "whiteboard" && !this.canStopWhiteboard) return;
const ownerId = mode === "whiteboard" ? this.session.participantId : null;
this.whiteboardOwnerId = ownerId;
this.mode = mode; this.mode = mode;
this.send("control", { type: "mode.set", mode }); this.send("control", { type: "mode.set", mode, ownerId });
}
/// A whiteboard belongs to whoever opened it, so nobody else can close it out from under
/// them. If that person has left the room the board is unowned and anyone may close it.
get canStopWhiteboard(): boolean {
if (this.whiteboardOwnerId === null) return true;
return this.whiteboardOwnerId === this.session.participantId;
}
get whiteboardOwnerName(): string | null {
if (this.whiteboardOwnerId === null) return null;
const owner = this.participants.find(
(participant) => participant.participantId === this.whiteboardOwnerId
);
return owner?.displayName ?? null;
} }
beginStroke(point: NormalizedPoint): void { beginStroke(point: NormalizedPoint): void {
@@ -429,6 +467,39 @@ export class RoomController {
this.send("whiteboard", { type: "whiteboard.patch", elements }); this.send("whiteboard", { type: "whiteboard.patch", elements });
} }
/// Image bytes are far too large for a data channel packet, so the binary goes to the
/// server over HTTP and only the URL it returns travels to the other participants.
async pushWhiteboardImage(file: {
id: string;
dataUrl: string;
mimeType: string;
}): Promise<void> {
try {
const blob = await (await fetch(file.dataUrl)).blob();
const { imageUrl } = await uploadWhiteboardImage(this.session.sessionId, blob);
const shared: WhiteboardFile = { id: file.id, url: imageUrl, mimeType: file.mimeType };
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, [shared]);
this.send("whiteboard", { type: "whiteboard.patch", elements: [], files: [shared] });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
this.errorMessage = `That image could not be shared: ${detail}`;
}
}
clearWhiteboard(): void {
this.whiteboardElements = [];
this.whiteboardFiles = [];
this.send("whiteboard", { type: "whiteboard.clear" });
}
get resolvedWhiteboardFiles(): WhiteboardFile[] {
return this.whiteboardFiles.map((file) => ({
...file,
url: file.url.startsWith("http") ? file.url : `${apiBaseUrl}${file.url}`
}));
}
private eraseAt(point: NormalizedPoint): void { private eraseAt(point: NormalizedPoint): void {
const strokeIds = eraseAtPoint(this.annotations, point, 0.02); const strokeIds = eraseAtPoint(this.annotations, point, 0.02);
if (strokeIds.length === 0) return; if (strokeIds.length === 0) return;
@@ -485,7 +556,7 @@ export class RoomController {
} }
if (isTopic(envelope, "control")) { if (isTopic(envelope, "control")) {
this.handleControlEvent(envelope.payload); this.handleControlEvent(envelope.payload, envelope.senderId);
return; return;
} }
@@ -494,14 +565,18 @@ export class RoomController {
} }
} }
private handleControlEvent(event: ControlEvent): void { private handleControlEvent(event: ControlEvent, senderId: string): void {
if (event.type === "mode.set") { if (event.type === "mode.set") {
this.mode = event.mode; this.mode = event.mode;
this.whiteboardOwnerId =
event.mode === "whiteboard" ? (event.ownerId ?? senderId) : null;
return; return;
} }
if (event.type === "room.state") { if (event.type === "room.state") {
this.mode = event.state.mode; this.mode = event.state.mode;
this.whiteboardOwnerId =
event.state.mode === "whiteboard" ? (event.state.whiteboardOwnerId ?? null) : null;
return; return;
} }
@@ -512,6 +587,7 @@ export class RoomController {
roomName: this.session.roomName, roomName: this.session.roomName,
mode: this.mode, mode: this.mode,
presenterId: this.session.participantId, presenterId: this.session.participantId,
whiteboardOwnerId: this.whiteboardOwnerId,
annotationsLocked: false, annotationsLocked: false,
updatedAt: Date.now() updatedAt: Date.now()
} }
@@ -522,16 +598,19 @@ export class RoomController {
private handleWhiteboardEvent(event: WhiteboardEvent): void { private handleWhiteboardEvent(event: WhiteboardEvent): void {
if (event.type === "whiteboard.patch") { if (event.type === "whiteboard.patch") {
this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements); this.whiteboardElements = mergeWhiteboardElements(this.whiteboardElements, event.elements);
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
return; return;
} }
if (event.type === "whiteboard.snapshot") { if (event.type === "whiteboard.snapshot") {
this.whiteboardElements = event.elements; this.whiteboardElements = event.elements;
this.whiteboardFiles = mergeWhiteboardFiles(this.whiteboardFiles, event.files ?? []);
return; return;
} }
if (event.type === "whiteboard.clear") { if (event.type === "whiteboard.clear") {
this.whiteboardElements = []; this.whiteboardElements = [];
this.whiteboardFiles = [];
return; return;
} }
@@ -539,6 +618,7 @@ export class RoomController {
this.send("whiteboard", { this.send("whiteboard", {
type: "whiteboard.snapshot", type: "whiteboard.snapshot",
elements: this.whiteboardElements, elements: this.whiteboardElements,
files: this.whiteboardFiles,
backgroundColor: "#0b0d10" backgroundColor: "#0b0d10"
}); });
} }
+8 -1
View File
@@ -167,7 +167,11 @@
<button <button
onclick={toggleWhiteboard} onclick={toggleWhiteboard}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors" disabled={current.mode === "whiteboard" && !current.canStopWhiteboard}
title={current.mode === "whiteboard" && !current.canStopWhiteboard
? `${current.whiteboardOwnerName ?? "Someone else"} opened this whiteboard and can close it`
: undefined}
class="flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
class:bg-accent={current.mode === "whiteboard"} class:bg-accent={current.mode === "whiteboard"}
class:text-white={current.mode === "whiteboard"} class:text-white={current.mode === "whiteboard"}
class:bg-surface-2={current.mode !== "whiteboard"} class:bg-surface-2={current.mode !== "whiteboard"}
@@ -214,7 +218,10 @@
{#if current.mode === "whiteboard"} {#if current.mode === "whiteboard"}
<WhiteboardCanvas <WhiteboardCanvas
elements={current.whiteboardElements} elements={current.whiteboardElements}
files={current.resolvedWhiteboardFiles}
onLocalChange={(changed) => current.pushWhiteboardElements(changed)} onLocalChange={(changed) => current.pushWhiteboardElements(changed)}
onLocalFile={(file) => void current.pushWhiteboardImage(file)}
onLocalClear={() => current.clearWhiteboard()}
/> />
{:else if current.localScreenTrack} {:else if current.localScreenTrack}
<TrackVideo track={current.localScreenTrack} /> <TrackVideo track={current.localScreenTrack} />
+1
View File
@@ -3,6 +3,7 @@ import type { RoomMode, RoomState } from "./room.js";
export interface ModeSetEvent { export interface ModeSetEvent {
type: "mode.set"; type: "mode.set";
mode: RoomMode; mode: RoomMode;
ownerId?: string | null;
} }
export interface PresenterSetEvent { export interface PresenterSetEvent {
+1
View File
@@ -16,6 +16,7 @@ export interface RoomState {
roomName: string; roomName: string;
mode: RoomMode; mode: RoomMode;
presenterId: string | null; presenterId: string | null;
whiteboardOwnerId?: string | null;
annotationsLocked: boolean; annotationsLocked: boolean;
updatedAt: number; updatedAt: number;
} }
+24
View File
@@ -6,14 +6,22 @@ export interface WhiteboardElement {
[key: string]: unknown; [key: string]: unknown;
} }
export interface WhiteboardFile {
id: string;
url: string;
mimeType: string;
}
export interface WhiteboardPatchEvent { export interface WhiteboardPatchEvent {
type: "whiteboard.patch"; type: "whiteboard.patch";
elements: WhiteboardElement[]; elements: WhiteboardElement[];
files?: WhiteboardFile[];
} }
export interface WhiteboardSnapshotEvent { export interface WhiteboardSnapshotEvent {
type: "whiteboard.snapshot"; type: "whiteboard.snapshot";
elements: WhiteboardElement[]; elements: WhiteboardElement[];
files?: WhiteboardFile[];
backgroundColor: string; backgroundColor: string;
} }
@@ -48,6 +56,22 @@ export function mergeWhiteboardElements(
return [...byId.values()]; return [...byId.values()];
} }
export function mergeWhiteboardFiles(
current: WhiteboardFile[],
incoming: WhiteboardFile[]
): WhiteboardFile[] {
const byId = new Map<string, WhiteboardFile>();
for (const file of current) {
byId.set(file.id, file);
}
for (const file of incoming) {
if (!byId.has(file.id)) {
byId.set(file.id, file);
}
}
return [...byId.values()];
}
function isNewerElement(candidate: WhiteboardElement, existing: WhiteboardElement): boolean { function isNewerElement(candidate: WhiteboardElement, existing: WhiteboardElement): boolean {
if (candidate.version !== existing.version) { if (candidate.version !== existing.version) {
return candidate.version > existing.version; return candidate.version > existing.version;
+107 -4
View File
@@ -1,19 +1,32 @@
<script lang="ts"> <script lang="ts">
import type { WhiteboardElement } from "@pistation/shared-types"; import type { WhiteboardElement, WhiteboardFile } from "@pistation/shared-types";
import { onMount } from "svelte"; import { onMount } from "svelte";
let { let {
elements, elements,
files = [],
readOnly = false, readOnly = false,
onLocalChange onLocalChange,
onLocalFile,
onLocalClear
}: { }: {
elements: WhiteboardElement[]; elements: WhiteboardElement[];
files?: WhiteboardFile[];
readOnly?: boolean; readOnly?: boolean;
onLocalChange?: (changed: WhiteboardElement[]) => void; onLocalChange?: (changed: WhiteboardElement[]) => void;
onLocalFile?: (file: { id: string; dataUrl: string; mimeType: string }) => void;
onLocalClear?: () => void;
} = $props(); } = $props();
interface BinaryFile {
id: string;
dataURL: string;
mimeType: string;
}
interface ExcalidrawApi { interface ExcalidrawApi {
updateScene: (scene: { elements: readonly WhiteboardElement[] }) => void; updateScene: (scene: { elements: readonly WhiteboardElement[] }) => void;
addFiles: (files: BinaryFile[]) => void;
} }
let container = $state<HTMLDivElement | null>(null); let container = $state<HTMLDivElement | null>(null);
@@ -21,6 +34,7 @@
let isDrawing = $state(false); let isDrawing = $state(false);
const knownVersions = new Map<string, number>(); const knownVersions = new Map<string, number>();
const knownFileIds = new Set<string>();
onMount(() => { onMount(() => {
let unmount: (() => void) | null = null; let unmount: (() => void) | null = null;
@@ -37,15 +51,24 @@
const root = clientModule.createRoot(container); const root = clientModule.createRoot(container);
root.render( root.render(
reactModule.createElement(excalidrawModule.Excalidraw, { reactModule.createElement(excalidrawModule.Excalidraw, {
theme: "dark", // Excalidraw's dark theme inverts the whole canvas in CSS and then cancels that
// out per image by assigning ctx.filter. When the image lands in its cache after
// the element is first drawn, the cancellation is missed and photos come out
// inverted. A light board has no filter to cancel and cannot drift out of step.
theme: "light",
viewModeEnabled: readOnly, viewModeEnabled: readOnly,
// Taken as unknown and narrowed here, so this file does not have to depend on // Taken as unknown and narrowed here, so this file does not have to depend on
// Excalidraw's exported API type just to hold a reference to it. // Excalidraw's exported API type just to hold a reference to it.
excalidrawAPI: (api: unknown) => { excalidrawAPI: (api: unknown) => {
excalidrawApi = api as ExcalidrawApi; excalidrawApi = api as ExcalidrawApi;
}, },
onChange: (sceneElements: readonly WhiteboardElement[]) => { onChange: (
sceneElements: readonly WhiteboardElement[],
_appState: unknown,
sceneFiles: Record<string, BinaryFile>
) => {
handleSceneChange(sceneElements); handleSceneChange(sceneElements);
handleSceneFiles(sceneFiles);
}, },
UIOptions: { UIOptions: {
canvasActions: { canvasActions: {
@@ -64,6 +87,19 @@
return () => unmount?.(); return () => unmount?.();
}); });
$effect(() => {
if (!excalidrawApi) return;
const arrived = files.filter((file) => !knownFileIds.has(file.id));
if (arrived.length === 0) return;
for (const file of arrived) {
knownFileIds.add(file.id);
}
void applyRemoteFiles(arrived);
});
$effect(() => { $effect(() => {
if (!excalidrawApi) return; if (!excalidrawApi) return;
@@ -72,6 +108,16 @@
// down; this effect runs again on release because isDrawing is reactive. // down; this effect runs again on release because isDrawing is reactive.
if (isDrawing) return; if (isDrawing) return;
// An emptied board has no versions to compare against, so it would never look like a
// change and the old scene would stay on screen.
if (elements.length === 0) {
if (knownVersions.size === 0) return;
knownVersions.clear();
knownFileIds.clear();
excalidrawApi.updateScene({ elements: [] });
return;
}
// Only push the scene back when something genuinely arrived from someone else. // Only push the scene back when something genuinely arrived from someone else.
// Feeding our own edits back in has the same truncating effect. // Feeding our own edits back in has the same truncating effect.
const hasRemoteChange = elements.some((element) => { const hasRemoteChange = elements.some((element) => {
@@ -88,12 +134,69 @@
excalidrawApi.updateScene({ elements }); excalidrawApi.updateScene({ elements });
}); });
/// Excalidraw only cancels out its dark theme canvas filter for images it has fully
/// resolved in its own cache, so remote images are fetched and handed over as data URLs
/// rather than as a link it has to load itself.
async function applyRemoteFiles(arrived: WhiteboardFile[]): Promise<void> {
const loaded = await Promise.all(arrived.map((file) => toBinaryFile(file)));
const usable = loaded.filter((file): file is BinaryFile => file !== null);
if (usable.length > 0) excalidrawApi?.addFiles(usable);
}
async function toBinaryFile(file: WhiteboardFile): Promise<BinaryFile | null> {
if (file.url.startsWith("data:")) {
return { id: file.id, dataURL: file.url, mimeType: file.mimeType };
}
try {
const response = await fetch(file.url);
if (!response.ok) throw new Error(`image request failed: ${response.status}`);
const blob = await response.blob();
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
return { id: file.id, dataURL: dataUrl, mimeType: blob.type || file.mimeType };
} catch {
knownFileIds.delete(file.id);
return null;
}
}
function handleSceneFiles(sceneFiles: Record<string, BinaryFile>): void {
if (readOnly || !onLocalFile) return;
for (const file of Object.values(sceneFiles ?? {})) {
if (!file || knownFileIds.has(file.id)) continue;
if (!file.dataURL.startsWith("data:")) continue;
knownFileIds.add(file.id);
onLocalFile({ id: file.id, dataUrl: file.dataURL, mimeType: file.mimeType });
}
}
const pendingChanges = new Map<string, WhiteboardElement>(); const pendingChanges = new Map<string, WhiteboardElement>();
let flushTimer: ReturnType<typeof setTimeout> | null = null; let flushTimer: ReturnType<typeof setTimeout> | null = null;
function handleSceneChange(sceneElements: readonly WhiteboardElement[]) { function handleSceneChange(sceneElements: readonly WhiteboardElement[]) {
if (readOnly) return; if (readOnly) return;
// Deleting one element leaves it in the scene marked isDeleted, which syncs like any
// other edit. Resetting the canvas empties the array instead, so there is nothing left
// to diff against and the other end has to be told outright.
if (sceneElements.length === 0) {
if (knownVersions.size === 0) return;
knownVersions.clear();
knownFileIds.clear();
pendingChanges.clear();
onLocalClear?.();
return;
}
for (const element of sceneElements) { for (const element of sceneElements) {
const known = knownVersions.get(element.id); const known = knownVersions.get(element.id);
if (known === undefined || element.version > known) { if (known === undefined || element.version > known) {
+35
View File
@@ -78,6 +78,11 @@ pub struct KioskIdentity {
pub room_name: String, pub room_name: String,
} }
pub struct SessionIdentity {
pub session_id: String,
pub kiosk_id: String,
}
impl FromRequestParts<AppState> for AdminIdentity { impl FromRequestParts<AppState> for AdminIdentity {
type Rejection = AppError; type Rejection = AppError;
@@ -126,6 +131,36 @@ impl FromRequestParts<AppState> for KioskIdentity {
} }
} }
impl FromRequestParts<AppState> for SessionIdentity {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let session_id = bearer_token(parts)?;
let row: Option<(String, i64)> = sqlx::query_as(
"SELECT kiosk_id, expires_at FROM sessions WHERE id = ? AND revoked = 0",
)
.bind(&session_id)
.fetch_optional(&state.db)
.await?;
let (kiosk_id, expires_at) =
row.ok_or_else(|| AppError::Unauthorized("unknown session".into()))?;
if expires_at <= crate::clock::now_ms() {
return Err(AppError::Unauthorized("session expired".into()));
}
Ok(SessionIdentity {
session_id,
kiosk_id,
})
}
}
fn bearer_token(parts: &Parts) -> AppResult<String> { fn bearer_token(parts: &Parts) -> AppResult<String> {
let header = parts let header = parts
.headers .headers
+12 -1
View File
@@ -69,12 +69,23 @@ async fn main() {
.allow_headers(Any) .allow_headers(Any)
.allow_origin(Any); .allow_origin(Any);
// Uploaded images are public and are read with fetch by kiosks and browsers alike, which
// is blocked without these headers even though the same file loads fine in an img tag.
let media_cors = CorsLayer::new()
.allow_methods([Method::GET, Method::HEAD])
.allow_origin(Any);
let app = Router::new() let app = Router::new()
.route("/", axum::routing::get(routes::service_index)) .route("/", axum::routing::get(routes::service_index))
.route("/install.sh", axum::routing::get(routes::install_script)) .route("/install.sh", axum::routing::get(routes::install_script))
.nest("/api/kiosk", routes::kiosk_router().layer(kiosk_cors)) .nest("/api/kiosk", routes::kiosk_router().layer(kiosk_cors))
.nest("/api", routes::api_router().layer(cors)) .nest("/api", routes::api_router().layer(cors))
.nest_service("/media", ServeDir::new(state.config.media_dir.clone())) .nest_service(
"/media",
Router::new()
.fallback_service(ServeDir::new(state.config.media_dir.clone()))
.layer(media_cors),
)
.nest_service( .nest_service(
"/downloads", "/downloads",
ServeDir::new(state.config.package_dir.clone()), ServeDir::new(state.config.package_dir.clone()),
+2
View File
@@ -4,6 +4,7 @@ pub mod join;
pub mod kiosk; pub mod kiosk;
pub mod media; pub mod media;
pub mod organization; pub mod organization;
pub mod whiteboard;
pub use install::install_script; pub use install::install_script;
@@ -19,6 +20,7 @@ pub fn api_router() -> Router<AppState> {
.route("/health", get(health)) .route("/health", get(health))
.merge(join::router()) .merge(join::router())
.merge(organization::public_router()) .merge(organization::public_router())
.merge(whiteboard::router())
.nest( .nest(
"/admin", "/admin",
admin::router() admin::router()
+34
View File
@@ -0,0 +1,34 @@
use axum::body::Bytes;
use axum::extract::{DefaultBodyLimit, State};
use axum::http::HeaderMap;
use axum::routing::put;
use axum::{Json, Router};
use serde::Serialize;
use crate::auth::SessionIdentity;
use crate::error::AppResult;
use crate::routes::media::{store_image, MAX_UPLOAD_BYTES};
use crate::state::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/whiteboard/image", put(upload_image))
.layer(DefaultBodyLimit::max(MAX_UPLOAD_BYTES))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UploadResponse {
image_url: String,
}
async fn upload_image(
State(state): State<AppState>,
session: SessionIdentity,
headers: HeaderMap,
body: Bytes,
) -> AppResult<Json<UploadResponse>> {
let prefix = format!("wb-{}", session.kiosk_id);
let image_url = store_image(&state, &headers, &body, &prefix).await?;
Ok(Json(UploadResponse { image_url }))
}