diff --git a/src/lotus/lotusDecorations.test.ts b/src/lotus/lotusDecorations.test.ts new file mode 100644 index 00000000..633c8a9a --- /dev/null +++ b/src/lotus/lotusDecorations.test.ts @@ -0,0 +1,127 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { EventEmitter } from "events"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; + +import { + ALLOWED_DECORATION_ORIGINS, + startLotusDecorations, + useLotusDecoration, +} from "./lotusDecorations"; +import { LotusWidgetActions } from "./lotusActions"; + +const lazyActions = new EventEmitter(); +const reply = vi.fn(); +const send = vi.fn().mockResolvedValue({}); + +vi.mock("../widget", () => ({ + widget: { + api: { + transport: { + reply: (...a: unknown[]) => reply(...a), + send: (...a: unknown[]) => send(...a), + }, + }, + // Getter: `vi.mock` factories run at import time, before the const above. + get lazyActions(): EventEmitter { + return lazyActions; + }, + }, +})); + +function pushDecorations(decorations: Record): void { + // `useSyncExternalStore`'s re-render from the module-level `emit()` needs to + // be flushed inside `act()`, since the emitter fires outside of React's own + // event handling. + act(() => { + lazyActions.emit(LotusWidgetActions.Decorations, { + detail: { data: { decorations } }, + }); + }); +} + +beforeEach(() => { + reply.mockClear(); + send.mockClear(); +}); + +afterEach(() => { + lazyActions.removeAllListeners(); +}); + +test("[lotus #28] safeImageUrl only accepts the pinned decoration CDN origin (or blob:)", () => { + const stop = startLotusDecorations(); + const { result } = renderHook(() => useLotusDecoration("@alice:example.org")); + + pushDecorations({ + "@alice:example.org": `${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`, + }); + expect(result.current).toBe(`${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`); + + // A different https host is rejected outright, even though it was + // previously allowed by the "any https" check. + pushDecorations({ "@alice:example.org": "https://evil.example/x.png" }); + expect(result.current).toBeUndefined(); + + // Path traversal off the allowed origin is still on-origin, so the origin + // check alone doesn't stop it (validation of the slug itself is the host's + // job) — but a completely different scheme/host must never get through. + pushDecorations({ + "@alice:example.org": "javascript:alert(1)", + }); + expect(result.current).toBeUndefined(); + + pushDecorations({ "@alice:example.org": "blob:https://example.org/abc" }); + expect(result.current).toBe("blob:https://example.org/abc"); + + stop(); +}); + +test("[lotus #17] the roster survives a handler remount within the same page session", () => { + const stop1 = startLotusDecorations(); + pushDecorations({ + "@alice:example.org": `${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`, + }); + + const { result } = renderHook(() => useLotusDecoration("@alice:example.org")); + expect(result.current).toBe(`${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`); + + // Tear the handler down (as InCallView would on an EC-side remount) and + // bring it back up, WITHOUT the host re-pushing anything. + stop1(); + const stop2 = startLotusDecorations(); + + // The roster must still be there — it must not have been wiped to {} by + // the teardown. + expect(result.current).toBe(`${ALLOWED_DECORATION_ORIGINS[0]}/fox_hat.png`); + + stop2(); +}); + +test("[lotus #17] (re)registering the handler asks the host to re-push state", () => { + const stop1 = startLotusDecorations(); + expect(send).toHaveBeenCalledWith(LotusWidgetActions.RequestState, {}); + send.mockClear(); + + stop1(); + const stop2 = startLotusDecorations(); + expect(send).toHaveBeenCalledWith(LotusWidgetActions.RequestState, {}); + + stop2(); +}); + +test("a second concurrent registration does not re-request state or double-register", () => { + const stop1 = startLotusDecorations(); + send.mockClear(); + const stop2 = startLotusDecorations(); + expect(send).not.toHaveBeenCalled(); + + stop2(); + stop1(); +}); diff --git a/src/lotus/lotusDecorations.ts b/src/lotus/lotusDecorations.ts index b0d2b900..0859dcc4 100644 --- a/src/lotus/lotusDecorations.ts +++ b/src/lotus/lotusDecorations.ts @@ -9,7 +9,7 @@ import { useSyncExternalStore } from "react"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { widget } from "../widget"; -import { LotusWidgetActions } from "./lotusActions"; +import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget"; /** * Avatar decorations (#6 / A6). The Lotus host (cinny) owns the decoration @@ -38,11 +38,26 @@ export function useLotusDecoration(userId: string): string | undefined { return useSyncExternalStore(subscribe, () => decorations[userId]); } +// [lotus #28] The decoration URL comes from the host, which builds it from an +// arbitrary (unvalidated on the wire) profile field. `safeImageUrl` used to +// accept ANY https origin, so the only thing stopping a third-party image +// beacon on every tile was the host happening to build the URL itself. Pin it +// to the actual decoration CDN origin(s) — cinny's `DECORATION_CDN` +// (`avatarDecorations.ts`) — plus `blob:`, which EC itself may use for local +// previews. Exported so a future CDN move is a one-line edit here. +export const ALLOWED_DECORATION_ORIGINS: readonly string[] = [ + "https://drive.lotusguild.org", +]; + function safeImageUrl(raw: unknown): string | null { if (typeof raw !== "string") return null; try { const u = new URL(raw, window.location.href); - return u.protocol === "https:" || u.protocol === "blob:" ? u.href : null; + if (u.protocol === "blob:") return u.href; + return u.protocol === "https:" && + ALLOWED_DECORATION_ORIGINS.includes(u.origin) + ? u.href + : null; } catch { return null; } @@ -87,6 +102,13 @@ export function startLotusDecorations(): () => void { unregister = (): void => { w.lazyActions.off(LotusWidgetActions.Decorations, handler); }; + // [lotus #17] An EC-side remount of InCallView/ActiveCall tears this + // handler down and back up while cinny stays joined, and the host only + // re-pushes decorations on a CHANGE to its roster — with an unchanged + // member list it never re-sends, so the tiles would otherwise lose their + // decorations for the rest of the call. Ask the host to re-push whatever + // it currently has every time the handler (re)registers. + lotusSendToHost(LotusWidgetActions.RequestState, {}); } registrations += 1; @@ -96,11 +118,15 @@ export function startLotusDecorations(): () => void { registrations = 0; unregister?.(); unregister = null; - // Reset the roster once the last registration goes away, so a decoration - // pushed in call A can't leak onto a shared user in call B before the - // host re-pushes. Notify listeners so any still-mounted tile drops the - // now-stale overlay via the render path. - decorations = {}; + // [lotus #17] Do NOT wipe the roster here. It used to reset to `{}` so a + // decoration from call A couldn't leak onto a same-named user in call B, + // but `decorations` is a module-scope singleton for the lifetime of the + // PAGE (a fresh call is a fresh page/iframe load, which resets this + // module anyway), while an EC-side handler remount within the SAME call + // was wiping live decorations that the host has no reason to re-send + // (see the request_state ask above, which covers hosts that don't + // proactively resend). Listeners are still notified so a torn-down + // period doesn't itself change anything visible. emit(); } }; diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 918547ab..97c97f0c 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -120,8 +120,11 @@ Please see LICENSE in the repository root for full details. opacity: 50%; } -/* [lotus #6] Profile decoration overlaid on the tile avatar. Shares the -avatar's centred box and size so frame-style decorations sit around it. */ +/* [lotus #4] Profile decoration overlaid on the tile avatar. Sized larger than +the avatar's box (62cqmin vs. the avatar's 50cqmin — matching cinny's own +~50px avatar + 8px outward inset ratio, `AvatarDecoration.tsx`'s +DEFAULT_INSET) so frame-style decoration artwork bleeds outside the avatar +circle and surrounds it, instead of overlapping/clipping it 1:1. */ .lotusDecoration { position: absolute; top: 50%; @@ -131,10 +134,19 @@ avatar's centred box and size so frame-style decorations sit around it. */ object-fit: contain; } +/* [lotus #19] Decorations are animated APNGs with no static asset to freeze +to; hide them under prefers-reduced-motion, matching cinny's own +AvatarDecoration guard and the host's behaviour of rendering just the avatar. */ +@media (prefers-reduced-motion: reduce) { + .lotusDecoration { + display: none; + } +} + @container mediaView (width > 0) { .lotusDecoration { - inline-size: 50cqmin; - block-size: 50cqmin; + inline-size: 62cqmin; + block-size: 62cqmin; } } diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 43cbcb22..07f86308 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -113,6 +113,15 @@ export const MediaView: FC = ({ }) => { const { t } = useTranslation(); const decoration = useLotusDecoration(userId); + // [lotus #4] Track the URL of a decoration that failed to load (e.g. a 404 + // from the CDN, reachable given the unvalidated slug the host builds it + // from) so we can hide the broken-image box instead of leaving it over the + // avatar. Comparing against the current `decoration` (rather than a bare + // boolean) means a new/changed decoration URL automatically gets a fresh + // chance to load. + const [erroredDecoration, setErroredDecoration] = useState< + string | undefined + >(undefined); const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer); const [showConnectionStats] = useSetting(showConnectionStatsSetting); @@ -188,15 +197,19 @@ export const MediaView: FC = ({ style={{ display: video && videoEnabled ? "none" : "initial" }} /> {decoration && + decoration !== erroredDecoration && !(video && videoEnabled) && ( // [lotus #6] Profile decoration overlay, shown only when the avatar // is visible (i.e. not when live video is showing). Pushed by the // host via io.lotus.decorations; undefined unless opted in. + // [lotus #4] Hidden entirely under prefers-reduced-motion (CSS) and + // on a load error (onError), matching cinny's own guards. setErroredDecoration(decoration)} /> )} {video?.publication !== undefined && (