Compare commits

..

1 Commits

Author SHA1 Message Date
Claude e4cfa1d17d feat(views): keep group visible as header for the running event
In the countdown view a user can subscribe to a group and to events
inside it. Once an event within the group started, the auto scroll
pushed the group card above the viewport, losing the context of which
group the running event belongs to. The operator view had the same
problem.

Group and its events are now wrapped in a section, and the group card is
pinned with position sticky, so it stays above the running event however
far down the group that event sits. The follow scroll offsets the sticky
header, and the follow button threshold discounts it so it keeps its
meaning.

A group holding the running event is now marked as active. In the
countdown the card keeps its background and gains a green outline, the
green fill stays reserved for the running event itself. In the operator
the card is already filled with the group colour, so the ring uses the
lighter active indicator over a dark frame to stay legible against any
group colour. Neither state was visible before: .sub--group overrode
both .sub--live and .sub--armed at equal specificity.

Also fixes the countdown handing the same selectedRef to both a group
row and its running child. React detaches refs before attaching them,
so the child unsetting the ref left it null while the group never
re-attached, silently disabling auto scroll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjX7D56AW8cL3FXjvtnjtL
2026-08-10 17:36:56 +00:00
13 changed files with 372 additions and 241 deletions
@@ -22,6 +22,11 @@ interface UseFollowComponentProps {
scrollRef: RefObject<HTMLElement | null>;
doFollow: boolean;
topOffset?: number;
/**
* Resolved at scroll time, for offsets which can only be measured from the DOM (eg. a sticky header).
* Takes precedence over topOffset. Keep the reference stable, it is a dependency of the follow effect.
*/
getTopOffset?: () => number;
setScrollFlag?: (newValue: boolean) => void;
followTrigger?: MaybeString; // this would be an entry id or null
}
@@ -31,6 +36,7 @@ export default function useFollowComponent({
scrollRef,
doFollow,
topOffset = 100,
getTopOffset,
setScrollFlag,
followTrigger,
}: UseFollowComponentProps) {
@@ -44,21 +50,23 @@ export default function useFollowComponent({
setScrollFlag?.(true);
// Use requestAnimationFrame to ensure the component is fully loaded
window.requestAnimationFrame(() => {
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
// resolve the offset after layout, so that measured values are up to date
const offset = getTopOffset?.() ?? topOffset;
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
setScrollFlag?.(false);
});
}
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]);
const scrollToRefComponent = useCallback(
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
(componentRef = followRef, containerRef = scrollRef, offset?: number) => {
if (componentRef && containerRef) {
// @ts-expect-error -- we know this are not null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
scrollToComponent(componentRef!, containerRef!, offset);
scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset);
}
},
[followRef, scrollRef, topOffset],
[followRef, scrollRef, topOffset, getTopOffset],
);
return scrollToRefComponent;
@@ -18,6 +18,14 @@
padding-bottom: 95vh;
}
/* a group and its events, bounds the sticky group header to its own section */
.groupSection {
display: flex;
flex-direction: column;
/* the rows used to be direct children of the list, keep the gap they had */
gap: 2px;
}
.editPrompt {
position: fixed;
z-index: $zindex-dialog;
+18 -6
View File
@@ -1,5 +1,5 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyFill from '../../common/components/state/EmptyFill';
import EmptyPage from '../../common/components/state/EmptyPage';
@@ -54,11 +54,21 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
// the group of the running event is pinned to the top of the list, the running event needs to clear it
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// account for the list gap, so that the rows do not touch
return header ? header.offsetHeight + 2 : 0;
}, []);
const getTopOffset = useCallback(() => selectedOffset + getStickyOffset(), [getStickyOffset]);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: selectedOffset,
getTopOffset,
followTrigger: selectedEventId,
});
@@ -86,7 +96,8 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
@@ -186,13 +197,14 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
}
return (
<Fragment key={entry.id}>
<div className={style.groupSection} key={entry.id}>
<OperatorGroup
key={entry.id}
ref={isCurrentParent ? stickyHeaderRef : undefined}
title={entry.title}
colour={entry.colour}
count={entry.entries.length}
duration={entry.duration}
isLive={isCurrentParent}
/>
{entry.entries.map((nestedEntryId) => {
const nestedEntry = rundown.entries[nestedEntryId];
@@ -239,7 +251,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
/>
);
})}
</Fragment>
</div>
);
}
return null;
@@ -3,17 +3,32 @@
min-height: 2.5rem;
padding: 0.4rem 0.75rem;
border-left: 0.35rem solid var(--group-colour, $gray-500);
background-color: $gray-1350;
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
/* the background comes inline from getAccessibleColour, which resolves to an opaque colour
and is what keeps the rows from showing through while the header is pinned */
font-size: 1.25rem;
font-weight: 600;
/* stays visible as a header for the events of the group, bound by the group section */
position: sticky;
/* covers the list padding, otherwise the rows scroll through it */
top: -0.25rem;
z-index: 1;
display: flex;
align-items: center;
gap: 1rem;
letter-spacing: 0;
}
/* a group holding the running event is only outlined, the green fill belongs to the event row itself.
the card is already filled with the group colour, so we use the lighter active indicator over a dark
frame, which stays legible against any of them, green groups included */
.live {
box-shadow:
inset 0 0 0 2px $ui-black,
inset 0 0 0 4px $active-indicator;
}
.title {
flex: 1 1 auto;
min-width: 0;
@@ -1,6 +1,6 @@
import { CSSProperties, memo } from 'react';
import { CSSProperties, Ref, memo } from 'react';
import { getAccessibleColour } from '../../../common/utils/styleUtils';
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
import { formatDuration } from '../../../common/utils/time';
import style from './OperatorGroup.module.scss';
@@ -10,15 +10,22 @@ interface OperatorGroup {
colour: string;
count: number;
duration: number;
/** whether the group contains the currently running event */
isLive?: boolean;
ref?: Ref<HTMLDivElement>;
}
export default memo(OperatorGroup);
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) {
const groupColour = colour || '#929292';
const groupColours = getAccessibleColour(groupColour);
return (
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
<div
className={cx([style.group, isLive && style.live])}
style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}
ref={ref}
>
<span className={style.title}>{title}</span>
<span className={style.meta}>
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
+34 -2
View File
@@ -103,6 +103,22 @@ $item-height: 3.5rem;
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
}
/* a subscribed group and its subscribed events, bounds the sticky header to its own section */
.sub-section {
display: flex;
flex-direction: column;
/* flex-grow is set inline from the row count, so that the rows keep the share they had in the list */
flex: 1 1 0;
}
/* scoped to the section, the select view shows the same cards in a flat list */
.sub-section .sub--group {
position: sticky;
/* sticky insets constrain the margin box, negate the row margin so the header sits flush */
top: -2px;
z-index: 1;
}
/* ====================== LIST-ITEM ======================*/
.sub {
margin: 2px;
@@ -196,9 +212,15 @@ $item-height: 3.5rem;
.sub--group {
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
background:
/* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header.
split into longhands since the shorthand only takes a colour in its last layer */
background-color: var(--background-color-override, $viewer-background-color);
background-image:
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
var(--card-background-color-override, $viewer-card-bg-color);
linear-gradient(
var(--card-background-color-override, $viewer-card-bg-color),
var(--card-background-color-override, $viewer-card-bg-color)
);
.sub__binder {
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
@@ -230,6 +252,16 @@ $item-height: 3.5rem;
}
}
/* a group holding the running event is only outlined, the green fill belongs to the event row itself */
.sub--group.sub--live {
box-shadow: inset 0 0 0 2px $active-green;
}
/* loaded but not yet running, subtle enough to not compete with the live ring */
.sub--group.sub--armed {
box-shadow: inset 0 0 0 2px $gray-1000;
}
.sub__title {
grid-area: title;
padding-bottom: 0.5rem;
@@ -1,6 +1,6 @@
import { MaybeNumber, OntimeEvent } from 'ontime-types';
import { dayInMs } from 'ontime-utils';
import { useEffect, useRef, useState } from 'react';
import { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { IoPencil } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
@@ -22,6 +22,7 @@ import {
CountdownTarget,
extendEventData,
getIsLive,
groupSubscriptionTargets,
isOutsideRange,
preferredFormat12,
preferredFormat24,
@@ -48,11 +49,22 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const [lockAutoScroll, setLockAutoScroll] = useState(false);
const selectedRef = useRef<HTMLDivElement | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRef = useRef<HTMLDivElement | null>(null);
const sections = useMemo(() => groupSubscriptionTargets(subscribedEvents), [subscribedEvents]);
// the group of the running event is pinned to the top of the list, the running event needs to clear it
const getStickyOffset = useCallback(() => {
const header = stickyHeaderRef.current;
// account for the row margin, so that the rows do not touch
return header ? header.offsetHeight + 2 : 0;
}, []);
const scrollToComponent = useFollowComponent({
followRef: selectedRef,
scrollRef,
doFollow: !lockAutoScroll,
topOffset: 0,
getTopOffset: getStickyOffset,
followTrigger: selectedEventId,
});
@@ -79,7 +91,8 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
const selectedRect = selectedRef.current.getBoundingClientRect();
const scrollerRect = scrollRef.current.getBoundingClientRect();
if (selectedRect && scrollerRect) {
const distanceFromTop = selectedRect.top - scrollerRect.top;
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
setLockAutoScroll(hasScrolledOutOfThreshold);
}
@@ -96,43 +109,68 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
throttledHandleScroll();
};
const renderRow = (event: CountdownTarget, rowRef: RefObject<HTMLDivElement | null> | undefined, isLive: boolean) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
return (
<div
key={event.id}
ref={rowRef}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
</div>
);
};
return (
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
{subscribedEvents.map((event) => {
// while a group is live, surface the running event's title as the secondary line
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
const isArmed = !isLive && activeEntryId === selectedEventId;
const countdownEvent = extendEventData(event, currentDay, actualStart, plannedStart, offset, mode, reportData);
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
{sections.map((section) => {
const rows = section.group ? [section.group, ...section.events] : section.events;
// the running event anchors the scroll, the group header stays pinned above it
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
return (
<div
key={event.id}
ref={isLive ? selectedRef : undefined}
className={cx([
'sub',
isLive && 'sub--live',
isArmed && 'sub--armed',
event.isGroup && 'sub--group',
isGroupedEvent && 'sub--in-group',
])}
data-testid={event.cue}
key={section.group?.id ?? rows[0].id}
className='sub-section'
// the rows used to be direct children of the list, keep their share of the available space
style={{ flexGrow: rows.length }}
>
<div
className='sub__binder'
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
/>
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
<SubscriptionStatus event={countdownEvent} />
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
{displayTitle}
</div>
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
{rows.map((event) => {
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
// a subscribed group is live when any of its children is the selected/running event
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
// only ever hand the ref to a single row, sharing it would null it out on the next commit
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
return renderRow(event, rowRef, isLive);
})}
</div>
);
})}
@@ -1,7 +1,7 @@
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { resolveSubscriptionTarget } from './countdown.utils';
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
/**
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
@@ -126,3 +126,89 @@ describe('resolveSubscriptionTarget()', () => {
expect(result?.liveEntry).toBeNull();
});
});
describe('groupSubscriptionTargets()', () => {
/**
* Resolves a group the same way the view does, so that the tests exercise the real target shape
* (a resolved group carries type Event, so the helper cannot rely on the entry type)
*/
function resolveGroup(group: ExtendedEntry<OntimeGroup>, flat: ExtendedEntry<OntimeEntry>[]): CountdownTarget {
const resolved = resolveSubscriptionTarget(group, flat);
if (resolved === null) {
throw new Error('test setup: group has no playable children');
}
return resolved;
}
it('returns no sections for an empty subscription list', () => {
expect(groupSubscriptionTargets([])).toEqual([]);
});
it('gives each ungrouped event its own section', () => {
const e1 = makeEvent({ id: 'e1' });
const e2 = makeEvent({ id: 'e2' });
expect(groupSubscriptionTargets([e1, e2])).toEqual([
{ group: null, events: [e1] },
{ group: null, events: [e2] },
]);
});
it('absorbs the children of a subscribed group into its section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const c2 = makeEvent({ id: 'c2', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1, c2]);
expect(groupSubscriptionTargets([resolved, c1, c2])).toEqual([{ group: resolved, events: [c1, c2] }]);
});
it('keeps a subscribed group with no subscribed children as an empty section', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved])).toEqual([{ group: resolved, events: [] }]);
});
it('does not absorb an event which belongs to a different group', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const flat = [group1, c1, group2, c2];
const resolved1 = resolveGroup(group1, flat);
const resolved2 = resolveGroup(group2, flat);
expect(groupSubscriptionTargets([resolved1, c1, resolved2, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: resolved2, events: [c2] },
]);
});
it('does not absorb an event whose parent group is not subscribed', () => {
const group1 = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const group2 = makeGroup({ id: 'g2' });
const c2 = makeEvent({ id: 'c2', parent: 'g2' });
const resolved1 = resolveGroup(group1, [group1, c1, group2, c2]);
// only the first group is subscribed, so the second group's child stands alone
expect(groupSubscriptionTargets([resolved1, c1, c2])).toEqual([
{ group: resolved1, events: [c1] },
{ group: null, events: [c2] },
]);
});
it('closes a section when an ungrouped event follows a group', () => {
const group = makeGroup({ id: 'g1' });
const c1 = makeEvent({ id: 'c1', parent: 'g1' });
const e1 = makeEvent({ id: 'e1' });
const resolved = resolveGroup(group, [group, c1]);
expect(groupSubscriptionTargets([resolved, c1, e1])).toEqual([
{ group: resolved, events: [c1] },
{ group: null, events: [e1] },
]);
});
});
@@ -252,6 +252,44 @@ export function resolveSubscriptionTarget(
};
}
/**
* A subscribed group along with the subscribed events which belong to it.
* Events without a subscribed parent group form their own section with no group.
*/
export type CountdownSection = {
group: CountdownTarget | null;
events: CountdownTarget[];
};
/**
* Folds the flat, rundown ordered subscription targets into sections.
* A group opens a section which absorbs the following targets that declare it as parent,
* which allows the group to be rendered as a sticky header for its own events.
*/
export function groupSubscriptionTargets(targets: CountdownTarget[]): CountdownSection[] {
const sections: CountdownSection[] = [];
let openSection: CountdownSection | null = null;
for (const target of targets) {
// resolveSubscriptionTarget spreads the first child, so we cannot rely on the entry type here
if (target.isGroup) {
openSection = { group: target, events: [] };
sections.push(openSection);
continue;
}
if (openSection?.group && target.parent === openSection.group.id) {
openSection.events.push(target);
continue;
}
openSection = null;
sections.push({ group: null, events: [target] });
}
return sections;
}
export function extendEventData(
event: CountdownTarget,
currentDay: number,
+21
View File
@@ -0,0 +1,21 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { hasPassword, hashedPassword } from '../api-data/session/session.service.js';
/**
* Wraps the app authenticate middleware with support for the Authorization header.
* MCP clients conventionally authenticate with `Authorization: Bearer <token>`
* rather than cookies or query params; any other request falls through to the
* app middleware, keeping the behaviour of the shared middleware untouched.
*/
export function makeMcpAuthenticate(fallback: RequestHandler): RequestHandler {
return function mcpAuthenticate(req: Request, res: Response, next: NextFunction) {
if (hasPassword) {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ') && authHeader.slice(7) === hashedPassword) {
return next();
}
}
return fallback(req, res, next);
};
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { socket } from './adapters/WebsocketAdapter.js';
// Import Routers
import { appRouter } from './api-data/index.js';
import { integrationRouter } from './api-integration/integration.router.js';
import { makeMcpAuthenticate } from './api-mcp/mcp.auth.js';
import { mcpRouter } from './api-mcp/mcp.router.js';
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
// Services
@@ -101,7 +102,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
app.use(`${prefix}/login`, loginRouter); // router for login flow
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
// serve static external files
app.use(
@@ -1,35 +1,6 @@
import type { IncomingMessage } from 'node:http';
import { describe, expect, it } from 'vitest';
import type { NextFunction, Request, Response } from 'express';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api-data/session/session.service.js', () => ({
hasPassword: true,
hashedPassword: 'valid-token',
}));
import {
authenticateSocket,
isPublicAssetRequest,
makeAuthenticateMiddleware,
} from '../authenticate.js';
function makeResponse() {
return {
redirect: vi.fn(),
send: vi.fn(),
status: vi.fn().mockReturnThis(),
} as unknown as Response;
}
function makeHeadersWithFailingAuthorization(cookie?: string) {
return {
cookie,
get authorization(): never {
throw new Error('Authorization header should not be read');
},
};
}
import { isPublicAssetRequest } from '../authenticate.js';
describe('isPublicAssetRequest()', () => {
it('allows root public assets without a prefix', () => {
@@ -47,102 +18,3 @@ describe('isPublicAssetRequest()', () => {
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
});
});
describe('bearer authentication', () => {
const next = vi.fn() as NextFunction;
beforeEach(() => {
next.mockClear();
});
it('prioritises cookie authentication for API requests', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
query: {},
} as unknown as Request;
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for redirecting routes', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
const req = {
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
headers: makeHeadersWithFailingAuthorization(),
originalUrl: '/external/image.png',
query: {},
} as unknown as Request;
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('prioritises cookie authentication for WebSocket handshakes', () => {
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
expect(next).toHaveBeenCalledOnce();
});
it('authenticates API requests with a bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.status).not.toHaveBeenCalled();
});
it('authenticates redirecting routes with a bearer token', () => {
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
const req = {
cookies: {},
headers: { authorization: 'Bearer valid-token' },
originalUrl: '/stage/external/image.png',
query: {},
} as unknown as Request;
const res = makeResponse();
authenticateAndRedirect(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(res.redirect).not.toHaveBeenCalled();
});
it('authenticates WebSocket handshakes with a bearer token', () => {
const req = {
headers: { authorization: 'Bearer valid-token' },
} as IncomingMessage;
authenticateSocket({} as never, req, next);
expect(next).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledWith();
});
it('rejects an invalid bearer token', () => {
const { authenticate } = makeAuthenticateMiddleware('');
const req = {
cookies: {},
headers: { authorization: 'Bearer invalid-token' },
query: {},
} as unknown as Request;
const res = makeResponse();
authenticate(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(401);
expect(res.send).toHaveBeenCalledWith('Unauthorized');
});
});
+48 -55
View File
@@ -77,16 +77,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
const loginRedirectBase = `${prefix}/login?redirect=`;
function authenticate(req: Request, res: Response, next: NextFunction) {
if (getTokenFromCookies(req.cookies) === hashedPassword) {
return next();
if (req.query.token) {
if (req.query.token === hashedPassword) {
return next();
}
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
}
if (getTokenFromParams(req.query) === hashedPassword) {
return next();
if (req.cookies?.token) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
return next();
}
}
res.status(401).send('Unauthorized');
@@ -104,17 +105,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
return next();
}
if (getTokenFromCookies(req.cookies) === hashedPassword) {
return next();
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
// we expect the token to be in the cookies
if (req.cookies?.token) {
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
if (tokenFromCookie === hashedPassword) {
return next();
}
}
// we use query params for generating authenticated URLs and for clients like the companion module
// if the user gives is a token in the query params, we set the cookie to be used in further requests
if (getTokenFromParams(req.query) === hashedPassword) {
if (req.query.token === hashedPassword) {
if (hashedPassword !== undefined) {
setSessionCookie(res, hashedPassword, prefix);
}
@@ -135,16 +136,33 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
return next();
}
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
// check if the token is in the cookie
const cookieString = req.headers.cookie;
if (typeof cookieString === 'string') {
const cookies = parseCookie(cookieString);
if (cookies.token) {
const token = getTokenFromCookie(cookies.token);
if (token === hashedPassword) {
return next();
}
}
}
// check if token is in the params - simple string check first
const urlString = req.url || '';
if (urlString.includes(`token=${hashedPassword}`)) {
return next();
}
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
return next();
}
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
return next();
// fallback to full URL parsing for other formats
try {
const url = new URL(urlString, `http://${req.headers.host}`);
const token = url.searchParams.get('token');
if (token === hashedPassword) {
return next();
}
} catch (_) {
// ignore URL parsing errors
}
return next(new Error('Unauthorized'));
@@ -163,18 +181,19 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
});
}
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
if (typeof cookieContents !== 'string') {
return undefined;
}
// Fast path: avoid JSON parsing when the expected token can be found directly
/**
* When calling this function we already know a cookie called 'token' exists
* And want to extract its value
*/
function getTokenFromCookie(cookieContents: string): string | undefined {
// Fast path: check if the hashed password is directly in the cookie string
// This avoids JSON parsing for the common case
const cookieTokenString = '"token":"' + hashedPassword + '}"';
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
return hashedPassword;
}
// Fallback to JSON parsing for other cases or validation
try {
const cookie = JSON.parse(cookieContents);
if (cookie && typeof cookie.token === 'string') {
@@ -184,29 +203,3 @@ function getTokenFromCookies(cookies: string | Record<string, unknown> | undefin
// no error handling to do here
}
}
function getTokenFromAuthHeader(authorization: string | undefined): string | undefined {
if (authorization?.startsWith('Bearer ')) {
return authorization.slice(7);
}
}
function getTokenFromParams(
params: string | Record<string, unknown> | undefined,
host?: string,
): string | undefined {
if (typeof params !== 'string') {
return typeof params?.token === 'string' ? params.token : undefined;
}
// Fast path for WebSocket URLs
if (params.includes(`token=${hashedPassword}`)) {
return hashedPassword;
}
try {
return new URL(params, `http://${host}`).searchParams.get('token') ?? undefined;
} catch (_) {
return undefined;
}
}