mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-20 14:39:06 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| beb0a1394d |
@@ -22,11 +22,6 @@ interface UseFollowComponentProps {
|
|||||||
scrollRef: RefObject<HTMLElement | null>;
|
scrollRef: RefObject<HTMLElement | null>;
|
||||||
doFollow: boolean;
|
doFollow: boolean;
|
||||||
topOffset?: number;
|
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;
|
setScrollFlag?: (newValue: boolean) => void;
|
||||||
followTrigger?: MaybeString; // this would be an entry id or null
|
followTrigger?: MaybeString; // this would be an entry id or null
|
||||||
}
|
}
|
||||||
@@ -36,7 +31,6 @@ export default function useFollowComponent({
|
|||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow,
|
doFollow,
|
||||||
topOffset = 100,
|
topOffset = 100,
|
||||||
getTopOffset,
|
|
||||||
setScrollFlag,
|
setScrollFlag,
|
||||||
followTrigger,
|
followTrigger,
|
||||||
}: UseFollowComponentProps) {
|
}: UseFollowComponentProps) {
|
||||||
@@ -50,23 +44,21 @@ export default function useFollowComponent({
|
|||||||
setScrollFlag?.(true);
|
setScrollFlag?.(true);
|
||||||
// Use requestAnimationFrame to ensure the component is fully loaded
|
// Use requestAnimationFrame to ensure the component is fully loaded
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
// resolve the offset after layout, so that measured values are up to date
|
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, topOffset);
|
||||||
const offset = getTopOffset?.() ?? topOffset;
|
|
||||||
scrollToComponent(followRef as RefObject<HTMLElement>, scrollRef as RefObject<HTMLElement>, offset);
|
|
||||||
setScrollFlag?.(false);
|
setScrollFlag?.(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset, getTopOffset]);
|
}, [followTrigger, doFollow, followRef, scrollRef, setScrollFlag, topOffset]);
|
||||||
|
|
||||||
const scrollToRefComponent = useCallback(
|
const scrollToRefComponent = useCallback(
|
||||||
(componentRef = followRef, containerRef = scrollRef, offset?: number) => {
|
(componentRef = followRef, containerRef = scrollRef, offset = topOffset) => {
|
||||||
if (componentRef && containerRef) {
|
if (componentRef && containerRef) {
|
||||||
// @ts-expect-error -- we know this are not null
|
// @ts-expect-error -- we know this are not null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
scrollToComponent(componentRef!, containerRef!, offset ?? getTopOffset?.() ?? topOffset);
|
scrollToComponent(componentRef!, containerRef!, offset);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[followRef, scrollRef, topOffset, getTopOffset],
|
[followRef, scrollRef, topOffset],
|
||||||
);
|
);
|
||||||
|
|
||||||
return scrollToRefComponent;
|
return scrollToRefComponent;
|
||||||
|
|||||||
@@ -18,14 +18,6 @@
|
|||||||
padding-bottom: 95vh;
|
padding-bottom: 95vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* a group and its events, bounds the sticky group header to its own section.
|
|
||||||
repeats the gap of the surrounding list, which no longer reaches the rows of a section */
|
|
||||||
.groupSection {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editPrompt {
|
.editPrompt {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: $zindex-dialog;
|
z-index: $zindex-dialog;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import EmptyFill from '../../common/components/state/EmptyFill';
|
import EmptyFill from '../../common/components/state/EmptyFill';
|
||||||
import EmptyPage from '../../common/components/state/EmptyPage';
|
import EmptyPage from '../../common/components/state/EmptyPage';
|
||||||
@@ -54,21 +54,11 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = 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({
|
const scrollToComponent = useFollowComponent({
|
||||||
followRef: selectedRef,
|
followRef: selectedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
getTopOffset,
|
topOffset: selectedOffset,
|
||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,8 +86,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
if (selectedRect && scrollerRect) {
|
if (selectedRect && scrollerRect) {
|
||||||
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
|
||||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > selectedOffset;
|
||||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
}
|
}
|
||||||
@@ -197,14 +186,13 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.groupSection} key={entry.id}>
|
<Fragment key={entry.id}>
|
||||||
<OperatorGroup
|
<OperatorGroup
|
||||||
ref={isCurrentParent ? stickyHeaderRef : undefined}
|
key={entry.id}
|
||||||
title={entry.title}
|
title={entry.title}
|
||||||
colour={entry.colour}
|
colour={entry.colour}
|
||||||
count={entry.entries.length}
|
count={entry.entries.length}
|
||||||
duration={entry.duration}
|
duration={entry.duration}
|
||||||
isLive={isCurrentParent}
|
|
||||||
/>
|
/>
|
||||||
{entry.entries.map((nestedEntryId) => {
|
{entry.entries.map((nestedEntryId) => {
|
||||||
const nestedEntry = rundown.entries[nestedEntryId];
|
const nestedEntry = rundown.entries[nestedEntryId];
|
||||||
@@ -251,7 +239,7 @@ function Operator({ rundown, rundownMetadata, customFields, settings }: Operator
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,32 +3,17 @@
|
|||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
border-left: 0.35rem solid var(--group-colour, $gray-500);
|
||||||
/* the background comes inline from getAccessibleColour, which resolves to an opaque colour
|
background-color: $gray-1350;
|
||||||
and is what keeps the rows from showing through while the header is pinned */
|
background: color-mix(in srgb, transparent 88%, var(--group-colour, $gray-500) 12%);
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
letter-spacing: 0;
|
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 {
|
.title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { CSSProperties, Ref, memo } from 'react';
|
import { CSSProperties, memo } from 'react';
|
||||||
|
|
||||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
import { getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||||
import { formatDuration } from '../../../common/utils/time';
|
import { formatDuration } from '../../../common/utils/time';
|
||||||
|
|
||||||
import style from './OperatorGroup.module.scss';
|
import style from './OperatorGroup.module.scss';
|
||||||
@@ -10,22 +10,15 @@ interface OperatorGroup {
|
|||||||
colour: string;
|
colour: string;
|
||||||
count: number;
|
count: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
/** whether the group contains the currently running event */
|
|
||||||
isLive?: boolean;
|
|
||||||
ref?: Ref<HTMLDivElement>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(OperatorGroup);
|
export default memo(OperatorGroup);
|
||||||
function OperatorGroup({ title, colour, count, duration, isLive, ref }: OperatorGroup) {
|
function OperatorGroup({ title, colour, count, duration }: OperatorGroup) {
|
||||||
const groupColour = colour || '#929292';
|
const groupColour = colour || '#929292';
|
||||||
const groupColours = getAccessibleColour(groupColour);
|
const groupColours = getAccessibleColour(groupColour);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={style.group} style={{ ...groupColours, '--group-colour': groupColour } as CSSProperties}>
|
||||||
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.title}>{title}</span>
|
||||||
<span className={style.meta}>
|
<span className={style.meta}>
|
||||||
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
<span>{`${count} ${count === 1 ? 'event' : 'events'}`}</span>
|
||||||
|
|||||||
@@ -103,22 +103,6 @@ $item-height: 3.5rem;
|
|||||||
padding-bottom: max(8rem, calc(5rem + env(safe-area-inset-bottom)));
|
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.
|
|
||||||
flex rather than block so that the row margins do not collapse, keeping the spacing between
|
|
||||||
rows of a section equal to the spacing between sections */
|
|
||||||
.sub-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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 ======================*/
|
/* ====================== LIST-ITEM ======================*/
|
||||||
.sub {
|
.sub {
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
@@ -212,15 +196,9 @@ $item-height: 3.5rem;
|
|||||||
|
|
||||||
.sub--group {
|
.sub--group {
|
||||||
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
box-shadow: inset 0 0 0 1px var(--user-color, $gray-1325);
|
||||||
/* the card colour is translucent, an opaque base keeps the rows from showing through the sticky header.
|
background:
|
||||||
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%),
|
linear-gradient(90deg, color-mix(in srgb, var(--user-color, transparent) 18%, transparent), transparent 42%),
|
||||||
linear-gradient(
|
var(--card-background-color-override, $viewer-card-bg-color);
|
||||||
var(--card-background-color-override, $viewer-card-bg-color),
|
|
||||||
var(--card-background-color-override, $viewer-card-bg-color)
|
|
||||||
);
|
|
||||||
|
|
||||||
.sub__binder {
|
.sub__binder {
|
||||||
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
background: var(--user-color, var(--card-background-color-override, $viewer-card-bg-color));
|
||||||
@@ -252,16 +230,6 @@ $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 {
|
.sub__title {
|
||||||
grid-area: title;
|
grid-area: title;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
import { MaybeNumber, OntimeEvent } from 'ontime-types';
|
||||||
import { dayInMs } from 'ontime-utils';
|
import { dayInMs } from 'ontime-utils';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { IoPencil } from 'react-icons/io5';
|
import { IoPencil } from 'react-icons/io5';
|
||||||
|
|
||||||
import Button from '../../common/components/buttons/Button';
|
import Button from '../../common/components/buttons/Button';
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
CountdownTarget,
|
CountdownTarget,
|
||||||
extendEventData,
|
extendEventData,
|
||||||
getIsLive,
|
getIsLive,
|
||||||
groupSubscriptionTargets,
|
|
||||||
isOutsideRange,
|
isOutsideRange,
|
||||||
preferredFormat12,
|
preferredFormat12,
|
||||||
preferredFormat24,
|
preferredFormat24,
|
||||||
@@ -49,25 +48,11 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
const [lockAutoScroll, setLockAutoScroll] = useState(false);
|
||||||
const selectedRef = useRef<HTMLDivElement | null>(null);
|
const selectedRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollRef = 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.
|
|
||||||
// This is measured rather than estimated: the card scales with the viewport (the font sizes are clamped
|
|
||||||
// against vw), it grows while live since the running event's title is added, and long titles wrap.
|
|
||||||
// Measured between 91px and 171px, so a constant would leave the running event behind the header.
|
|
||||||
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({
|
const scrollToComponent = useFollowComponent({
|
||||||
followRef: selectedRef,
|
followRef: selectedRef,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
doFollow: !lockAutoScroll,
|
doFollow: !lockAutoScroll,
|
||||||
getTopOffset: getStickyOffset,
|
topOffset: 0,
|
||||||
followTrigger: selectedEventId,
|
followTrigger: selectedEventId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,8 +79,7 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
const selectedRect = selectedRef.current.getBoundingClientRect();
|
const selectedRect = selectedRef.current.getBoundingClientRect();
|
||||||
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
const scrollerRect = scrollRef.current.getBoundingClientRect();
|
||||||
if (selectedRect && scrollerRect) {
|
if (selectedRect && scrollerRect) {
|
||||||
// discount the pinned group header, so that the threshold keeps its meaning when a header is stuck
|
const distanceFromTop = selectedRect.top - scrollerRect.top;
|
||||||
const distanceFromTop = selectedRect.top - scrollerRect.top - getStickyOffset();
|
|
||||||
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
const hasScrolledOutOfThreshold = distanceFromTop < -8 || distanceFromTop > 50;
|
||||||
setLockAutoScroll(hasScrolledOutOfThreshold);
|
setLockAutoScroll(hasScrolledOutOfThreshold);
|
||||||
}
|
}
|
||||||
@@ -114,63 +98,41 @@ export default function CountdownSubscriptions({ subscribedEvents, goToEditMode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
<div className='list-container' onWheel={handleScroll} onTouchMove={handleScroll} ref={scrollRef}>
|
||||||
{sections.map((section) => {
|
{subscribedEvents.map((event) => {
|
||||||
const rows = section.group ? [section.group, ...section.events] : section.events;
|
// while a group is live, surface the running event's title as the secondary line
|
||||||
// the running event anchors the scroll, the group header stays pinned above it
|
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
||||||
const anchorId = section.events.find((event) => getIsLive(event.id, selectedEventId, playback))?.id ?? null;
|
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');
|
||||||
return (
|
return (
|
||||||
<div key={section.group?.id ?? rows[0].id} className='sub-section'>
|
<div
|
||||||
{rows.map((event) => {
|
key={event.id}
|
||||||
// while a group is live, surface the running event's title as the secondary line
|
ref={isLive ? selectedRef : undefined}
|
||||||
const liveTitle = event.isGroup && event.liveEntry ? event.liveEntry.title : undefined;
|
className={cx([
|
||||||
const secondaryData = liveTitle ?? getPropertyValue(event, secondarySource);
|
'sub',
|
||||||
const isGroupedEvent = !event.isGroup && Boolean(event.parent);
|
isLive && 'sub--live',
|
||||||
const activeEntryId = event.isGroup ? (event.liveEntry?.id ?? event.targetId) : event.id;
|
isArmed && 'sub--armed',
|
||||||
// a subscribed group is live when any of its children is the selected/running event
|
event.isGroup && 'sub--group',
|
||||||
const isLive = activeEntryId ? getIsLive(activeEntryId, selectedEventId, playback) : false;
|
isGroupedEvent && 'sub--in-group',
|
||||||
const isArmed = !isLive && activeEntryId === selectedEventId;
|
])}
|
||||||
// only ever hand the ref to a single row, sharing it would null it out on the next commit
|
data-testid={event.cue}
|
||||||
const isAnchor = isLive && (anchorId === null || event.id === anchorId);
|
>
|
||||||
const rowRef = isAnchor ? selectedRef : event.isGroup && anchorId ? stickyHeaderRef : undefined;
|
<div
|
||||||
const countdownEvent = extendEventData(
|
className='sub__binder'
|
||||||
event,
|
style={{ '--user-color': event.colour, '--group-color': event.groupColour ?? 'transparent' }}
|
||||||
currentDay,
|
/>
|
||||||
actualStart,
|
<ScheduleTime event={countdownEvent} showExpected={showExpected} />
|
||||||
plannedStart,
|
<SubscriptionStatus event={countdownEvent} />
|
||||||
offset,
|
<div className={cx(['sub__title', !displayTitle && 'subdued'])}>
|
||||||
mode,
|
{event.isGroup && <span className='sub__eyebrow'>Group</span>}
|
||||||
reportData,
|
{displayTitle}
|
||||||
);
|
</div>
|
||||||
const displayTitle = getPropertyValue(event, mainSource ?? 'title');
|
{secondaryData && <div className='sub__secondary'>{secondaryData}</div>}
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
import { OntimeEntry, OntimeEvent, OntimeGroup, SupportedEntry } from 'ontime-types';
|
||||||
|
|
||||||
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
|
||||||
import { CountdownTarget, groupSubscriptionTargets, resolveSubscriptionTarget } from './countdown.utils';
|
import { resolveSubscriptionTarget } from './countdown.utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
* Minimal builders for the extended (metadata enriched) entries the countdown view consumes.
|
||||||
@@ -126,89 +126,3 @@ describe('resolveSubscriptionTarget()', () => {
|
|||||||
expect(result?.liveEntry).toBeNull();
|
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,44 +252,6 @@ 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(
|
export function extendEventData(
|
||||||
event: CountdownTarget,
|
event: CountdownTarget,
|
||||||
currentDay: number,
|
currentDay: number,
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ import { socket } from './adapters/WebsocketAdapter.js';
|
|||||||
// Import Routers
|
// Import Routers
|
||||||
import { appRouter } from './api-data/index.js';
|
import { appRouter } from './api-data/index.js';
|
||||||
import { integrationRouter } from './api-integration/integration.router.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 { mcpRouter } from './api-mcp/mcp.router.js';
|
||||||
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
import { flushPendingWrites, getDataProvider } from './classes/data-provider/DataProvider.js';
|
||||||
// Services
|
// Services
|
||||||
@@ -102,7 +101,7 @@ app.get(`${prefix}/ready`, (_req, res) => {
|
|||||||
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
app.use(`${prefix}/login`, loginRouter); // router for login flow
|
||||||
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
app.use(`${prefix}/data`, authenticate, appRouter); // router for application data
|
||||||
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations
|
||||||
app.use(`${prefix}/mcp`, makeMcpAuthenticate(authenticate), mcpRouter); // router for MCP agent integration
|
app.use(`${prefix}/mcp`, authenticate, mcpRouter); // router for MCP agent integration
|
||||||
|
|
||||||
// serve static external files
|
// serve static external files
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import type { IncomingMessage } from 'node:http';
|
||||||
|
|
||||||
import { isPublicAssetRequest } from '../authenticate.js';
|
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');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('isPublicAssetRequest()', () => {
|
describe('isPublicAssetRequest()', () => {
|
||||||
it('allows root public assets without a prefix', () => {
|
it('allows root public assets without a prefix', () => {
|
||||||
@@ -18,3 +47,102 @@ describe('isPublicAssetRequest()', () => {
|
|||||||
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -77,17 +77,16 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
const loginRedirectBase = `${prefix}/login?redirect=`;
|
const loginRedirectBase = `${prefix}/login?redirect=`;
|
||||||
|
|
||||||
function authenticate(req: Request, res: Response, next: NextFunction) {
|
function authenticate(req: Request, res: Response, next: NextFunction) {
|
||||||
if (req.query.token) {
|
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||||
if (req.query.token === hashedPassword) {
|
return next();
|
||||||
return next();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.cookies?.token) {
|
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
return next();
|
||||||
if (tokenFromCookie === hashedPassword) {
|
}
|
||||||
return next();
|
|
||||||
}
|
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||||
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(401).send('Unauthorized');
|
res.status(401).send('Unauthorized');
|
||||||
@@ -105,17 +104,17 @@ export function makeAuthenticateMiddleware(prefix: string) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// we expect the token to be in the cookies
|
if (getTokenFromCookies(req.cookies) === hashedPassword) {
|
||||||
if (req.cookies?.token) {
|
return next();
|
||||||
const tokenFromCookie = getTokenFromCookie(req.cookies.token);
|
}
|
||||||
if (tokenFromCookie === hashedPassword) {
|
|
||||||
return next();
|
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||||
}
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// we use query params for generating authenticated URLs and for clients like the companion module
|
// 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 the user gives is a token in the query params, we set the cookie to be used in further requests
|
||||||
if (req.query.token === hashedPassword) {
|
if (getTokenFromParams(req.query) === hashedPassword) {
|
||||||
if (hashedPassword !== undefined) {
|
if (hashedPassword !== undefined) {
|
||||||
setSessionCookie(res, hashedPassword, prefix);
|
setSessionCookie(res, hashedPassword, prefix);
|
||||||
}
|
}
|
||||||
@@ -136,33 +135,16 @@ export function authenticateSocket(_ws: WebSocket, req: IncomingMessage, next: (
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the token is in the cookie
|
if (getTokenFromCookies(req.headers.cookie) === hashedPassword) {
|
||||||
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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback to full URL parsing for other formats
|
if (getTokenFromAuthHeader(req.headers.authorization) === hashedPassword) {
|
||||||
try {
|
return next();
|
||||||
const url = new URL(urlString, `http://${req.headers.host}`);
|
}
|
||||||
const token = url.searchParams.get('token');
|
|
||||||
if (token === hashedPassword) {
|
if (getTokenFromParams(req.url, req.headers.host) === hashedPassword) {
|
||||||
return next();
|
return next();
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// ignore URL parsing errors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return next(new Error('Unauthorized'));
|
return next(new Error('Unauthorized'));
|
||||||
@@ -181,19 +163,18 @@ function setSessionCookie(res: Response, token: string, prefix: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getTokenFromCookies(cookies: string | Record<string, unknown> | undefined): string | undefined {
|
||||||
* When calling this function we already know a cookie called 'token' exists
|
const cookieContents = typeof cookies === 'string' ? parseCookie(cookies).token : cookies?.token;
|
||||||
* And want to extract its value
|
if (typeof cookieContents !== 'string') {
|
||||||
*/
|
return undefined;
|
||||||
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
|
// Fast path: avoid JSON parsing when the expected token can be found directly
|
||||||
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
const cookieTokenString = '"token":"' + hashedPassword + '}"';
|
||||||
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
if (cookieTokenString && cookieContents.includes(cookieTokenString)) {
|
||||||
return hashedPassword;
|
return hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to JSON parsing for other cases or validation
|
|
||||||
try {
|
try {
|
||||||
const cookie = JSON.parse(cookieContents);
|
const cookie = JSON.parse(cookieContents);
|
||||||
if (cookie && typeof cookie.token === 'string') {
|
if (cookie && typeof cookie.token === 'string') {
|
||||||
@@ -203,3 +184,29 @@ function getTokenFromCookie(cookieContents: string): string | undefined {
|
|||||||
// no error handling to do here
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user