Files
Nitro-V3/src/components/guide-tool/GuideToolView.tsx
T
simoleo89 24d10aced1 fix(security): harden external-link opening (protocol allow-list + noopener)
URLs reached window.open from user/server-controlled content without a protocol check or noopener, allowing reverse-tabnabbing and (for the chat link handler) a javascript:/data: href running in our origin.

- add isSafeExternalUrl() (http/https only) + tests; gate the chat link opener (useOnClickChat) and external photo opener with it

- SanitizeHtml: afterSanitizeAttributes hook forces rel="noopener noreferrer" on any target=_blank anchor (overrides attacker-supplied rel)

- add noopener,noreferrer to the remaining window.open(_blank) sites (YouTube share, external photo, guide forum link); drop a stray console.log
2026-06-17 19:12:01 +02:00

357 lines
16 KiB
TypeScript

import { AddLinkEventTracker, GetSessionDataManager, GuideOnDutyStatusMessageEvent, GuideSessionAttachedMessageEvent, GuideSessionDetachedMessageEvent, GuideSessionEndedMessageEvent, GuideSessionErrorMessageEvent, GuideSessionInvitedToGuideRoomMessageEvent, GuideSessionMessageMessageEvent, GuideSessionOnDutyUpdateMessageComposer, GuideSessionPartnerIsTypingMessageEvent, GuideSessionStartedMessageEvent, ILinkEventTracker, PerkAllowancesMessageEvent, PerkEnum, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { GetConfigurationValue, GuideSessionState, GuideToolMessage, GuideToolMessageGroup, LocalizeText, SendMessageComposer } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { GuideToolEvent } from '../../events';
import { useMessageEvent, useNotification, useUiEvent } from '../../hooks';
import { GuideToolAcceptView } from './views/GuideToolAcceptView';
import { GuideToolMenuView } from './views/GuideToolMenuView';
import { GuideToolOngoingView } from './views/GuideToolOngoingView';
import { GuideToolUserCreateRequestView } from './views/GuideToolUserCreateRequestView';
import { GuideToolUserFeedbackView } from './views/GuideToolUserFeedbackView';
import { GuideToolUserNoHelpersView } from './views/GuideToolUserNoHelpersView';
import { GuideToolUserPendingView } from './views/GuideToolUserPendingView';
import { GuideToolUserSomethingWrogView } from './views/GuideToolUserSomethingWrogView';
import { GuideToolUserThanksView } from './views/GuideToolUserThanksView';
export const GuideToolView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState<boolean>(false);
const [ headerText, setHeaderText ] = useState<string>(LocalizeText('guide.help.guide.tool.title'));
const [ noCloseButton, setNoCloseButton ] = useState<boolean>(false);
const [ sessionState, setSessionState ] = useState<string>(GuideSessionState.GUIDE_TOOL_MENU);
const [ isOnDuty, setIsOnDuty ] = useState<boolean>(false);
const [ isHandlingBullyReports, setIsHandlingBullyReports ] = useState<boolean>(false);
const [ isHandlingGuideRequests, setIsHandlingGuideRequests ] = useState<boolean>(false);
const [ isHandlingHelpRequests, setIsHandlingHelpRequests ] = useState<boolean>(false);
const [ helpersOnDuty, setHelpersOnDuty ] = useState<number>(0);
const [ guidesOnDuty, setGuidesOnDuty ] = useState<number>(0);
const [ guardiansOnDuty, setGuardiansOnDuty ] = useState<number>(0);
const [ userRequest, setUserRequest ] = useState<string>('');
const [ helpRequestDescription, setHelpRequestDescription ] = useState<string>(null);
const [ helpRequestAverageTime, setHelpRequestAverageTime ] = useState<number>(0);
const [ ongoingUserId, setOngoingUserId ] = useState<number>(0);
const [ ongoingUsername, setOngoingUsername ] = useState<string>(null);
const [ ongoingFigure, setOngoingFigure ] = useState<string>(null);
const [ ongoingIsTyping, setOngoingIsTyping ] = useState<boolean>(false);
const [ ongoingMessageGroups, setOngoingMessageGroups ] = useState<GuideToolMessageGroup[]>([]);
const { simpleAlert = null } = useNotification();
const updateSessionState = useCallback((newState: string, replacement?: string) =>
{
switch(newState)
{
case GuideSessionState.GUIDE_TOOL_MENU:
setHeaderText(LocalizeText('guide.help.guide.tool.title'));
setNoCloseButton(false);
break;
case GuideSessionState.GUIDE_ACCEPT:
setHeaderText(LocalizeText('guide.help.request.guide.accept.title'));
setNoCloseButton(true);
break;
case GuideSessionState.GUIDE_ONGOING:
setHeaderText(LocalizeText('guide.help.request.guide.ongoing.title', [ 'name' ], [ replacement ]));
setNoCloseButton(true);
break;
case GuideSessionState.USER_CREATE:
setHeaderText(LocalizeText('guide.help.request.user.create.title'));
setNoCloseButton(false);
break;
case GuideSessionState.USER_PENDING:
setHeaderText(LocalizeText('guide.help.request.user.pending.title'));
setNoCloseButton(true);
break;
case GuideSessionState.USER_ONGOING:
setHeaderText(LocalizeText('guide.help.request.user.ongoing.title', [ 'name' ], [ replacement ]));
setNoCloseButton(true);
break;
case GuideSessionState.USER_FEEDBACK:
setHeaderText(LocalizeText('guide.help.request.user.feedback.title'));
setNoCloseButton(true);
break;
case GuideSessionState.USER_THANKS:
setHeaderText(LocalizeText('guide.help.request.user.thanks.title'));
setNoCloseButton(false);
break;
case GuideSessionState.USER_NO_HELPERS:
setHeaderText(LocalizeText('guide.help.request.no_tour_guides.heading'));
setNoCloseButton(false);
break;
case GuideSessionState.USER_SOMETHING_WRONG:
setHeaderText(LocalizeText('guide.help.request.user.guide.disconnected.error.heading'));
setNoCloseButton(false);
break;
}
setSessionState(newState);
setIsVisible(true);
}, []);
const onGuideToolEvent = useCallback((event: GuideToolEvent) =>
{
switch(event.type)
{
case GuideToolEvent.SHOW_GUIDE_TOOL:
setIsVisible(true);
return;
case GuideToolEvent.HIDE_GUIDE_TOOL:
setIsVisible(false);
return;
case GuideToolEvent.TOGGLE_GUIDE_TOOL:
setIsVisible(value => !value);
return;
case GuideToolEvent.CREATE_HELP_REQUEST:
updateSessionState(GuideSessionState.USER_CREATE);
return;
}
}, [ updateSessionState ]);
useUiEvent(GuideToolEvent.SHOW_GUIDE_TOOL, onGuideToolEvent);
useUiEvent(GuideToolEvent.HIDE_GUIDE_TOOL, onGuideToolEvent);
useUiEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL, onGuideToolEvent);
useUiEvent(GuideToolEvent.CREATE_HELP_REQUEST, onGuideToolEvent);
useMessageEvent<PerkAllowancesMessageEvent>(PerkAllowancesMessageEvent, event =>
{
const parser = event.getParser();
if(!parser.isAllowed(PerkEnum.USE_GUIDE_TOOL) && isOnDuty)
{
setIsOnDuty(false);
SendMessageComposer(new GuideSessionOnDutyUpdateMessageComposer(false, false, false, false));
}
});
useMessageEvent<GuideOnDutyStatusMessageEvent>(GuideOnDutyStatusMessageEvent, event =>
{
const parser = event.getParser();
setIsOnDuty(parser.onDuty);
setGuidesOnDuty(parser.guidesOnDuty);
setHelpersOnDuty(parser.helpersOnDuty);
setGuardiansOnDuty(parser.guardiansOnDuty);
});
useMessageEvent<GuideSessionAttachedMessageEvent>(GuideSessionAttachedMessageEvent, event =>
{
const parser = event.getParser();
setHelpRequestDescription(parser.helpRequestDescription);
setHelpRequestAverageTime(parser.roleSpecificWaitTime);
if(parser.asGuide && isOnDuty) updateSessionState(GuideSessionState.GUIDE_ACCEPT);
if(!parser.asGuide) updateSessionState(GuideSessionState.USER_PENDING);
});
useMessageEvent<GuideSessionStartedMessageEvent>(GuideSessionStartedMessageEvent, event =>
{
const parser = event.getParser();
if(isOnDuty)
{
setOngoingUserId(parser.requesterUserId);
setOngoingUsername(parser.requesterName);
setOngoingFigure(parser.requesterFigure);
updateSessionState(GuideSessionState.GUIDE_ONGOING, parser.requesterName);
}
else
{
setOngoingUserId(parser.guideUserId);
setOngoingUsername(parser.guideName);
setOngoingFigure(parser.guideFigure);
updateSessionState(GuideSessionState.USER_ONGOING, parser.guideName);
}
});
useMessageEvent<GuideSessionPartnerIsTypingMessageEvent>(GuideSessionPartnerIsTypingMessageEvent, event =>
{
const parser = event.getParser();
setOngoingIsTyping(parser.isTyping);
});
useMessageEvent<GuideSessionMessageMessageEvent>(GuideSessionMessageMessageEvent, event =>
{
const parser = event.getParser();
const messageGroups = [ ...ongoingMessageGroups ];
let lastGroup = messageGroups[messageGroups.length - 1];
if(!lastGroup || lastGroup.userId !== parser.senderId)
{
lastGroup = new GuideToolMessageGroup(parser.senderId);
messageGroups.push(lastGroup);
}
lastGroup.addChat(new GuideToolMessage(parser.chatMessage));
setOngoingMessageGroups(messageGroups);
});
useMessageEvent<GuideSessionInvitedToGuideRoomMessageEvent>(GuideSessionInvitedToGuideRoomMessageEvent, event =>
{
const parser = event.getParser();
if(parser.roomId !== 0)
{
const messageGroups = [ ...ongoingMessageGroups ];
let lastGroup = messageGroups[messageGroups.length - 1];
const guideId = (isOnDuty ? GetSessionDataManager().userId : ongoingUserId);
if(!lastGroup || lastGroup.userId !== guideId)
{
lastGroup = new GuideToolMessageGroup(guideId);
messageGroups.push(lastGroup);
}
lastGroup.addChat(new GuideToolMessage(parser.roomName, parser.roomId));
setOngoingMessageGroups(messageGroups);
}
});
useMessageEvent<GuideSessionEndedMessageEvent>(GuideSessionEndedMessageEvent, event =>
{
if(isOnDuty)
{
setOngoingUserId(0);
setOngoingUsername(null);
setOngoingFigure(null);
setOngoingIsTyping(false);
setOngoingMessageGroups([]);
updateSessionState(GuideSessionState.GUIDE_TOOL_MENU);
}
else
{
updateSessionState(GuideSessionState.USER_FEEDBACK);
}
});
useMessageEvent<GuideSessionErrorMessageEvent>(GuideSessionErrorMessageEvent, event =>
{
const parser = event.getParser();
// SOMETHING_WRONG_REQUEST = 0, NO_HELPERS_AVAILABLE = 1, NO_GUARDIANS_AVAILABLE = 2
switch(parser['errorCode'])
{
case 0:
updateSessionState(GuideSessionState.USER_SOMETHING_WRONG);
break;
case 1:
case 2:
updateSessionState(GuideSessionState.USER_NO_HELPERS);
break;
}
});
useMessageEvent<GuideSessionDetachedMessageEvent>(GuideSessionDetachedMessageEvent, event =>
{
setOngoingUserId(0);
setOngoingUsername(null);
setOngoingFigure(null);
setOngoingIsTyping(false);
setOngoingMessageGroups([]);
if(isOnDuty)
{
updateSessionState(GuideSessionState.GUIDE_TOOL_MENU);
}
else
{
updateSessionState(GuideSessionState.USER_THANKS);
}
});
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'tour':
//Create Tour Request
return;
}
},
eventUrlPrefix: 'help/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
const processAction = useCallback((action: string) =>
{
switch(action)
{
case 'close':
setIsVisible(false);
setUserRequest('');
setSessionState(GuideSessionState.GUIDE_TOOL_MENU);
return;
case 'toggle_duty':
if(!isHandlingBullyReports && !isHandlingGuideRequests && !isHandlingHelpRequests)
{
simpleAlert(LocalizeText('guide.help.guide.tool.noqueueselected.message'), null, null, null, LocalizeText('guide.help.guide.tool.noqueueselected.caption'), null);
return;
}
setIsOnDuty(v =>
{
SendMessageComposer(new GuideSessionOnDutyUpdateMessageComposer(!v, v ? false : isHandlingGuideRequests, v ? false : isHandlingHelpRequests, v ? false : isHandlingBullyReports));
return !v;
});
return;
case 'forum_link':
const url: string = GetConfigurationValue<string>('group.homepage.url', '').replace('%groupid%', GetConfigurationValue<string>('guide.help.alpha.groupid', '0'));
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
}, [ isHandlingBullyReports, isHandlingGuideRequests, isHandlingHelpRequests, simpleAlert ]);
if(!isVisible) return null;
return (
<NitroCardView className="nitro-guide-tool" theme="primary-slim">
<NitroCardHeaderView headerText={ headerText } noCloseButton={ noCloseButton } onCloseClick={ event => processAction('close') } />
<NitroCardContentView className="text-black">
{ (sessionState === GuideSessionState.GUIDE_TOOL_MENU) &&
<GuideToolMenuView guardiansOnDuty={ guardiansOnDuty } guidesOnDuty={ guidesOnDuty } helpersOnDuty={ helpersOnDuty } isHandlingBullyReports={ isHandlingBullyReports } isHandlingGuideRequests={ isHandlingGuideRequests } isHandlingHelpRequests={ isHandlingHelpRequests } isOnDuty={ isOnDuty } processAction={ processAction } setIsHandlingBullyReports={ setIsHandlingBullyReports } setIsHandlingGuideRequests={ setIsHandlingGuideRequests } setIsHandlingHelpRequests={ setIsHandlingHelpRequests } /> }
{ (sessionState === GuideSessionState.GUIDE_ACCEPT) &&
<GuideToolAcceptView helpRequestAverageTime={ helpRequestAverageTime } helpRequestDescription={ helpRequestDescription } /> }
{ [ GuideSessionState.GUIDE_ONGOING, GuideSessionState.USER_ONGOING ].includes(sessionState) &&
<GuideToolOngoingView isGuide={ isOnDuty } isTyping={ ongoingIsTyping } messageGroups={ ongoingMessageGroups } userFigure={ ongoingFigure } userId={ ongoingUserId } userName={ ongoingUsername } /> }
{ (sessionState === GuideSessionState.USER_CREATE) &&
<GuideToolUserCreateRequestView setUserRequest={ setUserRequest } userRequest={ userRequest } /> }
{ (sessionState === GuideSessionState.USER_PENDING) &&
<GuideToolUserPendingView helpRequestAverageTime={ helpRequestAverageTime } helpRequestDescription={ helpRequestDescription } /> }
{ (sessionState === GuideSessionState.USER_FEEDBACK) &&
<GuideToolUserFeedbackView userName={ ongoingUsername } /> }
{ (sessionState === GuideSessionState.USER_THANKS) &&
<GuideToolUserThanksView /> }
{ (sessionState === GuideSessionState.USER_NO_HELPERS) &&
<GuideToolUserNoHelpersView /> }
{ (sessionState === GuideSessionState.USER_SOMETHING_WRONG) &&
<GuideToolUserSomethingWrogView /> }
</NitroCardContentView>
</NitroCardView>
);
};