diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index c6818119..23da072b 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -2,6 +2,7 @@ import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts"; import { createRelayReader } from "../src/features/relay/reader.ts"; import { createServer } from "node:http"; import { createHash } from "node:crypto"; +import { ReadableStream } from "node:stream/web"; import { setTimeout as delay } from "node:timers/promises"; import { test, expect, vi, beforeEach, afterEach } from "vitest"; import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools"; @@ -52,6 +53,7 @@ async function harness(respond) { url: upstreamUrl, body: init?.body ? JSON.parse(init.body) : undefined, signal: init?.signal, + headers: init?.headers, auth, at: performance.now(), }; @@ -198,6 +200,84 @@ test("GIF search follows the relay-advertised KLIPY path with signed, bounded in } }); +test("media proxy streams authenticated video ranges and preserves seek headers", async () => { + const bytes = Buffer.from("video-range"); + const h = await harness((call) => { + expect(call.url).toBe(`${fixtureRelayUrl}/media/clip.mp4`); + expect(call.headers.Range).toBe("bytes=100-"); + return new Response(bytes, { + status: 206, + headers: { + "Content-Type": "video/mp4", + "Content-Length": String(bytes.length), + "Content-Range": "bytes 100-110/1000", + "Accept-Ranges": "bytes", + }, + }); + }); + try { + const response = await fetch( + `${h.base}/api/relay/media?url=${encodeURIComponent(`${fixtureRelayUrl}/media/clip.mp4`)}`, + { headers: { Range: "bytes=100-" } }, + ); + expect(response.status).toBe(206); + expect(response.headers.get("content-type")).toBe("video/mp4"); + expect(response.headers.get("content-range")).toBe("bytes 100-110/1000"); + expect(response.headers.get("accept-ranges")).toBe("bytes"); + expect(Buffer.from(await response.arrayBuffer())).toEqual(bytes); + expect(h.calls).toHaveLength(1); + } finally { + await h.close(); + } +}); + +test("an upstream video stream error closes only that response, not the broker", async () => { + const h = await harness(() => { + let controller; + const body = new ReadableStream({ + start(value) { + controller = value; + value.enqueue(new Uint8Array([1, 2, 3])); + }, + }); + queueMicrotask(() => + controller.error(new DOMException("timed out", "TimeoutError")), + ); + return new Response(body, { + status: 206, + headers: { "Content-Type": "video/mp4", "Content-Range": "bytes 0-2/10" }, + }); + }); + try { + await fetch( + `${h.base}/api/relay/media?url=${encodeURIComponent(`${fixtureRelayUrl}/media/clip.mp4`)}`, + { headers: { Range: "bytes=0-" } }, + ) + .then((response) => response.arrayBuffer()) + .catch(() => {}); + const session = await fetch(`${h.base}/api/relay/session`); + expect(session.status).toBe(200); + } finally { + await h.close(); + } +}); + +test("media proxy rejects malformed ranges before upstream I/O", async () => { + const h = await harness(() => { + throw new Error("unexpected upstream call"); + }); + try { + const response = await fetch( + `${h.base}/api/relay/media?url=${encodeURIComponent(`${fixtureRelayUrl}/media/clip.mp4`)}`, + { headers: { Range: "items=0-1" } }, + ); + expect(response.status).toBe(416); + expect(h.calls).toHaveLength(0); + } finally { + await h.close(); + } +}); + test("upstream quota survives browser recreation, gates reads/profile/publish and leaves other communities independent", async () => { const h = await harness((call, count, event) => count === 1 diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index b6e56d46..5a375124 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -51,6 +51,7 @@ import { } from "../src/features/relay/http-admission.ts"; import { execFileSync } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; +import { Readable } from "node:stream"; import dc from "node:diagnostics_channel"; import { finalizeEvent, getPublicKey, nip19, verifyEvent } from "nostr-tools"; import { Agent, fetch as upstreamHttp, interceptors } from "undici"; @@ -724,11 +725,18 @@ export function relayBrokerPlugin({ }, key, ); + const range = req.headers.range; + if ( + range !== undefined && + (typeof range !== "string" || !/^bytes=\d+-\d*$/.test(range)) + ) + return json(res, 416, { error: "Media range rejected" }); const upstream = await fetchUpstream(target, { headers: { Authorization: "Nostr " + Buffer.from(JSON.stringify(auth)).toString("base64url"), + ...(range ? { Range: range } : {}), }, redirect: "error", signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), @@ -737,21 +745,51 @@ export function relayBrokerPlugin({ if (!upstream.ok) return json(res, upstream.status, { error: "Media read failed" }); const type = upstream.headers.get("content-type") ?? ""; - if (!type.startsWith("image/")) - return json(res, 415, { - error: "Only image previews are proxied", - }); - const bytes = Buffer.from(await upstream.arrayBuffer()); - if (bytes.length > MAX_MEDIA_BYTES) + const image = type.startsWith("image/"); + const video = type.startsWith("video/"); + if (!image && !video) + return json(res, 415, { error: "Media type rejected" }); + const length = Number(upstream.headers.get("content-length")); + if ( + Number.isFinite(length) && + length > MAX_MEDIA_BYTES && + !(video && upstream.status === 206) + ) return json(res, 413, { error: "Media budget exceeded" }); - server.config.logger.info( - `[relay-broker] media ${target.pathname} ${bytes.length}B ${type} (${Date.now() - startedAt}ms)`, - ); - res.writeHead(200, { + const headers = { "Content-Type": type, "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", - }); + ...(upstream.headers.get("content-length") + ? { "Content-Length": upstream.headers.get("content-length") } + : {}), + ...(upstream.headers.get("content-range") + ? { "Content-Range": upstream.headers.get("content-range") } + : {}), + ...(video + ? { + "Accept-Ranges": + upstream.headers.get("accept-ranges") ?? "bytes", + } + : {}), + }; + if (video) { + res.writeHead(upstream.status, headers); + if (!upstream.body) return res.end(); + const stream = Readable.fromWeb(upstream.body); + // A range request may time out or be cancelled after headers. A + // piped Readable has no automatic error consumer; without this, + // Node treats the upstream abort as an uncaught process error and + // kills the live broker along with unrelated message traffic. + stream.once("error", () => res.destroy()); + res.once("close", () => stream.destroy()); + stream.pipe(res); + return; + } + const bytes = Buffer.from(await upstream.arrayBuffer()); + if (bytes.length > MAX_MEDIA_BYTES) + return json(res, 413, { error: "Media budget exceeded" }); + res.writeHead(200, headers); return res.end(bytes); } if ( diff --git a/src/bundled/channels/ChannelsPage.test.tsx b/src/bundled/channels/ChannelsPage.test.tsx index cc16f7fb..a0175403 100644 --- a/src/bundled/channels/ChannelsPage.test.tsx +++ b/src/bundled/channels/ChannelsPage.test.tsx @@ -2,7 +2,7 @@ import { expect, it, vi } from "vitest"; import type { ReactElement } from "react"; import type { RelayData, RelaySnapshot } from "../../features/relay/service"; import type { Panels } from "../../features/panels/service"; -import { ChannelsPage } from "./ChannelsPage"; +import { ChannelsPage, mediaReviewForDestination } from "./ChannelsPage"; // This is a shallow element-boundary test, not a React render. Navigation effects // are exercised by the browser navigation journeys; do not execute them here. @@ -23,6 +23,16 @@ function workspace(scope: string, generation: number) { return page.props.children as ReactElement<{ scope: string }>; } +it("never carries a media review across channel navigation or resurrects it on return", () => { + const review = { channelId: "alpha", messageId: "root", entryId: "visit-a" }; + expect(mediaReviewForDestination(review, "alpha", "visit-a")).toBe(review); + expect(mediaReviewForDestination(review, "beta", "visit-a")).toBeUndefined(); + expect(mediaReviewForDestination(review, "alpha", "visit-b")).toBeUndefined(); + expect( + mediaReviewForDestination(undefined, "alpha", "visit-a"), + ).toBeUndefined(); +}); + it("distinguishes ready communities with the same connection generation", () => { const a = workspace("community-a:viewer", 1); const b = workspace("community-b:viewer", 1); diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index ba4c9730..918736bf 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -44,6 +44,8 @@ import { LiveStatus } from "./LiveStatus"; import { MessageComposer } from "../../features/messages/MessageComposer"; import { ChannelTimeline } from "../../features/messages/ChannelTimeline"; import { ThreadPanel } from "../../features/messages/ThreadPanel"; +import { MediaReviewViewer } from "../../features/messages/MediaReviewViewer"; +import type { Attachment } from "../../features/relay/contracts"; import { readView, writeView } from "../../shared/view-state"; import { useChannelLabels } from "./useChannelLabels"; import { useSidebarPreferences } from "./useSidebarPreferences"; @@ -355,6 +357,53 @@ function ChannelWorkspace({ }, [currentId, navigator, viewer, scope, open], ); + const mediaReviewTrigger = useRef(null); + const [mediaReview, setMediaReview] = useState<{ + channelId: string; + channelName: string; + messageId: string; + attachment: Attachment; + initialTime: number; + entryId?: string | undefined; + }>(); + const showingMediaReview = mediaReviewForDestination( + mediaReview, + current?.id, + navigation?.entryId, + ); + useEffect(() => { + if (mediaReview && !showingMediaReview) setMediaReview(undefined); + }, [mediaReview, showingMediaReview]); + const openMediaReview = useCallback( + (messageId: string, attachment: Attachment, initialTime: number) => { + if (!current) return; + mediaReviewTrigger.current = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + setThread(undefined); + setMediaReview({ + channelId: current.id, + channelName: current.name, + messageId, + attachment, + initialTime, + ...(navigation ? { entryId: navigation.entryId } : {}), + }); + }, + [current, navigation], + ); + useEffect(() => { + if ( + mediaReview && + list.status === "ready" && + list.coverage !== "partial" && + !list.channels.some( + (channel) => channel.id === mediaReview.channelId && !channel.archived, + ) + ) + setMediaReview(undefined); + }, [mediaReview, list]); const openActivityThread = useCallback( (channelId: string, rootId: string) => { threadTrigger.current = @@ -704,6 +753,7 @@ function ChannelWorkspace({ onOpenLink={openLink} canOpenLink={canOpenLink} onOpenThread={openThread} + onOpenMediaReview={openMediaReview} revealMessageId={ sent?.channelId === current.id ? sent.id : undefined } @@ -724,7 +774,21 @@ function ChannelWorkspace({ )} {drawer.content} - {(panel || showingThread || companion) && ( + {showingMediaReview && ( + setMediaReview(undefined)} + /> + )} + {!showingMediaReview && (panel || showingThread || companion) && (
{showingThread && ( )} @@ -762,6 +827,20 @@ function ChannelWorkspace({ ); } +export function mediaReviewForDestination< + T extends { channelId: string; entryId?: string | undefined }, +>( + review: T | undefined, + channelId: string | undefined, + entryId: string | undefined, +): T | undefined { + return review && + review.channelId === channelId && + (review.entryId === undefined || review.entryId === entryId) + ? review + : undefined; +} + const ChannelBody = memo(function ChannelBody({ viewer, extensions, @@ -772,6 +851,7 @@ const ChannelBody = memo(function ChannelBody({ canOpenLink, revealMessageId, onOpenThread, + onOpenMediaReview, navigation, }: { extensions?: ConversationExtensions | undefined; @@ -784,6 +864,11 @@ const ChannelBody = memo(function ChannelBody({ canOpenLink?: ((target: string) => boolean) | undefined; revealMessageId?: string | undefined; onOpenThread(messageId: string, threadRootId: string): void; + onOpenMediaReview( + messageId: string, + attachment: Attachment, + seconds: number, + ): void; }) { const window = useChannelWindow(queries.channels, channelId); useEffect(() => { @@ -827,6 +912,7 @@ const ChannelBody = memo(function ChannelBody({ onOpenLink={onOpenLink} canOpenLink={canOpenLink} onOpenThread={onOpenThread} + onOpenMediaReview={onOpenMediaReview} revealMessageId={revealMessageId} navigation={navigation} /> diff --git a/src/features/conversation/ComposerCompletions.tsx b/src/features/conversation/ComposerCompletions.tsx index b13e0386..891b9f64 100644 --- a/src/features/conversation/ComposerCompletions.tsx +++ b/src/features/conversation/ComposerCompletions.tsx @@ -340,7 +340,10 @@ function OwnedCompletion({
{status &&

{status}

} , - input.current.ownerDocument.body, + // A body sibling would sit behind a fullscreen modal and outside its + // accessibility boundary. Ordinary composers keep the body portal. + input.current.closest('[role="dialog"][aria-modal="true"]') ?? + input.current.ownerDocument.body, )} ); diff --git a/src/features/messages/AttachmentImage.tsx b/src/features/messages/AttachmentImage.tsx index 118ae1e1..e56e1caa 100644 --- a/src/features/messages/AttachmentImage.tsx +++ b/src/features/messages/AttachmentImage.tsx @@ -9,11 +9,13 @@ export function AttachmentImage({ url, source, onOpenLink, + onOpenReview, }: { attachment: Attachment; url: string; source: string; onOpenLink(url: string): boolean; + onOpenReview?: (attachment: Attachment, seconds: number) => void; }) { return ( { - if ( - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - onOpenLink(url) - ) + if (event.metaKey || event.ctrlKey || event.shiftKey) return; + if (onOpenReview) { event.preventDefault(); + onOpenReview(attachment, 0); + } else if (onOpenLink(url)) event.preventDefault(); }} > {/* Retargeting retires both the DOM pixels and all pending callbacks before diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 8a210217..6585795e 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -6,7 +6,7 @@ import type { RelaySession } from "../relay/session"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Virtualizer, type VirtualizerHandle } from "virtua"; import { MessageRow } from "./MessageRow"; -import type { ChannelWindow } from "../relay/contracts"; +import type { Attachment, ChannelWindow } from "../relay/contracts"; import { useRowProfiles } from "../relay/react"; import { geometryFor, geometrySignature } from "./geometry"; import { readView, writeView } from "../../shared/view-state"; @@ -77,6 +77,11 @@ export type ChannelTimelineProps = { revealMessageId?: string | undefined; navigation?: PageNavigation | undefined; onOpenThread?(messageId: string, threadRootId: string): void; + onOpenMediaReview?( + messageId: string, + attachment: Attachment, + seconds: number, + ): void; }; /** Safe to retarget through ordinary props; callers do not own internal remount keys. */ @@ -100,6 +105,7 @@ function Timeline({ revealMessageId, navigation, onOpenThread, + onOpenMediaReview, }: ChannelTimelineProps) { const [initialPosition] = useState(() => readView(scope, `scroll:${channelId}`, null), @@ -501,6 +507,7 @@ function Timeline({ onOpenLink={onOpenLink} canOpenLink={canOpenLink} onOpenThread={onOpenThread} + {...(onOpenMediaReview ? { onOpenMediaReview } : {})} retry={queries.outbox?.retry} day={day} /> diff --git a/src/features/messages/ImageReviewStage.tsx b/src/features/messages/ImageReviewStage.tsx new file mode 100644 index 00000000..f2b6dca7 --- /dev/null +++ b/src/features/messages/ImageReviewStage.tsx @@ -0,0 +1,208 @@ +import { useEffect, useRef, useState } from "react"; +import { ChevronLeft, ChevronRight, Download, Minus, Plus } from "lucide-react"; +import type { Attachment } from "../relay/contracts"; +import styles from "./Messages.module.css"; + +const MIN_ZOOM = 1; +const MAX_ZOOM = 4; +const ZOOM_STEP = 0.25; + +type Point = Readonly<{ x: number; y: number }>; + +type ImageReviewStageProps = { + attachments: readonly Attachment[]; + selectedUrl: string; + media(url: string): string | undefined; + select(url: string): void; +}; + +function clamp(value: number, limit: number) { + return Math.max(-limit, Math.min(limit, value)); +} + +export function ImageReviewStage({ + attachments, + selectedUrl, + media, + select, +}: ImageReviewStageProps) { + const stage = useRef(null); + const image = useRef(null); + const drag = useRef< + { pointer: number; origin: Point; offset: Point } | undefined + >(undefined); + const [zoom, setZoom] = useState(MIN_ZOOM); + const [offset, setOffset] = useState({ x: 0, y: 0 }); + const [dragging, setDragging] = useState(false); + const selectedIndex = Math.max( + 0, + attachments.findIndex((item) => item.url === selectedUrl), + ); + const selected = attachments[selectedIndex] ?? attachments[0]; + const source = selected ? media(selected.url) : undefined; + const pannable = zoom > MIN_ZOOM; + + const panLimits = (nextZoom = zoom) => { + const frame = stage.current?.getBoundingClientRect(); + const element = image.current; + if (!frame || !element?.naturalWidth || !element.naturalHeight) + return { x: 0, y: 0 }; + const fit = Math.min( + frame.width / element.naturalWidth, + frame.height / element.naturalHeight, + ); + const width = element.naturalWidth * fit * nextZoom; + const height = element.naturalHeight * fit * nextZoom; + return { + x: Math.max(0, (width - frame.width) / 2), + y: Math.max(0, (height - frame.height) / 2), + }; + }; + const setBoundedZoom = (value: number) => { + const next = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value)); + const limits = panLimits(next); + setZoom(next); + setOffset((current) => ({ + x: clamp(current.x, limits.x), + y: clamp(current.y, limits.y), + })); + }; + const choose = (index: number) => { + const item = attachments[index]; + if (!item) return; + setZoom(MIN_ZOOM); + setOffset({ x: 0, y: 0 }); + select(item.url); + }; + + useEffect(() => { + const reset = () => setBoundedZoom(zoom); + window.addEventListener("resize", reset); + return () => window.removeEventListener("resize", reset); + }); + + if (!selected || !source) + return ( +

+ Image unavailable +

+ ); + return ( +
{ + if (!pannable) return; + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { + pointer: event.pointerId, + origin: { x: event.clientX, y: event.clientY }, + offset, + }; + setDragging(true); + }} + onPointerMove={(event) => { + const active = drag.current; + if (!active || active.pointer !== event.pointerId) return; + const limits = panLimits(); + setOffset({ + x: clamp(active.offset.x + event.clientX - active.origin.x, limits.x), + y: clamp(active.offset.y + event.clientY - active.origin.y, limits.y), + }); + }} + onPointerUp={(event) => { + if (drag.current?.pointer !== event.pointerId) return; + drag.current = undefined; + setDragging(false); + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={() => { + drag.current = undefined; + setDragging(false); + }} + > + Attachment preview +
event.stopPropagation()} + > + {attachments.length > 1 && ( +
+ + + {selectedIndex + 1} / {attachments.length} + + +
+ )} +
+ + + setBoundedZoom(Number(event.currentTarget.value)) + } + /> + + +
+
+ +
+
+ ); +} diff --git a/src/features/messages/MediaAttachment.tsx b/src/features/messages/MediaAttachment.tsx new file mode 100644 index 00000000..9ff2608a --- /dev/null +++ b/src/features/messages/MediaAttachment.tsx @@ -0,0 +1,310 @@ +import { + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, + type RefObject, +} from "react"; +import { Expand, Pause, Play, X } from "lucide-react"; +import { createPortal } from "react-dom"; +import type { Attachment } from "../relay/contracts"; +import { formatMediaTime } from "./media-timecode"; +import styles from "./Messages.module.css"; +import { useModalBoundary } from "./useModalBoundary"; + +export type MediaPlayback = Readonly<{ + attachmentUrl: string; + seconds: number; +}>; + +type MediaAttachmentProps = { + attachment: Attachment; + media(url: string): string | undefined; + mode?: "inline" | "thread"; + seekTo?: number; + seekRequest?: number; + onPlayback?(playback: MediaPlayback): void; + onOpenReview?(attachment: Attachment, seconds: number): void; +}; + +function useVideoPosition( + video: RefObject, + seekTo: number | undefined, + seekRequest: number | undefined, +) { + useEffect(() => { + // A monotonically increasing request lets the same timecode seek again. + void seekRequest; + if (seekTo === undefined || !video.current) return; + const seek = () => { + if (!video.current) return; + video.current.currentTime = Math.max(0, seekTo); + void video.current.play().catch(() => {}); + }; + if (video.current.readyState >= HTMLMediaElement.HAVE_METADATA) seek(); + else video.current.addEventListener("loadedmetadata", seek, { once: true }); + }, [seekTo, seekRequest, video]); +} + +export function MediaAttachment({ + attachment, + media, + mode = "inline", + seekTo, + seekRequest, + onPlayback, + onOpenReview, +}: MediaAttachmentProps) { + const source = media(attachment.url); + const preview = attachment.previewUrl + ? media(attachment.previewUrl) + : undefined; + const video = useRef(null); + const [viewerOpen, setViewerOpen] = useState(false); + const [currentTime, setCurrentTime] = useState(seekTo ?? 0); + const [playing, setPlaying] = useState(false); + const [started, setStarted] = useState(false); + const [failed, setFailed] = useState(false); + const [capturedPreview, setCapturedPreview] = useState(); + const [measuredDimensions, setMeasuredDimensions] = useState<{ + width: number; + height: number; + }>(); + const dimensions = attachment.dimensions ?? measuredDimensions; + const previewStyle = dimensions + ? ({ + "--media-ratio": `${dimensions.width} / ${dimensions.height}`, + aspectRatio: "var(--media-ratio)", + } as CSSProperties) + : undefined; + const visiblePreview = preview ?? capturedPreview; + useVideoPosition(video, seekTo, seekRequest); + + if (!source) + return ( + + {attachment.video ? "Video unavailable" : "Image unavailable"} + + ); + + if (failed) + return ( + + {attachment.video ? "Video unavailable" : "Image unavailable"} + + ); + + if (!attachment.video) + return ( + <> + + {viewerOpen && + createPortal( + setViewerOpen(false)} + > + Attachment preview + , + document.body, + )} + + ); + + const videoElement = ( + // biome-ignore lint/a11y/useMediaCaption: signed attachment metadata has no caption track URL. +