From e0174e450c704860d342bdd76603a6c78732e9f9 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Tue, 21 Apr 2026 08:54:02 +0200 Subject: [PATCH 01/11] Align wired chat limits and formatting help UI --- src/api/utils/RoomChatFormatter.ts | 44 +++++++++++++- .../WiredActionBotTalkToAvatarView.tsx | 13 ++++- .../views/actions/WiredActionBotTalkView.tsx | 13 ++++- .../views/actions/WiredActionChatView.tsx | 29 ++++++++-- .../views/common/WiredTextFormattingHelp.tsx | 58 +++++++++++++++++++ 5 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 src/components/wired/views/common/WiredTextFormattingHelp.tsx diff --git a/src/api/utils/RoomChatFormatter.ts b/src/api/utils/RoomChatFormatter.ts index 6ca8964..a47dd2f 100644 --- a/src/api/utils/RoomChatFormatter.ts +++ b/src/api/utils/RoomChatFormatter.ts @@ -40,11 +40,53 @@ const encodeHTML = (str: string) => }); }; +const formatTag = (content: string, tag: string, replacement: (value: string) => string) => +{ + const pattern = new RegExp(`\\[${ tag }\\]([\\s\\S]*?)\\[\\/${ tag }\\]`, 'gi'); + let previous = ''; + let next = content; + let guard = 0; + + while((previous !== next) && (guard < 20)) + { + previous = next; + next = next.replace(pattern, (match, value) => replacement(value)); + guard++; + } + + return next; +}; + +const applyWiredTextMarkup = (content: string) => +{ + const colorStyles: Record = { + green: '#008000', + cyan: '#008b8b', + red: '#d60000', + blue: '#005dff', + purple: '#7d31b8' + }; + + let result = content; + + result = formatTag(result, 'b', value => `${ value }`); + result = formatTag(result, 'i', value => `${ value }`); + result = formatTag(result, 'u', value => `${ value }`); + + Object.entries(colorStyles).forEach(([ tag, color ]) => + { + result = formatTag(result, tag, value => `${ value }`); + }); + + return result; +}; + export const RoomChatFormatter = (content: string) => { let result = ''; content = encodeHTML(content); + content = applyWiredTextMarkup(content); //content = (joypixels.shortnameToUnicode(content) as string) if(!GetConfigurationValue('youtube.publish.disabled', false)) @@ -84,5 +126,5 @@ export const RoomChatFormatter = (content: string) => result = content; } - return result; + return result.replace(/\r\n|\r|\n/g, '
'); }; diff --git a/src/components/wired/views/actions/WiredActionBotTalkToAvatarView.tsx b/src/components/wired/views/actions/WiredActionBotTalkToAvatarView.tsx index 5d7db10..9d91996 100644 --- a/src/components/wired/views/actions/WiredActionBotTalkToAvatarView.tsx +++ b/src/components/wired/views/actions/WiredActionBotTalkToAvatarView.tsx @@ -1,9 +1,10 @@ import { FC, useEffect, useState } from 'react'; -import { GetConfigurationValue, LocalizeText, WIRED_STRING_DELIMETER, WiredFurniType } from '../../../../api'; +import { LocalizeText, WIRED_STRING_DELIMETER, WiredFurniType } from '../../../../api'; import { Text } from '../../../../common'; import { useWired } from '../../../../hooks'; import { NitroInput } from '../../../../layout'; import { WiredActionBaseView } from './WiredActionBaseView'; +import { WiredTextCounter, WiredTextFormattingHelp } from '../common/WiredTextFormattingHelp'; import { BOT_SOURCES, WiredSourcesSelector } from '../WiredSourcesSelector'; const normalizeBotSource = (value: number, hasBotName = false) => (BOT_SOURCES.some(option => (option.value === value)) ? value : (hasBotName ? 100 : 0)); @@ -15,6 +16,7 @@ export const WiredActionBotTalkToAvatarView: FC<{}> = props => const [ talkMode, setTalkMode ] = useState(-1); const [ botSource, setBotSource ] = useState(100); const { trigger = null, setStringParam = null, setIntParams = null } = useWired(); + const maxMessageLength = 100; const [ userSource, setUserSource ] = useState(() => { if(trigger?.intData?.length > 1) return trigger.intData[1]; @@ -59,7 +61,14 @@ export const WiredActionBotTalkToAvatarView: FC<{}> = props => }
{ LocalizeText('wiredfurni.params.message') } - ('wired.action.bot.talk.to.avatar.max.length', 64) } type="text" value={ message } onChange={ event => setMessage(event.target.value) } /> + - { LocalizeText('friendlist.invite.note') } -
+ + { LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) } + + { LocalizeText('friendlist.invite.note') } +
diff --git a/src/components/friends/views/friends-list/FriendsListSearchView.tsx b/src/components/friends/views/friends-list/FriendsListSearchView.tsx index fd53481..c9a15b8 100644 --- a/src/components/friends/views/friends-list/FriendsListSearchView.tsx +++ b/src/components/friends/views/friends-list/FriendsListSearchView.tsx @@ -1,8 +1,10 @@ import { HabboSearchComposer, HabboSearchResultData, HabboSearchResultEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { LocalizeText, OpenMessengerChat, SendMessageComposer } from '../../../../api'; -import { Column, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common'; +import { Column, LayoutAvatarImageView, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common'; import { useFriends, useMessageEvent } from '../../../../hooks'; +import { resolveAvatarFigure } from './resolveAvatarFigure'; +import { resolveAvatarGender } from './resolveAvatarGender'; interface FriendsSearchViewProps extends NitroCardAccordionSetViewProps { @@ -17,6 +19,22 @@ export const FriendsSearchView: FC = props => const [ otherResults, setOtherResults ] = useState(null); const { canRequestFriend = null, requestFriend = null } = useFriends(); + const getSearchResultFigure = (result: HabboSearchResultData) => + { + if(!result) return null; + + const typedResult = (result as HabboSearchResultData & { figureString?: string; avatarFigure?: string; figure?: string; avatarFigureString?: string }); + + return typedResult.figureString || typedResult.avatarFigure || typedResult.figure || typedResult.avatarFigureString || null; + }; + + const getSearchResultGender = (result: HabboSearchResultData) => + { + const typedResult = (result as HabboSearchResultData & { gender?: string | number; avatarGender?: string | number }); + + return resolveAvatarGender(typedResult.avatarGender ?? typedResult.gender); + }; + useMessageEvent(HabboSearchResultEvent, event => { const parser = event.getParser(); @@ -55,10 +73,15 @@ export const FriendsSearchView: FC = props => { friendResults.map(result => { return ( - -
- -
{ result.avatarName }
+ +
+
+ +
+
+ +
+
{ result.avatarName }
{ result.isAvatarOnline && @@ -82,10 +105,15 @@ export const FriendsSearchView: FC = props => { otherResults.map(result => { return ( - -
- -
{ result.avatarName }
+ +
+
+ +
+
+ +
+
{ result.avatarName }
{ canRequestFriend(result.avatarId) && diff --git a/src/components/friends/views/friends-list/FriendsListView.tsx b/src/components/friends/views/friends-list/FriendsListView.tsx index ef30f23..b9ad497 100644 --- a/src/components/friends/views/friends-list/FriendsListView.tsx +++ b/src/components/friends/views/friends-list/FriendsListView.tsx @@ -34,7 +34,7 @@ export const FriendsListView: FC<{}> = props => userNames.push(existingFriend.name); } - return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join(', ') ]); + return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join('\n') ]); }, [ offlineFriends, onlineFriends, selectedFriendsIds ]); const selectFriend = useCallback((userId: number) => @@ -60,6 +60,27 @@ export const FriendsListView: FC<{}> = props => }); }, [ setSelectedFriendsIds ]); + const toggleSelectFriends = useCallback((friendIds: number[]) => + { + if(!friendIds.length) return; + + setSelectedFriendsIds(prevValue => + { + const allSelected = friendIds.every(friendId => (prevValue.indexOf(friendId) >= 0)); + + if(allSelected) return prevValue.filter(friendId => (friendIds.indexOf(friendId) === -1)); + + const nextValue = [ ...prevValue ]; + + for(const friendId of friendIds) + { + if(nextValue.indexOf(friendId) === -1) nextValue.push(friendId); + } + + return nextValue; + }); + }, []); + const sendRoomInvite = (message: string) => { if(!selectedFriendsIds.length || !message || !message.length || (message.length > 255)) return; @@ -125,10 +146,24 @@ export const FriendsListView: FC<{}> = props => setIsVisible(false) } /> - + + + { event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); } }> + { onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + ? LocalizeText('friendlist.unselect_all') + : LocalizeText('friendlist.select_all') } + + + + { event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); } }> + { offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + ? LocalizeText('friendlist.unselect_all') + : LocalizeText('friendlist.select_all') } + + diff --git a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx index 0b75b86..dc23a3b 100644 --- a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx +++ b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx @@ -1,7 +1,9 @@ import { FC, MouseEvent, useState } from 'react'; import { LocalizeText, MessengerFriend, OpenMessengerChat } from '../../../../../api'; -import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; +import { LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { useFriends } from '../../../../../hooks'; +import { resolveAvatarFigure } from '../resolveAvatarFigure'; +import { resolveAvatarGender } from '../resolveAvatarGender'; export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: boolean, selectFriend: (userId: number) => void }> = props => { @@ -55,14 +57,17 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b if(!friend) return null; return ( - selectFriend(friend.id) }> -
+ selectFriend(friend.id) }> +
+
+ +
event.stopPropagation() }>
-
{ friend.name }
+
{ friend.name }
-
+
{ !isRelationshipOpen && <> { friend.online && diff --git a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx index c06840e..e3ff05b 100644 --- a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx +++ b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx @@ -1,7 +1,9 @@ import { FC } from 'react'; -import { MessengerRequest } from '../../../../../api'; -import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; +import { LocalizeText, MessengerRequest } from '../../../../../api'; +import { Button, LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { useFriends } from '../../../../../hooks'; +import { resolveAvatarFigure } from '../resolveAvatarFigure'; +import { resolveAvatarGender } from '../resolveAvatarGender'; export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = props => { @@ -11,14 +13,23 @@ export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = pro if(!request) return null; return ( - -
- -
{ request.name }
+ +
+
+ +
+
+ +
+
{ request.name }
-
requestResponse(request.id, true) } /> -
requestResponse(request.id, false) } /> + +
); diff --git a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx index 686b32d..eb002c0 100644 --- a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx +++ b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx @@ -17,8 +17,11 @@ export const FriendsListRequestView: FC = props { requests.map((request, index) => ) } -
- +
diff --git a/src/components/friends/views/friends-list/resolveAvatarFigure.ts b/src/components/friends/views/friends-list/resolveAvatarFigure.ts new file mode 100644 index 0000000..98079b1 --- /dev/null +++ b/src/components/friends/views/friends-list/resolveAvatarFigure.ts @@ -0,0 +1,15 @@ +import { resolveAvatarGender } from './resolveAvatarGender'; + +const DEFAULT_AVATAR_FIGURES: Record = { + M: 'hd-180-1.ch-210-66.lg-270-82.sh-290-80', + F: 'hd-600-1.ch-630-66.lg-695-82.sh-725-80' +}; + +export const resolveAvatarFigure = (figure: string | null | undefined, gender?: string | number | null) => +{ + const normalizedFigure = (figure || '').trim(); + + if(normalizedFigure.length && normalizedFigure.includes('hd-')) return normalizedFigure; + + return DEFAULT_AVATAR_FIGURES[resolveAvatarGender(gender)] || DEFAULT_AVATAR_FIGURES.M; +}; diff --git a/src/components/friends/views/friends-list/resolveAvatarGender.ts b/src/components/friends/views/friends-list/resolveAvatarGender.ts new file mode 100644 index 0000000..730e1fe --- /dev/null +++ b/src/components/friends/views/friends-list/resolveAvatarGender.ts @@ -0,0 +1,20 @@ +export const resolveAvatarGender = (value: string | number | null | undefined) => +{ + if(typeof value === 'string') + { + const normalized = value.trim().toUpperCase(); + + if(normalized === 'F') return 'F'; + if(normalized === 'M') return 'M'; + if(normalized === 'FEMALE') return 'F'; + if(normalized === 'MALE') return 'M'; + } + + if(typeof value === 'number') + { + if(value === 2) return 'F'; + if(value === 1) return 'M'; + } + + return 'M'; +}; diff --git a/src/components/friends/views/messenger/FriendsMessengerView.tsx b/src/components/friends/views/messenger/FriendsMessengerView.tsx index bbb5222..6b3e761 100644 --- a/src/components/friends/views/messenger/FriendsMessengerView.tsx +++ b/src/components/friends/views/messenger/FriendsMessengerView.tsx @@ -2,9 +2,8 @@ import { AddLinkEventTracker, FollowFriendMessageComposer, GetSessionDataManager import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; import { FaTimes } from 'react-icons/fa'; import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api'; -import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutGridItem, LayoutItemCountView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useHelp, useMessenger } from '../../../../hooks'; -import { NitroInput } from '../../../../layout'; +import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useHelp, useMessenger, useTranslation } from '../../../../hooks'; import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView'; export const FriendsMessengerView: FC<{}> = props => @@ -14,15 +13,35 @@ export const FriendsMessengerView: FC<{}> = props => const [ messageText, setMessageText ] = useState(''); const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger(); const { report = null } = useHelp(); + const { settings, translateOutgoing } = useTranslation(); const messagesBox = useRef(); const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id))); const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id)); - const send = () => + const send = async () => { if(!activeThread || !messageText.length) return; + const trimmedText = messageText.trimStart(); + const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':'); + + if(!shouldTranslateOutgoing) + { + sendMessage(activeThread, GetSessionDataManager().userId, messageText); + setMessageText(''); + return; + } + + const translation = await translateOutgoing(messageText); + + if(translation && translation.translatedText?.length && (translation.translatedText.length <= 255)) + { + sendMessage(activeThread, GetSessionDataManager().userId, translation.translatedText, 0, null, undefined, translation); + setMessageText(''); + return; + } + sendMessage(activeThread, GetSessionDataManager().userId, messageText); setMessageText(''); @@ -32,7 +51,7 @@ export const FriendsMessengerView: FC<{}> = props => { if(event.key !== 'Enter') return; - send(); + void send(); }; useEffect(() => @@ -107,71 +126,60 @@ export const FriendsMessengerView: FC<{}> = props => if(!isVisible) return null; return ( - + setIsVisible(false) } /> - - - - { LocalizeText('toolbar.icon.label.messenger') } - - - { visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread => - { - return ( - setActiveThreadId(thread.threadId) } className="py-1 px-2"> - { thread.unread && } - - 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure } - headOnly={ true } - direction={ thread.participant.id > 0 ? 2 : 3 } - style={{ width: '50px', height: '80px', backgroundPosition: 'center 45%', flexShrink: 0, alignSelf: 'flex-end' }} - /> - { thread.participant.name } - - - ); - }) } - - - - - { activeThread && - <> - { LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } - + +
+
+ { visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread => + { + return ( + + ); + }) } +
+ + { activeThread && + <> +
+ { LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } +
{ (activeThread.participant.id > 0) && -
-
- - -
- + + -
} - - - - - - - -
- setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> - + + } +
- } - - +
+ +
+ +
+ +
+ setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> + +
+ } +
); diff --git a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx index b9ace6c..97d74bf 100644 --- a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx +++ b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx @@ -28,14 +28,11 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M <> { group.chats.map((chat, index) => { + if(chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) return null; + return ( - { (chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) && - - - { chat.message } - } { (chat.type === MessengerThreadChat.ROOM_INVITE) && @@ -50,24 +47,46 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M } return ( - + { ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) && - } + } { (groupChatData && !isOwnChat) && - } + } - - - { group.chats[0].date.toLocaleTimeString() } + + { isOwnChat && GetSessionDataManager().userName } { !isOwnChat && (groupChatData ? groupChatData.username : thread.participant.name) } + : - { group.chats.map((chat, index) => { chat.message }) } + + { group.chats.map((chat, index) => + { + if(!chat.showTranslation) + { + return { chat.message }; + } + + return ( + + + original: + { chat.originalMessage || chat.message } + + + translate: + { chat.translatedMessage || chat.message } + + + ); + }) } + + { group.chats[0].date.toLocaleTimeString() } { isOwnChat && - + } ); diff --git a/src/components/inventory/views/prefix/InventoryPrefixView.tsx b/src/components/inventory/views/prefix/InventoryPrefixView.tsx index d959546..f956e13 100644 --- a/src/components/inventory/views/prefix/InventoryPrefixView.tsx +++ b/src/components/inventory/views/prefix/InventoryPrefixView.tsx @@ -1,24 +1,29 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; -import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; -import { useInventoryPrefixes, useNotification } from '../../../../hooks'; +import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, getPrefixFontStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { Button } from '../../../../common'; +import { GetNickIconUrl } from '../../../../assets/images/user_custom/nick_icons'; +import { useInventoryNickIcons, useInventoryPrefixes, useNotification } from '../../../../hooks'; import { NitroButton } from '../../../../layout'; -const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) => +type InventoryIdentityTab = 'prefixes' | 'icons'; + +const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; font?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', font = '', className = '', textSize = 'text-sm' }) => { const colors = parsePrefixColors(text, color); const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF'); + const fontStyle = getPrefixFontStyle(font); return ( - - { effect === 'pulse' && } + + { !!effect && } { icon && { icon } } - + {'{'} { hasMultiColor ? [ ...text ].map((char, i) => ( - { char } + { char } )) : text } @@ -40,7 +45,30 @@ const PrefixItemView: FC<{ ${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' } ${ prefix.active ? 'ring-2 ring-green-400' : '' }` } onClick={ onClick }> - + +
+ ); +}; + +const NickIconItemView: FC<{ + iconKey: string; + displayName: string; + isSelected: boolean; + isActive: boolean; + onClick: () => void; +}> = ({ iconKey, displayName, isSelected, isActive, onClick }) => +{ + return ( +
+ { isActive && Active } +
+ { + { displayName || iconKey } +
); }; @@ -48,8 +76,13 @@ const PrefixItemView: FC<{ export const InventoryPrefixView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); + const [ activeTab, setActiveTab ] = useState('prefixes'); const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes(); + const { nickIcons = [], activeNickIcon = null, selectedNickIcon = null, setSelectedNickIcon = null, activateNickIcon = null, deactivateNickIcon = null, activate: activateNickIcons = null, deactivate: deactivateNickIcons = null } = useInventoryNickIcons(); const { showConfirm = null } = useNotification(); + const hasPrefixes = prefixes && (prefixes.length > 0); + const hasNickIcons = nickIcons && (nickIcons.length > 0); + const selectedIconUrl = useMemo(() => selectedNickIcon ? GetNickIconUrl(selectedNickIcon.iconKey) : '', [ selectedNickIcon ]); const attemptDeletePrefix = () => { @@ -69,10 +102,15 @@ export const InventoryPrefixView: FC<{}> = () => { if(!isVisible) return; - const id = activate(); + const prefixVisibilityId = activate(); + const iconVisibilityId = activateNickIcons(); - return () => deactivate(id); - }, [ isVisible, activate, deactivate ]); + return () => + { + deactivate(prefixVisibilityId); + deactivateNickIcons(iconVisibilityId); + }; + }, [ isVisible, activate, activateNickIcons, deactivate, deactivateNickIcons ]); useEffect(() => { @@ -82,55 +120,115 @@ export const InventoryPrefixView: FC<{}> = () => }, []); return ( -
-
-
- { prefixes.map(prefix => ( - setSelectedPrefix(prefix) } /> - )) } +
+
+
+ +
- { (!prefixes || prefixes.length === 0) && -
- { LocalizeText('inventory.empty.title') } -
}
-
- { activePrefix && -
- Active prefix -
- + + { activeTab === 'prefixes' && +
+
+
+ { prefixes.map(prefix => ( + setSelectedPrefix(prefix) } /> + )) }
-
} - { !activePrefix && -
- Active prefix -
- No active prefix + { !hasPrefixes && +
+ { LocalizeText('inventory.empty.title') } +
} +
+
+ { activePrefix && +
+ Active prefix +
+ +
+
} + { !activePrefix && +
+ Active prefix +
+ No active prefix +
+
} + { !!selectedPrefix && +
+
+ +
+
+ selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }> + { selectedPrefix.active ? 'Deactivate' : 'Activate' } + + { !selectedPrefix.active && + + + } +
+
} +
+
} + + { activeTab === 'icons' && +
+
+
+ { nickIcons.map(icon => ( + setSelectedNickIcon(icon) } /> + )) }
-
} - { !!selectedPrefix && -
-
- + { !hasNickIcons && +
+ No purchased icons yet +
} +
+
+
+ Active icon +
+ { activeNickIcon && { } + { !activeNickIcon && No active icon } +
-
- selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }> - { selectedPrefix.active ? 'Deactivate' : 'Activate' } - - { !selectedPrefix.active && - - - } -
-
} -
+ { !!selectedNickIcon && +
+
+ { + { selectedNickIcon.displayName || selectedNickIcon.iconKey } +
+ +
} +
+
}
); }; diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 4f6bb8b..6bda43e 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -1,6 +1,6 @@ import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; -import { FaChevronDown, FaQuestionCircle } from 'react-icons/fa'; +import { FaChevronDown, FaLanguage, FaQuestionCircle } from 'react-icons/fa'; import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { usePurse } from '../../hooks'; @@ -91,6 +91,9 @@ export const PurseView: FC<{}> = props => {
}
+ diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index 12aca46..24a7f7e 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -2,7 +2,7 @@ import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusI import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react'; import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; -import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; +import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView, UserProfileIconView } from '../../../../../common'; import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks'; import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; @@ -29,7 +29,6 @@ export const InfoStandWidgetUserView: FC = props = const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`; const infostandStandClass = `stand-${standId ?? 'default'}`; const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`; - const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]); const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []); @@ -79,6 +78,12 @@ export const InfoStandWidgetUserView: FC = props = newValue.figure = event.figure; newValue.motto = event.customInfo; newValue.achievementScore = event.activityPoints; + newValue.nickIcon = event.nickIcon; + newValue.prefixText = event.prefixText; + newValue.prefixColor = event.prefixColor; + newValue.prefixIcon = event.prefixIcon; + newValue.prefixEffect = event.prefixEffect; + newValue.displayOrder = event.displayOrder; newValue.backgroundId = event.backgroundId; newValue.standId = event.standId; newValue.overlayId = event.overlayId; @@ -139,7 +144,17 @@ export const InfoStandWidgetUserView: FC = props =
- {avatarInfo.name} +
diff --git a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx index 1dba1a9..c791599 100644 --- a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx @@ -1,6 +1,7 @@ import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { ChatBubbleMessage } from '../../../../api'; +import { UserIdentityView } from '../../../../common'; import { useOnClickChat } from '../../../../hooks'; interface ChatWidgetMessageViewProps @@ -38,11 +39,11 @@ export const ChatWidgetMessageView: FC = ({ useEffect(() => { - setIsVisible(false); - const element = elementRef.current; if(!element) return; + const previousWidth = chat.width; + const previousHeight = chat.height; const { offsetWidth: width, offsetHeight: height } = element; chat.width = width; @@ -62,10 +63,14 @@ export const ChatWidgetMessageView: FC = ({ setIsReady(true); + if(isVisible && ((previousWidth !== width) || (previousHeight !== height)) && makeRoom) makeRoom(chat); + }, [ chat, chat.formattedText, chat.originalFormattedText, chat.showTranslation, chat.translatedFormattedText, isVisible, makeRoom ]); + + useEffect(() => + { return () => { chat.elementRef = null; - setIsReady(false); }; }, [ chat ]); @@ -77,6 +82,8 @@ export const ChatWidgetMessageView: FC = ({ setIsVisible(true); }, [ chat, isReady, isVisible, makeRoom ]); + const messageClassName = `message [overflow-wrap:anywhere] break-words${ chat.type === 1 ? ' italic text-[#595959]' : '' }${ chat.type === 2 ? ' font-bold' : '' }`; + return (
GetRoomEngine().selectRoomObject(chat.roomId, chat.senderId, RoomObjectCategory.UNIT) }> @@ -90,29 +97,33 @@ export const ChatWidgetMessageView: FC = ({ ) }
- { chat.prefixEffect === 'pulse' && } - { chat.prefixText && (() => { - const colors = parsePrefixColors(chat.prefixText, chat.prefixColor); - const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; - const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF'); - return ( - - { chat.prefixIcon && { chat.prefixIcon } } - - {'{'} - { hasMultiColor - ? [ ...chat.prefixText ].map((char, i) => ( - { char } - )) - : chat.prefixText - } - {'}'} - - - ); - })() } - - + + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
diff --git a/src/components/room/widgets/chat/ChatWidgetWindowView.tsx b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx index 7a6118a..7734bd8 100644 --- a/src/components/room/widgets/chat/ChatWidgetWindowView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx @@ -2,7 +2,7 @@ import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer'; import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChatEntryType, LocalizeText } from '../../../../api'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; -import { useChatHistory, useChatWindow } from '../../../../hooks'; +import { useChatHistory, useChatWindow, useOnClickChat } from '../../../../hooks'; import { useRoom } from '../../../../hooks/rooms'; const BOTTOM_SCROLL_THRESHOLD = 20; @@ -19,6 +19,7 @@ export const ChatWidgetWindowView: FC<{}> = () => const { chatHistory = [], clearChatHistory = null } = useChatHistory(); const [ , setChatWindowEnabled ] = useChatWindow(); const { roomSession = null } = useRoom(); + const { onClickChat } = useOnClickChat(); const ownUserId = (GetSessionDataManager()?.userId || -1); const roomChatHistory = useMemo(() => @@ -33,7 +34,7 @@ export const ChatWidgetWindowView: FC<{}> = () => if(!normalizedSearch.length) return true; - return (`${ chat.name } ${ chat.message }`.toLowerCase().includes(normalizedSearch)); + return (`${ chat.name } ${ chat.message || '' } ${ chat.originalMessage || '' } ${ chat.translatedMessage || '' }`.toLowerCase().includes(normalizedSearch)); }); }, [ chatHistory, roomSession?.roomId, hidePets, search ]); @@ -125,14 +126,27 @@ export const ChatWidgetWindowView: FC<{}> = () => { const isOwnMessage = (chat.webId === ownUserId); const rowClassName = `mb-1 flex items-start gap-1 break-words ${ isOwnMessage ? 'justify-end' : '' }`; + const messageClassName = `message${ chat.chatType === 1 ? ' italic text-[#595959]' : '' }${ chat.chatType === 2 ? ' font-bold' : '' }`; return (
{ hideBalloons && !hideAvatars &&
} { hideBalloons && ( -
+
- + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
) } { !hideBalloons && ( @@ -148,7 +162,19 @@ export const ChatWidgetWindowView: FC<{}> = () =>
- + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
diff --git a/src/components/translation/TranslationBootstrap.tsx b/src/components/translation/TranslationBootstrap.tsx new file mode 100644 index 0000000..389f566 --- /dev/null +++ b/src/components/translation/TranslationBootstrap.tsx @@ -0,0 +1,9 @@ +import { FC } from 'react'; +import { useTranslation } from '../../hooks'; + +export const TranslationBootstrap: FC<{}> = () => +{ + useTranslation(); + + return null; +}; diff --git a/src/components/translation/TranslationSettingsView.tsx b/src/components/translation/TranslationSettingsView.tsx new file mode 100644 index 0000000..efe4571 --- /dev/null +++ b/src/components/translation/TranslationSettingsView.tsx @@ -0,0 +1,138 @@ +import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; +import { useTranslation } from '../../hooks'; + +export const TranslationSettingsView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { + settings, + supportedLanguages = [], + availableTextLocales = [], + languagesLoading = false, + localizationTextsLoading = false, + lastIncomingLanguage = '', + lastOutgoingLanguage = '', + lastError = '', + updateSettings, + ensureSupportedLanguagesLoaded, + getLanguageName + } = useTranslation(); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(prevValue => !prevValue); + return; + } + }, + eventUrlPrefix: 'translation-settings/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(!isVisible) return; + + ensureSupportedLanguagesLoaded(); + }, [ ensureSupportedLanguagesLoaded, isVisible ]); + + if(!isVisible) return null; + + return ( + + setIsVisible(false) } /> + +
+ updateSettings({ enabled: event.target.checked }) } /> + Enable automatic translation +
+
+ When enabled, chat bubbles always show two lines: original: and translate:. +
+
+ Interface texts +
+ +
+
+
+ Incoming messages +
+ Detected language (auto): { getLanguageName(lastIncomingLanguage) } + +
+
+
+ Outgoing messages +
+ Detected writing language (auto): { getLanguageName(lastOutgoingLanguage) } + +
+
+
+ { languagesLoading ? 'Loading languages...' : `${ supportedLanguages.length } languages available` } + +
+ { localizationTextsLoading && +
+ Loading localized interface texts... +
} + { lastError.length > 0 && +
+ { lastError } +
} +
+
+ ); +}; diff --git a/src/components/user-profile/UserContainerView.tsx b/src/components/user-profile/UserContainerView.tsx index 88d3150..0425e2f 100644 --- a/src/components/user-profile/UserContainerView.tsx +++ b/src/components/user-profile/UserContainerView.tsx @@ -1,7 +1,7 @@ import { GetSessionDataManager, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api'; -import { LayoutAvatarImageView, Text } from '../../common'; +import { LayoutAvatarImageView, Text, UserIdentityView } from '../../common'; export const UserContainerView: FC<{ userProfile: UserProfileParser; @@ -18,7 +18,6 @@ export const UserContainerView: FC<{ const infostandBackgroundClass = `background-${userProfile.backgroundId ?? 'default'}`; const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`; const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`; - const addFriend = () => { setRequestSent(true); @@ -41,7 +40,16 @@ export const UserContainerView: FC<{
-

{ userProfile.username }

+

{ userProfile.motto }

@@ -115,4 +123,4 @@ export const UserContainerView: FC<{
); -}; \ No newline at end of file +}; diff --git a/src/components/wired-tools/WiredCreatorToolsView.tsx b/src/components/wired-tools/WiredCreatorToolsView.tsx index 7d1f539..b2279db 100644 --- a/src/components/wired-tools/WiredCreatorToolsView.tsx +++ b/src/components/wired-tools/WiredCreatorToolsView.tsx @@ -1,13 +1,13 @@ -import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, GetStage, GetTicker, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer'; import { WiredMonitorDataEvent, WiredMonitorRequestComposer } from '@nitrots/nitro-renderer'; -import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import furniInspectionIcon from '../../assets/images/wiredtools/furni.png'; import globalInspectionIcon from '../../assets/images/wiredtools/global.png'; import userInspectionIcon from '../../assets/images/wiredtools/user.png'; import contextInspectionIcon from '../../assets/images/wiredtools/context.png'; import wiredGlobalPlaceholderImage from '../../assets/images/wiredtools/wired_global_placeholder.png'; import wiredMonitorImage from '../../assets/images/wiredtools/wired_monitor.png'; -import { AvatarInfoFurni, AvatarInfoUtilities, LocalizeText, NotificationAlertType, SendMessageComposer } from '../../api'; +import { AvatarInfoFurni, AvatarInfoUtilities, GetRoomObjectBounds, GetRoomObjectScreenLocation, LocalizeText, NotificationAlertType, SendMessageComposer, WiredSelectionVisualizer } from '../../api'; import { Button, DraggableWindowPosition, LayoutAvatarImageView, LayoutPetImageView, LayoutRoomObjectImageView, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common'; import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedEvent, useRoom, useWiredTools } from '../../hooks'; import { WiredToolsSettingsTabView } from './WiredToolsSettingsTabView'; @@ -184,6 +184,21 @@ interface VariableManageEntry value: number | null; } +interface VariableHighlightTarget +{ + category: number; + hasValue: boolean; + objectId: number; + value: number | null; +} + +interface VariableHighlightOverlay extends VariableHighlightTarget +{ + key: string; + x: number; + y: number; +} + interface ManagedHolderVariableEntry { availability: string; @@ -631,6 +646,9 @@ export const WiredCreatorToolsView: FC<{}> = () => const [ isManagedGiveOpen, setIsManagedGiveOpen ] = useState(false); const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0); const [ managedGiveValue, setManagedGiveValue ] = useState('0'); + const [ isVariableHighlightActive, setIsVariableHighlightActive ] = useState(false); + const [ variableHighlightOverlays, setVariableHighlightOverlays ] = useState([]); + const variableHighlightObjectsRef = useRef>([]); const shouldPauseVariableSnapshotRefresh = (!!editingVariable || !!editingManagedHolderVariableId || isInspectionGiveOpen || isManagedGiveOpen); const [ selectedVariableKeys, setSelectedVariableKeys ] = useState>({ furni: VARIABLE_DEFINITIONS.furni[0].key, @@ -2400,6 +2418,155 @@ export const WiredCreatorToolsView: FC<{}> = () => manageLabel: 'Manage' } ]; }, [ selectedVariableDefinition, variablesType, roomSession, userVariableAssignments, furniVariableAssignments, roomVariableAssignmentMap ]); + const canVariableHighlight = !!selectedVariableDefinition?.itemId + && (selectedVariableDefinition.type === 'Custom') + && ((variablesType === 'user') || (variablesType === 'furni')) + && !!roomSession; + const variableHighlightTargets = useMemo((): VariableHighlightTarget[] => + { + if(!isVariableHighlightActive || !canVariableHighlight || !roomSession || !selectedVariableDefinition?.itemId) return []; + + if(variablesType === 'user') + { + const targets: VariableHighlightTarget[] = []; + + for(const [ userIdString, assignments ] of Object.entries(userVariableAssignments)) + { + const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId)); + + if(!assignment) continue; + + const userId = Number(userIdString); + const userData = roomSession.userDataManager.getUserData(userId) + ?? roomSession.userDataManager.getBotData(userId) + ?? roomSession.userDataManager.getRentableBotData(userId) + ?? roomSession.userDataManager.getPetData(userId); + const roomIndex = Number(userData?.roomIndex ?? -1); + + if(roomIndex < 0) continue; + + targets.push({ + category: RoomObjectCategory.UNIT, + objectId: roomIndex, + hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined), + value: assignment.value + }); + } + + return targets; + } + + if(variablesType === 'furni') + { + const targets: VariableHighlightTarget[] = []; + + for(const [ furniIdString, assignments ] of Object.entries(furniVariableAssignments)) + { + const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId)); + + if(!assignment) continue; + + const furniId = Number(furniIdString); + const floorObject = GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.FLOOR); + const wallObject = floorObject ? null : GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.WALL); + const category = floorObject ? RoomObjectCategory.FLOOR : (wallObject ? RoomObjectCategory.WALL : -1); + + if(category < 0) continue; + + targets.push({ + category, + objectId: furniId, + hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined), + value: assignment.value + }); + } + + return targets; + } + + return []; + }, [ canVariableHighlight, furniVariableAssignments, isVariableHighlightActive, roomSession, selectedVariableDefinition, userVariableAssignments, variablesType ]); + useEffect(() => + { + if(isVisible && (activeTab === 'variables') && canVariableHighlight) return; + + setIsVariableHighlightActive(false); + }, [ activeTab, canVariableHighlight, isVisible ]); + useEffect(() => + { + if(variableHighlightObjectsRef.current.length) + { + WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current); + variableHighlightObjectsRef.current = []; + } + + if(!isVariableHighlightActive || !variableHighlightTargets.length) + { + + setVariableHighlightOverlays([]); + + return; + } + + const objects = variableHighlightTargets.map(target => ({ + category: target.category, + objectId: target.objectId + })); + + WiredSelectionVisualizer.applyVariableHighlightToObjects(objects); + variableHighlightObjectsRef.current = objects; + + return () => + { + if(!variableHighlightObjectsRef.current.length) return; + + WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current); + variableHighlightObjectsRef.current = []; + }; + }, [ isVariableHighlightActive, variableHighlightTargets ]); + useEffect(() => + { + if(!isVariableHighlightActive || !roomSession?.roomId || !variableHighlightTargets.length) + { + setVariableHighlightOverlays([]); + + return; + } + + const updateOverlays = () => + { + const stage = GetStage(); + const nextOverlays: VariableHighlightOverlay[] = []; + + for(const target of variableHighlightTargets) + { + const bounds = GetRoomObjectBounds(roomSession.roomId, target.objectId, target.category); + const location = GetRoomObjectScreenLocation(roomSession.roomId, target.objectId, target.category); + + if(!bounds || !location) continue; + + const x = Math.max(8, Math.min(Math.round(location.x), (stage.width - 8))); + const y = Math.max(8, Math.min(Math.round(bounds.top), (stage.height - 40))); + + nextOverlays.push({ + ...target, + key: `${ target.category }:${ target.objectId }`, + x, + y + }); + } + + setVariableHighlightOverlays(nextOverlays); + }; + + updateOverlays(); + + const ticker = GetTicker(); + + ticker.add(updateOverlays); + + return () => ticker.remove(updateOverlays); + }, [ isVariableHighlightActive, roomSession?.roomId, variableHighlightTargets ]); const variableManageTypeOptions = useMemo(() => { switch(variablesType) @@ -3465,6 +3632,27 @@ export const WiredCreatorToolsView: FC<{}> = () => return ( <> + { isVariableHighlightActive && !!variableHighlightOverlays.length && +
+ { variableHighlightOverlays.map(overlay => ( +
+ { overlay.hasValue && +
+
+ { overlay.value ?? 0 } + +
+
} +
+ )) } +
} setIsVisible(false) } /> @@ -3830,7 +4018,12 @@ export const WiredCreatorToolsView: FC<{}> = () =>
- + - -
-
- -
- { LocalizeText(activeSources[activeSourceIndex].label) } + + { SOURCE_GROUP_BUTTONS.map(button => ( + + )) }
- -
-
+ } + onChange={ value => + { + if(isUserGroup) setUserSource(value); + else setFurniSource(value); + } } /> ); }; diff --git a/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx b/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx index f84035a..8715af4 100644 --- a/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx +++ b/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx @@ -2,6 +2,7 @@ import { FC, useEffect, useState } from 'react'; import { LocalizeText, WiredFurniType } from '../../../../api'; import { Text } from '../../../../common'; import { useWired } from '../../../../hooks'; +import { WiredTextFormattingHelp } from '../common/WiredTextFormattingHelp'; import { WiredExtraBaseView } from './WiredExtraBaseView'; const DEFAULT_CONNECTOR_PLACEHOLDER = '0=text 1\n1=text 2\n2 = text 3'; @@ -70,6 +71,7 @@ export const WiredExtraVariableTextConnectorView: FC<{}> = () => value={ mappingsText } onChange={ event => handleTextChange(event.target.value) } /> { `${ lineCount }/${ MAX_CONNECTOR_LINES } righe - ${ characterCount }/${ MAX_CONNECTOR_CHARACTERS } caratteri` } +
); diff --git a/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx b/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx index 621151d..8a4c682 100644 --- a/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx +++ b/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx @@ -15,7 +15,6 @@ const normalizeFurniSource = (value: number) => (FURNI_SOURCE_OPTIONS.some(optio export const WiredTriggerReceiveSignalView: FC<{}> = () => { const [ senderCount, setSenderCount ] = useState(0); - const [ maxSenders, setMaxSenders ] = useState(5); const [ channel, setChannel ] = useState(0); const [ furniSource, setFurniSource ] = useState(100); @@ -30,7 +29,6 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () => const p = trigger.intData; if(p.length >= 1) setChannel(p[0]); if(p.length >= 2) setSenderCount(p[1]); - if(p.length >= 3) setMaxSenders(p[2]); if(p.length >= 4) setFurniSource(normalizeFurniSource(p[3])); else setFurniSource(100); }, [ trigger ]); @@ -43,7 +41,7 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () => footer={ }>
{ LocalizeText('wiredfurni.params.signal.senders_connected') } - { senderCount }/{ maxSenders } + { senderCount }
); diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index aad4844..2b2ea17 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -106,21 +106,302 @@ } .nitro-friends { - width: 250px; - height: 300px; + width: 332px; + height: 445px; + min-width: 332px; + min-height: 445px; + max-width: 332px; + max-height: calc(100vh - 16px); + resize: none !important; + font-family: Ubuntu, sans-serif; + color: #111; + + & span, + & input, + & button { + font-family: Ubuntu, sans-serif !important; + } + + & .nitro-card-title { + font-family: UbuntuCondensed, Ubuntu, sans-serif !important; + font-size: 15px !important; + } + + & .nitro-card-header { + border-bottom: 1px solid rgba(0, 0, 0, .18); + } + + & .nitro-card-content-shell { + padding: 0 !important; + gap: 0 !important; + background: #f3f3ef; + } + + & .nitro-card-accordion-set-content, + & .nitro-card-content-shell { + scrollbar-width: thin; + scrollbar-color: #6d7b84 #cdd4d8; + } + + & .nitro-card-accordion-set-content::-webkit-scrollbar, + & .nitro-card-content-shell::-webkit-scrollbar { + width: 13px; + height: 13px; + } + + & .nitro-card-accordion-set-content::-webkit-scrollbar-track, + & .nitro-card-content-shell::-webkit-scrollbar-track { + background: linear-gradient(180deg, #e1e5e8 0%, #cad1d5 100%); + border-left: 1px solid #818a8f; + } + + & .nitro-card-accordion-set-content::-webkit-scrollbar-thumb, + & .nitro-card-content-shell::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #93b6c6 0%, #688fa2 100%); + border: 1px solid #476a7a; + border-radius: 2px; + } + + & .nitro-card-accordion-set { + background: transparent; + border: 0; + } + + & .nitro-card-accordion-set-header { + min-height: 24px; + padding: 3px 7px !important; + color: #111; + font-size: 12px; + font-weight: 700; + border: 0 !important; + } + + & .nitro-card-accordion-set-header span { + font-size: 12px; + color: #111 !important; + } + + & .nitro-card-accordion-set-header .fa-icon { + width: 10px; + height: 10px; + color: #000; + } + + & .nitro-card-accordion-set.active { + min-height: 0; + border: 0 !important; + } + + & .nitro-card-accordion-set-content { + overflow-y: auto !important; + } + + & .friends-list-item { + min-height: 34px; + padding: 0 4px !important; + color: #111; + font-size: 13px; + background: #f7f7f7; + border: 0 !important; + gap: 3px !important; + } + + & .friends-list-toolbar { + min-height: 22px; + font-size: 12px; + background: #efefef; + border-bottom: 1px solid rgba(0, 0, 0, .12); + } + + & .friends-list-toolbar-link { + color: #111; + font-size: 12px; + font-weight: 700; + cursor: pointer; + text-decoration: underline; + } + + & .friends-list-item:nth-child(even) { + background: #e6e6e6; + } + + & .friends-list-item.selected { + color: #000 !important; + background: #bfe7f6 !important; + } + + & .friends-list-user { + display: flex; + align-items: center; + min-width: 0; + gap: 3px; + } + + & .friends-list-user > div:nth-child(2) { + margin-left: 3px; + } + + & .friends-list-avatar { + position: relative; + width: 22px; + height: 34px; + flex-shrink: 0; + overflow: visible; + } + + & .friends-list-avatar .avatar-image { + position: absolute; + left: 50%; + top: -24px; + width: 90px; + height: 130px; + margin: 0; + transform: translateX(-50%); + background-position: center -8px !important; + } + + & .friends-list-name { + min-width: 0; + margin-left: 3px; + overflow: hidden; + color: #111; + font-size: 13px; + line-height: 1.15; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .friends-list-actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 3px; + } + + & .nitro-friends-spritesheet.icon-follow { + width: 15px; + height: 14px; + } + + & .nitro-friends-spritesheet.icon-chat { + width: 17px; + height: 16px; + } & .search-input { - border: 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.2); + min-height: 28px; + padding: 5px 8px; + color: #111 !important; + font-size: 13px !important; + line-height: 1.2; + background: #fff; + border: 1px solid #79858c; + border-radius: 2px; + box-shadow: inset 1px 1px 0 #ececec; + outline: none; + + &::placeholder { + color: #666; + opacity: 1; + } } } .nitro-friends-room-invite { - width: 250px; + width: 270px; + height: 225px; + min-width: 270px; + min-height: 225px; + max-width: 270px; + max-height: 225px; + resize: none !important; + + & .nitro-card-content-shell { + padding: 10px !important; + gap: 8px !important; + } +} + +.nitro-friends-room-invite-content { + height: 100%; +} + +.nitro-friends-room-invite-summary { + padding: 6px 8px; + color: #111; + font-size: 13px; + line-height: 1.3; + font-weight: 700; + background: #fff; + border: 1px solid rgba(0, 0, 0, .35); + border-radius: 8px; +} + +.nitro-friends-room-invite-textarea { + width: 100%; + height: 92px; + padding: 7px 8px; + color: #111; + font-size: 13px; + line-height: 1.3; + background: #fff; + border: 1px solid #888; + border-radius: 2px; + resize: none; + outline: none; + + &::placeholder { + color: #666; + opacity: 1; + } +} + +.nitro-friends-room-invite-note { + padding: 4px 6px; + color: #111 !important; + font-size: 12px !important; + line-height: 1.3; + background: rgba(255, 255, 255, .75); + border-radius: 8px; +} + +.nitro-friends-room-invite-actions { + display: flex; + gap: 8px; + margin-top: auto; } .nitro-friends-remove-confirmation { - width: 250px; + width: 270px; + height: 225px; + min-width: 270px; + min-height: 225px; + max-width: 270px; + max-height: 225px; + resize: none !important; +} + +.nitro-friends-remove-confirmation-text { + color: #111; + font-size: 13px; + line-height: 1.35; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.nitro-friends-remove-confirmation-content { + height: 100%; +} + +.nitro-friends-remove-confirmation-names { + margin-top: 4px; +} + +.nitro-friends-remove-confirmation-actions { + display: flex; + gap: 8px; + margin-top: auto; } .friend-bar { @@ -142,27 +423,298 @@ } } -.nitro-friends-messenger { - & .layout-grid-item { - min-height: 50px; +.messenger-card { + width: 332px; + min-width: 332px; + max-width: 332px; + height: 445px; + min-height: 445px; + max-height: 445px; + resize: none; + pointer-events: all; +} + +@media (max-width: 380px), (max-height: 470px) { + .messenger-card { + width: calc(100vw - 16px); + min-width: 0; + max-width: calc(100vw - 16px); + height: min(445px, calc(100vh - 16px)); + min-height: 0; + max-height: calc(100vh - 16px); + } +} + +.messenger-card { + & span, + & input, + & button { + box-sizing: border-box; + font-family: Ubuntu, sans-serif !important; + } + + & .nitro-card-content-shell { + padding: 0 !important; + gap: 0 !important; + background: #f3f3ef; + } + + & .nitro-card-content-shell, + & .chat-messages { + scrollbar-width: thin; + scrollbar-color: #6d7b84 #cdd4d8; + } + + & .nitro-card-content-shell::-webkit-scrollbar, + & .chat-messages::-webkit-scrollbar { + width: 13px; + height: 13px; + } + + & .nitro-card-content-shell::-webkit-scrollbar-track, + & .chat-messages::-webkit-scrollbar-track { + background: linear-gradient(180deg, #e1e5e8 0%, #cad1d5 100%); + border-left: 1px solid #818a8f; + } + + & .nitro-card-content-shell::-webkit-scrollbar-thumb, + & .chat-messages::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #93b6c6 0%, #688fa2 100%); + border: 1px solid #476a7a; + border-radius: 2px; + } + + & .nitro-card-header { + border-bottom: 1px solid rgba(0, 0, 0, .18); + } + + & .nitro-card-title { + font-family: UbuntuCondensed, Ubuntu, sans-serif !important; + font-size: 15px !important; + } + + & .messenger-card-body { + height: 100%; + display: flex; + flex-direction: column; + } + + & .messenger-avatar-bar { + display: flex; + gap: 4px; + align-items: center; + flex-shrink: 0; + overflow: hidden; + padding: 6px 8px; + background: #efefef; + border-bottom: 1px solid rgba(0, 0, 0, .12); + scrollbar-width: none; + } + + & .messenger-avatar-tab { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + min-width: 36px; + height: 36px; + min-height: 36px; + flex-shrink: 0; + overflow: hidden; + cursor: pointer; + background: #d7d7d7; + border: 0; + border-radius: 4px; + padding: 0; + + &.active { + background: #bfe7f6; + } + + &.unread { + background: #7dca73; + } + + & .avatar-image { + position: absolute; + left: 50% !important; + top: -31px !important; + width: 90px !important; + height: 130px !important; + margin: 0 !important; + background-position: center -8px !important; + transform: translateX(-50%) !important; + } + } + + & .messenger-thread-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding: 4px 8px; + background: #efefef; + border-bottom: 1px solid rgba(0, 0, 0, .12); + } + + & .messenger-thread-name { + color: #111 !important; + font-size: 13px !important; + font-weight: 700; + } + + & .messenger-actions { + display: flex; + gap: 4px; + align-items: center; + } + + & .messenger-btn { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 7px; + color: #fff !important; + font-size: 12px !important; + font-weight: 700; + line-height: 1; + cursor: pointer; + background: #2f84aa; + border: 0; + border-radius: 3px; + + &.danger { + background: #a81a12; + border-color: #a81a12; + } + + &.close-btn { + width: 22px; + padding: 0 5px; + color: #000 !important; + font-size: 13px; + background: #d7d7d7; + } + + &.icon-btn { + width: 22px; + padding: 0; + } + + &.send { + background: #00800b; + border-color: #00800b; + } } & .chat-messages { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; overflow-y: auto; + padding: 8px; + background: + linear-gradient(#f7f7f7, #f7f7f7) padding-box, + repeating-linear-gradient(180deg, #f7f7f7 0 34px, #ececec 34px 68px); + + & .messenger-message-row { + display: flex; + gap: 6px; + align-items: flex-start; + + &.own > .message-avatar:first-child { + display: none; + } + } & .message-avatar { position: relative; - overflow: hidden; - width: 50px; - height: 50px; + flex-shrink: 0; + overflow: visible; + width: 36px; + height: 36px; & .avatar-image { - position: absolute; - margin-left: -22px; - margin-top: -25px; + position: absolute; + left: 50%; + top: 56%; + width: 54px; + height: 54px; + margin: 0; + background-position: center center !important; + transform: translate(-50%, -50%) scale(.95); } } + & .messenger-message-body { + display: flex; + flex-direction: column; + gap: 2px; + max-width: 200px; + } + + & .messenger-message-name { + color: #111 !important; + font-size: 12px !important; + font-weight: 700; + } + + & .messenger-message-bubble { + position: relative; + padding: 4px 7px; + color: #111 !important; + font-size: 13px !important; + line-height: 1.35; + max-width: 200px; + overflow-wrap: anywhere; + word-break: break-word; + white-space: pre-wrap; + background: #fff; + border: 1px solid rgba(0, 0, 0, .08); + border-radius: 3px; + box-shadow: inset 1px 1px 0 rgba(255, 255, 255, .7); + } + + & .messenger-message-bubble .text-break { + overflow-wrap: anywhere; + word-break: break-word; + white-space: pre-wrap; + } + + & .messenger-translation-block { + display: flex; + flex-direction: column; + gap: 2px; + } + + & .messenger-translation-row { + display: flex; + align-items: flex-start; + gap: 4px; + line-height: 1.2; + } + + & .messenger-translation-label { + min-width: 48px; + flex-shrink: 0; + opacity: .7; + font-size: 12px; + font-weight: 700; + } + + & .messenger-message-time { + color: #666 !important; + font-size: 11px !important; + } + + & .messenger-message-row.own .messenger-message-time { + text-align: right; + } + & .messages-group-left { position: relative; @@ -171,12 +723,24 @@ content: ' '; width: 0; height: 0; - border-right: 8px solid #DFDFDF; + border-right: 8px solid #fff; border-top: 8px solid transparent; border-bottom: 8px solid transparent; top: 10px; left: -8px; } + + &:after { + position: absolute; + content: ' '; + width: 0; + height: 0; + border-right: 7px solid #fff; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + top: 11px; + left: -6px; + } } & .messages-group-right { @@ -187,12 +751,52 @@ content: ' '; width: 0; height: 0; - border-left: 8px solid #DFDFDF; + border-left: 8px solid #fff; border-top: 8px solid transparent; border-bottom: 8px solid transparent; top: 10px; right: -8px; } + + &:after { + position: absolute; + content: ' '; + width: 0; + height: 0; + border-left: 7px solid #fff; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + top: 11px; + right: -6px; + } + } + } + + & .messenger-input-row { + display: flex; + gap: 5px; + align-items: center; + flex-shrink: 0; + padding: 6px 8px; + background: #efefef; + border-top: 1px solid rgba(0, 0, 0, .12); + + & input { + flex: 1; + min-height: 28px; + padding: 0 8px; + color: #111 !important; + font-size: 13px !important; + line-height: 1.2; + background: #fff; + border: 1px solid #888; + border-radius: 3px; + outline: none; + + &::placeholder { + color: #666; + opacity: 1; + } } } } diff --git a/src/css/index.css b/src/css/index.css index dc2e809..ccf11b3 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@import url('https://fonts.googleapis.com/css2?family=Cherry+Bomb+One&family=Pixelify+Sans:wght@400..700&family=Vampiro+One&display=swap'); @config "../../tailwind.config.js"; @@ -22,6 +23,7 @@ body { -webkit-user-select: none; user-select: none; scrollbar-width: thin; + scrollbar-color: #6d7b84 #c8d0d4; } .image-rendering-pixelated { @@ -35,45 +37,76 @@ body { } ::-webkit-scrollbar { - width: .625rem; + width: .875rem; } ::-webkit-scrollbar:horizontal { - height: .625rem; + height: .875rem; } ::-webkit-scrollbar:not(:horizontal) { - width: .625rem; + width: .875rem; } ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, .08); - border-radius: .5rem; + background: linear-gradient(180deg, #dfe5e8 0%, #c9d1d5 100%); + border-left: 1px solid #7a858b; + border-right: 1px solid #eef3f5; + border-radius: 0; } ::-webkit-scrollbar-thumb { - background: rgba(30, 114, 149, .35); - border-radius: .5rem; - border: 2px solid transparent; - background-clip: padding-box; + background: linear-gradient(180deg, #8fb5c7 0%, #5d8ea5 100%); + border: 1px solid #446879; + border-radius: 2px; + box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.28); } ::-webkit-scrollbar-thumb:hover { - background: rgba(30, 114, 149, .6); - border-radius: .5rem; - border: 2px solid transparent; - background-clip: padding-box; + background: linear-gradient(180deg, #99c2d5 0%, #689ab0 100%); } ::-webkit-scrollbar-thumb:active { - background: #185D79; - border-radius: .5rem; - border: 2px solid transparent; - background-clip: padding-box; + background: linear-gradient(180deg, #5c889d 0%, #436977 100%); } ::-webkit-scrollbar-corner { - background: rgba(0, 0, 0, .08); + background: #c9d1d5; +} + +::-webkit-scrollbar-button:single-button { + display: block; + width: .875rem; + height: .875rem; + background-color: #d8dfe3; + background-repeat: no-repeat; + background-position: center; + border-left: 1px solid #7a858b; + border-right: 1px solid #eef3f5; +} + +::-webkit-scrollbar-button:single-button:vertical:decrement { + background-image: linear-gradient(135deg, transparent 50%, #35586a 50%), linear-gradient(225deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: calc(50% - 3px) 55%, calc(50% + 3px) 55%; +} + +::-webkit-scrollbar-button:single-button:vertical:increment { + background-image: linear-gradient(315deg, transparent 50%, #35586a 50%), linear-gradient(45deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: calc(50% - 3px) 45%, calc(50% + 3px) 45%; +} + +::-webkit-scrollbar-button:single-button:horizontal:decrement { + background-image: linear-gradient(45deg, transparent 50%, #35586a 50%), linear-gradient(135deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: 58% calc(50% - 3px), 58% calc(50% + 3px); +} + +::-webkit-scrollbar-button:single-button:horizontal:increment { + background-image: linear-gradient(225deg, transparent 50%, #35586a 50%), linear-gradient(315deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: 42% calc(50% - 3px), 42% calc(50% + 3px); } @layer components { diff --git a/src/css/purse/PurseView.css b/src/css/purse/PurseView.css index 099ab99..ebbe69e 100644 --- a/src/css/purse/PurseView.css +++ b/src/css/purse/PurseView.css @@ -113,6 +113,7 @@ max-height: 280px; opacity: 1; transform: translateY(0); + background: transparent; } .nitro-purse__content.is-closed { @@ -200,6 +201,7 @@ font-weight: 700; line-height: 1; letter-spacing: 0.01em; + color: rgba(255, 255, 255, 0.88) !important; } .nitro-purse .nitro-purse-button.currency--1 .text-white { @@ -270,10 +272,11 @@ justify-content: center; min-height: 20px; padding: 0; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(7, 23, 31, 0.82); border-radius: 7px; color: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.05); + box-shadow: none; transition: background-color 0.18s ease, transform 0.18s ease; } @@ -335,7 +338,7 @@ .seasonal-image { display: block; width: auto; - height: 13px; + height: 14px; object-fit: contain; } diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 65af759..5b75c6b 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -40,9 +40,11 @@ const useCatalogState = () => const [ secondsLeft, setSecondsLeft ] = useState(0); const [ updateTime, setUpdateTime ] = useState(0); const [ secondsLeftWithGrace, setSecondsLeftWithGrace ] = useState(0); + const [ catalogLocalizationVersion, setCatalogLocalizationVersion ] = useState(0); const [ builderPlacementBlockedByVisitors, setBuilderPlacementBlockedByVisitors ] = useState(false); const [ builderPlacementAllowedInCurrentRoom, setBuilderPlacementAllowedInCurrentRoom ] = useState(false); const [ builderTrialRoomHideConfirmed, setBuilderTrialRoomHideConfirmed ] = useState(false); + const resolvedOffersByProductKey = useRef>(new Map()); const { simpleAlert = null, showConfirm = null } = useNotification(); const requestedPage = useRef(new RequestedPage()); @@ -54,6 +56,7 @@ const useCatalogState = () => setOffersToNodes(null); setCurrentPage(null); setCurrentOffer(null); + resolvedOffersByProductKey.current.clear(); setActiveNodes([]); setSearchResult(null); setFrontPageItems([]); @@ -77,6 +80,7 @@ const useCatalogState = () => setOffersToNodes(null); setCurrentPage(null); setCurrentOffer(null); + resolvedOffersByProductKey.current.clear(); setActiveNodes([]); setSearchResult(null); setFrontPageItems([]); @@ -336,6 +340,53 @@ const useCatalogState = () => return offersToNodes.get(offerId); }, [ offersToNodes ]); + const getOfferProductKeys = useCallback((offer: IPurchasableOffer) => + { + const product = offer?.product; + const keys: string[] = []; + + if(!product) return keys; + + if(product.productType && (product.productClassId >= 0)) + { + keys.push(`${ product.productType }:id:${ product.productClassId }`); + } + + if(product.productType && product.furnitureData?.className?.length) + { + keys.push(`${ product.productType }:class:${ product.furnitureData.className }`); + } + + return keys; + }, []); + + const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) => + { + for(const key of getOfferProductKeys(offer)) + { + resolvedOffersByProductKey.current.set(key, offer); + } + }, [ getOfferProductKeys ]); + + const applySelectedOffer = useCallback((offer: IPurchasableOffer) => + { + if(!offer) return; + + setCurrentOffer(offer); + + if(offer.product && (offer.product.productType === ProductTypeEnum.WALL)) + { + setPurchaseOptions(prevValue => + { + const newValue = { ...prevValue }; + + newValue.extraData = (offer.product.extraParam || null); + + return newValue; + }); + } + }, []); + const loadCatalogPage = useCallback((pageId: number, offerId: number) => { if(pageId < 0) return; @@ -485,6 +536,22 @@ const useCatalogState = () => } }, [ isVisible, getNodesByOfferId, activateNode ]); + const selectCatalogOffer = useCallback((offer: IPurchasableOffer) => + { + if(!offer) return; + + if(!offer.isLazy) + { + applySelectedOffer(offer); + return; + } + + if(offer.offerId > -1) + { + offer.activate(); + } + }, [ applySelectedOffer ]); + const refreshBuilderStatus = useCallback(() => { @@ -544,16 +611,20 @@ const useCatalogState = () => const purchasableOffer = new Offer(offer.offerId, offer.localizationId, offer.rent, offer.priceCredits, offer.priceActivityPoints, offer.priceActivityPointsType, offer.giftable, offer.clubLevel, products, offer.bundlePurchaseAllowed); + cacheResolvedOffer(purchasableOffer); + if((currentType === CatalogType.NORMAL) || ((purchasableOffer.pricingModel !== Offer.PRICING_MODEL_BUNDLE) && (purchasableOffer.pricingModel !== Offer.PRICING_MODEL_MULTI))) purchasableOffers.push(purchasableOffer); } + const parsedCatalogPage = new CatalogPage(parser.pageId, parser.layoutCode, new PageLocalization(parser.localization.images.concat(), parser.localization.texts.concat()), purchasableOffers, parser.acceptSeasonCurrencyAsCredits); + if(parser.frontPageItems && parser.frontPageItems.length) setFrontPageItems(parser.frontPageItems); setIsBusy(false); if(pageId === parser.pageId) { - showCatalogPage(parser.pageId, parser.layoutCode, new PageLocalization(parser.localization.images.concat(), parser.localization.texts.concat()), purchasableOffers, parser.offerId, parser.acceptSeasonCurrencyAsCredits); + showCatalogPage(parsedCatalogPage.pageId, parsedCatalogPage.layoutCode, parsedCatalogPage.localization, parsedCatalogPage.offers, parser.offerId, parsedCatalogPage.acceptSeasonCurrencyAsCredits); } }); @@ -610,24 +681,31 @@ const useCatalogState = () => } const offer = new Offer(offerData.offerId, offerData.localizationId, offerData.rent, offerData.priceCredits, offerData.priceActivityPoints, offerData.priceActivityPointsType, offerData.giftable, offerData.clubLevel, products, offerData.bundlePurchaseAllowed); + cacheResolvedOffer(offer); + + const matchingNodes = getNodesByOfferId(offer.offerId, true) || getNodesByOfferId(offer.offerId); if(!((currentType === CatalogType.NORMAL) || ((offer.pricingModel !== Offer.PRICING_MODEL_BUNDLE) && (offer.pricingModel !== Offer.PRICING_MODEL_MULTI)))) return; - offer.page = currentPage; - - setCurrentOffer(offer); - - if(offer.product && (offer.product.productType === ProductTypeEnum.WALL)) + if(matchingNodes?.length) { - setPurchaseOptions(prevValue => - { - const newValue = { ...prevValue }; + const referencePage = currentPage; - newValue.extraData =( offer.product.extraParam || null); - - return newValue; - }); + offer.page = new CatalogPage( + matchingNodes[0].pageId, + referencePage?.layoutCode || 'default_3x3', + referencePage?.localization || new PageLocalization([], []), + [], + referencePage?.acceptSeasonCurrencyAsCredits || false, + referencePage?.mode ?? CatalogPage.MODE_NORMAL + ); } + else + { + offer.page = currentPage; + } + + applySelectedOffer(offer); // (this._isObjectMoverRequested) && (this._purchasableOffer) }); @@ -976,6 +1054,44 @@ const useCatalogState = () => if(!searchResult && currentPage && (currentPage.pageId === -1)) openPageById(previousPageId); }, [ searchResult, currentPage, previousPageId, openPageById ]); + useEffect(() => + { + const refreshCatalogLocalization = () => + { + setCatalogLocalizationVersion(value => (value + 1)); + setCurrentOffer(prevValue => (prevValue?.clone ? prevValue.clone() : prevValue)); + setCurrentPage(prevValue => + { + if(!prevValue) return prevValue; + + const offers = prevValue.offers?.map(offer => (offer?.clone ? offer.clone() : offer)) || []; + + return new CatalogPage(prevValue.pageId, prevValue.layoutCode, prevValue.localization, offers, prevValue.acceptSeasonCurrencyAsCredits, prevValue.mode); + }); + setCatalogOptions(prevValue => + { + if(!prevValue) return prevValue; + + const clubOffersByWindowId = { ...(prevValue.clubOffersByWindowId || {}) }; + + Object.keys(clubOffersByWindowId).forEach(key => + { + const offers = clubOffersByWindowId[key]; + + if(Array.isArray(offers)) clubOffersByWindowId[key] = [ ...offers ]; + }); + + const clubOffers = Array.isArray(prevValue.clubOffers) ? [ ...prevValue.clubOffers ] : prevValue.clubOffers; + + return { ...prevValue, clubOffers, clubOffersByWindowId }; + }); + }; + + window.addEventListener('nitro-localization-updated', refreshCatalogLocalization); + + return () => window.removeEventListener('nitro-localization-updated', refreshCatalogLocalization); + }, []); + useEffect(() => { if(!currentOffer) return; @@ -1013,7 +1129,7 @@ const useCatalogState = () => }; }, []); - return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogOptions, setCatalogOptions, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus }; + return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogOptions, setCatalogOptions, catalogLocalizationVersion, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus, selectCatalogOffer }; }; export const useCatalog = () => useBetween(useCatalogState); diff --git a/src/hooks/chat-history/useChatHistory.ts b/src/hooks/chat-history/useChatHistory.ts index 9897880..2cd021f 100644 --- a/src/hooks/chat-history/useChatHistory.ts +++ b/src/hooks/chat-history/useChatHistory.ts @@ -33,6 +33,26 @@ const useChatHistoryState = () => return newValue; }); + + return entry.id; + }; + + const updateChatEntry = (entryId: number, partial: Partial) => + { + if(entryId < 0) return; + + setChatHistory(prevValue => + { + const index = prevValue.findIndex(entry => (entry.id === entryId)); + + if(index === -1) return prevValue; + + const newValue = [ ...prevValue ]; + + newValue[index] = { ...newValue[index], ...partial }; + + return newValue; + }); }; const clearChatHistory = () => setChatHistory([]); @@ -101,7 +121,7 @@ const useChatHistoryState = () => addMessengerEntry({ id: -1, webId: parser.senderId, entityId: -1, name: '', message: parser.messageText, roomId: -1, timestamp: MessengerHistoryCurrentDate(), type: ChatEntryType.TYPE_IM }); }); - return { addChatEntry, clearChatHistory, chatHistory, roomHistory, messengerHistory }; + return { addChatEntry, updateChatEntry, clearChatHistory, chatHistory, roomHistory, messengerHistory }; }; export const useChatHistory = () => useBetween(useChatHistoryState); diff --git a/src/hooks/friends/useMessenger.ts b/src/hooks/friends/useMessenger.ts index d65af9e..7e54076 100644 --- a/src/hooks/friends/useMessenger.ts +++ b/src/hooks/friends/useMessenger.ts @@ -4,6 +4,7 @@ import { useBetween } from 'use-between'; import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; import { useMessageEvent } from '../events'; import { useNotification } from '../notification'; +import { IResolvedTranslation, useTranslation } from '../translation'; import { useFriends } from './useFriends'; const useMessengerState = () => @@ -14,6 +15,7 @@ const useMessengerState = () => const [iconState, setIconState] = useState(MessengerIconState.HIDDEN); const { getFriend = null } = useFriends(); const { simpleAlert = null } = useNotification(); + const { settings, translateIncoming } = useTranslation(); const visibleThreads = useMemo(() => messageThreads.filter(thread => (hiddenThreadIds.indexOf(thread.threadId) === -1)), [messageThreads, hiddenThreadIds]); const activeThread = useMemo(() => ((activeThreadId > 0) && visibleThreads.find(thread => (thread.threadId === activeThreadId) || null)), [activeThreadId, visibleThreads]); @@ -79,7 +81,7 @@ const useMessengerState = () => if (activeThreadId === threadId) setActiveThreadId(-1); }; - const sendMessage = (thread: MessengerThread, senderId: number, messageText: string, secondsSinceSent: number = 0, extraData: string = null, messageType: number = MessengerThreadChat.CHAT) => + const sendMessage = (thread: MessengerThread, senderId: number, messageText: string, secondsSinceSent: number = 0, extraData: string = null, messageType: number = MessengerThreadChat.CHAT, translation: IResolvedTranslation = null) => { if (!thread || !messageText || !messageText.length) return; @@ -87,6 +89,8 @@ const useMessengerState = () => if (ownMessage && (messageText.length <= 255)) SendMessageComposer(new SendMessageComposerPacket(thread.participant.id, messageText)); + let addedChatId = -1; + setMessageThreads(prevValue => { const newValue = [...prevValue]; @@ -98,7 +102,11 @@ const useMessengerState = () => if (ownMessage && (thread.groups.length === 1)) PlaySound(SoundNames.MESSENGER_NEW_THREAD); - thread.addMessage(((messageType === MessengerThreadChat.ROOM_INVITE) ? null : senderId), messageText, secondsSinceSent, extraData, messageType); + const addedChat = thread.addMessage(((messageType === MessengerThreadChat.ROOM_INVITE) ? null : senderId), messageText, secondsSinceSent, extraData, messageType); + + addedChatId = addedChat?.id || -1; + + if(translation && (messageType === MessengerThreadChat.CHAT)) addedChat?.setTranslation(translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage); if (activeThreadId === thread.threadId) thread.setRead(); @@ -108,6 +116,36 @@ const useMessengerState = () => return newValue; }); + + const canTranslateMessage = !translation + && settings.enabled + && (messageType === MessengerThreadChat.CHAT) + && !!messageText?.trim().length; + + if(!canTranslateMessage || (addedChatId <= 0)) return; + + void translateIncoming(messageText).then(translation => + { + if(!translation) return; + + setMessageThreads(prevValue => + { + const newValue = [ ...prevValue ]; + const index = newValue.findIndex(newThread => (newThread.threadId === thread.threadId)); + + if(index === -1) return prevValue; + + const clonedThread = CloneObject(newValue[index]); + const chat = clonedThread.getChat(addedChatId); + + if(!chat) return prevValue; + + chat.setTranslation(translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage); + newValue[index] = clonedThread; + + return newValue; + }); + }); }; useMessageEvent(NewConsoleMessageEvent, event => diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d898ed2..4d753c3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -19,6 +19,7 @@ export * from './rooms/promotes'; export * from './rooms/widgets'; export * from './rooms/widgets/furniture'; export * from './session'; +export * from './translation'; export * from './useLocalStorage'; export * from './useSharedVisibility'; export * from './wired'; diff --git a/src/hooks/inventory/index.ts b/src/hooks/inventory/index.ts index ea39265..20f0f60 100644 --- a/src/hooks/inventory/index.ts +++ b/src/hooks/inventory/index.ts @@ -1,6 +1,7 @@ export * from './useInventoryBadges'; export * from './useInventoryBots'; export * from './useInventoryFurni'; +export * from './useInventoryNickIcons'; export * from './useInventoryPets'; export * from './useInventoryPrefixes'; export * from './useInventoryTrade'; diff --git a/src/hooks/inventory/useInventoryFurni.ts b/src/hooks/inventory/useInventoryFurni.ts index 7dd16ad..520b9e4 100644 --- a/src/hooks/inventory/useInventoryFurni.ts +++ b/src/hooks/inventory/useInventoryFurni.ts @@ -292,6 +292,41 @@ const useInventoryFurniState = () => setNeedsUpdate(false); }, [ isVisible, needsUpdate ]); + useEffect(() => + { + const refreshFurnitureLocalization = () => + { + setGroupItems(prevValue => + { + if(!prevValue?.length) return prevValue; + + return prevValue.map(groupItem => + { + const nextGroupItem = groupItem.clone(); + + nextGroupItem.refreshLocalization(); + + return nextGroupItem; + }); + }); + + setSelectedItem(prevValue => + { + if(!prevValue) return prevValue; + + const nextGroupItem = prevValue.clone(); + + nextGroupItem.refreshLocalization(); + + return nextGroupItem; + }); + }; + + window.addEventListener('nitro-localization-updated', refreshFurnitureLocalization); + + return () => window.removeEventListener('nitro-localization-updated', refreshFurnitureLocalization); + }, []); + return { isVisible, groupItems, setGroupItems, selectedItem, setSelectedItem, activate, deactivate, getWallItemById, getFloorItemById, getItemsByType }; }; diff --git a/src/hooks/inventory/useInventoryNickIcons.ts b/src/hooks/inventory/useInventoryNickIcons.ts new file mode 100644 index 0000000..3643eed --- /dev/null +++ b/src/hooks/inventory/useInventoryNickIcons.ts @@ -0,0 +1,80 @@ +import { RequestNickIconsComposer, SetActiveNickIconComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer'; +import { useEffect, useState } from 'react'; +import { useBetween } from 'use-between'; +import { INickIconItem, SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; +import { useSharedVisibility } from '../useSharedVisibility'; + +const useInventoryNickIconsState = () => +{ + const [ needsUpdate, setNeedsUpdate ] = useState(true); + const [ nickIcons, setNickIcons ] = useState([]); + const [ activeNickIcon, setActiveNickIcon ] = useState(null); + const [ selectedNickIcon, setSelectedNickIcon ] = useState(null); + const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility(); + + useMessageEvent(UserNickIconsEvent, event => + { + const parser = event.getParser(); + const ownedNickIcons = parser.nickIcons + .filter(icon => icon.owned) + .map(icon => ({ + id: icon.id, + iconKey: icon.iconKey, + displayName: icon.displayName, + points: icon.points, + pointsType: icon.pointsType, + owned: true, + active: icon.active + })); + + setNickIcons(ownedNickIcons); + setActiveNickIcon(ownedNickIcons.find(icon => icon.active) || null); + }); + + const activateNickIcon = (nickIconId: number) => + { + SendMessageComposer(new SetActiveNickIconComposer(nickIconId)); + }; + + const deactivateNickIcon = () => + { + SendMessageComposer(new SetActiveNickIconComposer(0)); + }; + + useEffect(() => + { + if(!nickIcons.length) + { + setSelectedNickIcon(null); + return; + } + + setSelectedNickIcon(prevValue => + { + if(prevValue && nickIcons.find(icon => icon.id === prevValue.id)) return prevValue; + return nickIcons[0]; + }); + }, [ nickIcons ]); + + useEffect(() => + { + if(!isVisible || !needsUpdate) return; + + SendMessageComposer(new RequestNickIconsComposer()); + setNeedsUpdate(false); + }, [ isVisible, needsUpdate ]); + + return { + nickIcons, + activeNickIcon, + selectedNickIcon, + setSelectedNickIcon, + activateNickIcon, + deactivateNickIcon, + activate, + deactivate + }; +}; + +export const useInventoryNickIcons = () => useBetween(useInventoryNickIconsState); diff --git a/src/hooks/inventory/useInventoryPrefixes.ts b/src/hooks/inventory/useInventoryPrefixes.ts index 1d761c1..81702d3 100644 --- a/src/hooks/inventory/useInventoryPrefixes.ts +++ b/src/hooks/inventory/useInventoryPrefixes.ts @@ -1,4 +1,4 @@ -import { ActivePrefixUpdatedEvent, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, DeletePrefixComposer, UserPrefixesEvent } from '@nitrots/nitro-renderer'; +import { ActivePrefixUpdatedEvent, DeletePrefixComposer, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, UserNickIconsEvent, UserPrefixesEvent } from '@nitrots/nitro-renderer'; import { useEffect, useState } from 'react'; import { useBetween } from 'use-between'; import { IPrefixItem, SendMessageComposer, UnseenItemCategory } from '../../api'; @@ -24,6 +24,7 @@ const useInventoryPrefixesState = () => color: p.color, icon: p.icon || '', effect: p.effect || '', + font: p.font || '', active: p.active })); @@ -33,6 +34,28 @@ const useInventoryPrefixesState = () => setActivePrefix(active); }); + useMessageEvent(UserNickIconsEvent, event => + { + const parser = event.getParser(); + const newPrefixes: IPrefixItem[] = parser.ownedPrefixes.map(prefix => ({ + id: prefix.id, + displayName: prefix.displayName, + text: prefix.text, + color: prefix.color, + icon: prefix.icon || '', + effect: prefix.effect || '', + font: prefix.font || '', + active: prefix.active, + isCustom: prefix.isCustom, + points: prefix.points, + pointsType: prefix.pointsType, + catalogPrefixId: prefix.catalogPrefixId + })); + + setPrefixes(newPrefixes); + setActivePrefix(newPrefixes.find(prefix => prefix.active) || null); + }); + useMessageEvent(PrefixReceivedEvent, event => { const parser = event.getParser(); @@ -42,6 +65,7 @@ const useInventoryPrefixesState = () => color: parser.color, icon: parser.icon || '', effect: parser.effect || '', + font: parser.font || '', active: false }; @@ -69,8 +93,8 @@ const useInventoryPrefixesState = () => setActivePrefix(prev => { const found = prefixes.find(p => p.id === parser.prefixId); - if(found) return { ...found, active: true }; - return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', active: true }; + if(found) return { ...found, active: true, font: parser.font || found.font || '' }; + return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', font: parser.font || '', active: true }; }); } }); diff --git a/src/hooks/rooms/widgets/useAvatarInfoWidget.ts b/src/hooks/rooms/widgets/useAvatarInfoWidget.ts index 0259f20..0cf0238 100644 --- a/src/hooks/rooms/widgets/useAvatarInfoWidget.ts +++ b/src/hooks/rooms/widgets/useAvatarInfoWidget.ts @@ -382,6 +382,23 @@ const useAvatarInfoWidgetState = () => return () => clearPendingAvatarInfo(); }, []); + useEffect(() => + { + const refreshFurnitureInfo = () => + { + setAvatarInfo(prevValue => + { + if(!(prevValue instanceof AvatarInfoFurni)) return prevValue; + + return AvatarInfoUtilities.getFurniInfo(prevValue.id, prevValue.category) || prevValue; + }); + }; + + window.addEventListener('nitro-localization-updated', refreshFurnitureInfo); + + return () => window.removeEventListener('nitro-localization-updated', refreshFurnitureInfo); + }, []); + useEffect(() => { if(!roomSession) return; diff --git a/src/hooks/rooms/widgets/useChatInputWidget.ts b/src/hooks/rooms/widgets/useChatInputWidget.ts index b21efab..89451d9 100644 --- a/src/hooks/rooms/widgets/useChatInputWidget.ts +++ b/src/hooks/rooms/widgets/useChatInputWidget.ts @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../api'; import { useNitroEvent } from '../../events'; import { useNotification } from '../../notification'; +import { useTranslation } from '../../translation'; import { useObjectSelectedEvent } from '../engine'; import { useRoom } from '../useRoom'; @@ -15,6 +16,7 @@ const useChatInputWidgetState = () => const [ floodBlocked, setFloodBlocked ] = useState(false); const [ floodBlockedSeconds, setFloodBlockedSeconds ] = useState(0); const { showNitroAlert = null, showConfirm = null } = useNotification(); + const { settings, translateOutgoing, enqueueOutgoingTranslation } = useTranslation(); const { roomSession = null } = useRoom(); const sendChat = (text: string, chatType: number, recipientName: string = '', styleId: number = 0) => @@ -183,22 +185,57 @@ const useChatInputWidgetState = () => SendMessageComposer(new RoomSettingsComposer(roomSession.roomId)); } + return null; + case ':customize': + CreateLinkEvent('customize/show'); return null; } } - switch(chatType) + const preserveTrailingSpaces = (message: string) => message.replace(/ +$/g, match => '\u00A0'.repeat(match.length)); + + const dispatchChatMessage = (message: string) => { - case ChatMessageTypeEnum.CHAT_DEFAULT: - roomSession.sendChatMessage(text, styleId); - break; - case ChatMessageTypeEnum.CHAT_SHOUT: - roomSession.sendShoutMessage(text, styleId); - break; - case ChatMessageTypeEnum.CHAT_WHISPER: - roomSession.sendWhisperMessage(recipientName, text, styleId); - break; + const preservedMessage = preserveTrailingSpaces(message); + + switch(chatType) + { + case ChatMessageTypeEnum.CHAT_DEFAULT: + roomSession.sendChatMessage(preservedMessage, styleId); + return; + case ChatMessageTypeEnum.CHAT_SHOUT: + roomSession.sendShoutMessage(preservedMessage, styleId); + return; + case ChatMessageTypeEnum.CHAT_WHISPER: + roomSession.sendWhisperMessage(recipientName, preservedMessage, styleId); + return; + } + }; + + const trimmedText = text.trimStart(); + const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':'); + + if(!shouldTranslateOutgoing) + { + dispatchChatMessage(text); + return null; } + + void (async () => + { + const translation = await translateOutgoing(text); + + if(translation) + { + enqueueOutgoingTranslation(translation); + dispatchChatMessage(translation.translatedText); + return; + } + + dispatchChatMessage(text); + })(); + + return null; }; useNitroEvent(RoomSessionChatEvent.FLOOD_EVENT, event => diff --git a/src/hooks/rooms/widgets/useChatWidget.ts b/src/hooks/rooms/widgets/useChatWidget.ts index 0ff6f24..f278659 100644 --- a/src/hooks/rooms/widgets/useChatWidget.ts +++ b/src/hooks/rooms/widgets/useChatWidget.ts @@ -1,7 +1,8 @@ -import { GetGuestRoomResultEvent, GetRoomEngine, PetFigureData, RoomChatSettings, RoomChatSettingsEvent, RoomDragEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionChatEvent, RoomUserData, SystemChatStyleEnum } from '@nitrots/nitro-renderer'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { GetGuestRoomResultEvent, GetRoomEngine, GetSessionDataManager, PetFigureData, RoomChatSettings, RoomChatSettingsEvent, RoomDragEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionChatEvent, RoomUserData, SystemChatStyleEnum } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChatBubbleMessage, ChatBubbleUtilities, ChatEntryType, ChatHistoryCurrentDate, GetConfigurationValue, GetRoomObjectScreenLocation, IRoomChatSettings, LocalizeText, PlaySound, RoomChatFormatter } from '../../../api'; import { useMessageEvent, useNitroEvent } from '../../events'; +import { useTranslation } from '../../translation'; import { useRoom } from '../useRoom'; import { useChatHistory } from './../../chat-history'; @@ -18,8 +19,58 @@ const useChatWidgetState = () => protection: RoomChatSettings.FLOOD_FILTER_NORMAL }); const { roomSession = null } = useRoom(); - const { addChatEntry } = useChatHistory(); + const { addChatEntry, updateChatEntry } = useChatHistory(); + const { settings, translateIncoming, consumeOutgoingTranslation } = useTranslation(); const isDisposed = useRef(false); + const ownUserId = (GetSessionDataManager()?.userId || -1); + + const applyTranslationToBubble = useCallback((chatMessage: ChatBubbleMessage, originalText: string, translatedText: string, detectedLanguage: string, targetLanguage: string) => + { + const resolvedOriginalText = (originalText || chatMessage.text || ''); + const resolvedTranslatedText = (translatedText || resolvedOriginalText); + const originalFormattedText = RoomChatFormatter(resolvedOriginalText); + const translatedFormattedText = RoomChatFormatter(resolvedTranslatedText); + + chatMessage.text = resolvedOriginalText; + chatMessage.formattedText = originalFormattedText; + chatMessage.originalText = resolvedOriginalText; + chatMessage.originalFormattedText = originalFormattedText; + chatMessage.translatedText = resolvedTranslatedText; + chatMessage.translatedFormattedText = translatedFormattedText; + chatMessage.translationDetectedLanguage = detectedLanguage || ''; + chatMessage.translationTargetLanguage = targetLanguage || ''; + chatMessage.showTranslation = true; + }, []); + + const buildTranslatedEntryPatch = useCallback((originalText: string, translatedText: string, detectedLanguage: string, targetLanguage: string) => + { + const resolvedOriginalText = (originalText || ''); + const resolvedTranslatedText = (translatedText || resolvedOriginalText); + + return { + showTranslation: true, + message: RoomChatFormatter(resolvedOriginalText), + originalMessage: RoomChatFormatter(resolvedOriginalText), + translatedMessage: RoomChatFormatter(resolvedTranslatedText), + detectedLanguage: detectedLanguage || '', + targetLanguage: targetLanguage || '' + }; + }, []); + + const applyAsyncTranslation = useCallback((bubbleId: number, chatEntryId: number, originalText: string, translatedText: string, detectedLanguage: string, targetLanguage: string) => + { + setChatMessages(prevValue => + { + const newValue = [ ...prevValue ]; + const bubble = newValue.find(chat => (chat.id === bubbleId)); + + if(bubble) applyTranslationToBubble(bubble, originalText, translatedText, detectedLanguage, targetLanguage); + + return newValue; + }); + + updateChatEntry(chatEntryId, buildTranslatedEntryPatch(originalText, translatedText, detectedLanguage, targetLanguage)); + }, [ applyTranslationToBubble, buildTranslatedEntryPatch, updateChatEntry ]); const getScrollSpeed = useMemo(() => { @@ -133,14 +184,17 @@ const useChatWidgetState = () => } } - const formattedText = RoomChatFormatter(text); + const isTranslatableChatType = ((chatType === RoomSessionChatEvent.CHAT_TYPE_SPEAK) || (chatType === RoomSessionChatEvent.CHAT_TYPE_WHISPER) || (chatType === RoomSessionChatEvent.CHAT_TYPE_SHOUT)); + const outgoingTranslation = (isTranslatableChatType && (userData.webID === ownUserId)) ? consumeOutgoingTranslation(text) : null; + const originalText = outgoingTranslation?.originalText || text; + const formattedText = RoomChatFormatter(originalText); const color = (avatarColor && (('#' + (avatarColor.toString(16).padStart(6, '0'))) || null)); const chatMessage = new ChatBubbleMessage( userData.roomIndex, RoomObjectCategory.UNIT, roomSession.roomId, - text, + originalText, formattedText, username, { x: bubbleLocation.x, y: bubbleLocation.y }, @@ -149,10 +203,18 @@ const useChatWidgetState = () => imageUrl, color); + if(outgoingTranslation) + { + applyTranslationToBubble(chatMessage, outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage); + } + chatMessage.prefixText = event.prefixText || ''; chatMessage.prefixColor = event.prefixColor || ''; chatMessage.prefixIcon = event.prefixIcon || ''; chatMessage.prefixEffect = event.prefixEffect || ''; + chatMessage.prefixFont = event.prefixFont || ''; + chatMessage.nickIcon = event.nickIcon || ''; + chatMessage.displayOrder = event.displayOrder || 'icon-prefix-name'; setChatMessages(prevValue => { @@ -162,7 +224,31 @@ const useChatWidgetState = () => return newValue; }); - addChatEntry({ id: -1, webId: userData.webID, entityId: userData.roomIndex, name: username, imageUrl, style: styleId, chatType: chatType, entityType: userData.type, message: formattedText, timestamp: ChatHistoryCurrentDate(), type: ChatEntryType.TYPE_CHAT, roomId: roomSession.roomId, color }); + const chatEntryId = addChatEntry({ + id: -1, + webId: userData.webID, + entityId: userData.roomIndex, + name: username, + imageUrl, + style: styleId, + chatType: chatType, + entityType: userData.type, + message: formattedText, + timestamp: ChatHistoryCurrentDate(), + type: ChatEntryType.TYPE_CHAT, + roomId: roomSession.roomId, + color, + ...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {}) + }); + + if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return; + + void translateIncoming(text).then(translation => + { + if(!translation || isDisposed.current) return; + + applyAsyncTranslation(chatMessage.id, chatEntryId, translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage); + }); }); useNitroEvent(RoomDragEvent.ROOM_DRAG, event => diff --git a/src/hooks/translation/index.ts b/src/hooks/translation/index.ts new file mode 100644 index 0000000..cfd635c --- /dev/null +++ b/src/hooks/translation/index.ts @@ -0,0 +1 @@ +export * from './useTranslation'; diff --git a/src/hooks/translation/useTranslation.ts b/src/hooks/translation/useTranslation.ts new file mode 100644 index 0000000..9a575f3 --- /dev/null +++ b/src/hooks/translation/useTranslation.ts @@ -0,0 +1,589 @@ +import { GetConfiguration, GetLocalizationManager, GetSessionDataManager, TranslationLanguagesEvent, TranslationLanguagesRequestComposer, TranslationResultEvent, TranslationTextRequestComposer } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useBetween } from 'use-between'; +import { LocalStorageKeys, SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; +import { useLocalStorage } from '../useLocalStorage'; + +const REQUEST_TIMEOUT_MS = 8000; +const OUTGOING_QUEUE_TTL_MS = 30000; + +export interface ITranslationSettings +{ + enabled: boolean; + incomingTargetLanguage: string; + outgoingTargetLanguage: string; + uiTextLanguage: string; +} + +export interface ITranslationLanguage +{ + code: string; + name: string; +} + +export interface ITranslationTextLocale extends ITranslationLanguage +{ + file: string; +} + +export interface IResolvedTranslation +{ + originalText: string; + translatedText: string; + detectedLanguage: string; + targetLanguage: string; +} + +interface IPendingTranslationRequest +{ + resolve: (translation: IResolvedTranslation) => void; + reject: (error: Error) => void; + timeoutId: number; +} + +interface IQueuedOutgoingTranslation extends IResolvedTranslation +{ + expiresAt: number; +} + +const normalizeLanguageCode = (value: string) => +{ + if(!value || !value.trim().length) return ''; + + const normalized = value.trim().replace('_', '-'); + const parts = normalized.split('-'); + + if(parts.length === 1) return parts[0].toLowerCase(); + + return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`; +}; + +const TEXT_TRANSLATION_LOCALES: ITranslationTextLocale[] = [ + { code: 'pt-BR', name: 'Portuguese (Brazil)', file: 'br' }, + { code: 'en', name: 'English', file: 'com' }, + { code: 'de', name: 'German', file: 'de' }, + { code: 'es', name: 'Spanish', file: 'es' }, + { code: 'fi', name: 'Finnish', file: 'fi' }, + { code: 'fr', name: 'French', file: 'fr' }, + { code: 'it', name: 'Italian', file: 'it' }, + { code: 'nl', name: 'Dutch', file: 'nl' }, + { code: 'tr', name: 'Turkish', file: 'tr' } +]; + +const resolveTextTranslationLocale = (value: string) => +{ + const normalizedValue = normalizeLanguageCode(value); + + if(!normalizedValue.length) return null; + + const exactMatch = TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code) === normalizedValue)); + + if(exactMatch) return exactMatch; + + const normalizedBase = normalizedValue.split('-')[0]; + + if(normalizedBase === 'pt') return TEXT_TRANSLATION_LOCALES.find(locale => (locale.file === 'br')) || null; + + return TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code).split('-')[0] === normalizedBase)) || null; +}; + +const interpolateTranslationUrl = (template: string, file: string) => +{ + if(!template || !template.length) return ''; + + return GetConfiguration().interpolate( + template + .replace(/%locale%/gi, file) + .replace(/%timestamp%/gi, Date.now().toString())); +}; + +const getTextTranslationUrl = (file: string) => +{ + const configuredTranslationUrl = GetConfiguration().getValue('external.texts.translation.url') || ''; + + if(configuredTranslationUrl.length) + { + return interpolateTranslationUrl(configuredTranslationUrl, file); + } + + const externalTextUrls = GetConfiguration().getValue('external.texts.url') || []; + const externalTextsUrl = externalTextUrls.length ? GetConfiguration().interpolate(externalTextUrls[0]) : ''; + + if(!externalTextsUrl.length) return `/text_translate/ExternalTexts_${ file }.json`; + + const lastSlashIndex = externalTextsUrl.lastIndexOf('/'); + + if(lastSlashIndex === -1) return `text_translate/ExternalTexts_${ file }.json`; + + const basePath = externalTextsUrl.substring(0, lastSlashIndex); + + return `${ basePath }/text_translate/ExternalTexts_${ file }.json`; +}; + +const getFurnitureTranslationUrl = (file: string) => +{ + const configuredTranslationUrl = GetConfiguration().getValue('furnidata.translation.url') || ''; + + if(configuredTranslationUrl.length) + { + return interpolateTranslationUrl(configuredTranslationUrl, file); + } + + const furnidataUrl = GetConfiguration().interpolate(GetConfiguration().getValue('furnidata.url') || ''); + + if(!furnidataUrl.length) return `/furniture_translate/FurnitureData_${ file }.json`; + + const lastSlashIndex = furnidataUrl.lastIndexOf('/'); + + if(lastSlashIndex === -1) return `furniture_translate/FurnitureData_${ file }.json`; + + const basePath = furnidataUrl.substring(0, lastSlashIndex); + + return `${ basePath }/furniture_translate/FurnitureData_${ file }.json`; +}; + +const dispatchLocalizationUpdated = () => +{ + if(typeof window === 'undefined') return; + + window.dispatchEvent(new CustomEvent('nitro-localization-updated')); +}; + +const getBrowserLanguageCode = () => +{ + if(typeof navigator === 'undefined') return 'en'; + + return normalizeLanguageCode(navigator.language || 'en').split('-')[0] || 'en'; +}; + +const decodeHtmlEntities = (value: string) => +{ + if(!value || (typeof window === 'undefined')) return value; + + const textarea = document.createElement('textarea'); + + textarea.innerHTML = value; + + return textarea.value; +}; + +const resolveSupportedLanguage = (value: string, languages: ITranslationLanguage[]) => +{ + const normalizedValue = normalizeLanguageCode(value); + + if(!languages.length) return normalizedValue || 'en'; + + const exactMatch = languages.find(language => (normalizeLanguageCode(language.code) === normalizedValue)); + + if(exactMatch) return exactMatch.code; + + const normalizedBase = normalizedValue.split('-')[0]; + const baseMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase)); + + if(baseMatch) return baseMatch.code; + + const englishMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === 'en')); + + if(englishMatch) return englishMatch.code; + + return languages[0].code; +}; + +const useTranslationState = () => +{ + const defaultTargetLanguage = getBrowserLanguageCode(); + const [ settings, setSettings ] = useLocalStorage(LocalStorageKeys.CHAT_TRANSLATION_SETTINGS, { + enabled: false, + incomingTargetLanguage: defaultTargetLanguage, + outgoingTargetLanguage: defaultTargetLanguage, + uiTextLanguage: '' + }); + const [ supportedLanguages, setSupportedLanguages ] = useState([]); + const [ availableTextLocales ] = useState(TEXT_TRANSLATION_LOCALES); + const [ languagesLoading, setLanguagesLoading ] = useState(false); + const [ languagesLoaded, setLanguagesLoaded ] = useState(false); + const [ localizationTextsLoading, setLocalizationTextsLoading ] = useState(false); + const [ lastIncomingLanguage, setLastIncomingLanguage ] = useState(''); + const [ lastOutgoingLanguage, setLastOutgoingLanguage ] = useState(''); + const [ lastError, setLastError ] = useState(''); + const requestIdRef = useRef(0); + const languagesTimeoutRef = useRef(0); + const pendingRequestsRef = useRef(new Map()); + const translationCacheRef = useRef(new Map()); + const outgoingQueueRef = useRef(new Map()); + const localizationRequestRef = useRef(0); + + const clearLanguagesTimeout = useCallback(() => + { + if(!languagesTimeoutRef.current) return; + + window.clearTimeout(languagesTimeoutRef.current); + languagesTimeoutRef.current = 0; + }, []); + + const pruneOutgoingQueue = useCallback(() => + { + const now = Date.now(); + + outgoingQueueRef.current.forEach((entries, key) => + { + const activeEntries = entries.filter(entry => (entry.expiresAt > now)); + + if(activeEntries.length) + { + outgoingQueueRef.current.set(key, activeEntries); + return; + } + + outgoingQueueRef.current.delete(key); + }); + }, []); + + const updateSettings = useCallback((partial: Partial) => + { + setSettings(prevValue => ({ ...prevValue, ...partial })); + }, [ setSettings ]); + + const getLanguageName = useCallback((languageCode: string) => + { + const normalizedLanguageCode = normalizeLanguageCode(languageCode); + + if(!normalizedLanguageCode.length) return 'auto'; + + const exactMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code) === normalizedLanguageCode)); + + if(exactMatch) return exactMatch.name; + + const normalizedBase = normalizedLanguageCode.split('-')[0]; + const baseMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase)); + + return baseMatch?.name || normalizedLanguageCode; + }, [ supportedLanguages ]); + + const handleLanguagesEvent = useCallback((event: TranslationLanguagesEvent) => + { + const parser = event.getParser(); + + clearLanguagesTimeout(); + setLanguagesLoading(false); + + if(!parser.success) + { + setLanguagesLoaded(false); + setLastError(parser.errorMessage || 'Unable to load Google Translate languages.'); + return; + } + + const nextLanguages = parser.languages.map(language => ({ + code: normalizeLanguageCode(language.code), + name: language.name + })); + + setSupportedLanguages(nextLanguages); + setLanguagesLoaded(true); + setLastError(''); + }, [ clearLanguagesTimeout ]); + + const handleTranslationResult = useCallback((event: TranslationResultEvent) => + { + const parser = event.getParser(); + const pendingRequest = pendingRequestsRef.current.get(parser.requestId); + + if(!pendingRequest) return; + + window.clearTimeout(pendingRequest.timeoutId); + pendingRequestsRef.current.delete(parser.requestId); + + if(!parser.success) + { + pendingRequest.reject(new Error(parser.errorMessage || 'Unable to translate text.')); + return; + } + + pendingRequest.resolve({ + originalText: decodeHtmlEntities(parser.originalText || ''), + translatedText: decodeHtmlEntities(parser.translatedText || ''), + detectedLanguage: normalizeLanguageCode(parser.detectedLanguage || ''), + targetLanguage: normalizeLanguageCode(parser.targetLanguage || '') + }); + }, []); + + useMessageEvent(TranslationLanguagesEvent, handleLanguagesEvent); + useMessageEvent(TranslationResultEvent, handleTranslationResult); + + const ensureSupportedLanguagesLoaded = useCallback((force: boolean = false) => + { + if(languagesLoading) return; + if(languagesLoaded && !force) return; + + setLanguagesLoading(true); + setLastError(''); + clearLanguagesTimeout(); + + languagesTimeoutRef.current = window.setTimeout(() => + { + setLanguagesLoading(false); + setLastError('Google Translate did not respond while loading languages.'); + }, REQUEST_TIMEOUT_MS); + + SendMessageComposer(new TranslationLanguagesRequestComposer(getBrowserLanguageCode())); + }, [ clearLanguagesTimeout, languagesLoaded, languagesLoading ]); + + const translateText = useCallback((text: string, targetLanguage: string) => + { + const safeText = (text || ''); + const normalizedTargetLanguage = normalizeLanguageCode(targetLanguage || defaultTargetLanguage) || defaultTargetLanguage; + + if(!safeText.trim().length) + { + return Promise.resolve({ + originalText: safeText, + translatedText: safeText, + detectedLanguage: '', + targetLanguage: normalizedTargetLanguage + }); + } + + const cacheKey = `${ normalizedTargetLanguage }\u0000${ safeText }`; + const cachedValue = translationCacheRef.current.get(cacheKey); + + if(cachedValue) return Promise.resolve(cachedValue); + + return new Promise((resolve, reject) => + { + const requestId = ++requestIdRef.current; + const timeoutId = window.setTimeout(() => + { + pendingRequestsRef.current.delete(requestId); + reject(new Error('Google Translate did not respond in time.')); + }, REQUEST_TIMEOUT_MS); + + pendingRequestsRef.current.set(requestId, { resolve, reject, timeoutId }); + SendMessageComposer(new TranslationTextRequestComposer(requestId, safeText, normalizedTargetLanguage)); + }).then(result => + { + translationCacheRef.current.set(cacheKey, result); + + return result; + }); + }, [ defaultTargetLanguage ]); + + const translateIncoming = useCallback(async (text: string) => + { + if(!settings.enabled) return null; + + try + { + const result = await translateText(text, settings.incomingTargetLanguage || defaultTargetLanguage); + + setLastIncomingLanguage(result.detectedLanguage || ''); + setLastError(''); + + return result; + } + catch(error) + { + setLastError((error as Error)?.message || 'Unable to translate incoming text.'); + + return null; + } + }, [ defaultTargetLanguage, settings.enabled, settings.incomingTargetLanguage, translateText ]); + + const translateOutgoing = useCallback(async (text: string) => + { + if(!settings.enabled) return null; + + try + { + const result = await translateText(text, settings.outgoingTargetLanguage || defaultTargetLanguage); + + setLastOutgoingLanguage(result.detectedLanguage || ''); + setLastError(''); + + return result; + } + catch(error) + { + setLastError((error as Error)?.message || 'Unable to translate outgoing text.'); + + return null; + } + }, [ defaultTargetLanguage, settings.enabled, settings.outgoingTargetLanguage, translateText ]); + + const enqueueOutgoingTranslation = useCallback((translation: IResolvedTranslation) => + { + if(!translation) return; + + pruneOutgoingQueue(); + + const queueKey = translation.translatedText || translation.originalText; + const currentEntries = outgoingQueueRef.current.get(queueKey) || []; + + currentEntries.push({ + ...translation, + expiresAt: (Date.now() + OUTGOING_QUEUE_TTL_MS) + }); + + outgoingQueueRef.current.set(queueKey, currentEntries); + setLastOutgoingLanguage(translation.detectedLanguage || ''); + }, [ pruneOutgoingQueue ]); + + const consumeOutgoingTranslation = useCallback((translatedText: string) => + { + pruneOutgoingQueue(); + + const queueKey = translatedText || ''; + const currentEntries = outgoingQueueRef.current.get(queueKey); + + if(!currentEntries?.length) return null; + + const entry = currentEntries.shift(); + + if(currentEntries.length) outgoingQueueRef.current.set(queueKey, currentEntries); + else outgoingQueueRef.current.delete(queueKey); + + if(entry?.detectedLanguage) setLastOutgoingLanguage(entry.detectedLanguage); + + return entry || null; + }, [ pruneOutgoingQueue ]); + + useEffect(() => + { + if(!settings.enabled) return; + + ensureSupportedLanguagesLoaded(); + }, [ ensureSupportedLanguagesLoaded, settings.enabled ]); + + useEffect(() => + { + if(!supportedLanguages.length) return; + + const resolvedIncomingTargetLanguage = resolveSupportedLanguage(settings.incomingTargetLanguage || defaultTargetLanguage, supportedLanguages); + const resolvedOutgoingTargetLanguage = resolveSupportedLanguage(settings.outgoingTargetLanguage || defaultTargetLanguage, supportedLanguages); + + if((resolvedIncomingTargetLanguage === settings.incomingTargetLanguage) && (resolvedOutgoingTargetLanguage === settings.outgoingTargetLanguage)) return; + + setSettings(prevValue => ({ + ...prevValue, + incomingTargetLanguage: resolvedIncomingTargetLanguage, + outgoingTargetLanguage: resolvedOutgoingTargetLanguage + })); + }, [ defaultTargetLanguage, setSettings, settings.incomingTargetLanguage, settings.outgoingTargetLanguage, supportedLanguages ]); + + useEffect(() => + { + let disposed = false; + const requestId = ++localizationRequestRef.current; + const localizationManager = GetLocalizationManager(); + const sessionDataManager = GetSessionDataManager(); + const selectedLocale = resolveTextTranslationLocale(settings.uiTextLanguage || ''); + + const applyLocalizationOverride = async () => + { + if(!selectedLocale) + { + localizationManager.clearOverrideValues(); + sessionDataManager.clearFurnitureDataOverrides(); + dispatchLocalizationUpdated(); + + if((localizationRequestRef.current === requestId) && !disposed) + { + setLocalizationTextsLoading(false); + setLastError(''); + } + + return; + } + + if(!disposed) setLocalizationTextsLoading(true); + + try + { + const textUrl = getTextTranslationUrl(selectedLocale.file); + const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file); + const response = await fetch(textUrl); + + if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`); + + const data = await response.json(); + const overrideValues = new Map(); + + Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key])); + + if(disposed || (localizationRequestRef.current !== requestId)) return; + + localizationManager.setOverrideValues(overrideValues); + + try + { + await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl); + } + catch + { + if(disposed || (localizationRequestRef.current !== requestId)) return; + + sessionDataManager.clearFurnitureDataOverrides(); + } + + dispatchLocalizationUpdated(); + setLastError(''); + } + catch(error) + { + if(disposed || (localizationRequestRef.current !== requestId)) return; + + localizationManager.clearOverrideValues(); + sessionDataManager.clearFurnitureDataOverrides(); + dispatchLocalizationUpdated(); + setLastError((error as Error)?.message || 'Unable to load translated UI texts.'); + } + finally + { + if(disposed || (localizationRequestRef.current !== requestId)) return; + + setLocalizationTextsLoading(false); + } + }; + + applyLocalizationOverride(); + + return () => + { + disposed = true; + }; + }, [ settings.uiTextLanguage ]); + + useEffect(() => + { + return () => + { + clearLanguagesTimeout(); + + pendingRequestsRef.current.forEach(pendingRequest => window.clearTimeout(pendingRequest.timeoutId)); + pendingRequestsRef.current.clear(); + outgoingQueueRef.current.clear(); + }; + }, [ clearLanguagesTimeout ]); + + return { + settings, + supportedLanguages, + availableTextLocales, + languagesLoading, + languagesLoaded, + localizationTextsLoading, + lastIncomingLanguage, + lastOutgoingLanguage, + lastError, + updateSettings, + ensureSupportedLanguagesLoaded, + translateIncoming, + translateOutgoing, + enqueueOutgoingTranslation, + consumeOutgoingTranslation, + getLanguageName + }; +}; + +export const useTranslation = () => useBetween(useTranslationState); diff --git a/src/index.tsx b/src/index.tsx index 5ce3538..52d6e2a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,8 @@ import './css/common/Buttons.css'; import './css/forms/form_select.css'; +import './css/friends/FriendsView.css'; + import './css/hotelview/HotelView.css'; import './css/icons/icons.css'; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 1f06e77..b92790a 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -8,3 +8,8 @@ declare module '*.gif' { const src: string; export default src; } + +interface ImportMeta +{ + glob: (pattern: string, options?: { eager?: boolean; import?: string }) => Record; +} From 964ffa6c1f35ddd4c4815920eb9b65f02b7ecfbf Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Tue, 21 Apr 2026 11:23:40 +0200 Subject: [PATCH 03/11] Fix duckie merge vite layout alias --- vite.config.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.mjs b/vite.config.mjs index 9549310..f541b13 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -27,6 +27,7 @@ export default defineConfig({ tsconfigPaths: true, alias: { '@': resolve(__dirname, 'src'), + '@layout': resolve(__dirname, 'src/layout'), '~': resolve(__dirname, 'node_modules'), '@nitrots/api': resolve(rendererRoot, 'packages/api/src/index.ts'), '@nitrots/assets': resolve(rendererRoot, 'packages/assets/src/index.ts'), From f6096371be040a28a55c3a536f3ae3f819fffdec Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 22 Apr 2026 07:58:30 +0200 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=86=99=20Updated=20Register=20user?= =?UTF-8?q?=20for=20internal=20Client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make sure you have the hotlooks.json in the /dist folder ! --- public/hotlooks.json | 52 ++ src/components/login/LoginView.tsx | 790 +++++++++++++++++++++++++---- src/css/login/LoginView.css | 231 ++++++++- 3 files changed, 966 insertions(+), 107 deletions(-) create mode 100644 public/hotlooks.json diff --git a/public/hotlooks.json b/public/hotlooks.json new file mode 100644 index 0000000..cda5717 --- /dev/null +++ b/public/hotlooks.json @@ -0,0 +1,52 @@ +[ + { + "_gender": "m", + "_figure": "hr-155-40.hd-180-10.ch-255-1408.lg-280-64.sh-290-64.ha-1003-64", + "_hash": "b5d1a24d16c9d516b3d793c66d152b77" + }, + { + "_gender": "f", + "_figure": "hr-515-34.hd-629-8.ch-665-1408.lg-715-1320.sh-740-1408.he-1608", + "_hash": "694573ec86cf5346f1c88b1017f069f8" + }, + { + "_gender": "f", + "_figure": "hr-890-36.hd-629-8.ch-685-71.lg-715-71.sh-3068-71-73.ha-1018.fa-1202-71.ca-1802", + "_hash": "10b9a935209e6c213e54108474186dc8" + }, + { + "_gender": "m", + "_figure": "hr-115-42.hd-209-1.ch-255-73.lg-3078-82.sh-300-64", + "_hash": "1457ce2369b982bcce30e8307c005d98" + }, + { + "_gender": "m", + "_figure": "hr-115-40.hd-190-14.ch-235-1408.lg-280-1408.sh-908-1408.he-1608", + "_hash": "d35b7492386963d7612341b222f7f5d9" + }, + { + "_gender": "m", + "_figure": "hr-115-31.hd-180-14.ch-210-64.lg-3023-91.sh-300-91", + "_hash": "b49e529b7604fbd3596951bc69d6551b" + }, + { + "_gender": "m", + "_figure": "hr-100.hd-180-1.ch-210-1408.lg-270-64.sh-300-64.ha-1002-64.cc-260-64", + "_hash": "f052b0ccc54cfa933d473b433b154ef5" + }, + { + "_gender": "m", + "_figure": "hr-125-34.hd-205-14.ch-235-1408.lg-285-81.sh-300-64.wa-3211-64-64", + "_hash": "08c77292a4462c36f0393820d5753de3" + }, + { + "_gender": "f", + "_figure": "hr-890-31.hd-600-1.ch-822-71.lg-715-74.he-1602-71", + "_hash": "4102a76da4bca25d5125b75d9ea1ca14" + }, + { + "_gender": "f", + "_figure": "hr-515-35.hd-628-14.ch-667.lg-696-73.he-1606-82.ca-1810.cp-3124-81", + "_hash": "4987ff565ec8e6ecedb31b08c2b017a6" + } +] \ No newline at end of file diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 8116373..7ab8457 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -14,8 +14,8 @@ const interpolate = (value: string | null | undefined): string => const LOCK_KEY = 'nitro.login.lock'; const MAX_ATTEMPTS = 5; -const LOCK_WINDOW_MS = 60_000; // rolling 60s window -const LOCK_DURATION_MS = 2 * 60_000; // 2 minute lockout +const LOCK_WINDOW_MS = 60_000; +const LOCK_DURATION_MS = 2 * 60_000; type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; @@ -33,7 +33,7 @@ const readLock = (): AttemptState => const writeLock = (state: AttemptState) => { try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } - catch { /* ignore */ } + catch { } }; export interface LoginViewProps @@ -51,6 +51,8 @@ export const LoginView: FC = ({ onAuthenticated }) => const [ submitting, setSubmitting ] = useState(false); const [ loginTurnstileToken, setLoginTurnstileToken ] = useState(''); const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); + const [ loginServerReachable, setLoginServerReachable ] = useState(null); + const [ loginPingingServer, setLoginPingingServer ] = useState(false); const submitTimeRef = useRef(0); const loginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; @@ -62,22 +64,6 @@ export const LoginView: FC = ({ onAuthenticated }) => const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); - - useEffect(() => - { - // eslint-disable-next-line no-console - console.info('[LoginView] resolved background assets', { - 'asset.url': GetConfigurationValue('asset.url', ''), - login_background: background, - 'login_background.colour': backgroundColor, - login_sun: sun, - login_drape: drape, - login_left: left, - login_right: right, - 'login_right.repeat': rightRepeat - }); - }, [ background, backgroundColor, sun, drape, left, right, rightRepeat ]); - const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); @@ -88,33 +74,18 @@ export const LoginView: FC = ({ onAuthenticated }) => || rawTurnstileEnabled === 1 || rawTurnstileEnabled === '1') && !!turnstileSiteKey; - useEffect(() => - { - // eslint-disable-next-line no-console - console.info('[LoginView] turnstile config', { - rawTurnstileEnabled, - turnstileEnabled, - turnstileSiteKey: turnstileSiteKey ? (turnstileSiteKey.slice(0, 6) + '…') : '(empty)' - }); - }, [ rawTurnstileEnabled, turnstileEnabled, turnstileSiteKey ]); - const resetLoginTurnstile = useCallback(() => { setLoginTurnstileToken(''); setLoginTurnstileResetSignal(prev => prev + 1); }, []); - // Clear error on mode change but keep the success notification so users - // returning to the login form can read it (e.g. "Account created"). - // Reset the login captcha only when we're actually on the login form. useEffect(() => { setError(null); if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); - // Auto-dismiss the info notification after a few seconds so it doesn't - // hang around forever once the user has seen it. useEffect(() => { if(!info) return; @@ -162,11 +133,65 @@ export const LoginView: FC = ({ onAuthenticated }) => let payload: Record = {}; try { payload = await response.json(); } - catch { /* ignore non-json responses */ } + catch { } return { ok: response.ok, status: response.status, payload }; }, []); + const healthUrl = GetConfigurationValue('login.health.endpoint', '/api/health'); + const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); + const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); + const checkServerReachable = useCallback(async (): Promise => + { + if(!healthUrl) return true; + try + { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 5000); + try + { + const response = await fetch(healthUrl, { method: healthMethod, credentials: 'omit', signal: controller.signal }); + if(response.status === 403) return false; + if(response.status >= 500) return false; + return true; + } + finally + { + window.clearTimeout(timer); + } + } + catch + { + return false; + } + }, [ healthUrl, healthMethod ]); + + const pingLoginServer = useCallback(async () => + { + setLoginPingingServer(true); + try + { + const ok = await checkServerReachable(); + setLoginServerReachable(ok); + return ok; + } + finally + { + setLoginPingingServer(false); + } + }, [ checkServerReachable ]); + + useEffect(() => + { + let cancelled = false; + (async () => + { + const ok = await checkServerReachable(); + if(!cancelled) setLoginServerReachable(ok); + })(); + return () => { cancelled = true; }; + }, [ checkServerReachable ]); + const handleLoginSubmit = useCallback(async (event: FormEvent) => { event.preventDefault(); @@ -202,6 +227,12 @@ export const LoginView: FC = ({ onAuthenticated }) => try { + const serverOk = await pingLoginServer(); + if(!serverOk) + { + setError('The gameserver is not running. Please try again later.'); + return; + } const { ok, payload } = await postJson(loginUrl, { username: username.trim(), password, @@ -232,14 +263,60 @@ export const LoginView: FC = ({ onAuthenticated }) => { setSubmitting(false); } - }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]); + }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); - // Register + forgot-password submit handlers receive the Turnstile token - // from the dialog (the dialog owns its own widget lifecycle), so the - // login widget underneath can't reset or overwrite it while the user - // is working on the modal. + const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); + const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); + const imagingUrl = GetConfigurationValue('login.register.imaging.url', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l'); + const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => + { + const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; + const isFalse = (v: unknown) => v === false || v === 'false' || v === 0 || v === '0'; - const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => + if(ok) + { + if(isTrue(payload.available) || isFalse(payload.exists) || isFalse(payload.taken) || isFalse(payload.inUse) || isFalse(payload.in_use)) return { available: true }; + if(isFalse(payload.available) || isTrue(payload.exists) || isTrue(payload.taken) || isTrue(payload.inUse) || isTrue(payload.in_use)) return { available: false, error: typeof payload.error === 'string' ? payload.error : undefined }; + return { available: true }; + } + + if(status === 404 || status === 405 || status === 501) return { available: true }; + if(status === 409) return { available: false, error: typeof payload.error === 'string' ? payload.error : undefined }; + + return { available: true }; + }; + + const checkEmailAvailable = useCallback(async (email: string): Promise<{ available: boolean; error?: string }> => + { + try + { + const { ok, status, payload } = await postJson(checkEmailUrl, { email }); + const result = interpretAvailability(ok, status, payload); + if(result.available) return { available: true }; + return { available: false, error: result.error || 'This email is already in use.' }; + } + catch + { + return { available: true }; + } + }, [ checkEmailUrl, postJson ]); + + const checkUsernameAvailable = useCallback(async (username: string): Promise<{ available: boolean; error?: string }> => + { + try + { + const { ok, status, payload } = await postJson(checkUsernameUrl, { username }); + const result = interpretAvailability(ok, status, payload); + if(result.available) return { available: true }; + return { available: false, error: result.error || 'This Habbo name is already taken.' }; + } + catch + { + return { available: true }; + } + }, [ checkUsernameUrl, postJson ]); + + const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => { if(turnstileEnabled && !body.turnstileToken) { @@ -257,6 +334,8 @@ export const LoginView: FC = ({ onAuthenticated }) => username: body.username, email: body.email, password: body.password, + figure: body.figure, + gender: body.gender, turnstileToken: turnstileEnabled ? body.turnstileToken : undefined }); @@ -382,14 +461,22 @@ export const LoginView: FC = ({ onAuthenticated }) => onError={ () => setLoginTurnstileToken('') } resetSignal={ loginTurnstileResetSignal } /> } + { loginServerReachable === false && +
+ The gameserver isn't running right now. Please try again in a moment. + +
+ } { error &&
{ error }
} { info &&
{ info }
}
+ disabled={ submitting || isLocked || loginServerReachable === false || loginPingingServer } + >{ loginPingingServer ? 'Checking…' : 'OK' }
setMode('forgot') }>Forgotten your password? @@ -400,6 +487,10 @@ export const LoginView: FC = ({ onAuthenticated }) => setMode('login') } onSubmit={ handleRegisterSubmit } + onCheckEmail={ checkEmailAvailable } + onCheckUsername={ checkUsernameAvailable } + onCheckServer={ checkServerReachable } + imagingUrl={ imagingUrl } submitting={ submitting } error={ error } info={ info } @@ -433,19 +524,165 @@ interface DialogSharedProps interface RegisterDialogProps extends DialogSharedProps { - onSubmit: (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => void; + onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void; + onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; + onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; + onCheckServer: () => Promise; + imagingUrl: string; } +type RegisterStep = 'credentials' | 'avatar'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type GenderKey = 'M' | 'F'; + +const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ]; + +const FALLBACK_DEFAULTS: Record> = { + M: { + hr: { partId: 180, colors: [ 45 ] }, + hd: { partId: 180, colors: [ 1 ] }, + ch: { partId: 215, colors: [ 66 ] }, + lg: { partId: 270, colors: [ 82 ] }, + sh: { partId: 290, colors: [ 80 ] } + }, + F: { + hr: { partId: 515, colors: [ 45 ] }, + hd: { partId: 600, colors: [ 1 ] }, + ch: { partId: 660, colors: [ 100 ] }, + lg: { partId: 716, colors: [ 82 ] }, + sh: { partId: 725, colors: [ 61 ] } + } +}; + +const FALLBACK_HEX: Record = { + 1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f', + 45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30', + 92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff' +}; + +interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; } +interface FigurePalette { id: number; colors: FigureColor[]; } +interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; } +interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; } +interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; } + +interface PartSelection { partId: number; colors: number[]; } +type FigureSelection = Record; + +const buildFigureString = (selection: FigureSelection): string => +{ + const seen = new Set(); + const parts: string[] = []; + const push = (setType: string) => + { + if(seen.has(setType)) return; + seen.add(setType); + const sel = selection[setType]; + if(!sel || sel.partId < 0) return; + const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : ''; + parts.push(`${ setType }-${ sel.partId }${ tail }`); + }; + for(const setType of PART_ROWS) push(setType); + for(const setType of Object.keys(selection)) push(setType); + return parts.join('.'); +}; + +const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string => + template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + +const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]); + +const buildPartPreviewUrl = ( + template: string, + setType: string, + selection: FigureSelection, + gender: GenderKey +): string => +{ + const defaults = FALLBACK_DEFAULTS[gender]; + const partSel = selection[setType] ?? defaults[setType]; + const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : ''; + const isHeadOnly = HEAD_ONLY_PARTS.has(setType); + + let parts: string[]; + if(isHeadOnly) + { + const hd = defaults.hd; + const pieces = new Map(); + pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`); + pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`); + parts = Array.from(pieces.values()); + } + else + { + const hd = defaults.hd; + parts = [ + `hd-${ hd.partId }-${ hd.colors.join('-') }`, + `${ setType }-${ partSel.partId }${ tail }` + ]; + } + + const figure = parts.join('.'); + let url = template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + + url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s'); + if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s'; + if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1'; + + return url; +}; + const RegisterDialog: FC = props => { - const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; - const [ username, setUsername ] = useState(''); + const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + + const [ step, setStep ] = useState('credentials'); const [ email, setEmail ] = useState(''); const [ password, setPassword ] = useState(''); const [ confirm, setConfirm ] = useState(''); + const [ username, setUsername ] = useState(''); + const [ gender, setGender ] = useState('F'); + const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); const [ localError, setLocalError ] = useState(null); + const [ checking, setChecking ] = useState(false); const [ turnstileToken, setTurnstileToken ] = useState(''); const [ resetSignal, setResetSignal ] = useState(0); + const [ serverReachable, setServerReachable ] = useState(null); + const [ pingingServer, setPingingServer ] = useState(false); + + const pingServer = useCallback(async () => + { + setPingingServer(true); + try + { + const ok = await onCheckServer(); + setServerReachable(ok); + return ok; + } + finally + { + setPingingServer(false); + } + }, [ onCheckServer ]); + + useEffect(() => + { + let cancelled = false; + (async () => + { + const ok = await onCheckServer(); + if(!cancelled) setServerReachable(ok); + })(); + return () => { cancelled = true; }; + }, [ onCheckServer ]); const resetWidget = useCallback(() => { @@ -453,82 +690,457 @@ const RegisterDialog: FC = props => setResetSignal(prev => prev + 1); }, []); - const handle = (event: FormEvent) => + useEffect(() => { setLocalError(null); }, [ step ]); + + const [ figureData, setFigureData ] = useState(null); + const figureDataUrlRaw = GetConfigurationValue('avatar.figuredata.url', ''); + const figureDataUrl = useMemo(() => + { + if(!figureDataUrlRaw) return ''; + try { return GetConfiguration().interpolate(figureDataUrlRaw); } + catch { return figureDataUrlRaw; } + }, [ figureDataUrlRaw ]); + + useEffect(() => + { + if(step !== 'avatar' || figureData || !figureDataUrl) return; + let cancelled = false; + fetch(figureDataUrl, { credentials: 'omit' }) + .then(r => r.ok ? r.json() : null) + .then(json => { if(!cancelled && json) setFigureData(json as FigureData); }) + .catch(() => { }); + return () => { cancelled = true; }; + }, [ step, figureData, figureDataUrl ]); + + const partOptions = useMemo(() => + { + const result: Record> = {}; + if(!figureData) return result; + for(const st of figureData.setTypes) + { + if(!PART_ROWS.includes(st.type)) continue; + const forGender = (g: GenderKey) => st.sets + .filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U')) + .map(s => s.id); + result[st.type] = { M: forGender('M'), F: forGender('F') }; + } + return result; + }, [ figureData ]); + + const paletteOptions = useMemo(() => + { + const result: Record = {}; + if(!figureData) return result; + for(const st of figureData.setTypes) + { + if(!PART_ROWS.includes(st.type)) continue; + const palette = figureData.palettes.find(p => p.id === st.paletteId); + if(!palette) { result[st.type] = []; continue; } + result[st.type] = palette.colors + .filter(c => c.selectable && c.club === 0) + .map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() })); + } + return result; + }, [ figureData ]); + + const hexFor = useCallback((setType: string, colorId: number): string => + { + const list = paletteOptions[setType]; + if(list) + { + const found = list.find(c => c.id === colorId); + if(found) return found.hex; + } + return FALLBACK_HEX[colorId] || '#c9c9c9'; + }, [ paletteOptions ]); + + const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]); + const [ hotLookIndex, setHotLookIndex ] = useState(-1); + + useEffect(() => + { + if(step !== 'avatar' || hotLooks.length) return; + let cancelled = false; + fetch('hotlooks.json', { credentials: 'omit' }) + .then(r => r.ok ? r.json() : null) + .then((json: unknown) => + { + if(cancelled || !Array.isArray(json)) return; + const parsed: { gender: GenderKey; figure: string }[] = []; + for(const entry of json as Record[]) + { + const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : ''; + const figure = typeof entry._figure === 'string' ? entry._figure : ''; + if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue; + parsed.push({ gender: rawGender as GenderKey, figure }); + } + if(parsed.length) setHotLooks(parsed); + }) + .catch(() => { }); + return () => { cancelled = true; }; + }, [ step, hotLooks.length ]); + + const applyLook = useCallback((figure: string, lookGender: GenderKey) => + { + const next: FigureSelection = {}; + for(const setPart of figure.split('.')) + { + const bits = setPart.split('-'); + if(bits.length < 2) continue; + const setType = bits[0]; + const partId = parseInt(bits[1], 10); + if(!setType || Number.isNaN(partId)) continue; + const colors: number[] = []; + for(let i = 2; i < bits.length; i++) + { + const c = parseInt(bits[i], 10); + if(!Number.isNaN(c)) colors.push(c); + } + next[setType] = { partId, colors }; + } + + for(const setType of PART_ROWS) + { + if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] }; + } + setGender(lookGender); + setSelection(next); + }, []); + + const cycleHotLook = useCallback(() => + { + if(!hotLooks.length) return; + const nextIdx = (hotLookIndex + 1) % hotLooks.length; + setHotLookIndex(nextIdx); + const look = hotLooks[nextIdx]; + applyLook(look.figure, look.gender); + }, [ hotLooks, hotLookIndex, applyLook ]); + + const credentialsValid = + EMAIL_REGEX.test(email.trim()) && + password.length >= 8 && + password === confirm; + + const handleCredentialsNext = async (event: FormEvent) => { event.preventDefault(); setLocalError(null); - if(!username.trim() || !email.trim() || !password) + if(!email.trim() || !password || !confirm) { setLocalError('Please fill in every field.'); return; } - + if(!EMAIL_REGEX.test(email.trim())) + { + setLocalError('Please enter a valid email address.'); + return; + } if(password.length < 8) { setLocalError('Your password must be at least 8 characters.'); return; } - if(password !== confirm) { setLocalError('Passwords do not match.'); return; } - onSubmit({ username: username.trim(), email: email.trim(), password, turnstileToken }, resetWidget); + setChecking(true); + try + { + const serverOk = await pingServer(); + if(!serverOk) + { + setLocalError('The gameserver is not running. Please try again later.'); + return; + } + const result = await onCheckEmail(email.trim()); + if(!result.available) + { + setLocalError(result.error || 'This email is already in use.'); + return; + } + setStep('avatar'); + } + finally + { + setChecking(false); + } }; + const applyGender = (newGender: GenderKey) => + { + setGender(newGender); + setSelection({ ...FALLBACK_DEFAULTS[newGender] }); + setHotLookIndex(-1); + }; + + const getPartList = useCallback((setType: string): number[] => + { + const loaded = partOptions[setType]?.[gender]; + if(loaded && loaded.length) return loaded; + const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId; + return fallback !== undefined ? [ fallback ] : []; + }, [ partOptions, gender ]); + + const getColorList = useCallback((setType: string): number[] => + { + const loaded = paletteOptions[setType]; + if(loaded && loaded.length) return loaded.map(c => c.id); + const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0]; + return fallback !== undefined ? [ fallback ] : []; + }, [ paletteOptions, gender ]); + + const cyclePart = (setType: string, direction: 1 | -1) => + { + const options = getPartList(setType); + if(!options.length) return; + const current = selection[setType]?.partId ?? options[0]; + const idx = options.indexOf(current); + const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length; + const colors = getColorList(setType); + setSelection(prev => ({ + ...prev, + [setType]: { + partId: options[nextIdx], + colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ] + } + })); + }; + + const cycleColor = (setType: string, direction: 1 | -1) => + { + const colors = getColorList(setType); + if(!colors.length) return; + const currentColor = selection[setType]?.colors?.[0] ?? colors[0]; + const idx = colors.indexOf(currentColor); + const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length; + const parts = getPartList(setType); + setSelection(prev => ({ + ...prev, + [setType]: { + partId: prev[setType]?.partId ?? parts[0], + colors: [ colors[nextIdx] ] + } + })); + }; + + const figure = buildFigureString(selection); + const previewSrc = buildImagingUrl(imagingUrl, figure, gender); + + const handleAvatarSubmit = async (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + const trimmed = username.trim(); + if(!trimmed) + { + setLocalError('Please choose a Habbo name.'); + return; + } + if(trimmed.length < 3 || trimmed.length > 16) + { + setLocalError('Habbo name must be 3–16 characters.'); + return; + } + + if(turnstileEnabled && !turnstileToken) + { + setLocalError('Please complete the security check.'); + return; + } + + setChecking(true); + try + { + const serverOk = await pingServer(); + if(!serverOk) + { + setLocalError('The gameserver is not running. Please try again later.'); + return; + } + const result = await onCheckUsername(trimmed); + if(!result.available) + { + setLocalError(result.error || 'This Habbo name is already taken.'); + return; + } + } + finally + { + setChecking(false); + } + + onSubmit({ + username: trimmed, + email: email.trim(), + password, + figure, + gender, + turnstileToken + }, resetWidget); + }; + + const busy = submitting || checking || pingingServer; + const serverOffline = serverReachable === false; + return (
-
+
- Create a Habbo + Habbo Details
-
-
- - setUsername(e.target.value) } /> -
-
- - setEmail(e.target.value) } /> -
-
- - setPassword(e.target.value) } /> -
-
- - setConfirm(e.target.value) } /> -
- { turnstileEnabled && - setTurnstileToken('') } - onError={ () => setTurnstileToken('') } - resetSignal={ resetSignal } - /> } - { (localError || error) &&
{ localError || error }
} - { info &&
{ info }
} -
- -
- + + { step === 'credentials' && +
+
+ Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use. +
+ { serverOffline && +
+ The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment. + +
+ } +
+ + setEmail(e.target.value) } /> +
+
+ + setPassword(e.target.value) } /> +
+
+ + setConfirm(e.target.value) } /> +
+ { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ 1/2 + +
+
+ } + + { step === 'avatar' && +
+
+ Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name. +
+ { serverOffline && +
+ The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment. + +
+ } +
+ setUsername(e.target.value) } /> +
+ +
+ + +
+ +
+
+ { PART_ROWS.map(setType => { + const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); + return ( +
+ +
+ { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ ); + }) } +
+ +
+ Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ { PART_ROWS.map(setType => { + const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0; + const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor; + const swatchHex = hexFor(setType, currentColor); + return ( +
+ +
+ +
+ ); + }) } +
+
+ +
+ +
+ + { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 2/2 + +
+ + }
); }; + interface ForgotDialogProps extends DialogSharedProps { onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index c2f15d8..984a68f 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -1,18 +1,3 @@ -/* ─── Classic Login View ───────────────────────────────────────────────── - Port of the old Nitro HotelView background layering, used exclusively by - the login screen. Assets are driven by ui-config.json: - loginview.images.background → .login-background - loginview.images.background.colour → .nitro-login-view base colour - loginview.images.sun → .login-sun - loginview.images.drape → .login-drape - loginview.images.left → .login-left - loginview.images.right → .login-right - loginview.images.right.repeat → .login-right-repeat - - Class names are deliberately prefixed so HotelView.css rules - (.left { left: 18vw !important } etc.) cannot clobber us. - --------------------------------------------------------------------- */ - .nitro-login-view { position: fixed; inset: 0; @@ -82,7 +67,7 @@ background-position: right bottom; } -/* ─── Foreground Login Card Stack ───────────────────────────────────── */ +/* ─── Foreground Login Card Stack ─── */ .nitro-login-view .login-stack { position: absolute; @@ -242,8 +227,6 @@ max-width: 100%; } -/* Modal overlay used for register + forgot password dialogs */ - .nitro-login-modal { position: fixed; inset: 0; @@ -259,3 +242,215 @@ max-width: calc(100% - 40px); } +.nitro-login-modal .dialog.dialog-avatar { + width: 400px; +} + +/* ─── Multi-step register dialog ─── */ + +.nitro-login-card .register-intro { + background: #eef4f8; + border: 1px solid #b6cfdd; + border-radius: 4px; + padding: 6px 8px; + font-size: 11px; + line-height: 1.4; + color: #0a2e45; +} + +.nitro-login-card .step-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.nitro-login-card .step-footer-split { + justify-content: space-between; +} + +.nitro-login-card .step-indicator { + font-size: 11px; + color: #134b6e; + font-weight: 600; +} + +.nitro-login-card .back-button { + background: #d5e2eb; +} + +/* ─── Avatar builder (pre-login) ─── */ + +.nitro-login-card .gender-row { + display: flex; + justify-content: center; + gap: 22px; + font-size: 11px; + font-weight: 600; +} + +.nitro-login-card .gender-row label { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.nitro-login-card .avatar-builder { + display: grid; + grid-template-columns: 74px 1fr 74px; + gap: 6px; + align-items: stretch; + background: repeating-linear-gradient( + 0deg, + #ffffff 0, + #ffffff 8px, + #e5ecf1 8px, + #e5ecf1 16px + ); + border: 1px solid #7595ac; + border-radius: 6px; + padding: 6px; +} + +.nitro-login-card .avatar-part-col, +.nitro-login-card .avatar-color-col { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 4px; +} + +.nitro-login-card .avatar-part-row, +.nitro-login-card .avatar-color-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 3px; + min-height: 44px; +} + +.nitro-login-card .arrow-btn { + width: 16px; + height: 20px; + line-height: 1; + padding: 0; + border: 1px solid #7595ac; + border-radius: 3px; + background: #ffffff; + color: #0a2e45; + font-size: 14px; + font-weight: 700; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15); + flex-shrink: 0; +} + +.nitro-login-card .arrow-btn:hover { + background: #e9f1f7; +} + +.nitro-login-card .part-preview { + flex: 1; + height: 44px; + border: 1px solid #7595ac; + border-radius: 3px; + background: #ffffff; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8); +} + +.nitro-login-card .part-preview img { + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; + pointer-events: none; + user-select: none; + max-width: none; + height: auto; +} + +.nitro-login-card .part-preview-hr img, +.nitro-login-card .part-preview-hd img { + width: 40px; + height: auto; +} + +.nitro-login-card .part-preview-ch img { + width: 50px; + margin-top: 8px; +} + +.nitro-login-card .part-preview-lg img { + width: 50px; + margin-top: -8px; +} + +.nitro-login-card .part-preview-sh img { + width: 50px; + margin-top: -22px; +} + +.nitro-login-card .color-swatch { + flex: 1; + height: 18px; + border: 1px solid #7595ac; + border-radius: 3px; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.4); +} + +.nitro-login-card .avatar-preview { + display: flex; + align-items: flex-end; + justify-content: center; + min-height: 130px; + overflow: hidden; +} + +.nitro-login-card .avatar-preview img { + max-width: 100%; + max-height: 140px; + image-rendering: pixelated; + image-rendering: -moz-crisp-edges; +} + +.nitro-login-card .hot-looks-row { + display: flex; + justify-content: center; + margin-top: 2px; +} + +.nitro-login-card .server-offline { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px; + text-align: left; +} + +.nitro-login-card .server-offline .retry-link { + background: #ffffff; + border: 1px solid #3f6a85; + border-radius: 4px; + padding: 2px 10px; + font-size: 11px; + font-weight: 700; + color: #0a2e45; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15); +} + +.nitro-login-card .server-offline .retry-link:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.nitro-login-card .hot-looks-button { + padding: 4px 14px; + font-size: 11px; +} + From 237c523f9a4c899cc2331ca44e1a35b2aba8f86a Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Thu, 23 Apr 2026 07:01:09 +0200 Subject: [PATCH 05/11] checkpoint: secure assets and login flow baseline --- index.html | 36 +- package.json | 5 +- public/asset-loader.js | 1 + public/messenger-current-component.html | 437 ---- public/nitro_messenger_v2.html | 116 - public/renderer-config.json | 10 +- public/ui-config.json | 2787 +++++++++++++++++++++++ scripts/asset-codec.mjs | 13 + scripts/minify-dist.mjs | 88 + scripts/write-asset-loader.mjs | 8 + src/App.tsx | 215 +- src/bootstrap.ts | 41 + src/components/loading/LoadingView.tsx | 11 +- src/components/login/LoginView.tsx | 62 +- src/css/login/LoginView.css | 40 +- src/secure-assets.ts | 378 +++ vite.config.mjs | 19 +- 17 files changed, 3573 insertions(+), 694 deletions(-) create mode 100644 public/asset-loader.js delete mode 100644 public/messenger-current-component.html delete mode 100644 public/nitro_messenger_v2.html create mode 100644 public/ui-config.json create mode 100644 scripts/asset-codec.mjs create mode 100644 scripts/minify-dist.mjs create mode 100644 scripts/write-asset-loader.mjs create mode 100644 src/bootstrap.ts create mode 100644 src/secure-assets.ts diff --git a/index.html b/index.html index 4e6a87e..1c4fa2e 100644 --- a/index.html +++ b/index.html @@ -1,35 +1 @@ - - - - Nitro - - - - - - - - - - - - - - - - - - -
- - - - +
diff --git a/package.json b/package.json index efca2c3..3290203 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "homepage": ".", "private": true, "scripts": { - "start": "vite --base=/client/ --host", - "build": "vite --base=/client/ build", + "prebuild": "node scripts/write-asset-loader.mjs", + "start": "vite --host", + "build": "vite build && node scripts/minify-dist.mjs", "build:prod": "npx browserslist@latest --update-db && yarn build", "eslint": "eslint ./src" }, diff --git a/public/asset-loader.js b/public/asset-loader.js new file mode 100644 index 0000000..569b19d --- /dev/null +++ b/public/asset-loader.js @@ -0,0 +1 @@ +(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='
'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\.\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})(); \ No newline at end of file diff --git a/public/messenger-current-component.html b/public/messenger-current-component.html deleted file mode 100644 index be8380d..0000000 --- a/public/messenger-current-component.html +++ /dev/null @@ -1,437 +0,0 @@ - - - - - - Nitro Current Messenger Mockup - - - -
-
- Le tue chat aperte (2) -
-
- -
-
-
-
Messenger
-
-
-
1
-
-
- Jarchy -
-
Jarchy
-
-
- -
-
-
- ,Homy -
-
,Homy
-
-
-
-
- -
-
Tu + Jarchy
- -
-
- - - -
- -
- -
-
-
- Jarchy -
-
-
-
Jarchy
-
dddove sei?
-
-
7 ore fa
-
-
- -
-
-
-
Tu
-
su
-
slogga
-
vieni li
-
-
6 ore fa
-
-
- Tu -
-
- -
-
- Jarchy -
-
-
-
Jarchy
-
arrivo
-
-
6 ore fa
-
-
-
- -
- - -
-
-
-
-
- - diff --git a/public/nitro_messenger_v2.html b/public/nitro_messenger_v2.html deleted file mode 100644 index 81c3155..0000000 --- a/public/nitro_messenger_v2.html +++ /dev/null @@ -1,116 +0,0 @@ - - -
-
-
- Le tue chat aperte (7) -
-
- -
-
- Jarchy - 1 -
-
- ,Homy -
-
- u3 -
-
- u4 -
-
- u5 -
-
- u6 -
-
- u7 -
-
- -
- Tu + Jarchy -
- - - - -
-
- -
-
-
Jarchy
-
-
Jarchy:
-
dddove sei?
-
7 ore fa
-
-
- -
-
Tu
-
-
,Homy:
-
su
slogga
vieni li
-
7 ore fa
-
-
- -
-
Jarchy
-
-
Jarchy:
-
arrivo
-
7 ore fa
-
-
-
- -
- - -
-
-
diff --git a/public/renderer-config.json b/public/renderer-config.json index 229a8ef..170ae55 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -1,11 +1,11 @@ { "socket.url": "wss://nitro.slogga.it:2096", "api.url": "https://nitro.slogga.it:2096", - "asset.url": "https://client.slogga.it/nitro/bundled", - "image.library.url": "https://client.slogga.it/c_images/", - "hof.furni.url": "https://client.slogga.it/c_images/dcr/hof_furni", - "images.url": "https://client.slogga.it/nitro/images", - "gamedata.url": "https://client.slogga.it/nitro/gamedata", + "asset.url": "https://hotel.slogga.it/client/nitro/bundled", + "image.library.url": "https://hotel.slogga.it/client/c_images/", + "hof.furni.url": "https://hotel.slogga.it/client/c_images/dcr/hof_furni", + "images.url": "https://hotel.slogga.it/client/nitro/images", + "gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=", "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ "${gamedata.url}/ExternalTexts.json", diff --git a/public/ui-config.json b/public/ui-config.json new file mode 100644 index 0000000..d065661 --- /dev/null +++ b/public/ui-config.json @@ -0,0 +1,2787 @@ +{ + "image.library.notifications.url": "${image.library.url}notifications/%image%.png", + "achievements.images.url": "${image.library.url}Quests/%image%.png", + "camera.url": "https://hotel.slogga.it/client/camera/", + "thumbnails.url": "https://hotel.slogga.it/client/camera/thumbnail/%thumbnail%.png", + "url.prefix": "", + "habbopages.url": "/gamedata/habbopages/", + "group.homepage.url": "${url.prefix}/groups/%groupid%/id", + "guide.help.alpha.groupid": 0, + "chat.viewer.height.percentage": 0.4, + "widget.dimmer.colorwheel": false, + "avatar.wardrobe.max.slots": 10, + "user.badges.max.slots": 5, + "user.tags.enabled": false, + "camera.publish.disabled": false, + "hc.disabled": false, + "badge.descriptions.enabled": true, + "motto.max.length": 38, + "bot.name.max.length": 15, + "pet.package.name.max.length": 15, + "wired.action.bot.talk.to.avatar.max.length": 64, + "wired.action.bot.talk.max.length": 64, + "wired.action.chat.max.length": 100, + "wired.action.kick.from.room.max.length": 100, + "wired.action.mute.user.max.length": 100, + "game.center.enabled": false, + "guides.enabled": true, + "toolbar.hide.quests": true, + "catalog.style.new": true, + "show.google.ads": false, + "loginview": { + "images": { + "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", + "background.colour": "#6eadc8", + "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", + "left": "https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.slogga.it/client/nitro/images/reception/background_right.png" + } + }, + "navigator.room.models": [ + { + "clubLevel": 0, + "tileSize": 104, + "name": "a" + }, + { + "clubLevel": 0, + "tileSize": 94, + "name": "b" + }, + { + "clubLevel": 0, + "tileSize": 36, + "name": "c" + }, + { + "clubLevel": 0, + "tileSize": 84, + "name": "d" + }, + { + "clubLevel": 0, + "tileSize": 80, + "name": "e" + }, + { + "clubLevel": 0, + "tileSize": 80, + "name": "f" + }, + { + "clubLevel": 0, + "tileSize": 416, + "name": "i" + }, + { + "clubLevel": 0, + "tileSize": 320, + "name": "j" + }, + { + "clubLevel": 0, + "tileSize": 448, + "name": "k" + }, + { + "clubLevel": 0, + "tileSize": 352, + "name": "l" + }, + { + "clubLevel": 0, + "tileSize": 384, + "name": "m" + }, + { + "clubLevel": 0, + "tileSize": 372, + "name": "n" + }, + { + "clubLevel": 1, + "tileSize": 80, + "name": "g" + }, + { + "clubLevel": 1, + "tileSize": 74, + "name": "h" + }, + { + "clubLevel": 1, + "tileSize": 416, + "name": "o" + }, + { + "clubLevel": 1, + "tileSize": 352, + "name": "p" + }, + { + "clubLevel": 1, + "tileSize": 304, + "name": "q" + }, + { + "clubLevel": 1, + "tileSize": 336, + "name": "r" + }, + { + "clubLevel": 1, + "tileSize": 748, + "name": "u" + }, + { + "clubLevel": 1, + "tileSize": 438, + "name": "v" + }, + { + "clubLevel": 2, + "tileSize": 540, + "name": "t" + }, + { + "clubLevel": 2, + "tileSize": 512, + "name": "w" + }, + { + "clubLevel": 2, + "tileSize": 396, + "name": "x" + }, + { + "clubLevel": 2, + "tileSize": 440, + "name": "y" + }, + { + "clubLevel": 2, + "tileSize": 456, + "name": "z" + }, + { + "clubLevel": 2, + "tileSize": 208, + "name": "0" + }, + { + "clubLevel": 2, + "tileSize": 1009, + "name": "1" + }, + { + "clubLevel": 2, + "tileSize": 1044, + "name": "2" + }, + { + "clubLevel": 2, + "tileSize": 183, + "name": "3" + }, + { + "clubLevel": 2, + "tileSize": 254, + "name": "4" + }, + { + "clubLevel": 2, + "tileSize": 1024, + "name": "5" + }, + { + "clubLevel": 2, + "tileSize": 801, + "name": "6" + }, + { + "clubLevel": 2, + "tileSize": 354, + "name": "7" + }, + { + "clubLevel": 2, + "tileSize": 888, + "name": "8" + }, + { + "clubLevel": 2, + "tileSize": 926, + "name": "9" + } + ], + "backgrounds.data": [ + { + "backgroundId": 0, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 1, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 2, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 3, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 4, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 5, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 6, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 7, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 8, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 9, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 10, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 11, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 12, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 13, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 14, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 15, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 16, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 17, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 18, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 19, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 20, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 21, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 22, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 23, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 24, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 25, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 26, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 27, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 28, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 29, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 30, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 31, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 32, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 33, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 34, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 35, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 36, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 37, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 38, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 39, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 40, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 41, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 42, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 43, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 44, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 45, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 46, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 47, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 48, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 49, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 50, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 51, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 52, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 53, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 54, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 55, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 56, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 57, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 58, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 59, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 60, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 61, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 62, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 63, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 64, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 65, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 66, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 67, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 68, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 69, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 70, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 71, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 72, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 73, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 74, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 75, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 76, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 77, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 78, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 79, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 80, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 81, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 82, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 83, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 84, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 85, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 86, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 87, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 88, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 89, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 90, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 91, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 92, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 93, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 94, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 95, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 96, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 97, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 98, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 99, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 100, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 101, + "minRank": 2, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "backgroundId": 102, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 103, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 104, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 105, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 106, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 107, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 108, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 109, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 110, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 111, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 112, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 113, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 114, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 115, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 116, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 117, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 118, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 119, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 120, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 121, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 122, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 123, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 124, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 125, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 126, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 127, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 128, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 129, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 130, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 131, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 132, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 133, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 134, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 135, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 136, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 137, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 138, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 139, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 140, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 141, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 142, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 143, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 144, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 145, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 146, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 147, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 148, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 149, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 150, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 151, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 152, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 153, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 154, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 155, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 156, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 157, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 158, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 159, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 160, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 161, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 162, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 163, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 164, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 165, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 166, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 167, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 168, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 169, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 170, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 171, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 172, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 173, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 174, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 175, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 176, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 177, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 178, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 179, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 180, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 181, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 182, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 183, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 184, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 185, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 186, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "backgroundId": 187, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + } + ], + "stands.data": [ + { + "standId": 0, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 1, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 2, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 3, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 4, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 5, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 6, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 7, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 8, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 9, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 10, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 11, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 12, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 13, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 14, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 15, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "standId": 16, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "standId": 17, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "standId": 18, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "standId": 19, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "standId": 20, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "standId": 21, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + } + ], + "overlays.data": [ + { + "overlayId": 0, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "overlayId": 1, + "minRank": 0, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "overlayId": 2, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "overlayId": 3, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "overlayId": 4, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "overlayId": 5, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "overlayId": 6, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "overlayId": 7, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "overlayId": 8, + "minRank": 0, + "isHcOnly": true, + "isAmbassadorOnly": false + } + ], + "hotelview": { + "room.pool": "791", + "room.picnic": "2193", + "room.rooftop": "", + "room.rooftop.pool": "", + "room.peaceful": "", + "room.infobus": "5956", + "room.lobby": "1450", + "show.avatar": true, + "widgets": { + "slot.1.widget": "promoarticle", + "slot.1.conf": {}, + "slot.2.widget": "widgetcontainer", + "slot.2.conf": { + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "texts": "2021NitroPromo", + "btnLink": "" + }, + "slot.3.widget": "", + "slot.3.conf": {}, + "slot.4.widget": "", + "slot.4.conf": {}, + "slot.5.widget": "", + "slot.5.conf": {}, + "slot.6.widget": "", + "slot.6.conf": { + "campaign": "" + }, + "slot.7.widget": "", + "slot.7.conf": {} + }, + "images": { + "background": "${asset.url}/images/reception/stretch_blue.png", + "background.colour": "#8ee0f0", + "sun": "${asset.url}/images/reception/sun.png", + "drape": "${asset.url}/images/reception/drape.png", + "left": "", + "right": "", + "right.repeat": "" + } + }, + "achievements.unseen.ignored": [ + "ACH_AllTimeHotelPresence" + ], + "avatareditor.show.clubitems.dimmed": true, + "avatareditor.show.clubitems.first": true, + "chat.history.max.items": 100, + "system.currency.types": [ + -1, + 0, + 5, + 105 + ], + "catalog.links": { + "hc.buy_hc": "habbo_club", + "hc.hc_gifts": "club_gifts", + "pets.buy_food": "pet_food", + "pets.buy_saddle": "saddles" + }, + "hc.center": { + "benefits.info": true, + "payday.info": true, + "gift.info": true, + "benefits.habbopage": "habboclub", + "payday.habbopage": "hcpayday" + }, + "respect.options": { + "enabled": false, + "sound": "sound_respect_received" + }, + "currency.display.number.short": false, + "currency.asset.icon.url": "${images.url}/wallet/%type%.png", + "catalog.asset.url": "${image.library.url}catalogue", + "catalog.asset.image.url": "${catalog.asset.url}/%name%.gif", + "catalog.asset.icon.url": "${catalog.asset.url}/icon_%name%.png", + "catalog.tab.icons": false, + "catalog.headers": false, + "chat.input.maxlength": 100, + "chat.styles.disabled": [], + "chat.styles": [ + { + "styleId": 0, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 1, + "minRank": 5, + "isSystemStyle": true, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 2, + "minRank": 5, + "isSystemStyle": true, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 3, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 4, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 5, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 6, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 7, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 8, + "minRank": 5, + "isSystemStyle": true, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 9, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 10, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 11, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 12, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 13, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 14, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 15, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 16, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 17, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 18, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 19, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 20, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 21, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 22, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 23, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 24, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 25, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 26, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 27, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 28, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 29, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 30, + "minRank": 5, + "isSystemStyle": true, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 31, + "minRank": 5, + "isSystemStyle": true, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 32, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 33, + "minRank": 5, + "isSystemStyle": true, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 34, + "minRank": 5, + "isSystemStyle": true, + "isHcOnly": false, + "isAmbassadorOnly": false + }, + { + "styleId": 35, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 36, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 37, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 38, + "minRank": 0, + "isSystemStyle": false, + "isHcOnly": true, + "isAmbassadorOnly": false + }, + { + "styleId": 39, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 40, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 41, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 42, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 43, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 44, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 45, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 46, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 47, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 48, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 49, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 50, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 51, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 52, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + }, + { + "styleId": 53, + "minRank": 5, + "isSystemStyle": false, + "isHcOnly": false, + "isAmbassadorOnly": true + } + ], + "camera.available.effects": [ + { + "name": "dark_sepia", + "colorMatrix": [ + 0.4, + 0.4, + 0.1, + 0, + 110, + 0.3, + 0.4, + 0.1, + 0, + 30, + 0.3, + 0.2, + 0.1, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 0, + "enabled": true + }, + { + "name": "increase_saturation", + "colorMatrix": [ + 2, + -0.5, + -0.5, + 0, + 0, + -0.5, + 2, + -0.5, + 0, + 0, + -0.5, + -0.5, + 2, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 0, + "enabled": true + }, + { + "name": "increase_contrast", + "colorMatrix": [ + 1.5, + 0, + 0, + 0, + -50, + 0, + 1.5, + 0, + 0, + -50, + 0, + 0, + 1.5, + 0, + -50, + 0, + 0, + 0, + 1.5, + 0 + ], + "minLevel": 0, + "enabled": true + }, + { + "name": "shadow_multiply_02", + "colorMatrix": [], + "minLevel": 0, + "blendMode": 2, + "enabled": true + }, + { + "name": "color_1", + "colorMatrix": [ + 0.393, + 0.769, + 0.189, + 0, + 0, + 0.349, + 0.686, + 0.168, + 0, + 0, + 0.272, + 0.534, + 0.131, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 1, + "enabled": true + }, + { + "name": "hue_bright_sat", + "colorMatrix": [ + 1, + 0.6, + 0.2, + 0, + -50, + 0.2, + 1, + 0.6, + 0, + -50, + 0.6, + 0.2, + 1, + 0, + -50, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 1, + "enabled": true + }, + { + "name": "hearts_hardlight_02", + "colorMatrix": [], + "minLevel": 1, + "blendMode": 9, + "enabled": true + }, + { + "name": "texture_overlay", + "colorMatrix": [], + "minLevel": 1, + "blendMode": 4, + "enabled": true + }, + { + "name": "pinky_nrm", + "colorMatrix": [], + "minLevel": 1, + "blendMode": 0, + "enabled": true + }, + { + "name": "color_2", + "colorMatrix": [ + 0.333, + 0.333, + 0.333, + 0, + 0, + 0.333, + 0.333, + 0.333, + 0, + 0, + 0.333, + 0.333, + 0.333, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 2, + "enabled": true + }, + { + "name": "night_vision", + "colorMatrix": [ + 0, + 0, + 0, + 0, + 0, + 0, + 1.1, + 0, + 0, + -50, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 2, + "enabled": true + }, + { + "name": "stars_hardlight_02", + "colorMatrix": [], + "minLevel": 2, + "blendMode": 9, + "enabled": true + }, + { + "name": "coffee_mpl", + "colorMatrix": [], + "minLevel": 2, + "blendMode": 2, + "enabled": true + }, + { + "name": "security_hardlight", + "colorMatrix": [], + "minLevel": 3, + "blendMode": 9, + "enabled": true + }, + { + "name": "bluemood_mpl", + "colorMatrix": [], + "minLevel": 3, + "blendMode": 2, + "enabled": true + }, + { + "name": "rusty_mpl", + "colorMatrix": [], + "minLevel": 3, + "blendMode": 2, + "enabled": true + }, + { + "name": "decr_conrast", + "colorMatrix": [ + 0.5, + 0, + 0, + 0, + 50, + 0, + 0.5, + 0, + 0, + 50, + 0, + 0, + 0.5, + 0, + 50, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 4, + "enabled": true + }, + { + "name": "green_2", + "colorMatrix": [ + 0.5, + 0.5, + 0.5, + 0, + 0, + 0.5, + 0.5, + 0.5, + 0, + 90, + 0.5, + 0.5, + 0.5, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 4, + "enabled": true + }, + { + "name": "alien_hrd", + "colorMatrix": [], + "minLevel": 4, + "blendMode": 9, + "enabled": true + }, + { + "name": "color_3", + "colorMatrix": [ + 0.609, + 0.609, + 0.082, + 0, + 0, + 0.309, + 0.609, + 0.082, + 0, + 0, + 0.309, + 0.609, + 0.082, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 5, + "enabled": true + }, + { + "name": "color_4", + "colorMatrix": [ + 0.8, + -0.8, + 1, + 0, + 70, + 0.8, + -0.8, + 1, + 0, + 70, + 0.8, + -0.8, + 1, + 0, + 70, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 5, + "enabled": true + }, + { + "name": "toxic_hrd", + "colorMatrix": [], + "minLevel": 5, + "blendMode": 9, + "enabled": true + }, + { + "name": "hypersaturated", + "colorMatrix": [ + 2, + -1, + 0, + 0, + 0, + -1, + 2, + 0, + 0, + 0, + 0, + -1, + 2, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 6, + "enabled": true + }, + { + "name": "Yellow", + "colorMatrix": [ + 1, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 6, + "enabled": true + }, + { + "name": "misty_hrd", + "colorMatrix": [], + "minLevel": 6, + "blendMode": 9, + "enabled": true + }, + { + "name": "x_ray", + "colorMatrix": [ + 0, + 1.2, + 0, + 0, + -100, + 0, + 2, + 0, + 0, + -120, + 0, + 2, + 0, + 0, + -120, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 7, + "enabled": true + }, + { + "name": "decrease_saturation", + "colorMatrix": [ + 0.7, + 0.2, + 0.2, + 0, + 0, + 0.2, + 0.7, + 0.2, + 0, + 0, + 0.2, + 0.2, + 0.7, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 7, + "enabled": true + }, + { + "name": "drops_mpl", + "colorMatrix": [], + "minLevel": 8, + "blendMode": 2, + "enabled": true + }, + { + "name": "shiny_hrd", + "colorMatrix": [], + "minLevel": 9, + "blendMode": 9, + "enabled": true + }, + { + "name": "glitter_hrd", + "colorMatrix": [], + "minLevel": 10, + "blendMode": 9, + "enabled": true + }, + { + "name": "frame_gold", + "colorMatrix": [], + "minLevel": 10, + "blendMode": 0, + "enabled": true + }, + { + "name": "frame_gray_4", + "colorMatrix": [], + "minLevel": 10, + "blendMode": 0, + "enabled": true + }, + { + "name": "frame_black_2", + "colorMatrix": [], + "minLevel": 10, + "blendMode": 0, + "enabled": true + }, + { + "name": "frame_wood_2", + "colorMatrix": [], + "minLevel": 10, + "blendMode": 0, + "enabled": true + }, + { + "name": "finger_nrm", + "colorMatrix": [], + "minLevel": 10, + "blendMode": 0, + "enabled": true + }, + { + "name": "color_5", + "colorMatrix": [ + 3.309, + 0.609, + 1.082, + 0.2, + 0, + 0.309, + 0.609, + 0.082, + 0, + 0, + 1.309, + 0.609, + 0.082, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 10, + "enabled": true + }, + { + "name": "black_white_negative", + "colorMatrix": [ + -0.5, + -0.5, + -0.5, + 0, + 0, + -0.5, + -0.5, + -0.5, + 0, + 0, + -0.5, + -0.5, + -0.5, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 10, + "enabled": true + }, + { + "name": "blue", + "colorMatrix": [ + 0.5, + 0.5, + 0.5, + 0, + -255, + 0.5, + 0.5, + 0.5, + 0, + -170, + 0.5, + 0.5, + 0.5, + 0, + 0, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 10, + "enabled": true + }, + { + "name": "red", + "colorMatrix": [ + 0.5, + 0.5, + 0.5, + 0, + 0, + 0.5, + 0.5, + 0.5, + 0, + -170, + 0.5, + 0.5, + 0.5, + 0, + -170, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 10, + "enabled": true + }, + { + "name": "green", + "colorMatrix": [ + 0.5, + 0.5, + 0.5, + 0, + -170, + 0.5, + 0.5, + 0.5, + 0, + 0, + 0.5, + 0.5, + 0.5, + 0, + -170, + 0, + 0, + 0, + 1, + 0 + ], + "minLevel": 10, + "enabled": true + } + ], + "notification": { + "notification.admin.transient": { + "display": "POP_UP", + "image": "${image.library.url}/album1358/frank_wave_001.gif" + }, + "notification.builders_club.membership_expired": { + "display": "POP_UP" + }, + "notification.builders_club.membership_expires": { + "display": "POP_UP", + "image": "${image.library.url}/notifications/builders_club_room_locked_small.png" + }, + "notification.builders_club.membership_extended": { + "delivery": "PERSISTENT", + "display": "POP_UP" + }, + "notification.builders_club.membership_made": { + "delivery": "PERSISTENT", + "display": "POP_UP", + "image": "${image.library.url}/notifications/builders_club_membership_extended.png" + }, + "notification.builders_club.membership_renewed": { + "delivery": "PERSISTENT", + "display": "POP_UP", + "image": "${image.library.url}/notifications/builders_club_membership_extended.png" + }, + "notification.builders_club.room_locked": { + "display": "BUBBLE", + "image": "${image.library.url}/notifications/builders_club_room_locked_small.png" + }, + "notification.builders_club.room_unlocked": { + "display": "BUBBLE" + }, + "notification.builders_club.visit_denied_for_owner": { + "display": "BUBBLE", + "image": "${image.library.url}/notifications/builders_club_room_locked_small.png" + }, + "notification.builders_club.visit_denied_for_visitor": { + "display": "POP_UP", + "image": "${image.library.url}/notifications/builders_club_room_locked.png" + }, + "notification.campaign.credit.donation": { + "display": "BUBBLE" + }, + "notification.campaign.product.donation": { + "display": "BUBBLE" + }, + "notification.casino.too_many_dice.placement": { + "display": "POP_UP" + }, + "notification.casino.too_many_dice": { + "display": "POP_UP" + }, + "notification.cfh.created": { + "display": "POP_UP", + "title": "" + }, + "notification.feed.enabled": false, + "notification.floorplan_editor.error": { + "display": "POP_UP" + }, + "notification.forums.delivered": { + "delivery": "PERSISTENT", + "display": "POP_UP" + }, + "notification.forums.forum_settings_updated": { + "display": "BUBBLE" + }, + "notification.forums.message.hidden": { + "display": "BUBBLE" + }, + "notification.forums.message.restored": { + "display": "BUBBLE" + }, + "notification.forums.thread.hidden": { + "display": "BUBBLE" + }, + "notification.forums.thread.locked": { + "display": "BUBBLE" + }, + "notification.forums.thread.pinned": { + "display": "BUBBLE" + }, + "notification.forums.thread.restored": { + "display": "BUBBLE" + }, + "notification.forums.thread.unlocked": { + "display": "BUBBLE" + }, + "notification.forums.thread.unpinned": { + "display": "BUBBLE" + }, + "notification.furni_placement_error": { + "display": "BUBBLE" + }, + "notification.gifting.valentine": { + "delivery": "PERSISTENT", + "display": "BUBBLE", + "image": "${image.library.url}/notifications/polaroid_photo.png" + }, + "notification.items.enabled": true, + "notification.mute.forbidden.time": { + "display": "BUBBLE" + }, + "notification.npc.gift.received": { + "display": "BUBBLE", + "image": "${image.library.url}/album1584/X1517.gif" + } + } +} \ No newline at end of file diff --git a/scripts/asset-codec.mjs b/scripts/asset-codec.mjs new file mode 100644 index 0000000..9e82654 --- /dev/null +++ b/scripts/asset-codec.mjs @@ -0,0 +1,13 @@ +const KEY = new TextEncoder().encode('slogga-dist-assets-2026'); + +export const encodeBytes = bytes => +{ + const output = new Uint8Array(bytes.length); + + for(let index = 0; index < bytes.length; index++) + { + output[index] = bytes[index] ^ KEY[index % KEY.length] ^ ((index * 31) & 255); + } + + return output; +}; diff --git a/scripts/minify-dist.mjs b/scripts/minify-dist.mjs new file mode 100644 index 0000000..a5c7970 --- /dev/null +++ b/scripts/minify-dist.mjs @@ -0,0 +1,88 @@ +import { encodeBytes } from './asset-codec.mjs'; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { gzipSync } from 'zlib'; + +const dist = 'dist'; +const buildVersion = Date.now().toString(36); + +const walk = dir => +{ + const out = []; + + for(const entry of readdirSync(dir)) + { + const path = join(dir, entry); + const stat = statSync(path); + + if(stat.isDirectory()) out.push(...walk(path)); + else out.push(path); + } + + return out; +}; + +const minifyJson = path => +{ + try + { + writeFileSync(path, JSON.stringify(JSON.parse(readFileSync(path, 'utf8')))); + } + catch {} +}; + +const encryptFile = path => +{ + const bytes = gzipSync(readFileSync(path), { level: 9 }); + writeFileSync(path + '.dat', encodeBytes(bytes)); + rmSync(path); +}; + +if(!existsSync(dist)) throw new Error('dist folder not found'); + +for(const file of walk(dist)) +{ + if(file.endsWith('.json')) minifyJson(file); +} + +for(const file of [ 'renderer-config.json', 'ui-config.json' ]) +{ + const target = join(dist, file); + if(existsSync(target)) rmSync(target); +} + +for(const file of walk(dist)) +{ + if(file.endsWith('.js') && !file.endsWith('asset-loader.js')) encryptFile(file); + if(file.endsWith('.css')) encryptFile(file); +} + +const assetMirrorDir = join(dist, 'src', 'assets'); +mkdirSync(assetMirrorDir, { recursive: true }); + +for(const file of [ 'app.css.dat', 'app.js.dat' ]) +{ + const source = join(dist, 'assets', file); + const target = join(assetMirrorDir, file); + + if(existsSync(source)) copyFileSync(source, target); +} + +const publicLoaderAssets = [ + [ 'src/assets/images/loading/loading.gif', 'loading.gif' ], + [ 'src/assets/images/notifications/nitro_v3.png', 'nitro_v3.png' ] +]; + +for(const [ source, file ] of publicLoaderAssets) +{ + const target = join(dist, 'assets', file); + const mirrorTarget = join(assetMirrorDir, file); + + if(existsSync(source)) + { + copyFileSync(source, target); + copyFileSync(source, mirrorTarget); + } +} + +writeFileSync(join(dist, 'index.html'), `
`); diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs new file mode 100644 index 0000000..995e49c --- /dev/null +++ b/scripts/write-asset-loader.mjs @@ -0,0 +1,8 @@ +import { mkdirSync, writeFileSync } from 'fs'; +import { dirname, resolve } from 'path'; + +const loader = `(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='
'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\\.\\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})();`; +const target = resolve('public', 'asset-loader.js'); + +mkdirSync(dirname(target), { recursive: true }); +writeFileSync(target, loader); diff --git a/src/App.tsx b/src/App.tsx index 94128b3..ba9aff5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { GetUIVersion } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; @@ -10,13 +10,51 @@ import { useMessageEvent, useNitroEvent } from './hooks'; NitroVersion.UI_VERSION = GetUIVersion(); +const preloadUrl = async (url: string): Promise => +{ + if(!url) return; + + try + { + const response = await fetch(url, { cache: 'force-cache' }); + await response.arrayBuffer(); + } + catch {} +}; + +const preloadImage = (url: string): void => +{ + if(!url) return; + + try + { + const image = new Image(); + image.decoding = 'async'; + image.src = url; + } + catch {} +}; + +const asStringArray = (value: unknown): string[] => +{ + if(Array.isArray(value)) return value.filter(item => typeof item === 'string'); + if(typeof value === 'string' && value.length) return [ value ]; + + return []; +}; + export const App: FC<{}> = props => { const [ isReady, setIsReady ] = useState(false); const [ errorMessage, setErrorMessage ] = useState(''); const [ homeUrl, setHomeUrl ] = useState(''); - const [ showLogin, setShowLogin ] = useState(false); + const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket']); + const [ isEnteringHotel, setIsEnteringHotel ] = useState(false); const [ prepareTrigger, setPrepareTrigger ] = useState(0); + const warmupPromiseRef = useRef>(null); + const rendererPromiseRef = useRef>(null); + const tickersStartedRef = useRef(false); + const heartbeatIntervalRef = useRef(null); const showSessionExpired = useCallback(() => { const baseUrl = window.location.origin + '/'; @@ -24,13 +62,15 @@ export const App: FC<{}> = props => setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); setIsReady(false); setShowLogin(false); + setIsEnteringHotel(false); }, []); const handleAuthenticated = useCallback((ssoTicket: string) => { if(!ssoTicket) return; window.NitroConfig['sso.ticket'] = ssoTicket; - setShowLogin(false); + GetConfiguration().setValue('sso.ticket', ssoTicket); + setIsEnteringHotel(true); setErrorMessage(''); setPrepareTrigger(prev => prev + 1); }, []); @@ -47,10 +87,89 @@ export const App: FC<{}> = props => LegacyExternalInterface.callGame('showGame', parser.url); }); + const startRenderer = useCallback((width: number, height: number) => + { + if(rendererPromiseRef.current) return rendererPromiseRef.current; + + const rawUseBackBuffer = window.NitroConfig?.['renderer.useBackBuffer']; + const useBackBuffer = (rawUseBackBuffer === undefined) + ? true + : ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true')); + + rendererPromiseRef.current = PrepareRenderer({ + width: Math.floor(width), + height: Math.floor(height), + resolution: window.devicePixelRatio, + autoDensity: true, + backgroundAlpha: 0, + preference: 'webgl', + eventMode: 'none', + failIfMajorPerformanceCaveat: false, + roundPixels: true, + useBackBuffer + }); + + return rendererPromiseRef.current; + }, []); + + const startWarmup = useCallback((width: number, height: number) => + { + if(warmupPromiseRef.current) return warmupPromiseRef.current; + + warmupPromiseRef.current = (async () => + { + await GetConfiguration().init(); + + GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); + NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); + NitroLogger.LOG_WARN = GetConfiguration().getValue('system.log.warn', false); + NitroLogger.LOG_ERROR = GetConfiguration().getValue('system.log.error', false); + NitroLogger.LOG_EVENTS = GetConfiguration().getValue('system.log.events', false); + NitroLogger.LOG_PACKETS = GetConfiguration().getValue('system.log.packets', false); + + startRenderer(width, height).catch(error => NitroLogger.error('[LoginScreen] Renderer warmup failed', error)); + + const interpolate = (value: string) => GetConfiguration().interpolate(value); + const assetUrls = asStringArray(GetConfiguration().getValue('preload.assets.urls')).map(interpolate); + const gamedataUrls = [ + ...asStringArray(GetConfiguration().getValue('external.texts.url')).map(interpolate), + ...[ + 'furnidata.url', + 'productdata.url', + 'avatar.actions.url', + 'avatar.figuredata.url', + 'avatar.figuremap.url', + 'avatar.effectmap.url' + ].map(key => interpolate(GetConfiguration().getValue(key, ''))).filter(Boolean) + ]; + const loginImages = ((GetConfiguration().getValue>('loginview', {})?.images) as Record) ?? {}; + const loginImageUrls = [ + loginImages.background, + loginImages.sun, + loginImages.drape, + loginImages.left, + loginImages['right.repeat'], + loginImages.right + ].filter(Boolean).map(interpolate); + + loginImageUrls.forEach(preloadImage); + gamedataUrls.forEach(url => preloadUrl(url)); + + await Promise.all( + [ + GetAssetManager().downloadAssets(assetUrls), + GetLocalizationManager().init(), + GetAvatarRenderManager().init(), + GetSoundManager().init() + ] + ); + })(); + + return warmupPromiseRef.current; + }, [ startRenderer ]); + useEffect(() => { - let heartbeatInterval: number = null; - const prepare = async (width: number, height: number) => { try @@ -58,6 +177,7 @@ export const App: FC<{}> = props => if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); const ssoTicket = window.NitroConfig['sso.ticket']; + if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); if(!ssoTicket || ssoTicket === '') { @@ -79,62 +199,29 @@ export const App: FC<{}> = props => { setIsReady(false); setShowLogin(true); + startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error)); return; } - if(configInitError) - { - setHomeUrl(window.location.origin + '/'); - setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); - setIsReady(false); - setShowLogin(false); - return; - } + if(configInitError) + { + setHomeUrl(window.location.origin + '/'); + setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); + setIsReady(false); + setShowLogin(false); + setIsEnteringHotel(false); + return; + } showSessionExpired(); return; } - const rawUseBackBuffer = window.NitroConfig['renderer.useBackBuffer']; - const useBackBuffer = (rawUseBackBuffer === undefined) - ? true - : ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true')); - - const renderer = await PrepareRenderer({ - width: Math.floor(width), - height: Math.floor(height), - resolution: window.devicePixelRatio, - autoDensity: true, - backgroundAlpha: 0, - preference: 'webgl', - eventMode: 'none', - failIfMajorPerformanceCaveat: false, - roundPixels: true, - useBackBuffer // Keep disabled by default unless explicitly enabled in NitroConfig - }); - - await GetConfiguration().init(); - - GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); - NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); - NitroLogger.LOG_WARN = GetConfiguration().getValue('system.log.warn', false); - NitroLogger.LOG_ERROR = GetConfiguration().getValue('system.log.error', false); - NitroLogger.LOG_EVENTS = GetConfiguration().getValue('system.log.events', false); - NitroLogger.LOG_PACKETS = GetConfiguration().getValue('system.log.packets', false); - - const assetUrls = GetConfiguration().getValue('preload.assets.urls').map(url => GetConfiguration().interpolate(url)) ?? []; - - await Promise.all( - [ - GetAssetManager().downloadAssets(assetUrls), - GetLocalizationManager().init(), - GetAvatarRenderManager().init(), - GetSoundManager().init(), - GetSessionDataManager().init(), - GetRoomSessionManager().init() - ] - ); + const renderer = await startRenderer(width, height); + await startWarmup(width, height); + await GetSessionDataManager().init(); + await GetRoomSessionManager().init(); await GetRoomEngine().init(); await GetCommunication().init(); @@ -142,17 +229,25 @@ export const App: FC<{}> = props => HabboWebTools.sendHeartBeat(); - heartbeatInterval = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000); + if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000); - GetTicker().add(ticker => GetRoomEngine().update(ticker)); - GetTicker().add(ticker => renderer.render(GetStage())); - GetTicker().add(ticker => GetTexturePool().run()); + if(!tickersStartedRef.current) + { + tickersStartedRef.current = true; + GetTicker().add(ticker => GetRoomEngine().update(ticker)); + GetTicker().add(ticker => renderer.render(GetStage())); + GetTicker().add(ticker => GetTexturePool().run()); + } setIsReady(true); + setShowLogin(false); + setIsEnteringHotel(false); } catch(err) { NitroLogger.error(err); + setIsEnteringHotel(false); showSessionExpired(); } }; @@ -161,15 +256,15 @@ export const App: FC<{}> = props => return () => { - if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval); + if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); }; - }, [ prepareTrigger ]); + }, [ prepareTrigger, startWarmup, startRenderer ]); return ( - { !isReady && !showLogin && + { !isReady && !showLogin && errorMessage.length > 0 && 0 } message={ errorMessage } homeUrl={ homeUrl } /> } - { !isReady && showLogin && } + { !isReady && showLogin && } { isReady && } diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..c990925 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,41 @@ +import { installSecureFetch, secureUrl } from './secure-assets'; + +installSecureFetch(); + +const setBootDebug = (message: string) => +{ + try + { + (window as any).__nitroBootDebug = message; + const secureNode = document.getElementById('nitro-secure-debug'); + + if(secureNode) secureNode.textContent = `${ secureNode.textContent }\n${ message }`; + } + catch {} +}; + +setBootDebug('boot: secure fetch installed'); + +const search = new URLSearchParams(window.location.search); + +(window as any).NitroSecureApiUrl = 'https://nitro.slogga.it:2096'; +(window as any).NitroConfig = { + 'config.urls': [ + secureUrl('config', 'renderer-config.json'), + secureUrl('config', 'ui-config.json') + ], + 'sso.ticket': search.get('sso') || null, + 'forward.type': search.get('room') ? 2 : -1, + 'forward.id': search.get('room') || 0, + 'friend.id': search.get('friend') || 0 +}; + +setBootDebug('boot: NitroConfig assigned'); + +import('./index') + .then(() => setBootDebug('boot: app bundle imported')) + .catch(error => + { + setBootDebug(`boot: import failed ${ error?.message || error }`); + throw error; + }); diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx index c8bb131..a2e6a6d 100644 --- a/src/components/loading/LoadingView.tsx +++ b/src/components/loading/LoadingView.tsx @@ -11,11 +11,9 @@ export const LoadingView: FC = props => { const { isError = false, message = '', homeUrl = '' } = props; return ( - + - { !isError && - } { isError && (message && message.length) ? @@ -31,13 +29,10 @@ export const LoadingView: FC = props => { } - : - - The hotel is loading ... - + : null } ); -}; \ No newline at end of file +}; diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 7ab8457..218195a 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -16,6 +16,13 @@ const LOCK_KEY = 'nitro.login.lock'; const MAX_ATTEMPTS = 5; const LOCK_WINDOW_MS = 60_000; const LOCK_DURATION_MS = 2 * 60_000; +const DEFAULT_LOGIN_IMAGES: Record = { + background: 'https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png', + 'background.colour': '#6eadc8', + drape: 'https://hotel.slogga.it/client/nitro/images/reception/drape.png', + left: 'https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png', + right: 'https://hotel.slogga.it/client/nitro/images/reception/background_right.png' +}; type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; @@ -39,9 +46,10 @@ const writeLock = (state: AttemptState) => export interface LoginViewProps { onAuthenticated: (ssoTicket: string) => void; + isEntering?: boolean; } -export const LoginView: FC = ({ onAuthenticated }) => +export const LoginView: FC = ({ onAuthenticated, isEntering = false }) => { const [ mode, setMode ] = useState('login'); const [ username, setUsername ] = useState(''); @@ -55,7 +63,8 @@ export const LoginView: FC = ({ onAuthenticated }) => const [ loginPingingServer, setLoginPingingServer ] = useState(false); const submitTimeRef = useRef(0); - const loginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; + const configuredLoginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; + const loginImages: Record = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages }; const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); @@ -64,6 +73,8 @@ export const LoginView: FC = ({ onAuthenticated }) => const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); + const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right ]); + const [ loginImagesVersion, setLoginImagesVersion ] = useState(0); const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); @@ -86,6 +97,30 @@ export const LoginView: FC = ({ onAuthenticated }) => if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); + useEffect(() => + { + if(!loginImageUrls.length) return; + + let cancelled = false; + + loginImageUrls.forEach(url => + { + const image = new Image(); + + image.onload = image.onerror = () => + { + if(!cancelled) setLoginImagesVersion(version => version + 1); + }; + + image.src = url; + }); + + return () => + { + cancelled = true; + }; + }, [ loginImageUrls ]); + useEffect(() => { if(!info) return; @@ -138,7 +173,7 @@ export const LoginView: FC = ({ onAuthenticated }) => return { ok: response.ok, status: response.status, payload }; }, []); - const healthUrl = GetConfigurationValue('login.health.endpoint', '/api/health'); + const healthUrl = GetConfigurationValue('login.health.endpoint', ''); const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); const checkServerReachable = useCallback(async (): Promise => @@ -196,7 +231,7 @@ export const LoginView: FC = ({ onAuthenticated }) => { event.preventDefault(); - if(submitting) return; + if(submitting || isEntering) return; const nowTs = Date.now(); if(nowTs - submitTimeRef.current < 1000) return; @@ -263,7 +298,7 @@ export const LoginView: FC = ({ onAuthenticated }) => { setSubmitting(false); } - }, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); + }, [ submitting, isEntering, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); @@ -409,12 +444,15 @@ export const LoginView: FC = ({ onAuthenticated }) => className="nitro-login-view" style={ backgroundColor ? { background: backgroundColor } : undefined } > - { background ?
: null } - { sun ?
: null } - { drape ?
: null } - { left ?
: null } + { background ? : null } + { sun ? : null } + { drape ? : null } + { left ? : null } { rightRepeat ?
: null } - { right ?
: null } + { right ? : null } +
@@ -475,8 +513,8 @@ export const LoginView: FC = ({ onAuthenticated }) => + disabled={ submitting || isEntering || isLocked || loginServerReachable === false || loginPingingServer } + >{ isEntering ? 'Entrando…' : loginPingingServer ? 'Checking…' : 'OK' }
setMode('forgot') }>Forgotten your password? diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index 984a68f..78c9d52 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -12,6 +12,28 @@ position: absolute; background-repeat: no-repeat; pointer-events: none; + transform: translateZ(0); +} + +.nitro-login-view .login-layer-img { + display: block; + user-select: none; + -webkit-user-drag: none; + object-fit: none; +} + +.nitro-login-view .login-image-preloader { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + +.nitro-login-view .login-image-preloader img { + width: 1px; + height: 1px; } .nitro-login-view .login-background { @@ -19,8 +41,8 @@ left: 0; width: 100%; height: 100%; - background-repeat: repeat-x; - background-position: center top; + object-fit: cover; + object-position: center top; } .nitro-login-view .login-sun { @@ -28,8 +50,8 @@ transform: translateX(-50%); width: 600px; height: 600px; - background-size: contain; - background-position: center top; + object-fit: contain; + object-position: center top; } .nitro-login-view .login-drape { @@ -38,6 +60,8 @@ width: 190px; height: 220px; z-index: 3; + object-fit: contain; + object-position: left top; } .nitro-login-view .login-left { @@ -45,9 +69,8 @@ left: 0; width: 100%; height: 100%; - background-position: left bottom; - background-size: auto; - background-repeat: no-repeat; + object-fit: none; + object-position: left bottom; } .nitro-login-view .login-right-repeat { @@ -64,7 +87,8 @@ right: 0; width: 400px; height: 100%; - background-position: right bottom; + object-fit: none; + object-position: right bottom; } /* ─── Foreground Login Card Stack ─── */ diff --git a/src/secure-assets.ts b/src/secure-assets.ts new file mode 100644 index 0000000..6957316 --- /dev/null +++ b/src/secure-assets.ts @@ -0,0 +1,378 @@ +type SecureSession = { + publicKey: string; + key: CryptoKey; + fingerprint: string; +}; + +const isDebugEnabled = (): boolean => +{ + try + { + const search = new URLSearchParams(window.location.search); + + return search.get('secureDebug') === '1' || localStorage.getItem('nitro.secure.debug') === '1'; + } + catch + { + return false; + } +}; + +const setDebugState = (message: string): void => +{ + try + { + (window as any).__nitroSecureDebug = message; + const log = Array.isArray((window as any).__nitroSecureDebugLog) + ? (window as any).__nitroSecureDebugLog + : []; + + log.push(message); + (window as any).__nitroSecureDebugLog = log.slice(-50); + + if(!isDebugEnabled()) return; + + const existing = document.getElementById('nitro-secure-debug'); + + if(existing) + { + existing.textContent = (window as any).__nitroSecureDebugLog.slice(-8).join('\n'); + return; + } + + const node = document.createElement('div'); + node.id = 'nitro-secure-debug'; + node.style.position = 'fixed'; + node.style.left = '8px'; + node.style.bottom = '8px'; + node.style.zIndex = '2147483647'; + node.style.padding = '6px 8px'; + node.style.maxWidth = '70vw'; + node.style.background = 'rgba(0,0,0,0.85)'; + node.style.color = '#00ff90'; + node.style.font = '12px monospace'; + node.style.whiteSpace = 'pre-wrap'; + node.style.pointerEvents = 'none'; + node.textContent = (window as any).__nitroSecureDebugLog.slice(-8).join('\n'); + document.body.appendChild(node); + } + catch {} +}; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +let secureSessionPromise: Promise = null; +let installed = false; +const secureResponseCache = new Map>(); + +const bytesToBase64 = (bytes: ArrayBuffer): string => +{ + let binary = ''; + const view = new Uint8Array(bytes); + + for(let index = 0; index < view.length; index++) binary += String.fromCharCode(view[index]); + + return btoa(binary); +}; + +const hexValue = (code: number): number => +{ + if(code >= 48 && code <= 57) return code - 48; + if(code >= 65 && code <= 70) return code - 55; + if(code >= 97 && code <= 102) return code - 87; + + return -1; +}; + +const hexToBytes = (hex: string): Uint8Array => +{ + const normalized = hex.trim(); + + if((normalized.length % 2) !== 0) throw new Error('Invalid encrypted hex payload.'); + + const bytes = new Uint8Array(normalized.length / 2); + + for(let index = 0; index < bytes.length; index++) + { + const high = hexValue(normalized.charCodeAt(index * 2)); + const low = hexValue(normalized.charCodeAt((index * 2) + 1)); + + if(high < 0 || low < 0) throw new Error('Invalid encrypted hex payload.'); + + bytes[index] = (high << 4) | low; + } + + return bytes; +}; + +const deriveAesKey = async (privateKey: CryptoKey, serverKeyBase64: string): Promise<{ key: CryptoKey; fingerprint: string }> => +{ + const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0)); + const serverKey = await crypto.subtle.importKey( + 'spki', + serverBytes, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + + const secret = await crypto.subtle.deriveBits({ name: 'ECDH', public: serverKey }, privateKey, 256); + const salt = textEncoder.encode('nitro-secure-assets-v1'); + const material = new Uint8Array(secret.byteLength + salt.length); + material.set(new Uint8Array(secret), 0); + material.set(salt, secret.byteLength); + + const hash = await crypto.subtle.digest('SHA-256', material); + const fingerprintHash = await crypto.subtle.digest('SHA-256', hash); + const fingerprint = Array.from(new Uint8Array(fingerprintHash).slice(0, 8)).map(value => value.toString(16).padStart(2, '0')).join(''); + + return { + key: await crypto.subtle.importKey('raw', hash, 'AES-GCM', false, [ 'encrypt', 'decrypt' ]), + fingerprint + }; +}; + +const getApiBase = (): string => +{ + const configured = (window as any).NitroSecureApiUrl; + + if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, ''); + + return 'https://nitro.slogga.it:2096'; +}; + +export const secureUrl = (kind: 'config' | 'gamedata', file: string): string => +{ + const base = getApiBase(); + + return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }`; +}; + +const createSecureSession = async (): Promise => +{ + setDebugState('secure: generating ECDH session'); + + const pair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + [ 'deriveBits' ] + ); + const publicKey = await crypto.subtle.exportKey('spki', pair.publicKey); + const response = await fetch(`${ getApiBase() }/nitro-sec/bootstrap`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: bytesToBase64(publicKey) }) + }); + + if(!response.ok) throw new Error(`Secure bootstrap failed: HTTP ${ response.status }`); + + const fingerprint = response.headers.get('X-Nitro-Key-Fp') || 'none'; + const payload = await response.json(); + const serverKey = typeof payload.key === 'string' ? payload.key : ''; + const clientPublicKey = bytesToBase64(publicKey); + + if(!serverKey) throw new Error('Secure bootstrap returned an invalid server key.'); + + setDebugState(`secure: bootstrap ok fp=${ fingerprint }`); + + const derived = await deriveAesKey(pair.privateKey, serverKey); + + return { publicKey: clientPublicKey, key: derived.key, fingerprint: derived.fingerprint }; +}; + +export const getSecureSession = (): Promise => +{ + if(!secureSessionPromise) secureSessionPromise = createSecureSession(); + + return secureSessionPromise; +}; + +const decryptResponse = async (session: SecureSession, response: Response): Promise => +{ + setDebugState(`secure: decrypt start status=${ response.status }`); + const bytes = hexToBytes(await response.text()); + + if(bytes.length < 13) throw new Error('Encrypted response is too short.'); + + const iv = bytes.slice(0, 12); + const payload = bytes.slice(12); + const clear = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, session.key, payload); + const headers = new Headers(response.headers); + + headers.set('Content-Type', 'application/json; charset=utf-8'); + headers.delete('X-Nitro-Sec'); + + const text = textDecoder.decode(clear); + setDebugState(`secure: decrypt ok bytes=${ bytes.length }`); + + return new Response(text, { + status: response.status, + statusText: response.statusText, + headers + }); +}; + +const cloneCachedResponse = async (responsePromise: Promise): Promise => +{ + const response = await responsePromise; + + return response.clone(); +}; + +const normalizeSecureCacheKey = (requestUrl: string): string => +{ + try + { + const url = new URL(requestUrl, window.location.href); + + if(!url.pathname.includes('/nitro-sec/file')) return requestUrl; + + const kind = url.searchParams.get('kind') || ''; + const file = (url.searchParams.get('file') || '') + .replace(/^[\\/]+/, '') + .split('?')[0] + .split('#')[0]; + + return `${ url.origin }${ url.pathname }?kind=${ kind }&file=${ file }`; + } + catch + { + return requestUrl; + } +}; + +const bytesToHex = (bytes: Uint8Array): string => +{ + let output = ''; + + for(let index = 0; index < bytes.length; index++) output += bytes[index].toString(16).padStart(2, '0'); + + return output; +}; + +const encryptBytes = async (session: SecureSession, clear: ArrayBuffer): Promise => +{ + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, session.key, clear)); + const out = new Uint8Array(iv.length + encrypted.length); + + out.set(iv, 0); + out.set(encrypted, iv.length); + + return bytesToHex(out); +}; + +const isApiUrl = (requestUrl: string): boolean => +{ + try + { + return new URL(requestUrl, window.location.href).pathname.startsWith('/api/'); + } + catch + { + return requestUrl.startsWith('/api/'); + } +}; + +const readRequestBody = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise => +{ + if(method === 'GET' || method === 'HEAD') return null; + if(init?.body !== undefined) + { + if(typeof init.body === 'string') return textEncoder.encode(init.body).buffer; + if(init.body instanceof ArrayBuffer) return init.body; + if(ArrayBuffer.isView(init.body)) return init.body.buffer.slice(init.body.byteOffset, init.body.byteOffset + init.body.byteLength); + if(init.body instanceof Blob) return init.body.arrayBuffer(); + } + + if(input instanceof Request) return input.clone().arrayBuffer(); + + return null; +}; + +export const installSecureFetch = (): void => +{ + if(installed) return; + + installed = true; + const nativeFetch = window.fetch.bind(window); + + window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => + { + const requestUrl = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + + if(requestUrl.includes('/nitro-sec/file')) + { + const method = init?.method || (input instanceof Request ? input.method : 'GET'); + const cacheKey = method.toUpperCase() === 'GET' ? normalizeSecureCacheKey(requestUrl) : null; + + if(cacheKey && secureResponseCache.has(cacheKey)) return cloneCachedResponse(secureResponseCache.get(cacheKey)); + + const responsePromise = (async () => + { + const session = await getSecureSession(); + setDebugState(`secure: fetching ${ requestUrl }`); + const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); + + headers.set('X-Nitro-Key', session.publicKey); + + const response = await nativeFetch(input, { ...init, headers }); + setDebugState(`secure: response ${ response.status } encrypted=${ response.headers.get('X-Nitro-Sec') === '1' } fp=${ response.headers.get('X-Nitro-Key-Fp') || 'none' } derive=${ response.headers.get('X-Nitro-Derive-Fp') || 'none' } client=${ session.fingerprint }`); + + if(response.headers.get('X-Nitro-Sec') === '1') + { + try + { + const decrypted = await decryptResponse(session, response); + setDebugState(`secure: decrypted ${ requestUrl }`); + return decrypted; + } + catch(error) + { + setDebugState(`secure: decrypt failed ${ (error as Error)?.message || error }`); + throw error; + } + } + + setDebugState(`secure: plain response ${ requestUrl } status=${ response.status }`); + + return response; + })(); + + if(cacheKey) secureResponseCache.set(cacheKey, responsePromise); + + return cloneCachedResponse(responsePromise); + } + + if(isApiUrl(requestUrl)) + { + const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase(); + const session = await getSecureSession(); + const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); + const clearBody = await readRequestBody(input, init, method); + const encryptedInit: RequestInit = { ...init, method, headers }; + + headers.set('X-Nitro-Key', session.publicKey); + headers.set('X-Nitro-Api', '1'); + + if(clearBody) + { + encryptedInit.body = await encryptBytes(session, clearBody); + headers.set('Content-Type', 'text/plain; charset=utf-8'); + } + + const response = await nativeFetch(input, encryptedInit); + + if(response.headers.get('X-Nitro-Sec') === '1') return decryptResponse(session, response); + + return response; + } + + return nativeFetch(input, init); + }; +}; diff --git a/vite.config.mjs b/vite.config.mjs index 8fcb161..e8fa8ff 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -48,20 +48,17 @@ export default defineConfig({ } }, build: { - assetsInlineLimit: 102400, + assetsInlineLimit: 4096, chunkSizeWarningLimit: 200000, rollupOptions: { + input: resolve(__dirname, 'index.html'), output: { - assetFileNames: 'src/assets/[name]-[hash].[ext]', - manualChunks: id => - { - if(id.includes('node_modules')) - { - if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3') || id.includes('Nitro_Render_V3')) return 'nitro-renderer'; - - return 'vendor'; - } - } + inlineDynamicImports: true, + entryFileNames: 'assets/app.js', + chunkFileNames: 'assets/app.js', + assetFileNames: assetInfo => assetInfo.name && assetInfo.name.endsWith('.css') + ? 'assets/app.css' + : 'src/assets/[name]-[hash].[ext]' } } } From 541d3045f1b2d03d31e6ae2b40fd4b4dece5f80d Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Thu, 23 Apr 2026 16:26:32 +0200 Subject: [PATCH 06/11] Update secure login flow and login view --- public/renderer-config.json | 7 +- public/ui-config.json | 35 ++- src/App.tsx | 119 +++++-- src/api/utils/GetLocalStorage.ts | 2 +- src/api/utils/RememberLogin.ts | 59 ++++ src/api/utils/index.ts | 1 + src/assets/images/flag_icon/flag_icon_br.png | Bin 0 -> 2096 bytes src/assets/images/flag_icon/flag_icon_de.png | Bin 0 -> 1643 bytes src/assets/images/flag_icon/flag_icon_en.png | Bin 0 -> 2584 bytes src/assets/images/flag_icon/flag_icon_es.png | Bin 0 -> 1658 bytes src/assets/images/flag_icon/flag_icon_fi.png | Bin 0 -> 1783 bytes src/assets/images/flag_icon/flag_icon_fr.png | Bin 0 -> 1743 bytes src/assets/images/flag_icon/flag_icon_it.png | Bin 0 -> 1745 bytes src/assets/images/flag_icon/flag_icon_nl.png | Bin 0 -> 1679 bytes .../images/flag_icon/flag_icon_selected.png | Bin 0 -> 9336 bytes src/assets/images/flag_icon/flag_icon_tr.png | Bin 0 -> 1868 bytes src/bootstrap.ts | 6 +- src/components/loading/LoadingView.tsx | 12 +- src/components/login/LoginView.tsx | 296 ++++++++++++++++-- src/components/purse/PurseView.tsx | 6 +- src/css/login/LoginView.css | 189 ++++++++++- src/hooks/translation/useTranslation.ts | 73 +++-- src/secure-assets.ts | 86 ++++- vite.config.mjs | 16 +- 24 files changed, 801 insertions(+), 106 deletions(-) create mode 100644 src/api/utils/RememberLogin.ts create mode 100644 src/assets/images/flag_icon/flag_icon_br.png create mode 100644 src/assets/images/flag_icon/flag_icon_de.png create mode 100644 src/assets/images/flag_icon/flag_icon_en.png create mode 100644 src/assets/images/flag_icon/flag_icon_es.png create mode 100644 src/assets/images/flag_icon/flag_icon_fi.png create mode 100644 src/assets/images/flag_icon/flag_icon_fr.png create mode 100644 src/assets/images/flag_icon/flag_icon_it.png create mode 100644 src/assets/images/flag_icon/flag_icon_nl.png create mode 100644 src/assets/images/flag_icon/flag_icon_selected.png create mode 100644 src/assets/images/flag_icon/flag_icon_tr.png diff --git a/public/renderer-config.json b/public/renderer-config.json index 170ae55..634694b 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -1,11 +1,11 @@ { - "socket.url": "wss://nitro.slogga.it:2096", - "api.url": "https://nitro.slogga.it:2096", + "socket.url": "ws://192.168.1.52:2096", + "api.url": "http://192.168.1.52:2096", "asset.url": "https://hotel.slogga.it/client/nitro/bundled", "image.library.url": "https://hotel.slogga.it/client/c_images/", "hof.furni.url": "https://hotel.slogga.it/client/c_images/dcr/hof_furni", "images.url": "https://hotel.slogga.it/client/nitro/images", - "gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=", + "gamedata.url": "http://192.168.1.52:2096/nitro-sec/file?kind=gamedata&file=", "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ "${gamedata.url}/ExternalTexts.json", @@ -47,6 +47,7 @@ "login.register.endpoint": "${api.url}/api/auth/register", "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", "login.turnstile.enabled": false, "login.turnstile.sitekey": "", "avatar.mandatory.libraries": [ diff --git a/public/ui-config.json b/public/ui-config.json index d065661..50cef22 100644 --- a/public/ui-config.json +++ b/public/ui-config.json @@ -30,9 +30,7 @@ "show.google.ads": false, "loginview": { "images": { - "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", - "background.colour": "#6eadc8", - "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", + "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", "left": "https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png", "right": "https://hotel.slogga.it/client/nitro/images/reception/background_right.png" } @@ -1575,6 +1573,37 @@ "right.repeat": "" } }, + "loginview": { + "images": { + "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", + "background.colour": "#6eadc8", + "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", + "left": "https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.slogga.it/client/nitro/images/reception/background_right.png" + }, + "widgets": { + "slot.1.widget": "promoarticle", + "slot.1.conf": {}, + "slot.2.widget": "widgetcontainer", + "slot.2.conf": { + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "texts": "2021NitroPromo", + "btnLink": "" + }, + "slot.3.widget": "", + "slot.3.conf": {}, + "slot.4.widget": "", + "slot.4.conf": {}, + "slot.5.widget": "", + "slot.5.conf": {}, + "slot.6.widget": "", + "slot.6.conf": { + "campaign": "" + }, + "slot.7.widget": "", + "slot.7.conf": {} + } + }, "achievements.unseen.ignored": [ "ACH_AllTimeHotelPresence" ], diff --git a/src/App.tsx b/src/App.tsx index ba9aff5..0728274 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { GetUIVersion } from './api'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -43,13 +43,15 @@ const asStringArray = (value: unknown): string[] => return []; }; +const hasRememberLogin = (): boolean => !!GetRememberLogin(); + export const App: FC<{}> = props => { const [ isReady, setIsReady ] = useState(false); const [ errorMessage, setErrorMessage ] = useState(''); const [ homeUrl, setHomeUrl ] = useState(''); - const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket']); - const [ isEnteringHotel, setIsEnteringHotel ] = useState(false); + const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket'] && !hasRememberLogin()); + const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin()); const [ prepareTrigger, setPrepareTrigger ] = useState(0); const warmupPromiseRef = useRef>(null); const rendererPromiseRef = useRef>(null); @@ -65,14 +67,72 @@ export const App: FC<{}> = props => setIsEnteringHotel(false); }, []); - const handleAuthenticated = useCallback((ssoTicket: string) => + const applySsoTicket = useCallback((ssoTicket: string) => { if(!ssoTicket) return; window.NitroConfig['sso.ticket'] = ssoTicket; GetConfiguration().setValue('sso.ticket', ssoTicket); + }, []); + + const handleAuthenticated = useCallback((ssoTicket: string) => + { + if(!ssoTicket) return; + applySsoTicket(ssoTicket); setIsEnteringHotel(true); setErrorMessage(''); setPrepareTrigger(prev => prev + 1); + }, [ applySsoTicket ]); + + const tryRememberLogin = useCallback(async (): Promise => + { + const remembered = GetRememberLogin(); + + if(!remembered) return ''; + if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket; + + let allowSsoFallback = true; + + try + { + const rawEndpoint = GetConfiguration().getValue('login.remember.endpoint', '${api.url}/api/auth/remember'); + const endpoint = GetConfiguration().interpolate(rawEndpoint); + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroRememberLogin' + }, + body: JSON.stringify({ rememberToken: remembered.token }) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch {} + + const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); + + if(response.ok && ssoTicket) + { + StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket); + return ssoTicket; + } + + if(response.status === 400 || response.status === 401 || response.status === 403) + { + allowSsoFallback = false; + ClearRememberLogin(); + } + } + catch(error) + { + NitroLogger.error('[LoginScreen] Remember login failed', error); + } + + if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket; + + return ''; }, []); // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) @@ -176,7 +236,7 @@ export const App: FC<{}> = props => { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); - const ssoTicket = window.NitroConfig['sso.ticket']; + let ssoTicket = window.NitroConfig['sso.ticket']; if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); if(!ssoTicket || ssoTicket === '') @@ -197,24 +257,37 @@ export const App: FC<{}> = props => if(loginScreenEnabled) { - setIsReady(false); - setShowLogin(true); - startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error)); + const rememberedSsoTicket = await tryRememberLogin(); + + if(rememberedSsoTicket) + { + ssoTicket = rememberedSsoTicket; + applySsoTicket(rememberedSsoTicket); + setShowLogin(false); + } + else + { + setIsReady(false); + setShowLogin(true); + startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error)); + return; + } + } + else + { + if(configInitError) + { + setHomeUrl(window.location.origin + '/'); + setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); + setIsReady(false); + setShowLogin(false); + setIsEnteringHotel(false); + return; + } + + showSessionExpired(); return; } - - if(configInitError) - { - setHomeUrl(window.location.origin + '/'); - setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); - setIsReady(false); - setShowLogin(false); - setIsEnteringHotel(false); - return; - } - - showSessionExpired(); - return; } const renderer = await startRenderer(width, height); @@ -258,11 +331,11 @@ export const App: FC<{}> = props => { if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); }; - }, [ prepareTrigger, startWarmup, startRenderer ]); + }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket ]); return ( - { !isReady && !showLogin && errorMessage.length > 0 && + { !isReady && !showLogin && 0 } message={ errorMessage } homeUrl={ homeUrl } /> } { !isReady && showLogin && } { isReady && } diff --git a/src/api/utils/GetLocalStorage.ts b/src/api/utils/GetLocalStorage.ts index a4270cf..e82d44b 100644 --- a/src/api/utils/GetLocalStorage.ts +++ b/src/api/utils/GetLocalStorage.ts @@ -2,7 +2,7 @@ export const GetLocalStorage = (key: string) => { try { - JSON.parse(window.localStorage.getItem(key)) as T ?? null; + return JSON.parse(window.localStorage.getItem(key)) as T ?? null; } catch (e) { diff --git a/src/api/utils/RememberLogin.ts b/src/api/utils/RememberLogin.ts new file mode 100644 index 0000000..e886126 --- /dev/null +++ b/src/api/utils/RememberLogin.ts @@ -0,0 +1,59 @@ +export interface RememberLoginData +{ + token?: string; + ssoTicket?: string; + expiresAt: number; + username?: string; +} + +const REMEMBER_LOGIN_KEY = 'nitro.auth.remember'; +const DEFAULT_REMEMBER_SECONDS = 30 * 24 * 60 * 60; + +export const GetRememberLogin = (): RememberLoginData | null => +{ + try + { + const data = JSON.parse(window.localStorage.getItem(REMEMBER_LOGIN_KEY) || 'null') as RememberLoginData | null; + + if(!data?.token?.length && !data?.ssoTicket?.length) return null; + if(data.expiresAt && ((data.expiresAt * 1000) <= Date.now())) + { + ClearRememberLogin(); + return null; + } + + return data; + } + catch + { + return null; + } +}; + +export const SetRememberLogin = (data: RememberLoginData): void => +{ + if(!data?.token?.length && !data?.ssoTicket?.length) return; + + try { window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data)); } + catch {} +}; + +export const ClearRememberLogin = (): void => +{ + try { window.localStorage.removeItem(REMEMBER_LOGIN_KEY); } + catch {} +}; + +export const StoreRememberLoginFromPayload = (payload: Record, username?: string, ssoTicket?: string): void => +{ + const token = typeof payload.rememberToken === 'string' ? payload.rememberToken : ''; + const rawExpiresAt = payload.rememberExpiresAt; + const parsedExpiresAt = typeof rawExpiresAt === 'number' ? rawExpiresAt : Number(rawExpiresAt || 0); + const expiresAt = (Number.isFinite(parsedExpiresAt) && parsedExpiresAt > 0) + ? parsedExpiresAt + : Math.floor(Date.now() / 1000) + DEFAULT_REMEMBER_SECONDS; + + if(!token.length && !ssoTicket?.length) return; + + SetRememberLogin({ token: token || undefined, ssoTicket: ssoTicket || undefined, expiresAt, username }); +}; diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 1f22e7f..6e19efc 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -14,6 +14,7 @@ export * from './PlaySound'; export * from './PrefixUtils'; export * from './ProductImageUtility'; export * from './Randomizer'; +export * from './RememberLogin'; export * from './RoomChatFormatter'; export * from './SanitizeHtml'; export * from './SetLocalStorage'; diff --git a/src/assets/images/flag_icon/flag_icon_br.png b/src/assets/images/flag_icon/flag_icon_br.png new file mode 100644 index 0000000000000000000000000000000000000000..9d62a464e1639ae5aa6540d75fa22f48b71eff61 GIT binary patch literal 2096 zcmaJ?Yg7|w8jiOtpit2Qi-Jr8F2$NF1ajjhfdCTX2DFHV5R(B?NhVDOG6bYnw!458 zFNhp(p+^ul&_Z2z3k)pdrMHM=?Oe)CCoGLV@_6gUaWfYl-;h>4UyM?fG> zCdvf)ph%MUVIRoCVAlCblT+XnZURFL$p|7lhJeTvC>w)eg(C`)_$UYiN5D*}JQO#j z@5BL8Nht1pDwo7nM1fh-Sd9`)(j+E}HAlsC2`)SgU?B`tKnB7h0Fh9E~83+z0kb|hf!Q|Zlg+!(hN$4GnCkHX8 z!3;_Wuyo_Xv=g6TWKlaZRauPhi1&Mk>XzfMo<7}Pl)BmP*t*|u!j^QW&tWeCsUzxrJffR}*R?+f zeG|Xs!p7FtAia0ShR42GAB$JvvGf2qF~@bs!D%gwXh z5RmGVP?WK`5(?sCd9t^(kw4tKDVZL8X7Z%Tc0?4_jbn*frdpq{=bPcXW4)cZ&ZYs= zG1H-jdY6*scn)nX!Rv5BPf~|Dx^i+}F#5Xm=g|2wMU*4bSkRLsFke46 zd6IYADv*CE{AgjP4qba-K3i5m{>fKcn(P1K@a`vHXBDz|KV9}&s2F;c$Zo$FV%B|% zbaQSDEPQ)sxRJv$1Q=WOSFw1-5K56uqoa4Dys)EpBJbGWk?|7{Ovw9?}!Jk_1>Vy0{tU`-x(Y?+;NuXr7E z_ipuIQx)s2xt2^QjQh%VCGYKm=l>bRnZAeC+n8sf+qZnx|7LcqLEy9S;Nj50^_wc1 zTI^CzP9A&P_-iLCi2B9Qp)$4Y{@Sn45nJsc#VN#Nj`85b@Q?C4cdZfM@eo9qb)Wq0 zYD2$mYv=c61fPXld%MaFe>lTT)2ShF)O($}Yh+}9+YCNbdqW*=*2%1z3PQ#4>N^~k?Z!hed^hohu|&1u zQ~c;_+jv;pDR1jwEmCdCy}Tj8PdK69VU_gy3iCO7G)Z=txda&|2%B7aveJ-F?L%98 z&~wAB3Htl*G1L0SRTuto!D*g(r9Q3h$cMrk4$-C>3}=|e*6;k7>7&dm-aPTi%Dr92e@l9dmJeD9SZmM) zM7JSZa64tdps@s+rP)@#_4fE(}PPrYkQ zTXXt)-%itCzjEdEG#u>e*ix2zJ{|?tRhtekH2o2$Egm{?{7=5xVorn|GQo*(U9!`P znU?M3U*zw}@?`ay`b=>i?}UHM5_a#Lb_##`dG&j9n|b@2HTat&+hf{lFV49`mTeh5 zN%y>=OSRuETps-A*mVIOTiB`FP!Gx#tS{MBN z{2D!=t#>EZ7i`P^>_CmLw%+Y@fzyRaO=6>be*OgV0jGWTcjue@Un<8&9b;RzJ~6hU zP5um-N|=81FN-7XjAHMAE3bctLC$z;APFq9>1N$Qt3xc!tUERDmX3+h^I9Un8y)t4 OcwBTMyKztYiT?ulg?-Eb literal 0 HcmV?d00001 diff --git a/src/assets/images/flag_icon/flag_icon_de.png b/src/assets/images/flag_icon/flag_icon_de.png new file mode 100644 index 0000000000000000000000000000000000000000..6090d2c90aad5b46d6a1914565c08496d945b773 GIT binary patch literal 1643 zcmeAS@N?(olHy`uVBq!ia0vp^^MH5>2OE$~OPRg_NJ*BsMwA5Srr5%(GQ`zk9!uLS~AsQn;zFfp39xYDT62(s|s5su(?)1Hb_`sNdc^+B->UA;;0DU00rm#qErP_J!9Qu14BavGc!Fy z6H_xYLmdSp14AQy10XWfH8im@HM24@SAYT~plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OOP2xM!G;1y2X`wC5aWfdBw^w6I@b@lZ!G7N;32F6hI~>Cgqow z*eU^C3h_d20o>TUVrVb{15Cdnu|VHY&j92lm_lD){7Q3k;i`*Ef>IIg#cFVINM%8) zeo$(0erZuMFyhjbK~@!5ITxiSmgEr(2xdaO%|uIz}H9wMbD769T3m5EGtofgE_!Pt60S_ab1z7LxP-%fP^N*VDx@ zq~g|_+lGF(0~n4!w3(%Atn96>GDXwY!$K@N|B}p-nMx|}4g`gsYFMSQLuj3c@aLT4 z$KD^kuO6zTYOrJfze+xVZ@>R8NuFEpG)chfZ_1(M2_F`T9z85FOJMbUk){tPtZfzs z?pA#`F}I>=abd$D<=r2mSk333|0^8Ra5k+ttReJa?9MGJrQUZ+Uh2$YU9)2+hgg4! z`1;pNXP-@5SJbxccJBJq=^syIe_S=~+@aoV#@BlDk1DS{`a1jNw9`vh&slgi>+0)I zkv5m&Y`oKd?uxMMKE9#*`nR0)mpb=XK3!dXZF2=r(yecLVs@`ox7CRCn~1M}7pwC) zovTeB1mrmQ zA1c}i$Q@!vrZ_wP+1xw){(1B9&(q`ANx7Tp*Xzjby8=>=su0X;VFy~JV1rC?@VB&o zaHv40z*aRvY=lyn7Q+luF28(3cwgOpS(|-3i!WMitGyFxQ?rs4=tg8)pmrkL;!v?9 z=hheIdH3q-KYe@Gz54imIUTu3j^9peH`T|;+ia_t6Se2|@APi>L+N`24sG9`FLL<% z<;ebh=gU&_#rhM^RUDn~I{E1D|7)I~Nq;$O&RZS1X8pL6pDzBsBzjG(|BskHFr+_h zdLQ*#Z|+g$n@3-l{@lUyTrc+IqlwoZO`WATpH=4jp}7&eI*%&9Jo>uir$l7H#HE`n zRCZTP>%Zn5Ub3m~_iv!w{G;0S4m&l<13%VrXFTki_U`Uy9XUz8d%K&&Cq9opAgCL2 z^-`qGroF3kORk^JSabeoLbv<2?sP`oIY*WG*E8`joYVh)(r-&l8>smAboFyt=akR{ E05j@@ZvX%Q literal 0 HcmV?d00001 diff --git a/src/assets/images/flag_icon/flag_icon_en.png b/src/assets/images/flag_icon/flag_icon_en.png new file mode 100644 index 0000000000000000000000000000000000000000..5d1daa6e5bc15cadc2528fd68d683b46a4f53098 GIT binary patch literal 2584 zcmaJ@X;>3k7LA4m8^t(|3n;{N>~XY}Y>)sUVUe&CML>ZT!H~@mlVDOn0U`n;TL%y~ z2p}%4=qO8L5S3L#6BI-N1=(>jiV*=LvNer#g%-Pij8k7#y?Xb%=iGDOJMa4{#dC+V zf&Myu7z}3M>O%8^#(e0xv_uzrb68owK!XwB=m&UnIZv(q0Pa7f=~zzgHde3PzICF22jClE>B2Bk5<;AQ9KqE z?MtNN=%Ehm13Z@~G21(8hYvG~&$MEpZEa8#kPHzB*Z>0s3JwY-WRQxU=Oshm+AtQ4 znoj}vRP-OB{OF!22a%YKB4W%;nK(QiMIvGF=0p++zYRse;R#qAbdyZ+=42v?Ot3(G zzR*xLF^fa?qB(u81RU{M%AMMFyeXh9JAg;pr}yiCx7VL?VH7LUPcTUtn@)Bk^} zK=3770(i0i)cZe)B|ed%Y^)bsA_@~Tp^f8g)Rqb*JBZl~KqU4Ni4HETqUQk-Ad(yq zg`yn1NvO?q29qb$2DZ#2=ybBHPy#T7OtveHiiS8aJRXZoGb7N51S_J06UhpXcO+U_ z5}jyxGZN0yo@noAv3&tc6EVXCY$33KW&MdIeHE)UgCG>DOk<0A;cS+ZSR_ErH%;b! zU5nXQ`95P=U)N&(RV)?~gVk>BU$%N71o=n%`Ng=<p1BcnVkoHOH-zdK@&EXgh9RGAcSjpYOqW?olE%ExA^C#M5Q^3Istv%gKq z`)}1+SUdWkS(=$*gGxT1Og22Gm#`{dp4xNF^}?>?#}+^6>mz`*$YZ)i{FHm}o5!5% z;vyD#f#-|g$Lk9Tvb@OI%V`hxOx9L@`u>goljz@`9xB5jGptwd_kLM@cj+n`Jtc`C}RgJerpf7xl!J~Q*w`7o++nimOz2AHtJ+}0t+tUq}>!`(o+KE>{UUkV( z2ZC4=Gw1W7uP=XkPRO_M-dR44LpI<7hbBidMs{=Lgr}_^fSCzEf<5s!X-XbvCAo%_ z-v94)%ii#92*`$<=jD&X6~L9N9$a}%l#SYCYB(t9AJbLHnAq!|+NZ`uX7ZrDW3s{| zja|70w=Ym(MhUEJ4{4t{Jp8uh)vFl<4!I=%dE!JcQ#O*jbSG!ks7=`Erd5=?V znwo)vpG!Ssin&n}mw(QT)7T|x0t&isPZpjTm%473A8(|^WSp~(t(}=T*d}{7dS|h* zDh{I}vMc;vj}dqEtC$sX`rtLVRPW;Eu41>+j0;POfapCvWv#w%r%17%J`|+iA~pKd z$vX;WCqu87`}g@j0MA>hWd4nPX_km4{Hl~}%nGUA4YNMHd*eIeH%6k7f{iMBFC#<) zfUg6OCu*7sOG|uH%QW5Vob(-Xm#Y>TAwJ+u6ba^1ZiG%S7eB0GlBIf!-;V^m>jgJH zf?HJI`}OVf31ZE&zR@1K^WfqW=K1R9i8Za=HTTM-wk3f@Q|UU2giC3I{B*yqsh89G zZ)wUt2I7%dQBi{zM?3Q;G$+mWp$rkg`kgcQRbJ^WF1-LWdlDky_BkEoS41*nms0vO z^dg%=zQeX?l7SWv_!d?F5E%2y`Vd6jk8pbOvo>BJ#5>VMLIHQl6PC(03PosY-gtyU#BD@H?%P*cfn5u z?};bNG}?}hrQE2p0`B_t!%EMo@Rec<69wGFC}S)DfJ3P(V9EfU*7m$R+3A6%mriS7 zT4^z=*(v4eu89X(flU(Umdv}LouVFqv@L>$qZ*isLB0#3UVwu&mAh-}51blbUJv!! z0A?@8A@@@}^b8@N7Sw~Vm1&cQheLXG+&WZR5}2Axf!c(%TIBC(?s?I<`#N-}p+2%Qw1?X~uy7nKwO)BA2eW#NL2)rtF)BQoCcjlAEN3vKD=*sk(;{HMzLKU||)j6aIzuvfOpaP1YnGVlKdc5{dQO%#lj6yzEK7cgL%taZTBHlhjh@IB7;<)L P|J7U_chHKr?~nf-ldOpD literal 0 HcmV?d00001 diff --git a/src/assets/images/flag_icon/flag_icon_es.png b/src/assets/images/flag_icon/flag_icon_es.png new file mode 100644 index 0000000000000000000000000000000000000000..623c8c43b04cdb25cf8ae920c3bc699ec54ec2af GIT binary patch literal 1658 zcmaJ?4@?tx6s{vWA~QB_6m=P%hi)^rJ+9D}o^7?X6dNrgg~T!~TCPXwYVU5>!xrp7 z3Fu6t3^p;_B2I^Ki9!BF-4w=T>(u>`&5Xh9bW(+n1h}x?uj&`8*zPfnmMQ1Z*{UY9i->cdqSFJupky6f>Ja<+2_tL+~^R;FgKdL`*8fBywo- zfOirQCTG4QVrE(5pr(?jX6q zs&RzTh#5#XYb9~Qpi{wsL+o(au!Ka7UYn?wYxS}u6ityTlB9Yanjni$(nvKa^5iM3 zj>7X@1j$ZeZI7`@PsMV};G#ig9lwSxuEf;F~=-gGZzuRcM7eP_U8-;(YY@p#Yl8+4jX@3sCQnifpw zUF=py=4|V4GcC_gPTttJstfbnjxKyJ`>3|=i&d9O%@tkd#Y^DQlFc2at6#l!2JaaT z-F-LR;9T5RS6rR3wCda58vJl^awQ)dwYA~z3?7RPeEGr${FBIhjraGLs(<+8dgb6H znexJqN$a{REZ*9Z^XfGZR(3^fT3Ehjt~vU~?h{otcMU&s`U17h2T*)NKEz)2x8p5@q&`2xA?h?5!JyyTkH!W>0m|Xn#HzL#mHA`7FXC z0lX{V+ub3fE_ihGj|Xe~djn0rsw*M%N?>PQtK!p>bGw%-`LS(UFQhDe_DQpdKU&cI+S=^S z*y85FnuflSfsRo3W>MtA2|&aQ^Itfqyv`fBaHi?rJ;;v*3{RN0nM9m7se_U4x6osw z^qry0LlyS@-=_(} z**gsZJQ%X5l&>onse8)!>an5|i$8zSu6n2r9&E`{?osqaQ%x`Xhwo;!zu$gUstAHP zZ*8vZ{doBJ>hDCcBckl>Bf^_Szn%Wsg85S${hi@^4a;^|&Q%8DY6GjK7Ijq8Kv}Sn zecqpX)!*574{iJi-e4bEzhg+atoG2U+qQp(hhGtt`vi4=&*?q(YgXXM=5SB+k#VX^a*2sxzQmz7bX&Ko%huT;(~#uU4kp2ioNnV^LX|@A8aLB Szd6AD4Gbx1x>L#9O8y0gP=*iy literal 0 HcmV?d00001 diff --git a/src/assets/images/flag_icon/flag_icon_fi.png b/src/assets/images/flag_icon/flag_icon_fi.png new file mode 100644 index 0000000000000000000000000000000000000000..c547fb1a06dd053ca1c87fa4ac2b2ce97338e6bf GIT binary patch literal 1783 zcmeAS@N?(olHy`uVBq!ia0vp^^MH5>2OE$~OPRg_NJ*BsMwA5Srr5%(GQ`zk9!uLS~AsQn;zFfp39xYDT62(s|s5su(?)1Hb_`sNdc^+B->UA;;0DU00rm#qErP_J!9Qu14BavGc!Fy z6H_xYLmdSp14AQy10XWfH8im@HM24@SAYT~plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OOP2xM!G;1y2X`wC5aWfdBw^w6I@b@lZ!G7N;32F6hI~>Cgqow z*eU^C3h_d20o>TUVrVb{15Cdnu|VHY&j92lm_lD){7Q3k;i`*Ef>IIg#cFVINM%8) zeo$(0erZuMFyhjbK~@!5ITxiSmgE|*u>vYuTOWoutw+f;=a;km7f{;{L%V_DR$n>p7`%G&I=^8CdVAFnfc z-~Y)cpD2K2vp=3Xu-s%b^c*z3(27_?5A{My$V1Z2g^&^05(Cr@vpBx!&{Y z?4!!(jORU1;Jo;44bXMJ1K)1jEPHs%>&WwuE^f`(6bma7tKdMbWdHD3roM%~uSGI|FyU*#Ko_h1Q{rV}Xm9N(vUwFPO zG+(TLhx}EuxnFhWFXgtpZTa}(>YTS<3r|F@e;Jr}{q*ziF?)KJu0B`x{h3(*kMk7^ z)Au>+Diqpe@!DRtd@OiZGJpBzd)tif?>o2NZ_>U4U+=R{-*sW>>iKVh31Qwxr}-CG z{P+EE=beH0JA3cvJ??kX_w;U#Q~h%0xI=T*o|58jccJd|pEoLBzO$bBzjnW|9H;KR z+MS^nAGg_WZxDNT@zUD6=N?Ub@Mx{5&Ap;(Zi@x2*-=*A?wk6vn&rM4NqnxgyYXn^ ln@5oj-02@Z4(BtnF?^o#`xnQvH%CELk*BMl%Q~loCIBkg+6Mpt literal 0 HcmV?d00001 diff --git a/src/assets/images/flag_icon/flag_icon_fr.png b/src/assets/images/flag_icon/flag_icon_fr.png new file mode 100644 index 0000000000000000000000000000000000000000..2f438778db3a59d9792ec6d4dc4bcceba0209bc6 GIT binary patch literal 1743 zcmaKtYfuwc6vsmu)XF&M7u2e_24}QNvTQJk*(S!22c#IZKov}*hRud7lHHgrWQkTQ zTD6T1|F$8n9`lyJ$LV(|L>f8 z?%CO|RwTwvn)ty)4u>;I8?QjC6JUs0l7~5|$wl@PG%;kxSA>hDSOghX^X% zf!PTgO;9An5{>CpHZ9|VOy4fSY8%Fqj=?g4g29Z@1|$4vc1c5yI^F-fTCKz04mt^c zm+yaq9Vxjs98SU=RJI)h8)uGWL)lQJ9XHaHJ%ysOhN`&2LeZ4NLfIf?vIv^5GhzhE zPJB3^(CJVu>7b1yhHF(aE@1Ep!h|Xoi8X4CSfSL2BnYAwN)`(>DkMe}y;vbssKv2E zS`~$5TXB*e(wg3BW5?98&0w{G$ST}U#93h&#TlivANQ4#IaN}Db1`@=2; z@gi=(o}#1L*?aa^x9sg`QS%Hrf`;qx)83xUTVYL)3;hTB`&!*qiR|mzmcUku$jWTK zcY1lX=NiAMK2X9OJI|bf@An&zd#_x*ny-+5;jY|xps(@SqrwZJ_vqfQJ;gQpY2o>% zgLilE43(jkW+}`ga)Xow)qCPryZt>ELk*Yi9NhrqCc*Tc;2&O|EcADkZ(-g%2-$5Y z?>>{!761BLg0;P^cAq?<{zdnXUH6`}1kS%{uc%2Y|K$0j&$GYx_#$u4sNQ&PyT$b= zOgrQX?qEISY8v4z`rKDD)~H;VO9a+iz}f3wzG!lX*8GTNgeep*x3-JKnHTy=2U zX3o@E@ID`Qd_q|B9O>kUir{x<`}hha14&1k31I-clo@FzM*!*8rz6d15WtQG0qkfH ziyZ}Gu_H4Y2?DEY>iqk~^Ou$XD7VNofBMDY`LkA+^|jva#&QJD{6}iLd_}&?;k|{ddy8i%3(xk7N b3pl}?nX%7$f7j%h*2OE$~OPRg_NJ*BsMwA5Srr5%(GQ`zk9!uLS~AsQn;zFfp39xYDT62(s|s5su(?)1Hb_`sNdc^+B->UA;;0DU00rm#qErP_J!9Qu14BavGc!Fy z6H_xYLmdSp14AQy10XWfH8im@HM24@SAYT~plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OOP2xM!G;1y2X`wC5aWfdBw^w6I@b@lZ!G7N;32F6hI~>Cgqow z*eU^C3h_d20o>TUVrVb{15Cdnu|VHY&j92lm_lD){7Q3k;i`*Ef>IIg#cFVINM%8) zeo$(0erZuMFyhjbK~@!5ITxiSmgE(++|ba~)Y8J#&Bf5z%)r9Y z)X~-438vR2Ke;qFHLnDwHwCEI!p+jm%-P5Yr(RHE$SnZc?2=lPS(cjOR+OKs0QR(1 zCT_P_;4}}aHwBAZp#HGLsaGH97=2LGB1JV!2$+6AOnAZta^OinH4m8Hi+~9`@V;_7 zFuyo^x;TbZ+sJJNyq$ z_#yB`jV)b(;7|N9cRo^^2qbM5bs(bB4(n*ukUlAjRPp;c?} z@S;b>PSL0BnkI^A^+6(>{dw)`CSiG=6(wdi2Lmk}9$wt}!HZQ~Ht*V}P`URqUw=jJ zvyou0{j>M-XKU6OqW5g;9WxItShaL@_QMs|pS$y))=Mb5y?>Qhf7u?Ebu}-ywK?u@ z*!*|Lb^iw?<<=)2&x`TV+WU7=Yx4O=7uOy=9kc5IclyUyk$XbL3$8xhT(Kqlqm%C4 zZuRv|b&T=z_P+coY*F)H@5JMOzrHBG_!Mb#Yi~tvv{kow8Bp2$ql*KZH@!bth6<_eR|6-(of9uj(4y}NSzyrYZfc5i=`>ux`F?^hkUUIDp7 zhkL_sU4JkCT>fwUy{Z#c&ksFH{HQa3>E?rc7gAOHOq=Nz^;weI6lf^vtP zD->*6*bx*5e@puZhYDm$K#qg|VdDp6O3?;r74t)63f&-}vFHYYj72jDWGtFNAY+kT zgly1*cWkZo$NNv-?~iuAZ)@hKrzX~K8nx%i+w(t-p!W zr>We(Q`NHX?WKDsjlV1Pf?DY#wLFZRrTVy_0y8qhUilupb@66WU@ObXxIQz!w?2n`V z?^)!%z2Y`7`YIob*19Y2{W&YnVOH!8RqyA=7T-0$SHJz<)kUJU`qS&!N{e46b-SyJ zo&OYA^)hYlk^gn~dfnT_^273?zGytYuDhUh_r{XzcEHqg@aXQa4;s%u>n8Mz>1Dsv yk(*l^8vP2OE$~OPRg_NJ*BsMwA5Srr5%(GQ`zk9!uLS~AsQn;zFfp39xYDT62(s|s5su(?)1Hb_`sNdc^+B->UA;;0DU00rm#qErP_J!9Qu14BavGc!Fy z6H_xYLmdSp14AQy10XWfH8im@HM24@SAYT~plwAdX;wilZcw{`JX@uVl9B=|ef{$C za=mh6z5JqdeM3u2OOP2xM!G;1y2X`wC5aWfdBw^w6I@b@lZ!G7N;32F6hI~>Cgqow z*eU^C3h_d20o>TUVrVb{15Cdnu|VHY&j92lm_lD){7Q3k;i`*Ef>IIg#cFVINM%8) zeo$(0erZuMFyhjbK~@!5ITxiSmgE~)xn-DlIe_8#LmQ5yl~)@1a^^I6h2E%Q5a4D{co4>u!z0tsDqWHtvT{yC z??M>{!x<|QZkbem+Pv}g_c!k+2P~abvZ3zpcT3L|tFkU`zE`gh$x-`n1N%Dl$gnvZ zI>h{}KeD$bd^~adl4Re!bD!3pYY^*yBYytU#7pj%K1JGWirwSq{r&09;P7&$xcc9A zCxBvZf@?Qr{hZd;y1Aj6xqHuq^p7W!KmLl_^}Jtx=PA}-zdm1Ia60{?M)t=f@AQYy zwl=H;@=j!bY>V2Zo_`;t;^WK5AQdO7KkCeV`a1cij@)Ctxa*teF5O)7dz${&HR&HE zTpuqF-ox%KU8?U3Qd#^l=zY@6<-6LKeJ|6MixiMMbXaoz=3igs)<5@ke)HA3yZz|q znrY`A8J<77d;NWxJ6D@N2*`2pKUA~iT4*sVu~_hNIe ztGmVD-~D@bBBOfc^;x@048{5{p0AjDuBtcPPHpMiOLuK=N7{Uf*;C)Tz1ICO)48gL z-*oOCdNlFtqp!Pa&giUYTe`U-Wp~B2{&U{jU#9&1{+qko-LiYT=61a*(Vz9)84t^* zy}R~UM~+qR-rA;c-u~?k#!)+NT#B^m+PgZp^!n+XHUFQV6zfkCKhG3p(d{l-$8Esy XEad{{ZC{oyP!-|n>gTe~DWM4f#QK{< literal 0 HcmV?d00001 diff --git a/src/assets/images/flag_icon/flag_icon_selected.png b/src/assets/images/flag_icon/flag_icon_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..3aac0676d6b480f80cd7322c9c644a3a6a19ef00 GIT binary patch literal 9336 zcma)icTiNpv+j~%$w}f8MWO^*kes7P&QX?C$*{;OOO&t#35zI_GXj!9f@H~{#3f6T z1<5&sq`UrJ)vJ0{@7BG4%$YhfJ>5NLdb;NO=Dah|(2&77Q^V z_Bn}k_Qh5tFfB`O0DzSCU%>&SWiVnJIiOlbx&VM54*(Dx1^`^%Ve3r*;I$|Kuw@Sb z$fN)O44xmJ^=JS9+_&0l$|i`}olM()w`t$sciYhYRC9&DU$=WvoC$O>%$AS1NSuMK z(fozAVDoSzzWd4H5jKgV_jwgm8;7c zt=~A5<#Yd7&-~?IzW$@f%Kzn1THV>sWx#LmlWR!!uj9ikpO=fLb9d{$Q^^i5qtx=! zKP6Qv-KU{a=Sm3XqalgnO5pps=hvZAsQ_kRY}}Ab$$J;S;1amm!>ugWBO+aA=l2>< zAP|rk*=`xId*{!ReYZ)u64Nv#mq6bC14>sPn}n4R4a4>GBe7L9oMtlCm*lET2? zrE%Jvx!0vAe5?iqx&_3oAbVG3;M`lt0q+}|_h!^6kZpY0c!`F0-?z}VZ5yd^Mo z%>Ydt8=OM~)#C`S)-&jZ=}^0?VnlM*o$<9<587qa3*#%G^o3W+ccrnQRUNR0XHBtK zG5s0U5Z|STV^nKx6MA~7*z^5z_F1>$#Yr~i8D4_`ppWUf#D)`Y3e;dx>it;+$2?KH z?ZGG1>~XWbi9cPa+Ep_&C|mKy@xsSn$s*Ka`k3dv>;7wkKLrqzX2QVapH-1?^VkOa zq1M9o7d7iU{oAHJ5|e7?^P8y3-(8A+dxvoWnl@Dwj%=Z?dVV1AA0^ALJy2;Lcp+jp z(HO!InfDQAM9{?`&mDKV4v9C9<|oG-;T8)LOM~v_i5a`gC<(zRrm2~l+6!%*KZ2-;jC?RG9PiAi6R4T z*n(CY`wm4kSq&XAUk7G8f7#15aX`)hy^QBe}58{-go}eF4>Uwh==d(BNez>y5{j6V7E-ZLEY7u^FQi0%$ zLR{9YzCZcI-p6o;tkU-IraJC=zFw+YIz(m4+rxyqiLx>cu~}%lRJi?V>~ruIzq=+> zia_H5#Z9EkZm^nVM+Aaf|0!gr=2*eMMQ5+5@lRYk0Y7o50B+(lv($j& zd9)LK)-<(J!Kjmxc#dwcaWQcYg~5~!%*9JXNsgl1>hR@?8Nmi4z#H&EzN@G29aA8_ zH{h$tpBNAP{tB2P(TC;0pFnBe$iob7*C+W9w`GJo;dI$@XiZBnEvANAA)l^{BA%t( zo<1j5rIfzbNEB!wpj5KLE?t?pH|g1ryK*rZ_>Cp0q_ET*H6j+q$bk~236slTSxO=o zYr$m|&oHZ^e!`Fowmj6JN44xr?H>!T#_rivWt2QA;YH?4Bi(VDFp}ivk2xj|?@WMG z$b1Q)S!CDDMFTDzY3wLkaxt|3N*(>e=HrTD=@mb+o5KM?*f0JPh2aa{43Lr;fu%ul zG&-J(HhqqA3_%BZsQI|noNoy*xX7r}PufwbSckyO0Ahi>8ndOTyyXV`-MO0(F^66p?1pyMF;#`fHV>0;N$B`vnF)8BaCu9y0K9~*pj&rhg z65FCtDGg7iR&I;F4SH40NHP>7>(>F5v6F!5*mNAp^hPNEdaF$};VGwUJq@bR*B&@u z26#!DGbzvn0l;2PQAn|{R!us&2Uk`z>z1Oe2xO)5;!up7+keYYzx;~XVe+lkna+MGA*FhIhsFP)C>+M zYahN$Mb_Kqrfun&Hb_rV_Yr!`g|f_26PtS!)4$Yalo99Laf|INBU!4$i~8YCOUbhE zs-L}112+%YxJ)gU1n^T~C=2MT4NXw}1SZkRgr3NE)FJD6Pe8fblQJ?}OqpDaLl#?0 zIY8D0ePwF>C(^{tMV^}=dQ45IMNF|yMT*bVx+W0wlSE>)1SVS}(_l{~n?7Z~=rVY_ z!De^yD}HHc3SV?iA}Y={vdD~xD$dN6*W|T`E&K+xw zX>&76hDY%*>3h+HNFHN2g>{PCZem{!2&=bkxJz7WS$K%If8o8jaK2 zQWI2<*V#x|21LzRfj|b(MZVhd4!1%vSBcr|JHEDYm-$p@aJjaPn39v?jg$hX`(MoX z^d6=;Yw0gBRC7ZnzE_|&$-X?YI#<{}w(qOCXmlux46c^4Ght57yHY)=`rt{?9bNOM zn+`q9t$6+O)H*kzzS$T;>( zb9Oy>2iPh&U;vh>$u82StGbbd?@C|BBUBeD1~j{T|AE6~*pywCg**0`cttd^f)2GN zNI)j~iqN5r=F$>mxDQh^=AWwNJ7+4yY%C#bG@vg{iIuIxF6BZQAQ{h?2G<@)K?8J; zi~A0|oC_(xXSIb<7M*{;6>eituGIygo!FbIVk_o@{}sp-J&Y|#_I-Xuhg1#-yu z{U(M+6Fg(1`5Lc3*)DvMS56)be%R3QHVHzULH9;%orJ?3{zfAUM{4!qk z>a``p1(>_D=z*Zn926`l=$Mlv(VUx+N4p?l;P3;1rlt8M0qN`^@XKsp8b!__gOdr6 zTG1ekiEA zcE))2wINV@eAqu`)Lg2DxrUSXDQ7_UazbP8HFZpBH;LmIzB-SsrOoC>!~?h~e>w1` z8^4};M`d_J{37BPXfqnHd9C?#*5CbbxkhSvhRdM+>{`Vb)}5;bC3*}NfE$9h13VQd zrI#kT@5S2?JZ?#g>{}m^N3kcbJt*1yrFm!wK-{n`MhkyQd=|z?1f(je&`5!d)29oG zJXM|Oa2hL+FuG?Zy7!TnZ6&JUiE|onJxQW-MFVp~xx-x!Iq39Q11dSq$(zAqGo%Vx zb0OAVUqt`)nJ1C+&T%p}^hqonq5?wm; zLX3fz4zwN1`ruw9zv%;tRB;G1J^!Fqhd)6jQL31zU>RVeXoO_+&$$q=^t~IlihENi*8Hf{P}~Sa zpZT-~0m)7k<#^60@XKY;t~ut}sDN|wJBYo1;OE-7^>jA(>JRSRK}VxtpY8y!-?J+E zrKzW8;ZHV2{T=SfZWUx3z$wTlClmu%JU@iQc(Y_eFS4&lFTPK+Jrv6@hb@)AkRfv1 zS@BJ-U6+&V;jBOm?!;7=qoW5vTc=0&N-x90XzTst-@sqiC-GCg(_4)#cRUd%pLQM0 zjPPa-%xIoz2IR*z=JBh&QAN!-y0=Zlc_od4i)?e{N_y$`D$T588ZiJUi+A^%dP46h zCu}BBTQ{Jc{q-Iw;m-Kr*R0g~Ym0q8_nH~j&v~Th5`ddK#fZ1DhjlO%p%=sZdvjO% zZXh(dwLG#A@-9YF&x@IYNmf9HJqlPa;k7mIC+upms$AY%QvYOf&MUXIv@(e7J0YMl zi1qF{JAjfv?51&cuf^KaCUTWRS7?H4zy|E4raQhx|9+^5t*h_&FpLXb{BM(9V)M>n z2Yv*}s+g%ugnB>RrownSWZvmYB!K;UIpAjboMKMQiv2=d2Jb6f(wYH<4od`&PC5q^6#0Q6*GaQ&R11N?Ul?7$QhNsjuZWwrD3Y zC^emeTfChpnPe7!RAUDhvm~IM%dmYo)adciEc_ueoOAWb9KwBNuJwv0o@qI$%VPEl zwo%V`mdAJxr(Uwc(B-wohW1nnwXIRt(>wFU-p3L2cRPLyHm-ED6dT|iXWGs&Gn$gl z-d`%?*KbZEZZ~YNA4xtYT$s5J?p?GB_jd0ZyYSl>eqYGh#OcV&F(GLgD>Ljks4VDy z>S(w~?=RAZVp^_09Jm{UUQqJs)7X`llsR0xPYJ@(rP8hHd+senalvHVKgwi5y(JEe znk#feXVR-xOA9YWMg|%;BSl%GB_0eX*)=-Yc$@UuW~${4gfSWy;q-TquQ*IZJnkBk zQyiRYQ%EcgjFG5grJh+d=cwcgUr3>0G_?VKuT2cmf^%w`xU}+w>G@C$%+?FjIUcRM zLW&g#q8g7{*Zb}F>g736+aHCuIG%^f?M93boulxj@-RbuCNLJ`ChaXZC*vju zx83?$T-Z%{uzF>~ zcguR1pPNTAf3BnCP>N=|@6-KwvFo>h6qH}RS?jz<;VyE~OOR&aP5JDOzI^mueLnfN zw-S6~WX+ZiH+v&D>=CKuX)$7q7uP_aSn*pBLk+SxZ!Y(V^+a>08LQaJatizCCkxB_ zG}CgStX zt%mNJsu(70;0tBo-j~2!ZhcZ!F+o2h(LVQRE+W?U>_+3upj83upn-c8g8D}fBOE3 z{9{M3B*}mJupev; zL0S*)2kBrn+y~SDU)y=sq{}=HOtF40Q`kBf75vBN|F1*cAiovcYtW^OdL9em;jg{} z*`vv5HJY^l#T45Pg>NeYK5?PR!!CB97y3t7d;bP9jDK6~`%OiK)i^cobKw8)TjdsO z-EVrx@gcyS3+>E=PX3D}mXr`WD#ip=*O_XRp~4Gh`Z!$rh%DDiS}FB!j7{kJW+FUC zB109Ee+#-nd-LI>MEQ`ah*sWh`EwvN&w7ksS#APVnk0fvnMn~}0wT_W5?BpYOcRiK z48zp?+|Sx+-|4mY^2?kQA28}ShVt=Xu!6_}7pwG#weBUNUe%+uCj*Tzb*gDPc_EKv z24@uEL83I5E@B0@U_&`F{z0wJJbn~%S2)I~Y>>9oEMY^kc8L*8X&0 zl0z#`hSIXM*5px_C5e0rWd+5!Zk}!h96?eZ7Ok=#xxkVN6=29BL%EovfP%PbM7x5)p0z}|be>MdJrgP35m+&c3>Ki0!cdsc+EE@VD2xNkeTKM@ zX^PFSunC6Yg(z-4Nk)G)i|8HQCbB`vHcNd#D!_i7spg`?e*}hFlIwW8%hq-k)iH?; zT8J}Sp7*x({>ttZ+jQ0vsN8-h5Pw`;hJLINr-=q3{e?℘cW-$jNY|nr$W3qY_7Y zaC%6(0m}Em!sFrUKhb$H(z)^O!_perb=rM^@i(Auh9fn$5Ng0d0fWAE8UH8e<2_(< z3x=E{6>Utc-|Bdw5b;^Q;}qEzZ_ee+m8a7$ZZlH7tIh#sIiaKIi*1`oTES>Y0ffO< zTs*&dB1eQ|16RNSO6;uvu<$k4->gv|wpK7TnrGtjb@Mh!pyq~LZcH5+!xiPXS=dzl zc>UYalOk1bwdVjUw{w=i*BlXHq2|O?XUGa;r?k`LQ6FRl+Nli-k{(Z|D844rf8tG@ zY311#-%Q$>w?4YoG(%|X7I_2UUUG734wsDE4O0IZEGHOM;A`%yW#zw;1*Lf!+bi}L zGvLHzyN(76uxfw;La5$pOp4pAqkY)0ZKtf{(C#TOkkT`!{n#V9g^yucmVDOC zVdg-6qUn?Fjk-`!lc1Z{`)0e`O*xs;QAuP4r-cq~-o{f7!gcF6NDrp5TrA@uECt!0 zssrk$OXrm$3y?>wJ*~lVpF50k+6mJa7}=EA*`Gyz37*D;ouTYy!-x66NmaG@&G%;_ zu*i&CR|8J*Fc8-Mf>?)PfcOQ2{f>P8I#pUyMwLEC+3N+{`G_*g$R-Qeg0(%; z|42kYe^}6By@4Zp{TNH_%R|w?GeMujUBQ@ANA@1gg3Bn>B+EXAXqsYLr}Gu2+Qv^P zi7m!Ll&pf0QwW(Ihzp5-O)!}&*G;}oru!O`4C4gvas^y|>ZZz%48_SeCew{Abc9FX z1}9ZbuubtX=I7KUm-ndidUvjVzzv>0l#{`HrF>1wK$HQ;Z(vMn!fyww?E-V1y~6kT z1Eczog-ujh_b>zTDS~M!g2MD2PD(5U`SP)bqNJ4NVd7iU`cAz41t zKo%snZ;a1^mSbX8m4%=Hz%!%`|3Y|yooIO9DVGvrfx2lJ`YRIRj@5LABqz4bB`?x+ z4_aOnWfR|Eu|%YqCH@tv->bgCiS!C=rCeR{z<9NuSlGx4Rmk5v2URd&NNw7JC_MwQ zhh2lTB&QPEe{bX*i#`o)D0kQ3uj{6~LF@~#(zrE`i%Fk@ZGy*t-j!v5!2~})8K<{) zfX#P4t}nb7bm<+VO&lAI4!wFvR|`aasSPy>T8(<-Yf<}+R#-)^;Z-LO#!uPe3}ewS zN+|nMsAj&(gn!o#fhz*>7MMWOj|VD0#hwu5K%Iu5k=JL+CfBwCY!QqgIyy#R?O&#H zK*93v&sV;6S;XJZDB8ruQ?w;aO~(E$zcXa5jg$)O@ArcSyNmY>Z*aPE2OPAO^jicv zPXI~WjS%Lnyn9J{4<~k5(x%G3ft??f=e!|^*{8Yk!50ESHkk&5147O#p%<{o@;)7nps>I?GD1WP@v8-uebGKfT!T zyKhgs)A4op1ig-^(Q>rF;_QVod^ze3f?Nztm-v#;$RKd5ZX1!vFWjsJk^Fv6coONk zGL))3=LC)m{Zw7d{i;OagoMJuKUQi!CTy)u(4E zV!0+k3oxc`-odv1W%Y*7;oAWjQt!#Et$S}fW}eMFl&iP` zY>s*Jjmn)0-})P6YROt&gDQ+;;?XX(@{yvGPE6q2Dgj~7>a+=#E4(g%hZUuDOy=o8 z7W$MXCD>emMZX+*uinBmlyc*S`%%kXWM?uq z1g#c3`@m$7jgQ2HG~&lMK44i|!QoP7F3BOJfFHmWF(Y(yOsS>#`( z36k$vFeDN+*I8j6PRd-F8 z)~9IKttbKh-9r1Rd9PDV4#K{S1oFn^_TRfs?=wv}g3h-n=K=sD_@tHppf*DvDa?EcI#?v(f+Cel-?T(q+#j%8xLD zv}zZ1-q&O2WjIF;yMW6zu(aW1sf}1H#FQk;LKb?~b~Nx6;6tHgN~ zd-d@Ou71}e>#m)2qQL^u+4<}ij%TTX{{7Lm+VZt23ZoW9S+JT>Pv%ULTHl{Q{}zv#`uHll+pGvv43TE+x$SbWN6N(Z3UO*T zfp?@}M{-zdS3lo5dzKnen1xpl>sMA4kRpe`kG&2DU4q5J|C|s|+S?>b`{v^2BP$}|8oGF#1qN%cTk-zj4=CK*RjjX-JvV*_C z$AMREJU_c;jEDp8p_)LPn;jmATJF8zghxKC6E8)RH0~p-;QsPI054!#ETA!8f+A{; z31B^5qM;Mi!|4%m?$@Bf9UFZoc}o1MXRR^WZ!y}}`_|0SMiQEXXJda(+xY)5*&@e# zc5jUU@;|Bk5}WV;6~YplJHiB*V%2dQ;mf<@CndVKj2Smx4Y1Dx?qn3sd~h8wv7k81 z$mo+?bnsrVa0OwJ31ewKI0qr_2=Z3F$%!mhJmqT(xLZqdTYE>KAiU7Kx@VrwfMY)b z>w0t{cE|FDM)2WD;z(6xoX=j(yjR)Cf!psjGzY4|A?Ulvb=xee%lE^)y*H$UaGU84 zvZNF|9E_1uNha`uDWLF3;rNdPSywr>78nB1Bu6SG!CKmZ?R_Evb=~qWw%5(3K)afN z9ojy@p^qR^O)rYaa;e;U{S1aA#)i26IKc4r>~>EFZ+)5gq02ZyM(+2UA6V0>LOoK_ z3gkR-5X7;#%ZD6$p`!7qC(W^wQ>?q19?iH>zKWPc*vV(AEHGFe(0ig&B}p6AqF_@-?F-&u@7gr#U%6I^HjUCecxy# z0SZGqo`wb3qUnCJ)*4-GlJ=yGjWI|N>u*IkdUXq__4J!|^FYcT&cAxKN2|$-y{u{< z%$*=0KGjNzF4|xI9(1gIJ>~$^|mwLo9yM zp9{}KQT(JXrbITCUOK zT?lF_TE>vS_>fk-r>2N9e7oJ#l2buT~6k zvS<>&0Hk|foa5-*%PM}p!ur}|tLR_4)Eviw3N&m2un8#X;ppY~i()c>8+bU2VSwdC z5hHf0JvJ6Q;Fon0)1tOF+3%CtYDIo1;J>w*#lg;3t@DTKY6@l98#7&XF7zc2Ch5N= fVLVcA0|24Fv@2I^$zEgs8Ukpm>#0?!*oXcXDfeU) literal 0 HcmV?d00001 diff --git a/src/assets/images/flag_icon/flag_icon_tr.png b/src/assets/images/flag_icon/flag_icon_tr.png new file mode 100644 index 0000000000000000000000000000000000000000..9cd1c27a8541d55527104a3b6bf663e7ede28436 GIT binary patch literal 1868 zcmaJ?X;2eq7!KG2C~8q^ixyY{3^+(aI1)$`93TV&hG2pc6|pf{AV9J)S#>XyvH!s0ShlQB**IK!pN={epn~QM$AHeY@{I@AE$Iv9syn zVL?mHR-55)xTUOM1{dsVa21<;0@fH&{spjEp@Dpqhs2;tfgHy9iIANz#F7YNVJ<8X zCGTp6X*k>x2XO=+<+C?Xg@}Y8FklFnLrGk5dnTUsG zA!q^}|6UZI9S#K`av1U=c(@APNF<0%CXhV6$Yjzw$lZ+oQnmX==eAkl~IXArBX>ydJqtKERp2n<741(cXtH{S4FZE6=1GXh5bAO z16By-Vi_t%q>zD8uoFo_>3E>@eF_rU0Qbx;tZp&AQ__Rmc%&_rZ<(4uJ)Akd-b2grPx-$4 zIAwX|b<;ogH9T4OSXU8l7r$yRoTk~7MyWp4BUpXHZmc2v?pNv&8+DQRuRL>3`!a2} zXYzqG&8sv@f~+bsf5yb@<@1@Ivqob}hjME-7ZK7mAwk~8V^L9_O}r?5Uf$rO?r~kA zK$*p1_1ddPR#b4PSMND?^CwgqzOLlx;G3!CgT3ZcV|_v{Fg?^|XQqVS2%?>L>3Upg z-FHJ7T>ncfT_?`H`8@hs$h(f#BPmV14f^ZR*UsInp_@;45cHP##$!FrR>w z%jx>xhg43w>@2j2cT#t@;jxrXxhXudKZ)l_Lb zCxFmWn`RKg!mhKx=h1Wk?VfA&pw@Yno9CS z9gVGvdX_PgJ#EN`HhSK*~Sc7GdtR_bQu*9SpskU1$3h<2f;b zf!S%Oudnx34)tkmRGM}1V-jKX@L2yy#n4L54^6d~_d{yqpY$irB#(}+aZTBA@=eZU z;)BKy4$Zy%H}keBy?DCCekSmwnnY>@zyTZddtmM zibqNhW#*P=U7I%EkDY~{T!gmw-tWlpzb3vEQsVqrQ{yH`Nbd7GU zt-i*|xvI5u>@Cg6bUX{yMrUFz;a6fm>soe&X5_M>oI};E?I?T1imW?#rjyig9BbK3 gGhP8er)`bRan-W3p{0r^hJPiF6&S`S_y0Ea9|s#17XSbN literal 0 HcmV?d00001 diff --git a/src/bootstrap.ts b/src/bootstrap.ts index c990925..052bf70 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -18,11 +18,11 @@ setBootDebug('boot: secure fetch installed'); const search = new URLSearchParams(window.location.search); -(window as any).NitroSecureApiUrl = 'https://nitro.slogga.it:2096'; +(window as any).NitroSecureApiUrl = 'http://192.168.1.52:2096/'; (window as any).NitroConfig = { 'config.urls': [ - secureUrl('config', 'renderer-config.json'), - secureUrl('config', 'ui-config.json') + secureUrl('config', 'renderer-config.json', true), + secureUrl('config', 'ui-config.json', true) ], 'sso.ticket': search.get('sso') || null, 'forward.type': search.get('room') ? 2 : -1, diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx index a2e6a6d..c4447f0 100644 --- a/src/components/loading/LoadingView.tsx +++ b/src/components/loading/LoadingView.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; +import loadingGif from '@/assets/images/loading/loading.gif'; import { Base, Column, Text } from '../../common'; interface LoadingViewProps { @@ -29,7 +30,16 @@ export const LoadingView: FC = props => { } - : null + : + + + { message && message.length ? + + { message } + + : null + } + } diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 218195a..e22daf5 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,18 +1,62 @@ import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GetConfigurationValue } from '../../api'; +import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; +import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; +import flagEn from '../../assets/images/flag_icon/flag_icon_en.png'; +import flagEs from '../../assets/images/flag_icon/flag_icon_es.png'; +import flagFi from '../../assets/images/flag_icon/flag_icon_fi.png'; +import flagFr from '../../assets/images/flag_icon/flag_icon_fr.png'; +import flagIt from '../../assets/images/flag_icon/flag_icon_it.png'; +import flagNl from '../../assets/images/flag_icon/flag_icon_nl.png'; +import flagSelected from '../../assets/images/flag_icon/flag_icon_selected.png'; +import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png'; +import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation'; import { TurnstileWidget } from './TurnstileWidget'; type DialogMode = 'login' | 'register' | 'forgot'; +type LoginLocale = { code: string; file: string; label: string; flag: string }; const interpolate = (value: string | null | undefined): string => { if(!value) return ''; - try { return GetConfiguration().interpolate(value); } - catch { return value; } + + let output = value; + + try { output = GetConfiguration().interpolate(value) || value; } + catch {} + + return output.replace(/\$\{([^}]+)\}/g, (_, key: string) => + { + if(key === 'api.url' && typeof (window as any).NitroSecureApiUrl === 'string') + { + const secureApiUrl = (window as any).NitroSecureApiUrl.replace(/\/$/, ''); + + if(secureApiUrl) return secureApiUrl; + } + + try + { + const configValue = GetConfiguration().getValue(key, ''); + + if(configValue) return configValue; + } + catch {} + + try + { + const configValue = GetConfigurationValue(key, ''); + + if(configValue) return configValue; + } + catch {} + + return ''; + }); }; const LOCK_KEY = 'nitro.login.lock'; +const CHAT_TRANSLATION_SETTINGS_KEY = 'chatTranslationSettings'; const MAX_ATTEMPTS = 5; const LOCK_WINDOW_MS = 60_000; const LOCK_DURATION_MS = 2 * 60_000; @@ -23,6 +67,17 @@ const DEFAULT_LOGIN_IMAGES: Record = { left: 'https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png', right: 'https://hotel.slogga.it/client/nitro/images/reception/background_right.png' }; +const LOGIN_LOCALES: LoginLocale[] = [ + { code: 'it', file: 'it', label: 'Italiano', flag: flagIt }, + { code: 'en', file: 'com', label: 'English', flag: flagEn }, + { code: 'es', file: 'es', label: 'Español', flag: flagEs }, + { code: 'fr', file: 'fr', label: 'Français', flag: flagFr }, + { code: 'de', file: 'de', label: 'Deutsch', flag: flagDe }, + { code: 'pt-BR', file: 'br', label: 'Português', flag: flagBr }, + { code: 'nl', file: 'nl', label: 'Nederlands', flag: flagNl }, + { code: 'fi', file: 'fi', label: 'Suomi', flag: flagFi }, + { code: 'tr', file: 'tr', label: 'Türkçe', flag: flagTr } +]; type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; @@ -43,6 +98,70 @@ const writeLock = (state: AttemptState) => catch { } }; +const normalizeLanguageCode = (value: string): string => +{ + if(!value) return ''; + + const normalized = value.trim().replace('_', '-'); + const parts = normalized.split('-'); + + if(parts.length === 1) return parts[0].toLowerCase(); + + return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`; +}; + +const resolveLoginLocale = (value: string): LoginLocale => +{ + const normalized = normalizeLanguageCode(value); + const exactMatch = LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code) === normalized); + + if(exactMatch) return exactMatch; + + const base = normalized.split('-')[0]; + + if(base === 'pt') return LOGIN_LOCALES.find(locale => locale.file === 'br') || LOGIN_LOCALES[0]; + + return LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code).split('-')[0] === base) || LOGIN_LOCALES[0]; +}; + +const getBrowserLocale = (): LoginLocale => +{ + if(typeof navigator === 'undefined') return LOGIN_LOCALES[0]; + + return resolveLoginLocale(navigator.language || navigator.languages?.[0] || 'it'); +}; + +const readCachedLocale = (): LoginLocale => +{ + try + { + const settings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}'); + + if(typeof settings.uiTextLanguage === 'string' && settings.uiTextLanguage.length) return resolveLoginLocale(settings.uiTextLanguage); + } + catch {} + + return getBrowserLocale(); +}; + +const applyLocaleSelection = (locale: LoginLocale): void => +{ + try + { + const previousSettings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}'); + const nextSettings = { + enabled: previousSettings.enabled ?? false, + incomingTargetLanguage: previousSettings.incomingTargetLanguage || locale.code, + outgoingTargetLanguage: previousSettings.outgoingTargetLanguage || locale.code, + ...previousSettings, + uiTextLanguage: locale.code + }; + + localStorage.setItem(CHAT_TRANSLATION_SETTINGS_KEY, JSON.stringify(nextSettings)); + } + catch {} +}; + export interface LoginViewProps { onAuthenticated: (ssoTicket: string) => void; @@ -61,10 +180,31 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); const [ loginServerReachable, setLoginServerReachable ] = useState(null); const [ loginPingingServer, setLoginPingingServer ] = useState(false); + const [ rememberMe, setRememberMe ] = useState(() => !!GetRememberLogin()); + const [ selectedLocale, setSelectedLocale ] = useState(() => readCachedLocale()); + const [ localeApplying, setLocaleApplying ] = useState(false); + const [ localeError, setLocaleError ] = useState(''); + const [ loginViewConfig, setLoginViewConfig ] = useState>(() => GetConfigurationValue>('loginview', {})); const submitTimeRef = useRef(0); - const configuredLoginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; + const configuredLoginImages: Record = (loginViewConfig?.['images'] as Record) ?? {}; const loginImages: Record = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages }; + + const configuredLoginWidgets: Record = (loginViewConfig?.['widgets'] as Record) ?? {}; + const loginWidgetSlots = useMemo(() => + { + return Object.entries(configuredLoginWidgets) + .filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0) + .map(([ key, value ]) => + { + const slotNum = key.match(/\d+/)?.[0] ?? ''; + const conf = configuredLoginWidgets[`slot.${ slotNum }.conf`] as Record ?? {}; + + return { key, slotNum: Number(slotNum), type: value as string, conf }; + }) + .filter(slot => slot.slotNum > 0) + .sort((a, b) => a.slotNum - b.slotNum); + }, [ configuredLoginWidgets ]); const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); @@ -73,7 +213,10 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); - const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right ]); + const widgetImageUrls = useMemo(() => loginWidgetSlots + .map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '') + .filter(Boolean), [ loginWidgetSlots ]); + const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]); const [ loginImagesVersion, setLoginImagesVersion ] = useState(0); const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); @@ -97,6 +240,62 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); + useEffect(() => + { + let cancelled = false; + + const refreshLoginViewConfig = () => + { + if(cancelled) return; + + const nextConfig = GetConfigurationValue>('loginview', {}); + + setLoginViewConfig(previousConfig => + { + try + { + return JSON.stringify(previousConfig) === JSON.stringify(nextConfig) ? previousConfig : nextConfig; + } + catch + { + return nextConfig; + } + }); + }; + + refreshLoginViewConfig(); + + const timers = [ 50, 150, 300, 600, 1000, 2000 ].map(delay => window.setTimeout(refreshLoginViewConfig, delay)); + + return () => + { + cancelled = true; + timers.forEach(timer => window.clearTimeout(timer)); + }; + }, []); + + const confirmLocaleSelection = useCallback(async () => + { + if(localeApplying) return; + + setLocaleApplying(true); + setLocaleError(''); + + try + { + applyLocaleSelection(selectedLocale); + await applyTextTranslationLocale(selectedLocale.code); + } + catch + { + setLocaleError('Unable to load this language pack.'); + } + finally + { + setLocaleApplying(false); + } + }, [ localeApplying, selectedLocale ]); + useEffect(() => { if(!loginImageUrls.length) return; @@ -216,17 +415,6 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa } }, [ checkServerReachable ]); - useEffect(() => - { - let cancelled = false; - (async () => - { - const ok = await checkServerReachable(); - if(!cancelled) setLoginServerReachable(ok); - })(); - return () => { cancelled = true; }; - }, [ checkServerReachable ]); - const handleLoginSubmit = useCallback(async (event: FormEvent) => { event.preventDefault(); @@ -262,15 +450,10 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa try { - const serverOk = await pingLoginServer(); - if(!serverOk) - { - setError('The gameserver is not running. Please try again later.'); - return; - } const { ok, payload } = await postJson(loginUrl, { username: username.trim(), password, + remember: rememberMe, turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined }); @@ -279,6 +462,8 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa if(ok && ssoTicket) { clearLock(); + if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket); + else ClearRememberLogin(); onAuthenticated(ssoTicket); return; } @@ -298,7 +483,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { setSubmitting(false); } - }, [ submitting, isEntering, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); + }, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); @@ -454,7 +639,61 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa { loginImageUrls.map(url => ) }
+ { loginWidgetSlots.length > 0 && +
+ { loginWidgetSlots.map(slot => + { + const image = typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : ''; + const texts = typeof slot.conf.texts === 'string' ? slot.conf.texts : ''; + const btnText = typeof slot.conf.btnText === 'string' ? slot.conf.btnText : ''; + const btnLink = typeof slot.conf.btnLink === 'string' ? interpolate(slot.conf.btnLink) : ''; + const title = typeof slot.conf.title === 'string' ? slot.conf.title : (texts || slot.type); + const description = typeof slot.conf.description === 'string' ? slot.conf.description : ''; + + return ( +
+ { image && } +
+
{ title }
+ { description &&
{ description }
} + { btnText && + } +
+
+ ); + }) } +
} +
+
+
Choose your language
+
+ { LOGIN_LOCALES.map(locale => + ) } +
+ { localeError.length > 0 &&
{ localeError }
} + +
+
First time here?
@@ -490,6 +729,14 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa onChange={ e => setPassword(e.target.value) } />
+ { turnstileEnabled && mode === 'login' && = ({ onAuthenticated, isEntering = fa
setMode('forgot') }>Forgotten your password?
+
{ mode === 'register' && diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 9c81002..0a50441 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -1,7 +1,7 @@ import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa'; -import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; +import { ClearRememberLogin, FriendlyTime, GetConfigurationValue, GetRememberLogin, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { usePurse } from '../../hooks'; import purseIcon from '../../assets/images/rightside/purse.gif'; @@ -64,6 +64,7 @@ export const PurseView: FC<{}> = props => { const logoutUrl = GetConfigurationValue('login.logout.endpoint', '/api/auth/logout'); const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? ''; + const rememberToken = GetRememberLogin()?.token || ''; try { @@ -76,11 +77,12 @@ export const PurseView: FC<{}> = props => { 'Accept': 'application/json', 'X-Requested-With': 'NitroPurseLogout' }, - body: JSON.stringify({ ssoTicket }) + body: JSON.stringify({ ssoTicket, rememberToken }) }); } catch { /* best-effort — proceed with local logout regardless */ } + ClearRememberLogin(); if(window.NitroConfig) window.NitroConfig['sso.ticket'] = ''; window.location.reload(); }, []); diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css index 78c9d52..3c1badb 100644 --- a/src/css/login/LoginView.css +++ b/src/css/login/LoginView.css @@ -85,10 +85,11 @@ .nitro-login-view .login-right { bottom: 0; right: 0; - width: 400px; - height: 100%; - object-fit: none; - object-position: right bottom; + width: auto; + height: auto; + max-width: none; + object-fit: initial; + object-position: initial; } /* ─── Foreground Login Card Stack ─── */ @@ -106,6 +107,79 @@ pointer-events: auto; } +.nitro-login-view .login-widgets { + position: absolute; + top: 18px; + left: 240px; + right: 360px; + z-index: 25; + display: grid; + grid-template-columns: repeat(2, minmax(260px, 1fr)); + gap: 34px 58px; + pointer-events: auto; +} + +.nitro-login-view .login-widget-slot { + min-height: 110px; + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + align-items: center; + gap: 22px; + color: #ffffff; + font-family: Ubuntu, 'Helvetica Neue', Arial, sans-serif; + text-shadow: 0 2px 2px rgba(0, 0, 0, 0.45); +} + +.nitro-login-view .login-widget-image { + max-width: 150px; + max-height: 150px; + width: auto; + height: auto; + justify-self: center; + image-rendering: auto; + user-select: none; + -webkit-user-drag: none; +} + +.nitro-login-view .login-widget-content { + min-width: 0; +} + +.nitro-login-view .login-widget-title { + font-size: 18px; + line-height: 20px; + font-weight: 700; + letter-spacing: 0.2px; + margin-bottom: 5px; +} + +.nitro-login-view .login-widget-description { + max-width: 285px; + font-size: 12px; + line-height: 14px; + font-weight: 600; + margin-bottom: 14px; +} + +.nitro-login-view .login-widget-button { + min-width: 178px; + height: 25px; + padding: 0 18px; + border: 1px solid #777777; + border-radius: 3px; + background: linear-gradient(#ffffff, #d4d4d4); + color: #111111; + font-size: 11px; + font-weight: 700; + cursor: pointer; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.85), 0 1px 1px rgba(0, 0, 0, 0.35); + text-shadow: none; +} + +.nitro-login-view .login-widget-button:hover { + background: linear-gradient(#ffffff, #e9e9e9); +} + .nitro-login-card { background: #a2bfd1; border: 2px solid #3f6a85; @@ -176,6 +250,24 @@ box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(63, 106, 133, 0.3); } +.nitro-login-card .remember-row { + display: flex; + align-items: center; + gap: 6px; + color: #0a2e45; + font-size: 11px; + font-weight: 600; + cursor: pointer; + user-select: none; +} + +.nitro-login-card .remember-row input { + width: 13px; + height: 13px; + margin: 0; + cursor: pointer; +} + .nitro-login-card .submit-row { display: flex; justify-content: center; @@ -240,6 +332,75 @@ font-weight: 600; } +.nitro-login-card.login-language-card { + padding-bottom: 10px; +} + +.nitro-login-card .login-language-grid { + display: grid; + grid-template-columns: repeat(5, 46px); + justify-content: center; + gap: 7px 3px; +} + +.nitro-login-card .login-language-option { + position: relative; + width: 46px; + height: 52px; + padding: 0; + border: 0; + background: transparent center 2px no-repeat; + background-size: 38px 32px; + cursor: pointer; + image-rendering: auto; + overflow: hidden; +} + +.nitro-login-card .login-language-option.selected { + background-size: 38px 32px; +} + +.nitro-login-card .login-language-option img { + position: absolute; + top: 18px; + left: 50%; + width: auto; + height: auto; + max-width: 28px; + max-height: 22px; + transform: translate(-50%, -50%); + pointer-events: none; + user-select: none; + -webkit-user-drag: none; +} + +.nitro-login-card .login-language-option span { + position: absolute; + left: 0; + right: 0; + bottom: 0; + color: #1b3444; + font-size: 9px; + line-height: 10px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.nitro-login-card .language-error { + margin-top: 6px; + color: #9f1b15; + font-size: 10px; + text-align: center; +} + +.nitro-login-card .login-language-confirm { + display: block; + min-width: 58px; + margin: 7px auto 0; +} + .nitro-login-card .turnstile-slot { display: flex; justify-content: center; @@ -478,3 +639,23 @@ font-size: 11px; } +@media (max-width: 1180px) { + .nitro-login-view .login-widgets { + left: 210px; + right: 315px; + grid-template-columns: 1fr; + gap: 16px; + } + + .nitro-login-view .login-widget-slot { + grid-template-columns: 120px minmax(0, 1fr); + min-height: 86px; + gap: 14px; + } + + .nitro-login-view .login-widget-image { + max-width: 110px; + max-height: 110px; + } +} + diff --git a/src/hooks/translation/useTranslation.ts b/src/hooks/translation/useTranslation.ts index 9a575f3..0d1f17e 100644 --- a/src/hooks/translation/useTranslation.ts +++ b/src/hooks/translation/useTranslation.ts @@ -150,6 +150,44 @@ const dispatchLocalizationUpdated = () => window.dispatchEvent(new CustomEvent('nitro-localization-updated')); }; +export const applyTextTranslationLocale = async (languageCode: string): Promise => +{ + const localizationManager = GetLocalizationManager(); + const sessionDataManager = GetSessionDataManager(); + const selectedLocale = resolveTextTranslationLocale(languageCode || ''); + + if(!selectedLocale) + { + localizationManager.clearOverrideValues(); + sessionDataManager.clearFurnitureDataOverrides(); + dispatchLocalizationUpdated(); + return; + } + + const textUrl = getTextTranslationUrl(selectedLocale.file); + const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file); + const response = await fetch(textUrl); + + if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`); + + const data = await response.json(); + const overrideValues = new Map(); + + Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key])); + localizationManager.setOverrideValues(overrideValues); + + try + { + await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl); + } + catch + { + sessionDataManager.clearFurnitureDataOverrides(); + } + + dispatchLocalizationUpdated(); +}; + const getBrowserLanguageCode = () => { if(typeof navigator === 'undefined') return 'en'; @@ -475,17 +513,13 @@ const useTranslationState = () => { let disposed = false; const requestId = ++localizationRequestRef.current; - const localizationManager = GetLocalizationManager(); - const sessionDataManager = GetSessionDataManager(); const selectedLocale = resolveTextTranslationLocale(settings.uiTextLanguage || ''); const applyLocalizationOverride = async () => { if(!selectedLocale) { - localizationManager.clearOverrideValues(); - sessionDataManager.clearFurnitureDataOverrides(); - dispatchLocalizationUpdated(); + await applyTextTranslationLocale(''); if((localizationRequestRef.current === requestId) && !disposed) { @@ -500,42 +534,19 @@ const useTranslationState = () => try { - const textUrl = getTextTranslationUrl(selectedLocale.file); - const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file); - const response = await fetch(textUrl); + if(disposed || (localizationRequestRef.current !== requestId)) return; - if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`); - - const data = await response.json(); - const overrideValues = new Map(); - - Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key])); + await applyTextTranslationLocale(settings.uiTextLanguage || ''); if(disposed || (localizationRequestRef.current !== requestId)) return; - localizationManager.setOverrideValues(overrideValues); - - try - { - await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl); - } - catch - { - if(disposed || (localizationRequestRef.current !== requestId)) return; - - sessionDataManager.clearFurnitureDataOverrides(); - } - - dispatchLocalizationUpdated(); setLastError(''); } catch(error) { if(disposed || (localizationRequestRef.current !== requestId)) return; - localizationManager.clearOverrideValues(); - sessionDataManager.clearFurnitureDataOverrides(); - dispatchLocalizationUpdated(); + await applyTextTranslationLocale(''); setLastError((error as Error)?.message || 'Unable to load translated UI texts.'); } finally diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 6957316..05acb46 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -65,6 +65,13 @@ const textDecoder = new TextDecoder(); let secureSessionPromise: Promise = null; let installed = false; const secureResponseCache = new Map>(); +let secureSessionCreatedAt = 0; +const SECURE_SESSION_TTL_MS = 5 * 60 * 1000; +const REKEY_ENDPOINTS = new Set([ + '/api/auth/login', + '/api/auth/remember', + '/api/auth/logout' +]); const bytesToBase64 = (bytes: ArrayBuffer): string => { @@ -76,6 +83,13 @@ const bytesToBase64 = (bytes: ArrayBuffer): string => return btoa(binary); }; +const randomHex = (byteLength: number): string => +{ + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + + return Array.from(bytes).map(value => value.toString(16).padStart(2, '0')).join(''); +}; + const hexValue = (code: number): number => { if(code >= 48 && code <= 57) return code - 48; @@ -139,14 +153,15 @@ const getApiBase = (): string => if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, ''); - return 'https://nitro.slogga.it:2096'; + return 'http://localhost:8443/'; }; -export const secureUrl = (kind: 'config' | 'gamedata', file: string): string => +export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = false): string => { const base = getApiBase(); + const version = cacheBust ? `&v=${ encodeURIComponent(Date.now().toString(36)) }` : ''; - return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }`; + return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }${ version }`; }; const createSecureSession = async (): Promise => @@ -178,11 +193,26 @@ const createSecureSession = async (): Promise => const derived = await deriveAesKey(pair.privateKey, serverKey); + secureSessionCreatedAt = Date.now(); + return { publicKey: clientPublicKey, key: derived.key, fingerprint: derived.fingerprint }; }; +const clearSecureSession = (clearCache = false): void => +{ + secureSessionPromise = null; + secureSessionCreatedAt = 0; + if(clearCache) secureResponseCache.clear(); +}; + export const getSecureSession = (): Promise => { + if(secureSessionPromise && secureSessionCreatedAt && ((Date.now() - secureSessionCreatedAt) > SECURE_SESSION_TTL_MS)) + { + setDebugState('secure: session expired, rotating'); + clearSecureSession(); + } + if(!secureSessionPromise) secureSessionPromise = createSecureSession(); return secureSessionPromise; @@ -229,6 +259,8 @@ const normalizeSecureCacheKey = (requestUrl: string): string => if(!url.pathname.includes('/nitro-sec/file')) return requestUrl; const kind = url.searchParams.get('kind') || ''; + if(kind === 'config') return requestUrl; + const file = (url.searchParams.get('file') || '') .replace(/^[\\/]+/, '') .split('?')[0] @@ -291,6 +323,30 @@ const readRequestBody = async (input: RequestInfo | URL, init: RequestInit | und return null; }; +const buildSecureApiEnvelope = (requestUrl: string, method: string, clearBody: ArrayBuffer | null): ArrayBuffer | null => +{ + if(!clearBody) return null; + + const url = new URL(requestUrl, window.location.href); + const envelope = { + ts: Date.now(), + nonce: randomHex(16), + method, + path: `${ url.pathname }${ url.search }`, + body: bytesToBase64(clearBody) + }; + + return textEncoder.encode(JSON.stringify(envelope)).buffer; +}; + +const scheduleSecureRekey = (): void => +{ + queueMicrotask(() => + { + clearSecureSession(); + }); +}; + export const installSecureFetch = (): void => { if(installed) return; @@ -355,20 +411,38 @@ export const installSecureFetch = (): void => const session = await getSecureSession(); const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined)); const clearBody = await readRequestBody(input, init, method); + const secureBody = buildSecureApiEnvelope(requestUrl, method, clearBody); const encryptedInit: RequestInit = { ...init, method, headers }; headers.set('X-Nitro-Key', session.publicKey); headers.set('X-Nitro-Api', '1'); - if(clearBody) + if(secureBody) { - encryptedInit.body = await encryptBytes(session, clearBody); + encryptedInit.body = await encryptBytes(session, secureBody); headers.set('Content-Type', 'text/plain; charset=utf-8'); } const response = await nativeFetch(input, encryptedInit); - if(response.headers.get('X-Nitro-Sec') === '1') return decryptResponse(session, response); + if(response.headers.get('X-Nitro-Sec') === '1') + { + const decrypted = await decryptResponse(session, response); + + try + { + const pathname = new URL(requestUrl, window.location.href).pathname; + + if(response.ok && REKEY_ENDPOINTS.has(pathname)) + { + setDebugState(`secure: rekey after ${ pathname }`); + scheduleSecureRekey(); + } + } + catch {} + + return decrypted; + } return response; } diff --git a/vite.config.mjs b/vite.config.mjs index e8fa8ff..b8c6e4a 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -17,11 +17,17 @@ export default defineConfig({ ] }, proxy: { - '/api': { - target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096', - changeOrigin: true, - } - } + '/api': { + target: process.env.AUTH_PROXY_TARGET || 'http://192.168.1.52:2096/', + changeOrigin: true, + ws: true, + }, + '/nitro-sec': { + target: process.env.NITRO_PROXY_TARGET || 'http://192.168.1.52:2096/', + changeOrigin: true, + ws: true, + } + } }, resolve: { tsconfigPaths: true, From 42731218f8c4fa606c97a262322443ee77416849 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Fri, 24 Apr 2026 15:53:17 +0200 Subject: [PATCH 07/11] Add runtime toggle docs and secure mode switches --- docs/secure-runtime-modes.en.md | 307 ++++++++++++++++++++++++++++++++ docs/secure-runtime-modes.md | 307 ++++++++++++++++++++++++++++++++ public/asset-loader.js | 184 ++++++++++++++++++- public/client-mode.json | 8 + scripts/minify-dist.mjs | 1 - scripts/write-asset-loader.mjs | 185 ++++++++++++++++++- src/bootstrap.ts | 18 +- src/secure-assets.ts | 79 +++++++- 8 files changed, 1081 insertions(+), 8 deletions(-) create mode 100644 docs/secure-runtime-modes.en.md create mode 100644 docs/secure-runtime-modes.md create mode 100644 public/client-mode.json diff --git a/docs/secure-runtime-modes.en.md b/docs/secure-runtime-modes.en.md new file mode 100644 index 0000000..c2d6fbf --- /dev/null +++ b/docs/secure-runtime-modes.en.md @@ -0,0 +1,307 @@ +# Secure runtime modes + +This document summarizes all values you may need to configure for: + +- `dist` bundle obfuscation (`app.js` / `app.css` → `.dat`) +- secure runtime assets (`renderer-config.json`, `ui-config.json`, `gamedata`) +- secure runtime API (`/api/*`) +- plain fallbacks when you want to disable the secure layer without removing the code + +## 1. `Nitro-V3/public/client-mode.json` + +This file controls everything at runtime. + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +### Fields + +- `distObfuscationEnabled` + - `true`: `asset-loader.js` loads `app.css.dat` and `app.js.dat` + - `false`: it loads plain `assets/app.css` and `assets/app.js` + +- `secureAssetsEnabled` + - `true`: `bootstrap.ts` and `secure-assets.ts` use `/nitro-sec/file` + - `false`: `renderer-config.json`, `ui-config.json`, and gamedata are loaded in plain mode + +- `secureApiEnabled` + - `true`: the `fetch` wrapper encrypts `/api/*` requests + - `false`: `/api/*` requests stay plain + +- `apiBaseUrl` + - Nitro emulator / API base URL + - example: `https://nitro.slogga.it:2096` + - it is best to always set this explicitly, so you do not depend on the hardcoded fallback + +- `plainConfigBaseUrl` + - base URL for plain config files + - usually: `https://hotel.slogga.it/` + +- `plainGamedataBaseUrl` + - base URL for plain gamedata files + - usually: `https://hotel.slogga.it/client/nitro/gamedata/` + +## 2. `Nitro-V3/src/bootstrap.ts` + +`bootstrap.ts`: + +- installs the secure fetch wrapper +- reads `window.__nitroClientMode` +- builds `NitroConfig['config.urls']` + +### Current behavior + +- if `secureAssetsEnabled=true` + - it uses `secureUrl('config', 'renderer-config.json', true)` + - it uses `secureUrl('config', 'ui-config.json', true)` + +- if `secureAssetsEnabled=false` + - it uses plain files with cache busting (`?v=...`) + +### Important note + +The current fallback is: + +```ts +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +``` + +So in production it is better to always set `apiBaseUrl` inside `client-mode.json`. + +## 3. `Nitro-V3/src/secure-assets.ts` + +This file contains the runtime logic for: + +- ECDH bootstrap +- asset decrypt/encrypt +- secure `/api/*` +- plain fallback when the toggles are disabled + +### In practice + +- it reads flags from `window.__nitroClientMode` +- if `secureAssetsEnabled=false` + - it automatically rewrites `/nitro-sec/file?...` into plain URLs +- if `secureApiEnabled=false` + - it does not encrypt `/api/*` + +Normally you should not need to touch it unless you want to change the secure protocol itself. + +## 4. `Nitro-V3/public/renderer-config.json` + +This file still defines the paths used by the renderer. + +### Things to check + +- `api.url` +- `socket.url` +- `gamedata.url` +- `external.texts.url` +- `external.texts.translation.url` +- `furnidata.url` +- `furnidata.translation.url` + +### With secure assets enabled + +You can use: + +```json +"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=" +``` + +and the equivalent secure URLs for the other gamedata resources. + +### With secure assets disabled + +You can use plain classic paths, for example: + +```json +"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata" +``` + +or you can keep the renderer config as-is and let `secure-assets.ts` handle the fallback conversion. + +## 5. `Nitro-V3/public/ui-config.json` + +There is no secure logic here, but it is one of the files loaded through `config.urls`. + +If `secureAssetsEnabled=true`, it is served from `/nitro-sec/file`. +If `secureAssetsEnabled=false`, it is loaded from the static file with `?v=...`. + +So you only need to maintain the content itself correctly. + +## 6. `Nitro-V3/scripts/write-asset-loader.mjs` + +This script generates `public/asset-loader.js`. + +### What it does now + +- renders the initial shell +- reads `client-mode.json` +- decides whether to load: + - `app.css.dat` / `app.js.dat` + - or `assets/app.css` / `assets/app.js` + +### Important + +If you modify this script, the updated loader is regenerated on the next: + +```bash +yarn build +``` + +because `package.json` already contains: + +```json +"prebuild": "node scripts/write-asset-loader.mjs" +``` + +## 7. `Nitro-V3/scripts/minify-dist.mjs` + +This script now: + +- generates the `.dat` files +- keeps the original `app.css` and `app.js` files too + +This is required, otherwise `distObfuscationEnabled=false` would not have a working fallback. + +## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example` + +The current backend flags are: + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root= +nitro.secure.gamedata.root= +nitro.secure.master_key=change-me-to-a-long-random-secret +``` + +### Meaning + +- `nitro.secure.assets.enabled` + - enables `/nitro-sec/bootstrap` and `/nitro-sec/file` + +- `nitro.secure.api.enabled` + - enables the secure layer for `/api/*` + +- `nitro.secure.config.root` + - folder used to read `renderer-config.json` and `ui-config.json` + +- `nitro.secure.gamedata.root` + - folder used to read live gamedata + +- `nitro.secure.master_key` + - persistent server-side secret + - especially important when running behind Cloudflare / multiple backend requests + +## 9. Example setups + +### Everything enabled + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro +nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata +nitro.secure.master_key=a-long-random-secret +``` + +### `.dat` only, no secure assets/API + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +### Everything plain + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +## 10. When rebuild is required + +### No rebuild required + +For changes to: + +- `client-mode.json` +- `renderer-config.json` +- `ui-config.json` +- live gamedata +- `config.ini` + +### Rebuild required + +For changes to: + +- `src/bootstrap.ts` +- `src/secure-assets.ts` +- `scripts/write-asset-loader.mjs` +- `scripts/minify-dist.mjs` + +## 11. Deployment note + +To make the toggles work properly: + +- always deploy both plain files and `.dat` files +- make sure IIS / your host serves the `.dat` MIME type +- if you disable secure mode on the client, disable it on the backend too for consistency + +## 12. Quick checklist + +- `client-mode.json` configured +- `apiBaseUrl` correct +- `nitro.secure.master_key` set +- `nitro.secure.config.root` correct +- `nitro.secure.gamedata.root` correct +- both `.dat` and plain files deployed +- `.dat` MIME type configured on the web server diff --git a/docs/secure-runtime-modes.md b/docs/secure-runtime-modes.md new file mode 100644 index 0000000..fc36fb5 --- /dev/null +++ b/docs/secure-runtime-modes.md @@ -0,0 +1,307 @@ +# Secure runtime modes + +Questa doc riassume tutti i dati da impostare per: + +- offuscamento bundle `dist` (`app.js` / `app.css` → `.dat`) +- secure assets runtime (`renderer-config.json`, `ui-config.json`, `gamedata`) +- secure API runtime (`/api/*`) +- fallback plain quando vuoi spegnere tutto senza togliere il codice + +## 1. `Nitro-V3/public/client-mode.json` + +Questo file controlla tutto a runtime. + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +### Campi + +- `distObfuscationEnabled` + - `true`: `asset-loader.js` carica `app.css.dat` e `app.js.dat` + - `false`: carica i file normali `assets/app.css` e `assets/app.js` + +- `secureAssetsEnabled` + - `true`: `bootstrap.ts` e `secure-assets.ts` usano `/nitro-sec/file` + - `false`: `renderer-config.json`, `ui-config.json` e gamedata vengono letti in plain + +- `secureApiEnabled` + - `true`: il wrapper `fetch` cifra le chiamate `/api/*` + - `false`: le chiamate `/api/*` restano normali + +- `apiBaseUrl` + - base URL dell’emulatore / API Nitro + - esempio: `https://nitro.slogga.it:2096` + - meglio valorizzarlo sempre, così non dipendi dal fallback hardcoded + +- `plainConfigBaseUrl` + - base URL dei file config plain + - normalmente: `https://hotel.slogga.it/` + +- `plainGamedataBaseUrl` + - base URL del gamedata plain + - normalmente: `https://hotel.slogga.it/client/nitro/gamedata/` + +## 2. `Nitro-V3/src/bootstrap.ts` + +`bootstrap.ts`: + +- installa il secure fetch wrapper +- legge `window.__nitroClientMode` +- costruisce `NitroConfig['config.urls']` + +### Comportamento attuale + +- se `secureAssetsEnabled=true` + - usa `secureUrl('config', 'renderer-config.json', true)` + - usa `secureUrl('config', 'ui-config.json', true)` + +- se `secureAssetsEnabled=false` + - usa i file plain con cache bust (`?v=...`) + +### Nota importante + +Il fallback attuale è: + +```ts +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +``` + +Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `client-mode.json`. + +## 3. `Nitro-V3/src/secure-assets.ts` + +Qui vive tutta la logica runtime: + +- bootstrap ECDH +- decrypt/encrypt assets +- secure `/api/*` +- fallback plain quando i toggle sono spenti + +### In pratica + +- legge i flag da `window.__nitroClientMode` +- se `secureAssetsEnabled=false` + - converte automaticamente `/nitro-sec/file?...` in URL plain +- se `secureApiEnabled=false` + - non cifra `/api/*` + +Normalmente non serve toccarlo, a meno che tu non voglia cambiare il protocollo secure. + +## 4. `Nitro-V3/public/renderer-config.json` + +Questo file continua a definire i path usati dal renderer. + +### Da controllare + +- `api.url` +- `socket.url` +- `gamedata.url` +- `external.texts.url` +- `external.texts.translation.url` +- `furnidata.url` +- `furnidata.translation.url` + +### Con secure assets attivo + +Puoi usare: + +```json +"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=" +``` + +e gli altri URL secure equivalenti. + +### Con secure assets disattivo + +Conviene usare i path plain classici, per esempio: + +```json +"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata" +``` + +oppure lasciare il renderer configurato com’è e demandare il fallback a `secure-assets.ts`. + +## 5. `Nitro-V3/public/ui-config.json` + +Qui non c’è logica secure, ma è uno dei file caricati da `config.urls`. + +Se `secureAssetsEnabled=true`, arriva da `/nitro-sec/file`. +Se `secureAssetsEnabled=false`, arriva dal file statico con `?v=...`. + +Quindi basta mantenerlo corretto come contenuto, non serve altro. + +## 6. `Nitro-V3/scripts/write-asset-loader.mjs` + +Questo script genera `public/asset-loader.js`. + +### Cosa fa ora + +- mostra la shell iniziale +- legge `client-mode.json` +- decide se caricare: + - `app.css.dat` / `app.js.dat` + - oppure `assets/app.css` / `assets/app.js` + +### Importante + +Se modifichi questo script, il loader aggiornato viene rigenerato al prossimo: + +```bash +yarn build +``` + +perché in `package.json` c’è: + +```json +"prebuild": "node scripts/write-asset-loader.mjs" +``` + +## 7. `Nitro-V3/scripts/minify-dist.mjs` + +Adesso questo script: + +- genera i `.dat` +- lascia anche i file originali `app.css` e `app.js` + +Questa parte è fondamentale, altrimenti il toggle `distObfuscationEnabled=false` non avrebbe fallback. + +## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example` + +I flag backend attuali sono: + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root= +nitro.secure.gamedata.root= +nitro.secure.master_key=change-me-to-a-long-random-secret +``` + +### Significato + +- `nitro.secure.assets.enabled` + - abilita `/nitro-sec/bootstrap` e `/nitro-sec/file` + +- `nitro.secure.api.enabled` + - abilita il layer secure per `/api/*` + +- `nitro.secure.config.root` + - cartella dove leggere `renderer-config.json` e `ui-config.json` + +- `nitro.secure.gamedata.root` + - cartella dove leggere il gamedata live + +- `nitro.secure.master_key` + - segreto persistente lato server + - necessario soprattutto con Cloudflare / richieste multiple + +## 9. Esempi di configurazione + +### Tutto attivo + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro +nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata +nitro.secure.master_key=una-chiave-lunga-random +``` + +### Solo `.dat`, senza secure assets/api + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +### Tutto plain + +`client-mode.json` + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.slogga.it:2096", + "plainConfigBaseUrl": "https://hotel.slogga.it/", + "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" +} +``` + +## 10. Quando serve rebuild + +### Non serve rebuild + +Per cambiare: + +- `client-mode.json` +- `renderer-config.json` +- `ui-config.json` +- gamedata live +- `config.ini` + +### Serve rebuild + +Per cambiare: + +- `src/bootstrap.ts` +- `src/secure-assets.ts` +- `scripts/write-asset-loader.mjs` +- `scripts/minify-dist.mjs` + +## 11. Nota pratica deployment + +Per usare bene i toggle: + +- pubblica sempre sia i file plain sia i `.dat` +- assicurati che IIS/host serva il MIME type per `.dat` +- se spegni il secure mode nel client, spegnilo anche nel backend per coerenza + +## 12. Checklist veloce + +- `client-mode.json` configurato +- `apiBaseUrl` corretto +- `nitro.secure.master_key` valorizzata +- `nitro.secure.config.root` corretto +- `nitro.secure.gamedata.root` corretto +- `.dat` e file plain entrambi deployati +- MIME `.dat` presente sul web server diff --git a/public/asset-loader.js b/public/asset-loader.js index 569b19d..e02be4d 100644 --- a/public/asset-loader.js +++ b/public/asset-loader.js @@ -1 +1,183 @@ -(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='
'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\.\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})(); \ No newline at end of file +(() => { + const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); + const MODE_DEFAULTS = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true + }; + + const isDebug = () => { + try { + const search = new URLSearchParams(location.search); + return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1"; + } catch { + return false; + } + }; + + const debug = (message) => { + try { + window.__nitroLoaderDebug = message; + const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : []; + log.push(message); + window.__nitroLoaderDebugLog = log.slice(-30); + if(!isDebug()) { + document.getElementById("nitro-loader-debug")?.remove(); + return; + } + let node = document.getElementById("nitro-loader-debug"); + if(!node) { + node = document.createElement("div"); + node.id = "nitro-loader-debug"; + node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap"; + document.body.appendChild(node); + } + node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\n"); + } catch {} + }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const renderShell = () => { + const root = document.getElementById("root"); + if(!root || root.firstChild) return; + root.innerHTML = '
'; + }; + + const decodeAsset = (bytes) => { + const output = new Uint8Array(bytes.length); + for(let index = 0; index < bytes.length; index++) { + output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255); + } + return output; + }; + + const gunzip = async (bytes) => { + if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }; + + const resolveAssetCandidates = (path) => { + const base = getBase(); + const normalized = path.replace(/^\.\//, ""); + const file = normalized.split("/").pop(); + const urls = [ + new URL("./src/assets/" + file, base), + new URL("./assets/" + file, base), + new URL("/src/assets/" + file, base.origin), + new URL("/assets/" + file, base.origin), + new URL("/client/src/assets/" + file, base.origin), + new URL("/client/assets/" + file, base.origin) + ]; + return [...new Map(urls.map(url => [url.href, url])).values()]; + }; + + const fetchBytes = async (path) => { + let error = null; + debug("loader: fetching " + path); + for(const candidate of resolveAssetCandidates(path)) { + try { + debug("loader: try " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + error = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + debug("loader: ok " + candidate.href); + return new Uint8Array(await response.arrayBuffer()); + } catch(caught) { + error = caught; + } + } + throw error || new Error("asset " + path + " not found"); + }; + + const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path))); + + const injectCssText = (bytes) => { + const node = document.createElement("style"); + node.textContent = new TextDecoder().decode(bytes); + document.head.appendChild(node); + debug("loader: css injected from dat"); + }; + + const loadPlainCss = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + await new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href.href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error("plain css failed")); + document.head.appendChild(link); + }); + debug("loader: css linked"); + }; + + const importBytes = async (bytes) => { + const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); + try { + debug("loader: importing app blob"); + await import(blobUrl); + debug("loader: app blob imported"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const importPlainJs = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + debug("loader: importing plain js"); + await import(href.href); + debug("loader: plain js imported"); + }; + + const readClientMode = async () => { + try { + const url = withCacheBust(new URL("./client-mode.json", getBase())); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("client-mode " + response.status); + const payload = await response.json(); + const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) }; + window.__nitroClientMode = mode; + debug("loader: client-mode loaded"); + return mode; + } catch(error) { + window.__nitroClientMode = { ...MODE_DEFAULTS }; + debug("loader: client-mode fallback " + (error?.message || error)); + return window.__nitroClientMode; + } + }; + + (async () => { + debug("loader: start"); + renderShell(); + const mode = await readClientMode(); + if(mode.distObfuscationEnabled) { + const [cssBytes, jsBytes] = await Promise.all([ + loadDatAsset("./assets/app.css.dat"), + loadDatAsset("./assets/app.js.dat") + ]); + injectCssText(cssBytes); + await importBytes(jsBytes); + return; + } + await loadPlainCss("./assets/app.css"); + await importPlainJs("./assets/app.js"); + })().catch(error => { + console.error(error); + debug("loader: failed " + (error?.message || error)); + document.body.textContent = "Unable to load client."; + }); +})(); \ No newline at end of file diff --git a/public/client-mode.json b/public/client-mode.json new file mode 100644 index 0000000..a738f14 --- /dev/null +++ b/public/client-mode.json @@ -0,0 +1,8 @@ +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "", + "plainConfigBaseUrl": "", + "plainGamedataBaseUrl": "" +} diff --git a/scripts/minify-dist.mjs b/scripts/minify-dist.mjs index a5c7970..a9d3d18 100644 --- a/scripts/minify-dist.mjs +++ b/scripts/minify-dist.mjs @@ -35,7 +35,6 @@ const encryptFile = path => { const bytes = gzipSync(readFileSync(path), { level: 9 }); writeFileSync(path + '.dat', encodeBytes(bytes)); - rmSync(path); }; if(!existsSync(dist)) throw new Error('dist folder not found'); diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 995e49c..b33aa36 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -1,7 +1,190 @@ import { mkdirSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; -const loader = `(()=>{const h=()=>{try{const s=new URLSearchParams(location.search);return s.get("loaderDebug")==="1"||localStorage.getItem("nitro.loader.debug")==="1"}catch{return!1}},m=t=>{if(!h()){document.getElementById("nitro-loader-debug")?.remove();return}let n=document.getElementById("nitro-loader-debug");if(!n){n=document.createElement("div");n.id="nitro-loader-debug";n.style.cssText="position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap";document.body.appendChild(n)}n.textContent=t},n=()=>{const s=document.currentScript?.src||location.href;return new URL(".",s)},v=()=>{const r=document.getElementById("root");if(!r||r.firstChild)return;r.innerHTML='
'},k=new TextEncoder().encode("slogga-dist-assets-2026"),d=b=>{const o=new Uint8Array(b.length);for(let i=0;i{if(!("DecompressionStream" in self))throw new Error("gzip decompression unsupported");const s=new Blob([b]).stream().pipeThrough(new DecompressionStream("gzip"));return new Uint8Array(await new Response(s).arrayBuffer())},u=p=>{const b=n(),q=p.replace(/^\\.\\//,""),f=q.split("/").pop(),c=[new URL("./src/assets/"+f,b),new URL("./assets/"+f,b),new URL("/src/assets/"+f,b.origin),new URL("/assets/"+f,b.origin),new URL("/client/src/assets/"+f,b.origin),new URL("/client/assets/"+f,b.origin)];return[...new Map(c.map(x=>[x.href,x])).values()]},g=async p=>{let e=null;m("loader: fetching "+p);for(const a of u(p)){try{m("loader: try "+a.href);const r=await fetch(a,{cache:"no-store"});if(!r.ok){e=new Error("asset "+a.pathname+" "+r.status);continue}m("loader: ok "+a.href);return z(d(new Uint8Array(await r.arrayBuffer())))}catch(x){e=x}}throw e||new Error("asset "+p+" not found")},s=c=>{const l=document.createElement("style");l.textContent=new TextDecoder().decode(c);document.head.appendChild(l);m("loader: css injected")},j=async c=>{const u=URL.createObjectURL(new Blob([c],{type:"text/javascript"}));try{m("loader: importing app blob");await import(u);m("loader: app blob imported")}finally{URL.revokeObjectURL(u)}};(async()=>{m("loader: start");v();const[c,a]=await Promise.all([g("./assets/app.css.dat"),g("./assets/app.js.dat")]);s(c);await j(a)})().catch(e=>{console.error(e);m("loader: failed "+(e?.message||e));document.body.textContent="Unable to load client."})})();`; +const loader = `(() => { + const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); + const MODE_DEFAULTS = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true + }; + + const isDebug = () => { + try { + const search = new URLSearchParams(location.search); + return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1"; + } catch { + return false; + } + }; + + const debug = (message) => { + try { + window.__nitroLoaderDebug = message; + const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : []; + log.push(message); + window.__nitroLoaderDebugLog = log.slice(-30); + if(!isDebug()) { + document.getElementById("nitro-loader-debug")?.remove(); + return; + } + let node = document.getElementById("nitro-loader-debug"); + if(!node) { + node = document.createElement("div"); + node.id = "nitro-loader-debug"; + node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap"; + document.body.appendChild(node); + } + node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n"); + } catch {} + }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const renderShell = () => { + const root = document.getElementById("root"); + if(!root || root.firstChild) return; + root.innerHTML = '
'; + }; + + const decodeAsset = (bytes) => { + const output = new Uint8Array(bytes.length); + for(let index = 0; index < bytes.length; index++) { + output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255); + } + return output; + }; + + const gunzip = async (bytes) => { + if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }; + + const resolveAssetCandidates = (path) => { + const base = getBase(); + const normalized = path.replace(/^\\.\\//, ""); + const file = normalized.split("/").pop(); + const urls = [ + new URL("./src/assets/" + file, base), + new URL("./assets/" + file, base), + new URL("/src/assets/" + file, base.origin), + new URL("/assets/" + file, base.origin), + new URL("/client/src/assets/" + file, base.origin), + new URL("/client/assets/" + file, base.origin) + ]; + return [...new Map(urls.map(url => [url.href, url])).values()]; + }; + + const fetchBytes = async (path) => { + let error = null; + debug("loader: fetching " + path); + for(const candidate of resolveAssetCandidates(path)) { + try { + debug("loader: try " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + error = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + debug("loader: ok " + candidate.href); + return new Uint8Array(await response.arrayBuffer()); + } catch(caught) { + error = caught; + } + } + throw error || new Error("asset " + path + " not found"); + }; + + const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path))); + + const injectCssText = (bytes) => { + const node = document.createElement("style"); + node.textContent = new TextDecoder().decode(bytes); + document.head.appendChild(node); + debug("loader: css injected from dat"); + }; + + const loadPlainCss = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + await new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href.href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error("plain css failed")); + document.head.appendChild(link); + }); + debug("loader: css linked"); + }; + + const importBytes = async (bytes) => { + const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); + try { + debug("loader: importing app blob"); + await import(blobUrl); + debug("loader: app blob imported"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const importPlainJs = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + debug("loader: importing plain js"); + await import(href.href); + debug("loader: plain js imported"); + }; + + const readClientMode = async () => { + try { + const url = withCacheBust(new URL("./client-mode.json", getBase())); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("client-mode " + response.status); + const payload = await response.json(); + const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) }; + window.__nitroClientMode = mode; + debug("loader: client-mode loaded"); + return mode; + } catch(error) { + window.__nitroClientMode = { ...MODE_DEFAULTS }; + debug("loader: client-mode fallback " + (error?.message || error)); + return window.__nitroClientMode; + } + }; + + (async () => { + debug("loader: start"); + renderShell(); + const mode = await readClientMode(); + if(mode.distObfuscationEnabled) { + const [cssBytes, jsBytes] = await Promise.all([ + loadDatAsset("./assets/app.css.dat"), + loadDatAsset("./assets/app.js.dat") + ]); + injectCssText(cssBytes); + await importBytes(jsBytes); + return; + } + await loadPlainCss("./assets/app.css"); + await importPlainJs("./assets/app.js"); + })().catch(error => { + console.error(error); + debug("loader: failed " + (error?.message || error)); + document.body.textContent = "Unable to load client."; + }); +})();`; + const target = resolve('public', 'asset-loader.js'); mkdirSync(dirname(target), { recursive: true }); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 052bf70..077a6c9 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,4 +1,4 @@ -import { installSecureFetch, secureUrl } from './secure-assets'; +import { getClientMode, installSecureFetch, secureUrl } from './secure-assets'; installSecureFetch(); @@ -17,12 +17,22 @@ const setBootDebug = (message: string) => setBootDebug('boot: secure fetch installed'); const search = new URLSearchParams(window.location.search); +const clientMode = getClientMode(); +const cacheBustUrl = (path: string): string => +{ + const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`); -(window as any).NitroSecureApiUrl = 'http://192.168.1.52:2096/'; + url.searchParams.set('v', Date.now().toString(36)); + + return url.toString(); +}; + +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +(window as any).NitroClientMode = clientMode; (window as any).NitroConfig = { 'config.urls': [ - secureUrl('config', 'renderer-config.json', true), - secureUrl('config', 'ui-config.json', true) + clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('renderer-config.json'), + clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('ui-config.json') ], 'sso.ticket': search.get('sso') || null, 'forward.type': search.get('room') ? 2 : -1, diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 05acb46..f0af707 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -4,6 +4,21 @@ type SecureSession = { fingerprint: string; }; +export type NitroClientMode = { + distObfuscationEnabled: boolean; + secureAssetsEnabled: boolean; + secureApiEnabled: boolean; + apiBaseUrl?: string; + plainConfigBaseUrl?: string; + plainGamedataBaseUrl?: string; +}; + +const CLIENT_MODE_DEFAULTS: NitroClientMode = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true +}; + const isDebugEnabled = (): boolean => { try @@ -73,6 +88,29 @@ const REKEY_ENDPOINTS = new Set([ '/api/auth/logout' ]); +export const getClientMode = (): NitroClientMode => +{ + try + { + const configured = (window as any).__nitroClientMode; + + if(configured && typeof configured === 'object') + { + return { + distObfuscationEnabled: configured.distObfuscationEnabled !== false, + secureAssetsEnabled: configured.secureAssetsEnabled !== false, + secureApiEnabled: configured.secureApiEnabled !== false, + apiBaseUrl: typeof configured.apiBaseUrl === 'string' ? configured.apiBaseUrl : '', + plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '', + plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : '' + }; + } + } + catch {} + + return { ...CLIENT_MODE_DEFAULTS }; +}; + const bytesToBase64 = (bytes: ArrayBuffer): string => { let binary = ''; @@ -149,6 +187,9 @@ const deriveAesKey = async (privateKey: CryptoKey, serverKeyBase64: string): Pro const getApiBase = (): string => { + const mode = getClientMode(); + if(typeof mode.apiBaseUrl === 'string' && mode.apiBaseUrl.length) return mode.apiBaseUrl.replace(/\/$/, ''); + const configured = (window as any).NitroSecureApiUrl; if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, ''); @@ -156,8 +197,42 @@ const getApiBase = (): string => return 'http://localhost:8443/'; }; +const getPlainAssetBase = (kind: 'config' | 'gamedata'): string => +{ + const mode = getClientMode(); + const configured = kind === 'config' ? mode.plainConfigBaseUrl : mode.plainGamedataBaseUrl; + + if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`; + + if(kind === 'config') return `${ window.location.origin }/`; + + return `${ window.location.origin }/nitro/gamedata/`; +}; + +const mapSecureAssetRequestToPlainUrl = (requestUrl: string): string => +{ + const url = new URL(requestUrl, window.location.href); + const kind = (url.searchParams.get('kind') || 'config') as 'config' | 'gamedata'; + const file = (url.searchParams.get('file') || '').replace(/^[\\/]+/, ''); + const plainUrl = new URL(file, getPlainAssetBase(kind)); + const cacheBust = url.searchParams.get('v'); + + if(cacheBust) plainUrl.searchParams.set('v', cacheBust); + + return plainUrl.toString(); +}; + export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = false): string => { + if(!getClientMode().secureAssetsEnabled) + { + const plainUrl = new URL(file.replace(/^\/+/, ''), `${ window.location.origin }/`); + + if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36)); + + return plainUrl.toString(); + } + const base = getApiBase(); const version = cacheBust ? `&v=${ encodeURIComponent(Date.now().toString(36)) }` : ''; @@ -364,6 +439,8 @@ export const installSecureFetch = (): void => if(requestUrl.includes('/nitro-sec/file')) { + if(!getClientMode().secureAssetsEnabled) return nativeFetch(mapSecureAssetRequestToPlainUrl(requestUrl), init); + const method = init?.method || (input instanceof Request ? input.method : 'GET'); const cacheKey = method.toUpperCase() === 'GET' ? normalizeSecureCacheKey(requestUrl) : null; @@ -405,7 +482,7 @@ export const installSecureFetch = (): void => return cloneCachedResponse(responsePromise); } - if(isApiUrl(requestUrl)) + if(getClientMode().secureApiEnabled && isApiUrl(requestUrl)) { const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase(); const session = await getSecureSession(); From 21dd3573978712c1ce7f88d04d99298925efe310 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Fri, 24 Apr 2026 15:59:55 +0200 Subject: [PATCH 08/11] Replace production domain references with examples --- docs/secure-runtime-modes.en.md | 36 +++++++++++++++--------------- docs/secure-runtime-modes.md | 36 +++++++++++++++--------------- public/asset-loader.js | 2 +- public/renderer-config.json | 14 ++++++------ public/ui-config.json | 18 +++++++-------- scripts/write-asset-loader.mjs | 2 +- src/bootstrap.ts | 2 +- src/components/login/LoginView.tsx | 8 +++---- src/secure-assets.ts | 2 +- vite.config.mjs | 4 ++-- 10 files changed, 62 insertions(+), 62 deletions(-) diff --git a/docs/secure-runtime-modes.en.md b/docs/secure-runtime-modes.en.md index c2d6fbf..5ff441e 100644 --- a/docs/secure-runtime-modes.en.md +++ b/docs/secure-runtime-modes.en.md @@ -16,9 +16,9 @@ This file controls everything at runtime. "distObfuscationEnabled": true, "secureAssetsEnabled": true, "secureApiEnabled": true, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -38,16 +38,16 @@ This file controls everything at runtime. - `apiBaseUrl` - Nitro emulator / API base URL - - example: `https://nitro.slogga.it:2096` + - example: `https://nitro.example.com:2096` - it is best to always set this explicitly, so you do not depend on the hardcoded fallback - `plainConfigBaseUrl` - base URL for plain config files - - usually: `https://hotel.slogga.it/` + - usually: `https://hotel.example.com/` - `plainGamedataBaseUrl` - base URL for plain gamedata files - - usually: `https://hotel.slogga.it/client/nitro/gamedata/` + - usually: `https://hotel.example.com/client/nitro/gamedata/` ## 2. `Nitro-V3/src/bootstrap.ts` @@ -71,7 +71,7 @@ This file controls everything at runtime. The current fallback is: ```ts -(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; ``` So in production it is better to always set `apiBaseUrl` inside `client-mode.json`. @@ -114,7 +114,7 @@ This file still defines the paths used by the renderer. You can use: ```json -"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=" +"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=" ``` and the equivalent secure URLs for the other gamedata resources. @@ -124,7 +124,7 @@ and the equivalent secure URLs for the other gamedata resources. You can use plain classic paths, for example: ```json -"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata" +"gamedata.url": "https://hotel.example.com/client/nitro/gamedata" ``` or you can keep the renderer config as-is and let `secure-assets.ts` handle the fallback conversion. @@ -214,9 +214,9 @@ nitro.secure.master_key=change-me-to-a-long-random-secret "distObfuscationEnabled": true, "secureAssetsEnabled": true, "secureApiEnabled": true, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -239,9 +239,9 @@ nitro.secure.master_key=a-long-random-secret "distObfuscationEnabled": true, "secureAssetsEnabled": false, "secureApiEnabled": false, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -261,9 +261,9 @@ nitro.secure.api.enabled=false "distObfuscationEnabled": false, "secureAssetsEnabled": false, "secureApiEnabled": false, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` diff --git a/docs/secure-runtime-modes.md b/docs/secure-runtime-modes.md index fc36fb5..3b59b39 100644 --- a/docs/secure-runtime-modes.md +++ b/docs/secure-runtime-modes.md @@ -16,9 +16,9 @@ Questo file controlla tutto a runtime. "distObfuscationEnabled": true, "secureAssetsEnabled": true, "secureApiEnabled": true, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -38,16 +38,16 @@ Questo file controlla tutto a runtime. - `apiBaseUrl` - base URL dell’emulatore / API Nitro - - esempio: `https://nitro.slogga.it:2096` + - esempio: `https://nitro.example.com:2096` - meglio valorizzarlo sempre, così non dipendi dal fallback hardcoded - `plainConfigBaseUrl` - base URL dei file config plain - - normalmente: `https://hotel.slogga.it/` + - normalmente: `https://hotel.example.com/` - `plainGamedataBaseUrl` - base URL del gamedata plain - - normalmente: `https://hotel.slogga.it/client/nitro/gamedata/` + - normalmente: `https://hotel.example.com/client/nitro/gamedata/` ## 2. `Nitro-V3/src/bootstrap.ts` @@ -71,7 +71,7 @@ Questo file controlla tutto a runtime. Il fallback attuale è: ```ts -(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; ``` Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `client-mode.json`. @@ -114,7 +114,7 @@ Questo file continua a definire i path usati dal renderer. Puoi usare: ```json -"gamedata.url": "https://nitro.slogga.it:2096/nitro-sec/file?kind=gamedata&file=" +"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=" ``` e gli altri URL secure equivalenti. @@ -124,7 +124,7 @@ e gli altri URL secure equivalenti. Conviene usare i path plain classici, per esempio: ```json -"gamedata.url": "https://hotel.slogga.it/client/nitro/gamedata" +"gamedata.url": "https://hotel.example.com/client/nitro/gamedata" ``` oppure lasciare il renderer configurato com’è e demandare il fallback a `secure-assets.ts`. @@ -214,9 +214,9 @@ nitro.secure.master_key=change-me-to-a-long-random-secret "distObfuscationEnabled": true, "secureAssetsEnabled": true, "secureApiEnabled": true, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -239,9 +239,9 @@ nitro.secure.master_key=una-chiave-lunga-random "distObfuscationEnabled": true, "secureAssetsEnabled": false, "secureApiEnabled": false, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -261,9 +261,9 @@ nitro.secure.api.enabled=false "distObfuscationEnabled": false, "secureAssetsEnabled": false, "secureApiEnabled": false, - "apiBaseUrl": "https://nitro.slogga.it:2096", - "plainConfigBaseUrl": "https://hotel.slogga.it/", - "plainGamedataBaseUrl": "https://hotel.slogga.it/client/nitro/gamedata/" + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` diff --git a/public/asset-loader.js b/public/asset-loader.js index e02be4d..be407cc 100644 --- a/public/asset-loader.js +++ b/public/asset-loader.js @@ -49,7 +49,7 @@ const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/public/renderer-config.json b/public/renderer-config.json index 634694b..6b769d7 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -1,11 +1,11 @@ { - "socket.url": "ws://192.168.1.52:2096", - "api.url": "http://192.168.1.52:2096", - "asset.url": "https://hotel.slogga.it/client/nitro/bundled", - "image.library.url": "https://hotel.slogga.it/client/c_images/", - "hof.furni.url": "https://hotel.slogga.it/client/c_images/dcr/hof_furni", - "images.url": "https://hotel.slogga.it/client/nitro/images", - "gamedata.url": "http://192.168.1.52:2096/nitro-sec/file?kind=gamedata&file=", + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "asset.url": "https://hotel.example.com/client/nitro/bundled", + "image.library.url": "https://hotel.example.com/client/c_images/", + "hof.furni.url": "https://hotel.example.com/client/c_images/dcr/hof_furni", + "images.url": "https://hotel.example.com/client/nitro/images", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ "${gamedata.url}/ExternalTexts.json", diff --git a/public/ui-config.json b/public/ui-config.json index 50cef22..f625755 100644 --- a/public/ui-config.json +++ b/public/ui-config.json @@ -1,8 +1,8 @@ { "image.library.notifications.url": "${image.library.url}notifications/%image%.png", "achievements.images.url": "${image.library.url}Quests/%image%.png", - "camera.url": "https://hotel.slogga.it/client/camera/", - "thumbnails.url": "https://hotel.slogga.it/client/camera/thumbnail/%thumbnail%.png", + "camera.url": "https://hotel.example.com/client/camera/", + "thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png", "url.prefix": "", "habbopages.url": "/gamedata/habbopages/", "group.homepage.url": "${url.prefix}/groups/%groupid%/id", @@ -30,9 +30,9 @@ "show.google.ads": false, "loginview": { "images": { - "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", - "left": "https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png", - "right": "https://hotel.slogga.it/client/nitro/images/reception/background_right.png" + "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", + "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" } }, "navigator.room.models": [ @@ -1575,11 +1575,11 @@ }, "loginview": { "images": { - "background": "https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png", + "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", "background.colour": "#6eadc8", - "drape": "https://hotel.slogga.it/client/nitro/images/reception/drape.png", - "left": "https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png", - "right": "https://hotel.slogga.it/client/nitro/images/reception/background_right.png" + "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", + "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" }, "widgets": { "slot.1.widget": "promoarticle", diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index b33aa36..5b3aa5d 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -52,7 +52,7 @@ const loader = `(() => { const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 077a6c9..a9d95c3 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -27,7 +27,7 @@ const cacheBustUrl = (path: string): string => return url.toString(); }; -(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'http://192.168.1.52:2096/'; +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; (window as any).NitroClientMode = clientMode; (window as any).NitroConfig = { 'config.urls': [ diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index e22daf5..28575a3 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -61,11 +61,11 @@ const MAX_ATTEMPTS = 5; const LOCK_WINDOW_MS = 60_000; const LOCK_DURATION_MS = 2 * 60_000; const DEFAULT_LOGIN_IMAGES: Record = { - background: 'https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png', + background: 'https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png', 'background.colour': '#6eadc8', - drape: 'https://hotel.slogga.it/client/nitro/images/reception/drape.png', - left: 'https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png', - right: 'https://hotel.slogga.it/client/nitro/images/reception/background_right.png' + drape: 'https://hotel.example.com/client/nitro/images/reception/drape.png', + left: 'https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png', + right: 'https://hotel.example.com/client/nitro/images/reception/background_right.png' }; const LOGIN_LOCALES: LoginLocale[] = [ { code: 'it', file: 'it', label: 'Italiano', flag: flagIt }, diff --git a/src/secure-assets.ts b/src/secure-assets.ts index f0af707..a44e9d0 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -194,7 +194,7 @@ const getApiBase = (): string => if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, ''); - return 'http://localhost:8443/'; + return 'https://nitro.example.com:2096/'; }; const getPlainAssetBase = (kind: 'config' | 'gamedata'): string => diff --git a/vite.config.mjs b/vite.config.mjs index b8c6e4a..5922a33 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -18,12 +18,12 @@ export default defineConfig({ }, proxy: { '/api': { - target: process.env.AUTH_PROXY_TARGET || 'http://192.168.1.52:2096/', + target: process.env.AUTH_PROXY_TARGET || 'https://nitro.example.com:2096/', changeOrigin: true, ws: true, }, '/nitro-sec': { - target: process.env.NITRO_PROXY_TARGET || 'http://192.168.1.52:2096/', + target: process.env.NITRO_PROXY_TARGET || 'https://nitro.example.com:2096/', changeOrigin: true, ws: true, } From 6c7d78c15690865d89a4a0a5fe1f0118d05c8b01 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Fri, 24 Apr 2026 16:12:04 +0200 Subject: [PATCH 09/11] Move runtime URLs to config examples --- mockup/assets/README.md | 12 - mockup/index.html | 623 ------------------ public/asset-loader.js | 2 +- public/client-mode.example.json | 8 + public/renderer-config.example | 53 ++ scripts/write-asset-loader.mjs | 2 +- src/bootstrap.ts | 2 +- .../InterfaceImageTabView.tsx | 2 +- src/components/login/LoginView.tsx | 23 +- src/secure-assets.ts | 2 +- 10 files changed, 81 insertions(+), 648 deletions(-) delete mode 100644 mockup/assets/README.md delete mode 100644 mockup/index.html create mode 100644 public/client-mode.example.json create mode 100644 public/renderer-config.example diff --git a/mockup/assets/README.md b/mockup/assets/README.md deleted file mode 100644 index f902c1b..0000000 --- a/mockup/assets/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Mockup Assets - -Questa cartella e' pronta per immagini o sprite dedicate ai mockup HTML. - -Uso previsto: -- copiare qui versioni statiche di asset che vuoi testare fuori dal progetto -- collegarle da `mockup/index.html` -- tenere separati i file di esperimento dagli asset reali di `src/assets` - -Percorso base: -- `Nitro-V3/mockup/index.html` -- `Nitro-V3/mockup/assets/` diff --git a/mockup/index.html b/mockup/index.html deleted file mode 100644 index 8a08573..0000000 --- a/mockup/index.html +++ /dev/null @@ -1,623 +0,0 @@ - - - - - - Nitro V3 Mockup Lab - - - -

Nitro V3 Mockup Lab

-

Mockup HTML standalone dei componenti principali attuali. La resa è pensata per darti una base visiva da modificare rapidamente fuori dal progetto reale.

- -
-
-
-

NitroCard

-

Base card attuale con header blu, tabs grigie e content chiaro.

-
-
-
-
-
-
Navigator
-
-
-
-
Hotel
-
Rooms
-
+
-
-
- Contenuto card attuale, usato come base da vari componenti. -
-
-
-
Source files: -src/common/card/NitroCardView.tsx -src/common/card/NitroCardHeaderView.tsx -src/common/card/NitroCardContentView.tsx -src/common/card/tabs/NitroCardTabsView.tsx -src/common/card/tabs/NitroCardTabsItemView.tsx -src/css/nitrocard/NitroCardView.css
-
-
- -
-
-

Purse

-

Layout attuale con currency, box HC, pulsanti laterali e seasonal sotto.

-
-
-
-
-
-
-
3601 ◉
-
5365 ◎
-
700 ◈
-
-
-
HC
-
78 g
-
-
-
-
-
-
-
- Stagionale - 99 999 -
-
-
-
Source files: -src/components/purse/PurseView.tsx -src/components/purse/views/CurrencyView.tsx -src/components/purse/views/SeasonalView.tsx -src/css/purse/PurseView.css
-
-
- -
-
-

Toolbar

-

Barra bassa attuale con area me, icone centrali e blocco friend/message.

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Source files: -src/components/toolbar/ToolbarView.tsx -src/components/toolbar/ToolbarItemView.tsx -src/components/toolbar/ToolbarMeView.tsx
-
-
- -
-
-

Navigator

-

Finestra navigator attuale con card base, search e footer actions.

-
-
-
- -
-
Source files: -src/components/navigator/NavigatorView.tsx -src/components/navigator/NavigatorView.scss -src/css/room/NavigatorRoomSettings.css
-
-
- -
-
-

Notifications

-

Bubble attuale con fondo scuro e inner shadow.

-
-
-
-
- Hai ricevuto una nuova notifica. Questo box rappresenta lo stato attuale delle bubble notifications. -
-
-
Source files: -src/components/notification-center/NotificationCenterView.tsx -src/css/notification/NotificationCenterView.css
-
-
- -
-
-

Friends

-

Barra amici e blocchi friend pill attuali.

-
-
-
-
-
-
-
-
-
-
-
Source files: -src/components/friends/FriendsView.tsx -src/css/friends/FriendsView.css
-
-
- -
-
-

HotelView

-

Mockup della scena hotel attuale con sfondo e hotspot.

-
-
-
-
-
-
-
-
-
-
-
Source files: -src/components/hotel-view/HotelView.tsx -src/css/hotelview/HotelView.css
-
-
-
- - diff --git a/public/asset-loader.js b/public/asset-loader.js index be407cc..f64cbe6 100644 --- a/public/asset-loader.js +++ b/public/asset-loader.js @@ -49,7 +49,7 @@ const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/public/client-mode.example.json b/public/client-mode.example.json new file mode 100644 index 0000000..4582313 --- /dev/null +++ b/public/client-mode.example.json @@ -0,0 +1,8 @@ +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} diff --git a/public/renderer-config.example b/public/renderer-config.example new file mode 100644 index 0000000..4a294f2 --- /dev/null +++ b/public/renderer-config.example @@ -0,0 +1,53 @@ +{ + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "asset.url": "https://hotel.example.com/client/nitro/bundled", + "image.library.url": "https://hotel.example.com/client/c_images/", + "hof.furni.url": "https://hotel.example.com/client/c_images/dcr/hof_furni", + "images.url": "https://hotel.example.com/client/nitro/images", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", + "sounds.url": "${asset.url}/sounds/%sample%.mp3", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", + "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "avatar.asset.url": "${asset.url}/figure/%libname%.nitro", + "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", + "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", + "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", + "pet.asset.url": "${asset.url}/pets/%libname%.nitro", + "generic.asset.url": "${asset.url}/generic/%libname%.nitro", + "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", + "furni.rotation.bounce.steps": 20, + "furni.rotation.bounce.height": 0.0625, + "enable.avatar.arrow": false, + "system.log.debug": true, + "system.log.warn": true, + "system.log.error": true, + "system.log.events": false, + "system.log.packets": true, + "system.fps.animation": 24, + "system.fps.max": 60, + "system.pong.manually": true, + "system.pong.interval.ms": 20000, + "room.color.skip.transition": true, + "room.landscapes.enabled": true, + "room.zoom.enabled": true, + "login.screen.enabled": true, + "login.endpoint": "${api.url}/api/auth/login", + "login.register.endpoint": "${api.url}/api/auth/register", + "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", + "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.turnstile.enabled": false, + "login.turnstile.sitekey": "" +} diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 5b3aa5d..a94d388 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -52,7 +52,7 @@ const loader = `(() => { const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/src/bootstrap.ts b/src/bootstrap.ts index a9d95c3..2df054b 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -27,7 +27,7 @@ const cacheBustUrl = (path: string): string => return url.toString(); }; -(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || window.location.origin; (window as any).NitroClientMode = clientMode; (window as any).NitroConfig = { 'config.urls': [ diff --git a/src/components/interface-settings/InterfaceImageTabView.tsx b/src/components/interface-settings/InterfaceImageTabView.tsx index 390a390..1b9860e 100644 --- a/src/components/interface-settings/InterfaceImageTabView.tsx +++ b/src/components/interface-settings/InterfaceImageTabView.tsx @@ -12,7 +12,7 @@ export const InterfaceImageTabView: FC<{}> = () => const baseUrl = useMemo(() => { - return GetConfigurationValue('ui.header.images.url', 'https://image.webbo.city/image/headerImage/image{id}.gif'); + return GetConfigurationValue('ui.header.images.url', ''); }, []); const images = useMemo(() => diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 28575a3..ceba536 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -60,12 +60,19 @@ const CHAT_TRANSLATION_SETTINGS_KEY = 'chatTranslationSettings'; const MAX_ATTEMPTS = 5; const LOCK_WINDOW_MS = 60_000; const LOCK_DURATION_MS = 2 * 60_000; -const DEFAULT_LOGIN_IMAGES: Record = { - background: 'https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png', - 'background.colour': '#6eadc8', - drape: 'https://hotel.example.com/client/nitro/images/reception/drape.png', - left: 'https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png', - right: 'https://hotel.example.com/client/nitro/images/reception/background_right.png' +const getDefaultLoginImages = (): Record => +{ + const imagesBase = (GetConfigurationValue('images.url', '') || '').replace(/\/$/, ''); + + if(!imagesBase.length) return { 'background.colour': '#6eadc8' }; + + return { + background: `${ imagesBase }/reception/background_gradient_apr25.png`, + 'background.colour': '#6eadc8', + drape: `${ imagesBase }/reception/drape.png`, + left: `${ imagesBase }/reception/mute_reception_backdrop_left.png`, + right: `${ imagesBase }/reception/background_right.png` + }; }; const LOGIN_LOCALES: LoginLocale[] = [ { code: 'it', file: 'it', label: 'Italiano', flag: flagIt }, @@ -188,7 +195,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const submitTimeRef = useRef(0); const configuredLoginImages: Record = (loginViewConfig?.['images'] as Record) ?? {}; - const loginImages: Record = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages }; + const loginImages: Record = { ...getDefaultLoginImages(), ...configuredLoginImages }; const configuredLoginWidgets: Record = (loginViewConfig?.['widgets'] as Record) ?? {}; const loginWidgetSlots = useMemo(() => @@ -487,7 +494,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); - const imagingUrl = GetConfigurationValue('login.register.imaging.url', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l'); + const imagingUrl = GetConfigurationValue('login.register.imaging.url', ''); const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; diff --git a/src/secure-assets.ts b/src/secure-assets.ts index a44e9d0..31018ac 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -194,7 +194,7 @@ const getApiBase = (): string => if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, ''); - return 'https://nitro.example.com:2096/'; + return window.location.origin; }; const getPlainAssetBase = (kind: 'config' | 'gamedata'): string => From 3c9a599505b5297ee6f7378aba2d62575ad54105 Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Sat, 25 Apr 2026 13:29:48 +0200 Subject: [PATCH 10/11] Add secure configuration bootstrap flow --- .gitignore | 7 + README.md | 11 +- docs/secure-runtime-modes.en.html | 236 ++ docs/secure-runtime-modes.en.md | 44 +- docs/secure-runtime-modes.html | 236 ++ docs/secure-runtime-modes.md | 44 +- localization/badge-texts-en.json | 3 - localization/badge-texts-it.json | 3 - public/UITexts.example | 113 - public/client-mode.json | 8 - public/configuration/UITexts.example | 116 + .../adsense.example} | 0 public/{ => configuration}/asset-loader.js | 4 + public/configuration/bootstrap.js | 133 + .../client-mode.example} | 2 +- .../hotlooks.example} | 0 .../renderer-config.example | 0 public/{ => configuration}/ui-config.example | 0 public/renderer-config.json | 598 ---- public/ui-config.json | 2816 ----------------- scripts/minify-dist.mjs | 8 +- scripts/write-asset-loader.mjs | 158 +- src/bootstrap.ts | 4 +- src/components/ads/GoogleAdsView.tsx | 5 +- src/components/login/LoginView.tsx | 3 +- src/secure-assets.ts | 13 +- vite.config.mjs | 13 +- 27 files changed, 962 insertions(+), 3616 deletions(-) create mode 100644 docs/secure-runtime-modes.en.html create mode 100644 docs/secure-runtime-modes.html delete mode 100644 localization/badge-texts-en.json delete mode 100644 localization/badge-texts-it.json delete mode 100644 public/UITexts.example delete mode 100644 public/client-mode.json create mode 100644 public/configuration/UITexts.example rename public/{adsense.json => configuration/adsense.example} (100%) rename public/{ => configuration}/asset-loader.js (97%) create mode 100644 public/configuration/bootstrap.js rename public/{client-mode.example.json => configuration/client-mode.example} (76%) rename public/{hotlooks.json => configuration/hotlooks.example} (100%) rename public/{ => configuration}/renderer-config.example (100%) rename public/{ => configuration}/ui-config.example (100%) delete mode 100644 public/renderer-config.json delete mode 100644 public/ui-config.json diff --git a/.gitignore b/.gitignore index 249e7db..90a9bfe 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,10 @@ Thumbs.db *.zip .env .claude/ + +# Local runtime config copies +/public/configuration/renderer-config.json +/public/configuration/ui-config.json +/public/configuration/client-mode.json +/public/configuration/adsense.json +/public/configuration/hotlooks.json diff --git a/README.md b/README.md index a733fba..bc91d90 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,13 @@ - `yarn install` - `yarn link "@nitrots/nitro-renderer"` <== This will link the renderer in the project - Rename a few files - - Rename `public/renderer-config.json.example` to `public/renderer-config.json` - - Rename `public/ui-config.json.example` to `public/ui-config.json` -- Set your links - - Open `public/renderer-config.json` + - Copy `public/configuration/renderer-config.example` to `public/configuration/renderer-config.json` + - Copy `public/configuration/ui-config.example` to `public/configuration/ui-config.json` + - Copy `public/configuration/client-mode.example` to `public/configuration/client-mode.json` + - Set your links + - Open `public/configuration/renderer-config.json` - Update `socket.url, asset.url, image.library.url, & hof.furni.url` - - Open `public/ui-config.json` + - Open `public/configuration/ui-config.json` - Update `camera.url, thumbnails.url, url.prefix, habbopages.url` - `yarn build` <== the final step to build the DIST folder this is where your browser needs to point / or upload this to your /client if you do the compile on a other machine (preferd) - You can override any variable by passing it to `NitroConfig` in the index.html diff --git a/docs/secure-runtime-modes.en.html b/docs/secure-runtime-modes.en.html new file mode 100644 index 0000000..491608d --- /dev/null +++ b/docs/secure-runtime-modes.en.html @@ -0,0 +1,236 @@ + + + + + + Nitro Secure Runtime Modes + + + +
+
+
+ Nitro V3 + Secure Runtime +
+

Runtime configuration guide

+

+ This page gives you a cleaner, readable overview of runtime toggles, example files and the values that belong in config files + rather than hardcoded inside src. +

+
+ +
+ + +
+
+

Overview

+
+
+

Dist Obfuscation

+

Chooses whether the client loads app.js/app.css or the obfuscated .dat versions.

+
+
+

Secure Assets

+

Controls whether renderer-config, ui-config and gamedata go through /nitro-sec/file.

+
+
+

Secure API

+

Enables or disables runtime encryption for /api/* requests.

+
+
+
+ +
+

Files to use

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurposeNote
public/configuration/client-mode.exampleTemplate for runtime togglesCopy it into a real configuration/client-mode.json in deployment; that real file stays ignored by Git
public/configuration/renderer-config.exampleClean renderer config templateDoes not touch your local configuration/renderer-config.json
public/configuration/ui-config.exampleUI config reference templateUse it as the source of truth for UI URLs and widgets
Latest_Compiled_Version/config.ini.exampleBackend secure flagsDefines the emulator-side runtime settings
+
+
+ +
+

client-mode.example

+

This is the main runtime switchboard. You can enable or disable behavior without editing client source code.

+
{
+    "distObfuscationEnabled": true,
+    "secureAssetsEnabled": true,
+    "secureApiEnabled": true,
+    "apiBaseUrl": "https://nitro.example.com:2096",
+    "plainConfigBaseUrl": "https://hotel.example.com/configuration/",
+    "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
+}
+
+
+

Fields

+
    +
  • distObfuscationEnabled: use .dat or plain assets
  • +
  • secureAssetsEnabled: enables /nitro-sec/file
  • +
  • secureApiEnabled: encrypts /api/* requests
  • +
  • apiBaseUrl: emulator/API base URL
  • +
+
+
+

Recommendation

+

Always set apiBaseUrl explicitly so you do not rely on fallback logic.

+
+
+
+ +
+

renderer-config.example

+

Socket, API, asset and gamedata URLs should live here, not inside React components.

+
+
+

Main keys

+
    +
  • socket.url
  • +
  • api.url
  • +
  • asset.url
  • +
  • image.library.url
  • +
  • images.url
  • +
  • gamedata.url
  • +
+
+
+

Translations

+
    +
  • external.texts.translation.url
  • +
  • furnidata.translation.url
  • +
  • Uses %locale% and %timestamp%
  • +
+
+
+
+ +
+

ui-config.example

+

UI image and login view sources should come from config values here or from renderer config, never from hardcoded URLs in components.

+
+

Login view

+
    +
  • loginview.images.background
  • +
  • loginview.images.drape
  • +
  • loginview.images.left
  • +
  • loginview.images.right
  • +
  • loginview.widgets for promotional blocks
  • +
+
+
+ +
+

Runtime code involved

+
+
+

src/bootstrap.ts

+

Reads client-mode, builds NitroConfig['config.urls'] and prepares client bootstrap.

+
+
+

src/secure-assets.ts

+

Handles ECDH, decrypt/encrypt, plain fallback and secure API runtime behavior.

+
+
+

scripts/write-asset-loader.mjs

+

Generates public/configuration/asset-loader.js and decides between plain assets and .dat.

+
+
+

scripts/minify-dist.mjs

+

Generates .dat files while keeping plain files available for runtime switching.

+
+
+
+ +
+

Emulator

+
nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=C:/path/to/Nitro-V3/public
+nitro.secure.gamedata.root=C:/path/to/gamedata
+nitro.secure.master_key=change-me-to-a-long-random-secret
+
    +
  • nitro.secure.assets.enabled: enables /nitro-sec/bootstrap and /nitro-sec/file
  • +
  • nitro.secure.api.enabled: enables secure handling for /api/*
  • +
  • nitro.secure.config.root: path to live config files
  • +
  • nitro.secure.gamedata.root: path to live gamedata
  • +
  • nitro.secure.master_key: persistent server-side secret
  • +
+
+ +
+

Quick scenarios

+
+
+

Everything enabled

+

Secure assets, secure API and dist obfuscation all enabled.

+
+
+

Only .dat

+

Uses obfuscated assets but leaves config/API in plain mode.

+
+
+

Everything plain

+

Complete fallback mode for local testing or debugging.

+
+
+
+ +
+

Final checklist

+
+
You created real files from client-mode.example, renderer-config.example and ui-config.example
+
Public URLs live in config files, not in React components
+
Both plain files and .dat files are deployed
+
Your server exposes a proper MIME type for .dat
+
You set nitro.secure.master_key on the emulator side
+
+
+
+
+
+ + + + diff --git a/docs/secure-runtime-modes.en.md b/docs/secure-runtime-modes.en.md index 5ff441e..3bac47f 100644 --- a/docs/secure-runtime-modes.en.md +++ b/docs/secure-runtime-modes.en.md @@ -3,11 +3,11 @@ This document summarizes all values you may need to configure for: - `dist` bundle obfuscation (`app.js` / `app.css` → `.dat`) -- secure runtime assets (`renderer-config.json`, `ui-config.json`, `gamedata`) +- secure runtime assets (`configuration/renderer-config.json`, `configuration/ui-config.json`, `gamedata`) - secure runtime API (`/api/*`) - plain fallbacks when you want to disable the secure layer without removing the code -## 1. `Nitro-V3/public/client-mode.json` +## 1. `Nitro-V3/public/configuration/client-mode.json` This file controls everything at runtime. @@ -17,7 +17,7 @@ This file controls everything at runtime. "secureAssetsEnabled": true, "secureApiEnabled": true, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -30,7 +30,7 @@ This file controls everything at runtime. - `secureAssetsEnabled` - `true`: `bootstrap.ts` and `secure-assets.ts` use `/nitro-sec/file` - - `false`: `renderer-config.json`, `ui-config.json`, and gamedata are loaded in plain mode + - `false`: `configuration/renderer-config.json`, `configuration/ui-config.json`, and gamedata are loaded in plain mode - `secureApiEnabled` - `true`: the `fetch` wrapper encrypts `/api/*` requests @@ -43,7 +43,7 @@ This file controls everything at runtime. - `plainConfigBaseUrl` - base URL for plain config files - - usually: `https://hotel.example.com/` + - usually: `https://hotel.example.com/configuration/` - `plainGamedataBaseUrl` - base URL for plain gamedata files @@ -74,7 +74,7 @@ The current fallback is: (window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; ``` -So in production it is better to always set `apiBaseUrl` inside `client-mode.json`. +So in production it is better to always set `apiBaseUrl` inside `configuration/client-mode.json`. ## 3. `Nitro-V3/src/secure-assets.ts` @@ -95,7 +95,7 @@ This file contains the runtime logic for: Normally you should not need to touch it unless you want to change the secure protocol itself. -## 4. `Nitro-V3/public/renderer-config.json` +## 4. `Nitro-V3/public/configuration/renderer-config.json` This file still defines the paths used by the renderer. @@ -129,7 +129,7 @@ You can use plain classic paths, for example: or you can keep the renderer config as-is and let `secure-assets.ts` handle the fallback conversion. -## 5. `Nitro-V3/public/ui-config.json` +## 5. `Nitro-V3/public/configuration/ui-config.json` There is no secure logic here, but it is one of the files loaded through `config.urls`. @@ -140,12 +140,12 @@ So you only need to maintain the content itself correctly. ## 6. `Nitro-V3/scripts/write-asset-loader.mjs` -This script generates `public/asset-loader.js`. +This script generates `public/configuration/asset-loader.js`. ### What it does now - renders the initial shell -- reads `client-mode.json` +- reads `configuration/client-mode.json` - decides whether to load: - `app.css.dat` / `app.js.dat` - or `assets/app.css` / `assets/app.js` @@ -194,7 +194,7 @@ nitro.secure.master_key=change-me-to-a-long-random-secret - enables the secure layer for `/api/*` - `nitro.secure.config.root` - - folder used to read `renderer-config.json` and `ui-config.json` + - folder used to read `configuration/renderer-config.json` and `configuration/ui-config.json` - `nitro.secure.gamedata.root` - folder used to read live gamedata @@ -207,7 +207,7 @@ nitro.secure.master_key=change-me-to-a-long-random-secret ### Everything enabled -`client-mode.json` +`configuration/client-mode.json` ```json { @@ -215,7 +215,7 @@ nitro.secure.master_key=change-me-to-a-long-random-secret "secureAssetsEnabled": true, "secureApiEnabled": true, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -232,7 +232,7 @@ nitro.secure.master_key=a-long-random-secret ### `.dat` only, no secure assets/API -`client-mode.json` +`configuration/client-mode.json` ```json { @@ -240,7 +240,7 @@ nitro.secure.master_key=a-long-random-secret "secureAssetsEnabled": false, "secureApiEnabled": false, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -254,7 +254,7 @@ nitro.secure.api.enabled=false ### Everything plain -`client-mode.json` +`configuration/client-mode.json` ```json { @@ -262,7 +262,7 @@ nitro.secure.api.enabled=false "secureAssetsEnabled": false, "secureApiEnabled": false, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -273,9 +273,9 @@ nitro.secure.api.enabled=false For changes to: -- `client-mode.json` -- `renderer-config.json` -- `ui-config.json` +- `configuration/client-mode.json` +- `configuration/renderer-config.json` +- `configuration/ui-config.json` - live gamedata - `config.ini` @@ -298,10 +298,12 @@ To make the toggles work properly: ## 12. Quick checklist -- `client-mode.json` configured +- `configuration/client-mode.json` configured - `apiBaseUrl` correct - `nitro.secure.master_key` set - `nitro.secure.config.root` correct - `nitro.secure.gamedata.root` correct - both `.dat` and plain files deployed - `.dat` MIME type configured on the web server + + diff --git a/docs/secure-runtime-modes.html b/docs/secure-runtime-modes.html new file mode 100644 index 0000000..d54c635 --- /dev/null +++ b/docs/secure-runtime-modes.html @@ -0,0 +1,236 @@ + + + + + + Nitro Secure Runtime Modes + + + +
+
+
+ Nitro V3 + Secure Runtime +
+

Documentazione configurazione runtime

+

+ Questa pagina riassume in modo ordinato come configurare i toggle runtime, i file example e i parametri lato client / emulatore + senza sporcare i componenti in src. +

+
+ +
+ + +
+
+

Overview

+
+
+

Dist Obfuscation

+

Sceglie se caricare app.js/app.css oppure .dat.

+
+
+

Secure Assets

+

Controlla se renderer-config, ui-config e gamedata passano da /nitro-sec/file.

+
+
+

Secure API

+

Attiva o disattiva la cifratura runtime automatica su /api/*.

+
+
+
+ +
+

File da usare

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileScopoNota
public/configuration/client-mode.exampleTemplate per i toggle runtimeDa copiare in configuration/client-mode.json nel deploy reale, che resta ignorato da Git
public/configuration/renderer-config.exampleTemplate sicuro del renderer configNon tocca il tuo configuration/renderer-config.json locale
public/configuration/ui-config.exampleTemplate UI configDa mantenere come riferimento pulito
Latest_Compiled_Version/config.ini.exampleFlag backend secureSpecifica la parte lato emulatore
+
+
+ +
+

client-mode.example

+

È il punto centrale per attivare o disattivare il comportamento runtime senza dover modificare il codice.

+
{
+    "distObfuscationEnabled": true,
+    "secureAssetsEnabled": true,
+    "secureApiEnabled": true,
+    "apiBaseUrl": "https://nitro.example.com:2096",
+    "plainConfigBaseUrl": "https://hotel.example.com/configuration/",
+    "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
+}
+
+
+

Campi

+
    +
  • distObfuscationEnabled: usa .dat oppure file plain
  • +
  • secureAssetsEnabled: attiva /nitro-sec/file
  • +
  • secureApiEnabled: cifra le richieste /api/*
  • +
  • apiBaseUrl: base URL emulatore/API
  • +
+
+
+

Suggerimento

+

Conviene impostare sempre apiBaseUrl in modo esplicito, così non dipendi da fallback impliciti del runtime.

+
+
+
+ +
+

renderer-config.example

+

Qui definisci URL di socket, API, asset library e gamedata. Tutti i link pubblici dovrebbero vivere qui, non nei componenti React.

+
+
+

Chiavi principali

+
    +
  • socket.url
  • +
  • api.url
  • +
  • asset.url
  • +
  • image.library.url
  • +
  • images.url
  • +
  • gamedata.url
  • +
+
+
+

Traduzioni

+
    +
  • external.texts.translation.url
  • +
  • furnidata.translation.url
  • +
  • Usano %locale% e %timestamp%
  • +
+
+
+
+ +
+

ui-config.example

+

Per la login view e altre immagini UI, la sorgente deve stare qui o in renderer config, non hardcoded nei componenti.

+
+

Login view

+
    +
  • loginview.images.background
  • +
  • loginview.images.drape
  • +
  • loginview.images.left
  • +
  • loginview.images.right
  • +
  • loginview.widgets per i blocchi promozionali
  • +
+
+
+ +
+

Codice runtime coinvolto

+
+
+

src/bootstrap.ts

+

Legge client-mode, costruisce NitroConfig['config.urls'] e prepara il bootstrap del client.

+
+
+

src/secure-assets.ts

+

Gestisce ECDH, decrypt/encrypt, fallback plain e secure API runtime.

+
+
+

scripts/write-asset-loader.mjs

+

Genera public/configuration/asset-loader.js e decide se usare file plain o .dat.

+
+
+

scripts/minify-dist.mjs

+

Genera i .dat ma mantiene anche i file plain per il toggle runtime.

+
+
+
+ +
+

Emulatore

+
nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=C:/path/to/Nitro-V3/public
+nitro.secure.gamedata.root=C:/path/to/gamedata
+nitro.secure.master_key=change-me-to-a-long-random-secret
+
    +
  • nitro.secure.assets.enabled: abilita /nitro-sec/bootstrap e /nitro-sec/file
  • +
  • nitro.secure.api.enabled: abilita la cifratura su /api/*
  • +
  • nitro.secure.config.root: cartella dei config live
  • +
  • nitro.secure.gamedata.root: cartella del gamedata live
  • +
  • nitro.secure.master_key: chiave persistente server-side
  • +
+
+ +
+

Scenari rapidi

+
+
+

Tutto attivo

+

Secure assets, secure API e dist obfuscation tutti attivi.

+
+
+

Solo .dat

+

Usi i .dat, ma lasci config/API in plain.

+
+
+

Tutto plain

+

Modalità fallback completa per debug o test locali.

+
+
+
+ +
+

Checklist finale

+
+
Hai creato i file reali partendo da client-mode.example, renderer-config.example e ui-config.example
+
Gli URL pubblici stanno nei file config, non nei componenti React
+
Hai deployato sia i file plain sia i .dat
+
Il server espone correttamente il MIME type per .dat
+
Hai impostato nitro.secure.master_key lato emulatore
+
+
+
+
+
+ + + + diff --git a/docs/secure-runtime-modes.md b/docs/secure-runtime-modes.md index 3b59b39..4a9d311 100644 --- a/docs/secure-runtime-modes.md +++ b/docs/secure-runtime-modes.md @@ -3,11 +3,11 @@ Questa doc riassume tutti i dati da impostare per: - offuscamento bundle `dist` (`app.js` / `app.css` → `.dat`) -- secure assets runtime (`renderer-config.json`, `ui-config.json`, `gamedata`) +- secure assets runtime (`configuration/renderer-config.json`, `configuration/ui-config.json`, `gamedata`) - secure API runtime (`/api/*`) - fallback plain quando vuoi spegnere tutto senza togliere il codice -## 1. `Nitro-V3/public/client-mode.json` +## 1. `Nitro-V3/public/configuration/client-mode.json` Questo file controlla tutto a runtime. @@ -17,7 +17,7 @@ Questo file controlla tutto a runtime. "secureAssetsEnabled": true, "secureApiEnabled": true, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -30,7 +30,7 @@ Questo file controlla tutto a runtime. - `secureAssetsEnabled` - `true`: `bootstrap.ts` e `secure-assets.ts` usano `/nitro-sec/file` - - `false`: `renderer-config.json`, `ui-config.json` e gamedata vengono letti in plain + - `false`: `configuration/renderer-config.json`, `configuration/ui-config.json` e gamedata vengono letti in plain - `secureApiEnabled` - `true`: il wrapper `fetch` cifra le chiamate `/api/*` @@ -43,7 +43,7 @@ Questo file controlla tutto a runtime. - `plainConfigBaseUrl` - base URL dei file config plain - - normalmente: `https://hotel.example.com/` + - normalmente: `https://hotel.example.com/configuration/` - `plainGamedataBaseUrl` - base URL del gamedata plain @@ -74,7 +74,7 @@ Il fallback attuale è: (window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; ``` -Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `client-mode.json`. +Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `configuration/client-mode.json`. ## 3. `Nitro-V3/src/secure-assets.ts` @@ -95,7 +95,7 @@ Qui vive tutta la logica runtime: Normalmente non serve toccarlo, a meno che tu non voglia cambiare il protocollo secure. -## 4. `Nitro-V3/public/renderer-config.json` +## 4. `Nitro-V3/public/configuration/renderer-config.json` Questo file continua a definire i path usati dal renderer. @@ -129,7 +129,7 @@ Conviene usare i path plain classici, per esempio: oppure lasciare il renderer configurato com’è e demandare il fallback a `secure-assets.ts`. -## 5. `Nitro-V3/public/ui-config.json` +## 5. `Nitro-V3/public/configuration/ui-config.json` Qui non c’è logica secure, ma è uno dei file caricati da `config.urls`. @@ -140,12 +140,12 @@ Quindi basta mantenerlo corretto come contenuto, non serve altro. ## 6. `Nitro-V3/scripts/write-asset-loader.mjs` -Questo script genera `public/asset-loader.js`. +Questo script genera `public/configuration/asset-loader.js`. ### Cosa fa ora - mostra la shell iniziale -- legge `client-mode.json` +- legge `configuration/client-mode.json` - decide se caricare: - `app.css.dat` / `app.js.dat` - oppure `assets/app.css` / `assets/app.js` @@ -194,7 +194,7 @@ nitro.secure.master_key=change-me-to-a-long-random-secret - abilita il layer secure per `/api/*` - `nitro.secure.config.root` - - cartella dove leggere `renderer-config.json` e `ui-config.json` + - cartella dove leggere `configuration/renderer-config.json` e `configuration/ui-config.json` - `nitro.secure.gamedata.root` - cartella dove leggere il gamedata live @@ -207,7 +207,7 @@ nitro.secure.master_key=change-me-to-a-long-random-secret ### Tutto attivo -`client-mode.json` +`configuration/client-mode.json` ```json { @@ -215,7 +215,7 @@ nitro.secure.master_key=change-me-to-a-long-random-secret "secureAssetsEnabled": true, "secureApiEnabled": true, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -232,7 +232,7 @@ nitro.secure.master_key=una-chiave-lunga-random ### Solo `.dat`, senza secure assets/api -`client-mode.json` +`configuration/client-mode.json` ```json { @@ -240,7 +240,7 @@ nitro.secure.master_key=una-chiave-lunga-random "secureAssetsEnabled": false, "secureApiEnabled": false, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -254,7 +254,7 @@ nitro.secure.api.enabled=false ### Tutto plain -`client-mode.json` +`configuration/client-mode.json` ```json { @@ -262,7 +262,7 @@ nitro.secure.api.enabled=false "secureAssetsEnabled": false, "secureApiEnabled": false, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } ``` @@ -273,9 +273,9 @@ nitro.secure.api.enabled=false Per cambiare: -- `client-mode.json` -- `renderer-config.json` -- `ui-config.json` +- `configuration/client-mode.json` +- `configuration/renderer-config.json` +- `configuration/ui-config.json` - gamedata live - `config.ini` @@ -298,10 +298,12 @@ Per usare bene i toggle: ## 12. Checklist veloce -- `client-mode.json` configurato +- `configuration/client-mode.json` configurato - `apiBaseUrl` corretto - `nitro.secure.master_key` valorizzata - `nitro.secure.config.root` corretto - `nitro.secure.gamedata.root` corretto - `.dat` e file plain entrambi deployati - MIME `.dat` presente sul web server + + diff --git a/localization/badge-texts-en.json b/localization/badge-texts-en.json deleted file mode 100644 index a8ab19a..0000000 --- a/localization/badge-texts-en.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "notification.badge.received": "New Badge!" -} diff --git a/localization/badge-texts-it.json b/localization/badge-texts-it.json deleted file mode 100644 index 10c1271..0000000 --- a/localization/badge-texts-it.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "notification.badge.received": "Nuovo Distintivo!" -} diff --git a/public/UITexts.example b/public/UITexts.example deleted file mode 100644 index bb8779b..0000000 --- a/public/UITexts.example +++ /dev/null @@ -1,113 +0,0 @@ -{ - "friendlist.search": "Search friends", - "purse.seasonal.currency.101": "cash", - "widget.chooser.checkall": "Select furniture", - "widget.chooser.btn.pickall": "pick up selected items!", - "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", - "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", - "widget.settings.general": "General", - "widget.settings.general.title": "Adjust the default Nitro settings", - "widget.settings.volume": "Volume", - "widget.settings.interface": "Interface", - "widget.settings.interface.title": "Adjust the interface settings", - "widget.settings.interface.fps.automatic": "Set FPS to unlimited", - "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", - "widget.settings.interface.secondary": "Change the window header color", - "widget.settings.interface.reset": "Reset header color to default", - "widget.room.chat.hide_pets": "Hide pets", - "widget.room.chat.hide_avatars": "Hide avatars", - "widget.room.chat.hide_balloon": "Hide speech bubble", - "widget.room.chat.show_balloon": "Speech bubble", - "widget.room.chat.clear_history": "clear history", - "widget.room.youtube.shared": "YouTube is being shared", - "widget.room.youtube.open_video": "Open the video", - "wiredfurni.tooltip.select.tile": "Select tile", - "wiredfurni.tooltip.remove.tile": "Deselect tile", - "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", - "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", - "wiredfurni.params.furni_neighborhood.group.user": "Players", - "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", - "wiredfurni.params.selector_option.bot": "No bots", - "wiredfurni.params.selector_option.pet": "No pets", - "catalog.title": "Catalog", - "catalog.favorites": "Favorites", - "catalog.favorites.pages": "Pages", - "catalog.favorites.furni": "Furni", - "catalog.favorites.empty": "No favorites", - "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", - "catalog.admin": "Admin", - "catalog.admin.new": "New", - "catalog.admin.root": "Root", - "catalog.admin.new.root.category": "New root category", - "catalog.admin.edit.root": "Edit Root", - "catalog.admin.edit": "Edit:", - "catalog.admin.edit.page": "Edit Page", - "catalog.admin.hidden": "hidden", - "catalog.admin.edit.title": "Edit \"%name%\"", - "catalog.admin.show": "Show", - "catalog.admin.hide": "Hide", - "catalog.admin.delete": "Delete", - "catalog.admin.delete.title": "Delete \"%name%\"", - "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", - "catalog.admin.delete.page": "Delete page", - "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", - "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", - "catalog.admin.create": "Create", - "catalog.admin.save": "Save", - "catalog.admin.create.subpage": "Create sub-page", - "catalog.admin.order": "Order", - "catalog.admin.visible": "Visible", - "catalog.admin.enabled": "Enabled", - "catalog.admin.offer.new": "New Offer", - "catalog.admin.offer.edit": "Edit Offer", - "catalog.admin.offer.name": "Catalog Name", - "catalog.admin.offer.general": "General", - "catalog.admin.offer.quantity": "Quantity", - "catalog.admin.offer.prices": "Prices", - "catalog.admin.offer.credits": "Credits", - "catalog.admin.offer.points": "Points", - "catalog.admin.offer.points.type": "Points Type", - "catalog.admin.offer.options": "Options", - "catalog.admin.offer.club.only": "Club Only", - "catalog.admin.offer.extradata": "Extra Data (optional)....", - "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", - "catalog.trophies.title": "Trophies", - "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", - "catalog.trophies.inscription": "Trophy Inscription", - "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", - "catalog.pets.show.colors": "Show colors", - "catalog.pets.choose.color": "Choose color", - "catalog.pets.choose.breed": "Choose breed", - "catalog.pets.back.breeds": "← Breeds", - "catalog.prefix.text": "Text", - "catalog.prefix.text.placeholder": "Enter text...", - "catalog.prefix.icon": "Icon", - "catalog.prefix.icon.remove": "Remove icon", - "catalog.prefix.effect": "Effect", - "catalog.prefix.color": "Color", - "catalog.prefix.color.single": "🎨 Single", - "catalog.prefix.color.per.letter": "🌈 Per Letter", - "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", - "catalog.prefix.color.apply.all.title": "Apply current color to all letters", - "catalog.prefix.color.apply.all": "Apply to all", - "catalog.prefix.color.selected": "Selected letter:", - "catalog.prefix.price": "Price:", - "catalog.prefix.price.amount": "5 Credits", - "catalog.prefix.purchased": "✓ Purchased!", - "catalog.prefix.purchase": "Purchase", - "groupforum.list.tab.most_active": "Most active threads", - "groupforum.list.tab.my_forums": "My group forums", - "groupforum.list.no_forums": "There are no forums", - "groupforum.view.threads": "Number of threads", - "groupforum.thread.pin": "Pin thread", - "groupforum.thread.unpin": "Unpin thread", - "groupforum.thread.lock": "Lock thread", - "groupforum.thread.unlock": "Unlock thread", - "groupforum.thread.hide": "Hide thread", - "groupforum.thread.restore": "Restore thread", - "groupforum.thread.delete": "Delete thread + posts", - "groupforum.message.hide": "Hide message", - "group.forum.enable.caption": "Enable / Disable group forum", - "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", - "groupforum.view.no_threads": "There are currently no active threads" -} \ No newline at end of file diff --git a/public/client-mode.json b/public/client-mode.json deleted file mode 100644 index a738f14..0000000 --- a/public/client-mode.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "distObfuscationEnabled": true, - "secureAssetsEnabled": true, - "secureApiEnabled": true, - "apiBaseUrl": "", - "plainConfigBaseUrl": "", - "plainGamedataBaseUrl": "" -} diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example new file mode 100644 index 0000000..acf246e --- /dev/null +++ b/public/configuration/UITexts.example @@ -0,0 +1,116 @@ +{ + "notification.badge.received": "Nuovo Distintivo!", + "wiredfurni.badgereceived.title": "Distintivo ricevuto!", + "wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!", + "friendlist.search": "Search friends", + "purse.seasonal.currency.101": "cash", + "widget.chooser.checkall": "Select furniture", + "widget.chooser.btn.pickall": "pick up selected items!", + "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", + "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", + "widget.settings.general": "General", + "widget.settings.general.title": "Adjust the default Nitro settings", + "widget.settings.volume": "Volume", + "widget.settings.interface": "Interface", + "widget.settings.interface.title": "Adjust the interface settings", + "widget.settings.interface.fps.automatic": "Set FPS to unlimited", + "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", + "widget.settings.interface.secondary": "Change the window header color", + "widget.settings.interface.reset": "Reset header color to default", + "widget.room.chat.hide_pets": "Hide pets", + "widget.room.chat.hide_avatars": "Hide avatars", + "widget.room.chat.hide_balloon": "Hide speech bubble", + "widget.room.chat.show_balloon": "Speech bubble", + "widget.room.chat.clear_history": "clear history", + "widget.room.youtube.shared": "YouTube is being shared", + "widget.room.youtube.open_video": "Open the video", + "wiredfurni.tooltip.select.tile": "Select tile", + "wiredfurni.tooltip.remove.tile": "Deselect tile", + "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", + "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", + "wiredfurni.params.furni_neighborhood.group.user": "Players", + "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", + "wiredfurni.params.selector_option.bot": "No bots", + "wiredfurni.params.selector_option.pet": "No pets", + "catalog.title": "Catalog", + "catalog.favorites": "Favorites", + "catalog.favorites.pages": "Pages", + "catalog.favorites.furni": "Furni", + "catalog.favorites.empty": "No favorites", + "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", + "catalog.admin": "Admin", + "catalog.admin.new": "New", + "catalog.admin.root": "Root", + "catalog.admin.new.root.category": "New root category", + "catalog.admin.edit.root": "Edit Root", + "catalog.admin.edit": "Edit:", + "catalog.admin.edit.page": "Edit Page", + "catalog.admin.hidden": "hidden", + "catalog.admin.edit.title": "Edit \"%name%\"", + "catalog.admin.show": "Show", + "catalog.admin.hide": "Hide", + "catalog.admin.delete": "Delete", + "catalog.admin.delete.title": "Delete \"%name%\"", + "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", + "catalog.admin.delete.page": "Delete page", + "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", + "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", + "catalog.admin.create": "Create", + "catalog.admin.save": "Save", + "catalog.admin.create.subpage": "Create sub-page", + "catalog.admin.order": "Order", + "catalog.admin.visible": "Visible", + "catalog.admin.enabled": "Enabled", + "catalog.admin.offer.new": "New Offer", + "catalog.admin.offer.edit": "Edit Offer", + "catalog.admin.offer.name": "Catalog Name", + "catalog.admin.offer.general": "General", + "catalog.admin.offer.quantity": "Quantity", + "catalog.admin.offer.prices": "Prices", + "catalog.admin.offer.credits": "Credits", + "catalog.admin.offer.points": "Points", + "catalog.admin.offer.points.type": "Points Type", + "catalog.admin.offer.options": "Options", + "catalog.admin.offer.club.only": "Club Only", + "catalog.admin.offer.extradata": "Extra Data (optional)....", + "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", + "catalog.trophies.title": "Trophies", + "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", + "catalog.trophies.inscription": "Trophy Inscription", + "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", + "catalog.pets.show.colors": "Show colors", + "catalog.pets.choose.color": "Choose color", + "catalog.pets.choose.breed": "Choose breed", + "catalog.pets.back.breeds": "? Breeds", + "catalog.prefix.text": "Text", + "catalog.prefix.text.placeholder": "Enter text...", + "catalog.prefix.icon": "Icon", + "catalog.prefix.icon.remove": "Remove icon", + "catalog.prefix.effect": "Effect", + "catalog.prefix.color": "Color", + "catalog.prefix.color.single": "?? Single", + "catalog.prefix.color.per.letter": "?? Per Letter", + "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", + "catalog.prefix.color.apply.all.title": "Apply current color to all letters", + "catalog.prefix.color.apply.all": "Apply to all", + "catalog.prefix.color.selected": "Selected letter:", + "catalog.prefix.price": "Price:", + "catalog.prefix.price.amount": "5 Credits", + "catalog.prefix.purchased": "? Purchased!", + "catalog.prefix.purchase": "Purchase", + "groupforum.list.tab.most_active": "Most active threads", + "groupforum.list.tab.my_forums": "My group forums", + "groupforum.list.no_forums": "There are no forums", + "groupforum.view.threads": "Number of threads", + "groupforum.thread.pin": "Pin thread", + "groupforum.thread.unpin": "Unpin thread", + "groupforum.thread.lock": "Lock thread", + "groupforum.thread.unlock": "Unlock thread", + "groupforum.thread.hide": "Hide thread", + "groupforum.thread.restore": "Restore thread", + "groupforum.thread.delete": "Delete thread + posts", + "groupforum.message.hide": "Hide message", + "group.forum.enable.caption": "Enable / Disable group forum", + "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", + "groupforum.view.no_threads": "There are currently no active threads" +} diff --git a/public/adsense.json b/public/configuration/adsense.example similarity index 100% rename from public/adsense.json rename to public/configuration/adsense.example diff --git a/public/asset-loader.js b/public/configuration/asset-loader.js similarity index 97% rename from public/asset-loader.js rename to public/configuration/asset-loader.js index f64cbe6..c1cfde3 100644 --- a/public/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -145,6 +145,10 @@ const readClientMode = async () => { try { + if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") { + debug("loader: client-mode preset"); + return window.__nitroClientMode; + } const url = withCacheBust(new URL("./client-mode.json", getBase())); const response = await fetch(url, { cache: "no-store" }); if(!response.ok) throw new Error("client-mode " + response.status); diff --git a/public/configuration/bootstrap.js b/public/configuration/bootstrap.js new file mode 100644 index 0000000..f2a9d7e --- /dev/null +++ b/public/configuration/bootstrap.js @@ -0,0 +1,133 @@ +(() => { + const API_BASE = "https://nitro.slogga.it:2096"; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const bytesToBase64 = (buffer) => { + let binary = ""; + const bytes = new Uint8Array(buffer); + for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + }; + + const hexValue = (code) => { + if(code >= 48 && code <= 57) return code - 48; + if(code >= 65 && code <= 70) return code - 55; + if(code >= 97 && code <= 102) return code - 87; + return -1; + }; + + const hexToBytes = (hex) => { + const normalized = hex.trim(); + if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload."); + const bytes = new Uint8Array(normalized.length / 2); + for(let i = 0; i < bytes.length; i++) { + const high = hexValue(normalized.charCodeAt(i * 2)); + const low = hexValue(normalized.charCodeAt((i * 2) + 1)); + if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload."); + bytes[i] = (high << 4) | low; + } + return bytes; + }; + + const deriveAesKey = async (privateKey, serverKeyBase64) => { + const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0)); + const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []); + const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256); + const salt = new TextEncoder().encode("nitro-secure-assets-v1"); + const material = new Uint8Array(secret.byteLength + salt.length); + material.set(new Uint8Array(secret), 0); + material.set(salt, secret.byteLength); + const hash = await crypto.subtle.digest("SHA-256", material); + return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]); + }; + + const decryptPayload = async (key, response) => { + if(response.headers.get("X-Nitro-Sec") !== "1") return response.text(); + const bytes = hexToBytes(await response.text()); + if(bytes.length < 13) throw new Error("Encrypted response is too short."); + const iv = bytes.slice(0, 12); + const payload = bytes.slice(12); + const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload); + return new TextDecoder().decode(clear); + }; + + const importTextModule = async (sourceText) => { + const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" })); + try { + await import(blobUrl); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const loadPlainBootstrap = async () => { + const url = withCacheBust(new URL("./asset-loader.js", getBase())); + await import(url.href); + }; + + const loadSecureBootstrap = async () => { + if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap."); + + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); + const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey); + const publicKey = bytesToBase64(publicKeyBuffer); + const base = API_BASE.replace(/\/$/, ""); + const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: publicKey }) + }); + + if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status); + + const bootstrapPayload = await bootstrapResponse.json(); + if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) { + throw new Error("Secure bootstrap returned an invalid server key."); + } + + const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key); + + const fetchSecureConfig = async (file) => { + const url = new URL(base + "/nitro-sec/file"); + url.searchParams.set("kind", "config"); + url.searchParams.set("file", file); + url.searchParams.set("v", Date.now().toString(36)); + + const response = await fetch(url.toString(), { + headers: { "X-Nitro-Key": publicKey }, + cache: "no-store" + }); + + if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status); + + return decryptPayload(sessionKey, response); + }; + + const modeText = await fetchSecureConfig("client-mode.json"); + window.__nitroClientMode = JSON.parse(modeText); + + const loaderText = await fetchSecureConfig("asset-loader.js"); + await importTextModule(loaderText); + }; + + (async () => { + try { + await loadSecureBootstrap(); + } catch(error) { + console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); + await loadPlainBootstrap(); + } + })().catch(error => { + console.error(error); + document.body.textContent = "Unable to load client."; + }); +})(); \ No newline at end of file diff --git a/public/client-mode.example.json b/public/configuration/client-mode.example similarity index 76% rename from public/client-mode.example.json rename to public/configuration/client-mode.example index 4582313..a6e11ed 100644 --- a/public/client-mode.example.json +++ b/public/configuration/client-mode.example @@ -3,6 +3,6 @@ "secureAssetsEnabled": true, "secureApiEnabled": true, "apiBaseUrl": "https://nitro.example.com:2096", - "plainConfigBaseUrl": "https://hotel.example.com/", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" } diff --git a/public/hotlooks.json b/public/configuration/hotlooks.example similarity index 100% rename from public/hotlooks.json rename to public/configuration/hotlooks.example diff --git a/public/renderer-config.example b/public/configuration/renderer-config.example similarity index 100% rename from public/renderer-config.example rename to public/configuration/renderer-config.example diff --git a/public/ui-config.example b/public/configuration/ui-config.example similarity index 100% rename from public/ui-config.example rename to public/configuration/ui-config.example diff --git a/public/renderer-config.json b/public/renderer-config.json deleted file mode 100644 index 6b769d7..0000000 --- a/public/renderer-config.json +++ /dev/null @@ -1,598 +0,0 @@ -{ - "socket.url": "wss://nitro.example.com:2096", - "api.url": "https://nitro.example.com:2096", - "asset.url": "https://hotel.example.com/client/nitro/bundled", - "image.library.url": "https://hotel.example.com/client/c_images/", - "hof.furni.url": "https://hotel.example.com/client/c_images/dcr/hof_furni", - "images.url": "https://hotel.example.com/client/nitro/images", - "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", - "sounds.url": "${asset.url}/sounds/%sample%.mp3", - "external.texts.url": [ - "${gamedata.url}/ExternalTexts.json", - "${gamedata.url}/UITexts.json" - ], - "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", - "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", - "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", - "furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%", - "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", - "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", - "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", - "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", - "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", - "avatar.asset.url": "${asset.url}/figure/%libname%.nitro", - "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", - "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", - "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", - "pet.asset.url": "${asset.url}/pets/%libname%.nitro", - "generic.asset.url": "${asset.url}/generic/%libname%.nitro", - "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", - "furni.rotation.bounce.steps": 20, - "furni.rotation.bounce.height": 0.0625, - "enable.avatar.arrow": false, - "system.log.debug": true, - "system.log.warn": true, - "system.log.error": true, - "system.log.events": false, - "system.log.packets": true, - "system.fps.animation": 24, - "system.fps.max": 60, - "system.pong.manually": true, - "system.pong.interval.ms": 20000, - "room.color.skip.transition": true, - "room.landscapes.enabled": true, - "room.zoom.enabled": true, - "login.screen.enabled": true, - "login.endpoint": "${api.url}/api/auth/login", - "login.register.endpoint": "${api.url}/api/auth/register", - "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", - "login.logout.endpoint": "${api.url}/api/auth/logout", - "login.remember.endpoint": "${api.url}/api/auth/remember", - "login.turnstile.enabled": false, - "login.turnstile.sitekey": "", - "avatar.mandatory.libraries": [ - "bd:1", - "li:0" - ], - "avatar.mandatory.effect.libraries": [ - "dance.1", - "dance.2", - "dance.3", - "dance.4" - ], - "avatar.default.figuredata": { - "palettes": [ - { - "id": 1, - "colors": [ - { - "id": 99999, - "index": 1001, - "club": 0, - "selectable": false, - "hexCode": "DDDDDD" - }, - { - "id": 99998, - "index": 1001, - "club": 0, - "selectable": false, - "hexCode": "FAFAFA" - } - ] - }, - { - "id": 3, - "colors": [ - { - "id": 10001, - "index": 1001, - "club": 0, - "selectable": false, - "hexCode": "EEEEEE" - }, - { - "id": 10002, - "index": 1002, - "club": 0, - "selectable": false, - "hexCode": "FA3831" - }, - { - "id": 10003, - "index": 1003, - "club": 0, - "selectable": false, - "hexCode": "FD92A0" - }, - { - "id": 10004, - "index": 1004, - "club": 0, - "selectable": false, - "hexCode": "2AC7D2" - }, - { - "id": 10005, - "index": 1005, - "club": 0, - "selectable": false, - "hexCode": "35332C" - }, - { - "id": 10006, - "index": 1006, - "club": 0, - "selectable": false, - "hexCode": "EFFF92" - }, - { - "id": 10007, - "index": 1007, - "club": 0, - "selectable": false, - "hexCode": "C6FF98" - }, - { - "id": 10008, - "index": 1008, - "club": 0, - "selectable": false, - "hexCode": "FF925A" - }, - { - "id": 10009, - "index": 1009, - "club": 0, - "selectable": false, - "hexCode": "9D597E" - }, - { - "id": 10010, - "index": 1010, - "club": 0, - "selectable": false, - "hexCode": "B6F3FF" - }, - { - "id": 10011, - "index": 1011, - "club": 0, - "selectable": false, - "hexCode": "6DFF33" - }, - { - "id": 10012, - "index": 1012, - "club": 0, - "selectable": false, - "hexCode": "3378C9" - }, - { - "id": 10013, - "index": 1013, - "club": 0, - "selectable": false, - "hexCode": "FFB631" - }, - { - "id": 10014, - "index": 1014, - "club": 0, - "selectable": false, - "hexCode": "DFA1E9" - }, - { - "id": 10015, - "index": 1015, - "club": 0, - "selectable": false, - "hexCode": "F9FB32" - }, - { - "id": 10016, - "index": 1016, - "club": 0, - "selectable": false, - "hexCode": "CAAF8F" - }, - { - "id": 10017, - "index": 1017, - "club": 0, - "selectable": false, - "hexCode": "C5C6C5" - }, - { - "id": 10018, - "index": 1018, - "club": 0, - "selectable": false, - "hexCode": "47623D" - }, - { - "id": 10019, - "index": 1019, - "club": 0, - "selectable": false, - "hexCode": "8A8361" - }, - { - "id": 10020, - "index": 1020, - "club": 0, - "selectable": false, - "hexCode": "FF8C33" - }, - { - "id": 10021, - "index": 1021, - "club": 0, - "selectable": false, - "hexCode": "54C627" - }, - { - "id": 10022, - "index": 1022, - "club": 0, - "selectable": false, - "hexCode": "1E6C99" - }, - { - "id": 10023, - "index": 1023, - "club": 0, - "selectable": false, - "hexCode": "984F88" - }, - { - "id": 10024, - "index": 1024, - "club": 0, - "selectable": false, - "hexCode": "77C8FF" - }, - { - "id": 10025, - "index": 1025, - "club": 0, - "selectable": false, - "hexCode": "FFC08E" - }, - { - "id": 10026, - "index": 1026, - "club": 0, - "selectable": false, - "hexCode": "3C4B87" - }, - { - "id": 10027, - "index": 1027, - "club": 0, - "selectable": false, - "hexCode": "7C2C47" - }, - { - "id": 10028, - "index": 1028, - "club": 0, - "selectable": false, - "hexCode": "D7FFE3" - }, - { - "id": 10029, - "index": 1029, - "club": 0, - "selectable": false, - "hexCode": "8F3F1C" - }, - { - "id": 10030, - "index": 1030, - "club": 0, - "selectable": false, - "hexCode": "FF6393" - }, - { - "id": 10031, - "index": 1031, - "club": 0, - "selectable": false, - "hexCode": "1F9B79" - }, - { - "id": 10032, - "index": 1032, - "club": 0, - "selectable": false, - "hexCode": "FDFF33" - } - ] - } - ], - "setTypes": [ - { - "type": "hd", - "paletteId": 1, - "mandatory_f_0": true, - "mandatory_f_1": true, - "mandatory_m_0": true, - "mandatory_m_1": true, - "sets": [ - { - "id": 99999, - "gender": "U", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [ - { - "id": 1, - "type": "bd", - "colorable": true, - "index": 0, - "colorindex": 1 - }, - { - "id": 1, - "type": "hd", - "colorable": true, - "index": 0, - "colorindex": 1 - }, - { - "id": 1, - "type": "lh", - "colorable": true, - "index": 0, - "colorindex": 1 - }, - { - "id": 1, - "type": "rh", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ] - } - ] - }, - { - "type": "bds", - "paletteId": 1, - "mandatory_f_0": false, - "mandatory_f_1": false, - "mandatory_m_0": false, - "mandatory_m_1": false, - "sets": [ - { - "id": 10001, - "gender": "U", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [ - { - "id": 10001, - "type": "bds", - "colorable": true, - "index": 0, - "colorindex": 1 - }, - { - "id": 10001, - "type": "lhs", - "colorable": true, - "index": 0, - "colorindex": 1 - }, - { - "id": 10001, - "type": "rhs", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ], - "hiddenLayers": [ - { - "partType": "bd" - }, - { - "partType": "rh" - }, - { - "partType": "lh" - } - ] - } - ] - }, - { - "type": "ss", - "paletteId": 3, - "mandatory_f_0": false, - "mandatory_f_1": false, - "mandatory_m_0": false, - "mandatory_m_1": false, - "sets": [ - { - "id": 10010, - "gender": "F", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [ - { - "id": 10001, - "type": "ss", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ], - "hiddenLayers": [ - { - "partType": "ch" - }, - { - "partType": "lg" - }, - { - "partType": "ca" - }, - { - "partType": "wa" - }, - { - "partType": "sh" - }, - { - "partType": "ls" - }, - { - "partType": "rs" - }, - { - "partType": "lc" - }, - { - "partType": "rc" - }, - { - "partType": "cc" - }, - { - "partType": "cp" - } - ] - }, - { - "id": 10011, - "gender": "M", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [ - { - "id": 10002, - "type": "ss", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ], - "hiddenLayers": [ - { - "partType": "ch" - }, - { - "partType": "lg" - }, - { - "partType": "ca" - }, - { - "partType": "wa" - }, - { - "partType": "sh" - }, - { - "partType": "ls" - }, - { - "partType": "rs" - }, - { - "partType": "lc" - }, - { - "partType": "rc" - }, - { - "partType": "cc" - }, - { - "partType": "cp" - } - ] - } - ] - } - ] - }, - "avatar.default.actions": { - "actions": [ - { - "id": "Default", - "state": "std", - "precedence": 1000, - "main": true, - "isDefault": true, - "geometryType": "vertical", - "activePartSet": "figure", - "assetPartDefinition": "std" - } - ] - }, - "pet.types": [ - "dog", - "cat", - "croco", - "terrier", - "bear", - "pig", - "lion", - "rhino", - "spider", - "turtle", - "chicken", - "frog", - "dragon", - "monster", - "monkey", - "horse", - "monsterplant", - "bunnyeaster", - "bunnyevil", - "bunnydepressed", - "bunnylove", - "pigeongood", - "pigeonevil", - "demonmonkey", - "bearbaby", - "terrierbaby", - "gnome", - "gnome", - "kittenbaby", - "puppybaby", - "pigletbaby", - "haloompa", - "fools", - "pterosaur", - "velociraptor", - "cow", - "LeetPen", - "bbwibb", - "elephants" - ], - "preload.assets.urls": [ - "${asset.url}/generic/avatar_additions.nitro", - "${asset.url}/generic/group_badge.nitro", - "${asset.url}/generic/floor_editor.nitro", - "${images.url}/loading_icon.png", - "${images.url}/clear_icon.png", - "${images.url}/big_arrow.png" - ] -} diff --git a/public/ui-config.json b/public/ui-config.json deleted file mode 100644 index f625755..0000000 --- a/public/ui-config.json +++ /dev/null @@ -1,2816 +0,0 @@ -{ - "image.library.notifications.url": "${image.library.url}notifications/%image%.png", - "achievements.images.url": "${image.library.url}Quests/%image%.png", - "camera.url": "https://hotel.example.com/client/camera/", - "thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png", - "url.prefix": "", - "habbopages.url": "/gamedata/habbopages/", - "group.homepage.url": "${url.prefix}/groups/%groupid%/id", - "guide.help.alpha.groupid": 0, - "chat.viewer.height.percentage": 0.4, - "widget.dimmer.colorwheel": false, - "avatar.wardrobe.max.slots": 10, - "user.badges.max.slots": 5, - "user.tags.enabled": false, - "camera.publish.disabled": false, - "hc.disabled": false, - "badge.descriptions.enabled": true, - "motto.max.length": 38, - "bot.name.max.length": 15, - "pet.package.name.max.length": 15, - "wired.action.bot.talk.to.avatar.max.length": 64, - "wired.action.bot.talk.max.length": 64, - "wired.action.chat.max.length": 100, - "wired.action.kick.from.room.max.length": 100, - "wired.action.mute.user.max.length": 100, - "game.center.enabled": false, - "guides.enabled": true, - "toolbar.hide.quests": true, - "catalog.style.new": true, - "show.google.ads": false, - "loginview": { - "images": { - "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", - "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", - "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" - } - }, - "navigator.room.models": [ - { - "clubLevel": 0, - "tileSize": 104, - "name": "a" - }, - { - "clubLevel": 0, - "tileSize": 94, - "name": "b" - }, - { - "clubLevel": 0, - "tileSize": 36, - "name": "c" - }, - { - "clubLevel": 0, - "tileSize": 84, - "name": "d" - }, - { - "clubLevel": 0, - "tileSize": 80, - "name": "e" - }, - { - "clubLevel": 0, - "tileSize": 80, - "name": "f" - }, - { - "clubLevel": 0, - "tileSize": 416, - "name": "i" - }, - { - "clubLevel": 0, - "tileSize": 320, - "name": "j" - }, - { - "clubLevel": 0, - "tileSize": 448, - "name": "k" - }, - { - "clubLevel": 0, - "tileSize": 352, - "name": "l" - }, - { - "clubLevel": 0, - "tileSize": 384, - "name": "m" - }, - { - "clubLevel": 0, - "tileSize": 372, - "name": "n" - }, - { - "clubLevel": 1, - "tileSize": 80, - "name": "g" - }, - { - "clubLevel": 1, - "tileSize": 74, - "name": "h" - }, - { - "clubLevel": 1, - "tileSize": 416, - "name": "o" - }, - { - "clubLevel": 1, - "tileSize": 352, - "name": "p" - }, - { - "clubLevel": 1, - "tileSize": 304, - "name": "q" - }, - { - "clubLevel": 1, - "tileSize": 336, - "name": "r" - }, - { - "clubLevel": 1, - "tileSize": 748, - "name": "u" - }, - { - "clubLevel": 1, - "tileSize": 438, - "name": "v" - }, - { - "clubLevel": 2, - "tileSize": 540, - "name": "t" - }, - { - "clubLevel": 2, - "tileSize": 512, - "name": "w" - }, - { - "clubLevel": 2, - "tileSize": 396, - "name": "x" - }, - { - "clubLevel": 2, - "tileSize": 440, - "name": "y" - }, - { - "clubLevel": 2, - "tileSize": 456, - "name": "z" - }, - { - "clubLevel": 2, - "tileSize": 208, - "name": "0" - }, - { - "clubLevel": 2, - "tileSize": 1009, - "name": "1" - }, - { - "clubLevel": 2, - "tileSize": 1044, - "name": "2" - }, - { - "clubLevel": 2, - "tileSize": 183, - "name": "3" - }, - { - "clubLevel": 2, - "tileSize": 254, - "name": "4" - }, - { - "clubLevel": 2, - "tileSize": 1024, - "name": "5" - }, - { - "clubLevel": 2, - "tileSize": 801, - "name": "6" - }, - { - "clubLevel": 2, - "tileSize": 354, - "name": "7" - }, - { - "clubLevel": 2, - "tileSize": 888, - "name": "8" - }, - { - "clubLevel": 2, - "tileSize": 926, - "name": "9" - } - ], - "backgrounds.data": [ - { - "backgroundId": 0, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 1, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 2, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 3, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 4, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 5, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 6, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 7, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 8, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 9, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 10, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 11, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 12, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 13, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 14, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 15, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 16, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 17, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 18, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 19, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 20, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 21, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 22, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 23, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 24, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 25, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 26, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 27, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 28, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 29, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 30, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 31, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 32, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 33, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 34, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 35, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 36, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 37, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 38, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 39, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 40, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 41, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 42, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 43, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 44, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 45, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 46, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 47, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 48, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 49, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 50, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 51, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 52, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 53, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 54, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 55, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 56, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 57, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 58, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 59, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 60, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 61, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 62, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 63, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 64, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 65, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 66, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 67, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 68, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 69, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 70, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 71, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 72, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 73, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 74, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 75, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 76, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 77, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 78, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 79, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 80, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 81, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 82, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 83, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 84, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 85, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 86, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 87, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 88, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 89, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 90, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 91, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 92, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 93, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 94, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 95, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 96, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 97, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 98, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 99, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 100, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 101, - "minRank": 2, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "backgroundId": 102, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 103, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 104, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 105, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 106, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 107, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 108, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 109, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 110, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 111, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 112, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 113, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 114, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 115, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 116, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 117, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 118, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 119, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 120, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 121, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 122, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 123, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 124, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 125, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 126, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 127, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 128, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 129, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 130, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 131, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 132, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 133, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 134, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 135, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 136, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 137, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 138, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 139, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 140, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 141, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 142, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 143, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 144, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 145, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 146, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 147, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 148, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 149, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 150, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 151, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 152, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 153, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 154, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 155, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 156, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 157, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 158, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 159, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 160, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 161, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 162, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 163, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 164, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 165, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 166, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 167, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 168, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 169, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 170, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 171, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 172, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 173, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 174, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 175, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 176, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 177, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 178, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 179, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 180, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 181, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 182, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 183, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 184, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 185, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 186, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "backgroundId": 187, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - } - ], - "stands.data": [ - { - "standId": 0, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 1, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 2, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 3, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 4, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 5, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 6, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 7, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 8, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 9, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 10, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 11, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 12, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 13, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 14, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 15, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "standId": 16, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "standId": 17, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "standId": 18, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "standId": 19, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "standId": 20, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "standId": 21, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - } - ], - "overlays.data": [ - { - "overlayId": 0, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "overlayId": 1, - "minRank": 0, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "overlayId": 2, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "overlayId": 3, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "overlayId": 4, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "overlayId": 5, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "overlayId": 6, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "overlayId": 7, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "overlayId": 8, - "minRank": 0, - "isHcOnly": true, - "isAmbassadorOnly": false - } - ], - "hotelview": { - "room.pool": "791", - "room.picnic": "2193", - "room.rooftop": "", - "room.rooftop.pool": "", - "room.peaceful": "", - "room.infobus": "5956", - "room.lobby": "1450", - "show.avatar": true, - "widgets": { - "slot.1.widget": "promoarticle", - "slot.1.conf": {}, - "slot.2.widget": "widgetcontainer", - "slot.2.conf": { - "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", - "texts": "2021NitroPromo", - "btnLink": "" - }, - "slot.3.widget": "", - "slot.3.conf": {}, - "slot.4.widget": "", - "slot.4.conf": {}, - "slot.5.widget": "", - "slot.5.conf": {}, - "slot.6.widget": "", - "slot.6.conf": { - "campaign": "" - }, - "slot.7.widget": "", - "slot.7.conf": {} - }, - "images": { - "background": "${asset.url}/images/reception/stretch_blue.png", - "background.colour": "#8ee0f0", - "sun": "${asset.url}/images/reception/sun.png", - "drape": "${asset.url}/images/reception/drape.png", - "left": "", - "right": "", - "right.repeat": "" - } - }, - "loginview": { - "images": { - "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", - "background.colour": "#6eadc8", - "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", - "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", - "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" - }, - "widgets": { - "slot.1.widget": "promoarticle", - "slot.1.conf": {}, - "slot.2.widget": "widgetcontainer", - "slot.2.conf": { - "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", - "texts": "2021NitroPromo", - "btnLink": "" - }, - "slot.3.widget": "", - "slot.3.conf": {}, - "slot.4.widget": "", - "slot.4.conf": {}, - "slot.5.widget": "", - "slot.5.conf": {}, - "slot.6.widget": "", - "slot.6.conf": { - "campaign": "" - }, - "slot.7.widget": "", - "slot.7.conf": {} - } - }, - "achievements.unseen.ignored": [ - "ACH_AllTimeHotelPresence" - ], - "avatareditor.show.clubitems.dimmed": true, - "avatareditor.show.clubitems.first": true, - "chat.history.max.items": 100, - "system.currency.types": [ - -1, - 0, - 5, - 105 - ], - "catalog.links": { - "hc.buy_hc": "habbo_club", - "hc.hc_gifts": "club_gifts", - "pets.buy_food": "pet_food", - "pets.buy_saddle": "saddles" - }, - "hc.center": { - "benefits.info": true, - "payday.info": true, - "gift.info": true, - "benefits.habbopage": "habboclub", - "payday.habbopage": "hcpayday" - }, - "respect.options": { - "enabled": false, - "sound": "sound_respect_received" - }, - "currency.display.number.short": false, - "currency.asset.icon.url": "${images.url}/wallet/%type%.png", - "catalog.asset.url": "${image.library.url}catalogue", - "catalog.asset.image.url": "${catalog.asset.url}/%name%.gif", - "catalog.asset.icon.url": "${catalog.asset.url}/icon_%name%.png", - "catalog.tab.icons": false, - "catalog.headers": false, - "chat.input.maxlength": 100, - "chat.styles.disabled": [], - "chat.styles": [ - { - "styleId": 0, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 1, - "minRank": 5, - "isSystemStyle": true, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 2, - "minRank": 5, - "isSystemStyle": true, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 3, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 4, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 5, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 6, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 7, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 8, - "minRank": 5, - "isSystemStyle": true, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 9, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 10, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 11, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 12, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 13, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 14, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 15, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 16, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 17, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 18, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 19, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 20, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 21, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 22, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 23, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 24, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 25, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 26, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 27, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 28, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 29, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 30, - "minRank": 5, - "isSystemStyle": true, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 31, - "minRank": 5, - "isSystemStyle": true, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 32, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 33, - "minRank": 5, - "isSystemStyle": true, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 34, - "minRank": 5, - "isSystemStyle": true, - "isHcOnly": false, - "isAmbassadorOnly": false - }, - { - "styleId": 35, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 36, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 37, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 38, - "minRank": 0, - "isSystemStyle": false, - "isHcOnly": true, - "isAmbassadorOnly": false - }, - { - "styleId": 39, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 40, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 41, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 42, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 43, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 44, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 45, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 46, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 47, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 48, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 49, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 50, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 51, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 52, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - }, - { - "styleId": 53, - "minRank": 5, - "isSystemStyle": false, - "isHcOnly": false, - "isAmbassadorOnly": true - } - ], - "camera.available.effects": [ - { - "name": "dark_sepia", - "colorMatrix": [ - 0.4, - 0.4, - 0.1, - 0, - 110, - 0.3, - 0.4, - 0.1, - 0, - 30, - 0.3, - 0.2, - 0.1, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 0, - "enabled": true - }, - { - "name": "increase_saturation", - "colorMatrix": [ - 2, - -0.5, - -0.5, - 0, - 0, - -0.5, - 2, - -0.5, - 0, - 0, - -0.5, - -0.5, - 2, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 0, - "enabled": true - }, - { - "name": "increase_contrast", - "colorMatrix": [ - 1.5, - 0, - 0, - 0, - -50, - 0, - 1.5, - 0, - 0, - -50, - 0, - 0, - 1.5, - 0, - -50, - 0, - 0, - 0, - 1.5, - 0 - ], - "minLevel": 0, - "enabled": true - }, - { - "name": "shadow_multiply_02", - "colorMatrix": [], - "minLevel": 0, - "blendMode": 2, - "enabled": true - }, - { - "name": "color_1", - "colorMatrix": [ - 0.393, - 0.769, - 0.189, - 0, - 0, - 0.349, - 0.686, - 0.168, - 0, - 0, - 0.272, - 0.534, - 0.131, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 1, - "enabled": true - }, - { - "name": "hue_bright_sat", - "colorMatrix": [ - 1, - 0.6, - 0.2, - 0, - -50, - 0.2, - 1, - 0.6, - 0, - -50, - 0.6, - 0.2, - 1, - 0, - -50, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 1, - "enabled": true - }, - { - "name": "hearts_hardlight_02", - "colorMatrix": [], - "minLevel": 1, - "blendMode": 9, - "enabled": true - }, - { - "name": "texture_overlay", - "colorMatrix": [], - "minLevel": 1, - "blendMode": 4, - "enabled": true - }, - { - "name": "pinky_nrm", - "colorMatrix": [], - "minLevel": 1, - "blendMode": 0, - "enabled": true - }, - { - "name": "color_2", - "colorMatrix": [ - 0.333, - 0.333, - 0.333, - 0, - 0, - 0.333, - 0.333, - 0.333, - 0, - 0, - 0.333, - 0.333, - 0.333, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 2, - "enabled": true - }, - { - "name": "night_vision", - "colorMatrix": [ - 0, - 0, - 0, - 0, - 0, - 0, - 1.1, - 0, - 0, - -50, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 2, - "enabled": true - }, - { - "name": "stars_hardlight_02", - "colorMatrix": [], - "minLevel": 2, - "blendMode": 9, - "enabled": true - }, - { - "name": "coffee_mpl", - "colorMatrix": [], - "minLevel": 2, - "blendMode": 2, - "enabled": true - }, - { - "name": "security_hardlight", - "colorMatrix": [], - "minLevel": 3, - "blendMode": 9, - "enabled": true - }, - { - "name": "bluemood_mpl", - "colorMatrix": [], - "minLevel": 3, - "blendMode": 2, - "enabled": true - }, - { - "name": "rusty_mpl", - "colorMatrix": [], - "minLevel": 3, - "blendMode": 2, - "enabled": true - }, - { - "name": "decr_conrast", - "colorMatrix": [ - 0.5, - 0, - 0, - 0, - 50, - 0, - 0.5, - 0, - 0, - 50, - 0, - 0, - 0.5, - 0, - 50, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 4, - "enabled": true - }, - { - "name": "green_2", - "colorMatrix": [ - 0.5, - 0.5, - 0.5, - 0, - 0, - 0.5, - 0.5, - 0.5, - 0, - 90, - 0.5, - 0.5, - 0.5, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 4, - "enabled": true - }, - { - "name": "alien_hrd", - "colorMatrix": [], - "minLevel": 4, - "blendMode": 9, - "enabled": true - }, - { - "name": "color_3", - "colorMatrix": [ - 0.609, - 0.609, - 0.082, - 0, - 0, - 0.309, - 0.609, - 0.082, - 0, - 0, - 0.309, - 0.609, - 0.082, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 5, - "enabled": true - }, - { - "name": "color_4", - "colorMatrix": [ - 0.8, - -0.8, - 1, - 0, - 70, - 0.8, - -0.8, - 1, - 0, - 70, - 0.8, - -0.8, - 1, - 0, - 70, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 5, - "enabled": true - }, - { - "name": "toxic_hrd", - "colorMatrix": [], - "minLevel": 5, - "blendMode": 9, - "enabled": true - }, - { - "name": "hypersaturated", - "colorMatrix": [ - 2, - -1, - 0, - 0, - 0, - -1, - 2, - 0, - 0, - 0, - 0, - -1, - 2, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 6, - "enabled": true - }, - { - "name": "Yellow", - "colorMatrix": [ - 1, - 0, - 0, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 6, - "enabled": true - }, - { - "name": "misty_hrd", - "colorMatrix": [], - "minLevel": 6, - "blendMode": 9, - "enabled": true - }, - { - "name": "x_ray", - "colorMatrix": [ - 0, - 1.2, - 0, - 0, - -100, - 0, - 2, - 0, - 0, - -120, - 0, - 2, - 0, - 0, - -120, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 7, - "enabled": true - }, - { - "name": "decrease_saturation", - "colorMatrix": [ - 0.7, - 0.2, - 0.2, - 0, - 0, - 0.2, - 0.7, - 0.2, - 0, - 0, - 0.2, - 0.2, - 0.7, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 7, - "enabled": true - }, - { - "name": "drops_mpl", - "colorMatrix": [], - "minLevel": 8, - "blendMode": 2, - "enabled": true - }, - { - "name": "shiny_hrd", - "colorMatrix": [], - "minLevel": 9, - "blendMode": 9, - "enabled": true - }, - { - "name": "glitter_hrd", - "colorMatrix": [], - "minLevel": 10, - "blendMode": 9, - "enabled": true - }, - { - "name": "frame_gold", - "colorMatrix": [], - "minLevel": 10, - "blendMode": 0, - "enabled": true - }, - { - "name": "frame_gray_4", - "colorMatrix": [], - "minLevel": 10, - "blendMode": 0, - "enabled": true - }, - { - "name": "frame_black_2", - "colorMatrix": [], - "minLevel": 10, - "blendMode": 0, - "enabled": true - }, - { - "name": "frame_wood_2", - "colorMatrix": [], - "minLevel": 10, - "blendMode": 0, - "enabled": true - }, - { - "name": "finger_nrm", - "colorMatrix": [], - "minLevel": 10, - "blendMode": 0, - "enabled": true - }, - { - "name": "color_5", - "colorMatrix": [ - 3.309, - 0.609, - 1.082, - 0.2, - 0, - 0.309, - 0.609, - 0.082, - 0, - 0, - 1.309, - 0.609, - 0.082, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 10, - "enabled": true - }, - { - "name": "black_white_negative", - "colorMatrix": [ - -0.5, - -0.5, - -0.5, - 0, - 0, - -0.5, - -0.5, - -0.5, - 0, - 0, - -0.5, - -0.5, - -0.5, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 10, - "enabled": true - }, - { - "name": "blue", - "colorMatrix": [ - 0.5, - 0.5, - 0.5, - 0, - -255, - 0.5, - 0.5, - 0.5, - 0, - -170, - 0.5, - 0.5, - 0.5, - 0, - 0, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 10, - "enabled": true - }, - { - "name": "red", - "colorMatrix": [ - 0.5, - 0.5, - 0.5, - 0, - 0, - 0.5, - 0.5, - 0.5, - 0, - -170, - 0.5, - 0.5, - 0.5, - 0, - -170, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 10, - "enabled": true - }, - { - "name": "green", - "colorMatrix": [ - 0.5, - 0.5, - 0.5, - 0, - -170, - 0.5, - 0.5, - 0.5, - 0, - 0, - 0.5, - 0.5, - 0.5, - 0, - -170, - 0, - 0, - 0, - 1, - 0 - ], - "minLevel": 10, - "enabled": true - } - ], - "notification": { - "notification.admin.transient": { - "display": "POP_UP", - "image": "${image.library.url}/album1358/frank_wave_001.gif" - }, - "notification.builders_club.membership_expired": { - "display": "POP_UP" - }, - "notification.builders_club.membership_expires": { - "display": "POP_UP", - "image": "${image.library.url}/notifications/builders_club_room_locked_small.png" - }, - "notification.builders_club.membership_extended": { - "delivery": "PERSISTENT", - "display": "POP_UP" - }, - "notification.builders_club.membership_made": { - "delivery": "PERSISTENT", - "display": "POP_UP", - "image": "${image.library.url}/notifications/builders_club_membership_extended.png" - }, - "notification.builders_club.membership_renewed": { - "delivery": "PERSISTENT", - "display": "POP_UP", - "image": "${image.library.url}/notifications/builders_club_membership_extended.png" - }, - "notification.builders_club.room_locked": { - "display": "BUBBLE", - "image": "${image.library.url}/notifications/builders_club_room_locked_small.png" - }, - "notification.builders_club.room_unlocked": { - "display": "BUBBLE" - }, - "notification.builders_club.visit_denied_for_owner": { - "display": "BUBBLE", - "image": "${image.library.url}/notifications/builders_club_room_locked_small.png" - }, - "notification.builders_club.visit_denied_for_visitor": { - "display": "POP_UP", - "image": "${image.library.url}/notifications/builders_club_room_locked.png" - }, - "notification.campaign.credit.donation": { - "display": "BUBBLE" - }, - "notification.campaign.product.donation": { - "display": "BUBBLE" - }, - "notification.casino.too_many_dice.placement": { - "display": "POP_UP" - }, - "notification.casino.too_many_dice": { - "display": "POP_UP" - }, - "notification.cfh.created": { - "display": "POP_UP", - "title": "" - }, - "notification.feed.enabled": false, - "notification.floorplan_editor.error": { - "display": "POP_UP" - }, - "notification.forums.delivered": { - "delivery": "PERSISTENT", - "display": "POP_UP" - }, - "notification.forums.forum_settings_updated": { - "display": "BUBBLE" - }, - "notification.forums.message.hidden": { - "display": "BUBBLE" - }, - "notification.forums.message.restored": { - "display": "BUBBLE" - }, - "notification.forums.thread.hidden": { - "display": "BUBBLE" - }, - "notification.forums.thread.locked": { - "display": "BUBBLE" - }, - "notification.forums.thread.pinned": { - "display": "BUBBLE" - }, - "notification.forums.thread.restored": { - "display": "BUBBLE" - }, - "notification.forums.thread.unlocked": { - "display": "BUBBLE" - }, - "notification.forums.thread.unpinned": { - "display": "BUBBLE" - }, - "notification.furni_placement_error": { - "display": "BUBBLE" - }, - "notification.gifting.valentine": { - "delivery": "PERSISTENT", - "display": "BUBBLE", - "image": "${image.library.url}/notifications/polaroid_photo.png" - }, - "notification.items.enabled": true, - "notification.mute.forbidden.time": { - "display": "BUBBLE" - }, - "notification.npc.gift.received": { - "display": "BUBBLE", - "image": "${image.library.url}/album1584/X1517.gif" - } - } -} \ No newline at end of file diff --git a/scripts/minify-dist.mjs b/scripts/minify-dist.mjs index a9d3d18..c61ff54 100644 --- a/scripts/minify-dist.mjs +++ b/scripts/minify-dist.mjs @@ -44,12 +44,6 @@ for(const file of walk(dist)) if(file.endsWith('.json')) minifyJson(file); } -for(const file of [ 'renderer-config.json', 'ui-config.json' ]) -{ - const target = join(dist, file); - if(existsSync(target)) rmSync(target); -} - for(const file of walk(dist)) { if(file.endsWith('.js') && !file.endsWith('asset-loader.js')) encryptFile(file); @@ -84,4 +78,4 @@ for(const [ source, file ] of publicLoaderAssets) } } -writeFileSync(join(dist, 'index.html'), `
`); +writeFileSync(join(dist, 'index.html'), `
`); diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index a94d388..59e403c 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname, resolve } from 'path'; const loader = `(() => { @@ -148,6 +148,10 @@ const loader = `(() => { const readClientMode = async () => { try { + if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") { + debug("loader: client-mode preset"); + return window.__nitroClientMode; + } const url = withCacheBust(new URL("./client-mode.json", getBase())); const response = await fetch(url, { cache: "no-store" }); if(!response.ok) throw new Error("client-mode " + response.status); @@ -185,7 +189,157 @@ const loader = `(() => { }); })();`; -const target = resolve('public', 'asset-loader.js'); +const clientModePath = resolve('public', 'configuration', 'client-mode.json'); +let bootstrapApiBase = ''; + +if(existsSync(clientModePath)) +{ + try + { + const clientMode = JSON.parse(readFileSync(clientModePath, 'utf8')); + + if(typeof clientMode.apiBaseUrl === 'string') bootstrapApiBase = clientMode.apiBaseUrl; + } + catch {} +} + +const bootstrap = `(() => { + const API_BASE = ${ JSON.stringify(bootstrapApiBase) }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const bytesToBase64 = (buffer) => { + let binary = ""; + const bytes = new Uint8Array(buffer); + for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + }; + + const hexValue = (code) => { + if(code >= 48 && code <= 57) return code - 48; + if(code >= 65 && code <= 70) return code - 55; + if(code >= 97 && code <= 102) return code - 87; + return -1; + }; + + const hexToBytes = (hex) => { + const normalized = hex.trim(); + if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload."); + const bytes = new Uint8Array(normalized.length / 2); + for(let i = 0; i < bytes.length; i++) { + const high = hexValue(normalized.charCodeAt(i * 2)); + const low = hexValue(normalized.charCodeAt((i * 2) + 1)); + if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload."); + bytes[i] = (high << 4) | low; + } + return bytes; + }; + + const deriveAesKey = async (privateKey, serverKeyBase64) => { + const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0)); + const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []); + const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256); + const salt = new TextEncoder().encode("nitro-secure-assets-v1"); + const material = new Uint8Array(secret.byteLength + salt.length); + material.set(new Uint8Array(secret), 0); + material.set(salt, secret.byteLength); + const hash = await crypto.subtle.digest("SHA-256", material); + return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]); + }; + + const decryptPayload = async (key, response) => { + if(response.headers.get("X-Nitro-Sec") !== "1") return response.text(); + const bytes = hexToBytes(await response.text()); + if(bytes.length < 13) throw new Error("Encrypted response is too short."); + const iv = bytes.slice(0, 12); + const payload = bytes.slice(12); + const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload); + return new TextDecoder().decode(clear); + }; + + const importTextModule = async (sourceText) => { + const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" })); + try { + await import(blobUrl); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const loadPlainBootstrap = async () => { + const url = withCacheBust(new URL("./asset-loader.js", getBase())); + await import(url.href); + }; + + const loadSecureBootstrap = async () => { + if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap."); + + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); + const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey); + const publicKey = bytesToBase64(publicKeyBuffer); + const base = API_BASE.replace(/\\/$/, ""); + const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: publicKey }) + }); + + if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status); + + const bootstrapPayload = await bootstrapResponse.json(); + if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) { + throw new Error("Secure bootstrap returned an invalid server key."); + } + + const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key); + + const fetchSecureConfig = async (file) => { + const url = new URL(base + "/nitro-sec/file"); + url.searchParams.set("kind", "config"); + url.searchParams.set("file", file); + url.searchParams.set("v", Date.now().toString(36)); + + const response = await fetch(url.toString(), { + headers: { "X-Nitro-Key": publicKey }, + cache: "no-store" + }); + + if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status); + + return decryptPayload(sessionKey, response); + }; + + const modeText = await fetchSecureConfig("client-mode.json"); + window.__nitroClientMode = JSON.parse(modeText); + + const loaderText = await fetchSecureConfig("asset-loader.js"); + await importTextModule(loaderText); + }; + + (async () => { + try { + await loadSecureBootstrap(); + } catch(error) { + console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); + await loadPlainBootstrap(); + } + })().catch(error => { + console.error(error); + document.body.textContent = "Unable to load client."; + }); +})();`; + +const target = resolve('public', 'configuration', 'asset-loader.js'); +const bootstrapTarget = resolve('public', 'configuration', 'bootstrap.js'); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, loader); +writeFileSync(bootstrapTarget, bootstrap); diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 2df054b..395f3b2 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -31,8 +31,8 @@ const cacheBustUrl = (path: string): string => (window as any).NitroClientMode = clientMode; (window as any).NitroConfig = { 'config.urls': [ - clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('renderer-config.json'), - clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('ui-config.json') + clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('configuration/renderer-config.json'), + clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('configuration/ui-config.json') ], 'sso.ticket': search.get('sso') || null, 'forward.type': search.get('room') ? 2 : -1, diff --git a/src/components/ads/GoogleAdsView.tsx b/src/components/ads/GoogleAdsView.tsx index b31574e..4b65295 100644 --- a/src/components/ads/GoogleAdsView.tsx +++ b/src/components/ads/GoogleAdsView.tsx @@ -1,6 +1,7 @@ import { FC, useEffect, useRef, useState } from 'react'; import { GetConfigurationValue } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { configFileUrl } from '../../secure-assets'; interface AdsenseConfig { slot: string; @@ -70,7 +71,7 @@ export const GoogleAdsView: FC<{}> = () => { try { const [ adsTxtRes, configRes ] = await Promise.all([ fetch('/ads.txt', { cache: 'no-cache' }), - fetch('/adsense.json', { cache: 'no-cache' }) + fetch(configFileUrl('adsense.json', true), { cache: 'no-cache' }) ]); if (!adsTxtRes.ok) throw new Error(`ads.txt ${ adsTxtRes.status }`); @@ -156,7 +157,7 @@ export const GoogleAdsView: FC<{}> = () => { data-full-width-responsive={ (config.fullWidthResponsive ?? true) ? 'true' : 'false' } /> } { !loadError && publisherId && config && !config.slot && -
Ad slot not configured in adsense.json
} +
Ad slot not configured in configuration/adsense.json
}
diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index ceba536..dd08aed 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,6 +1,7 @@ import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import { configFileUrl } from '../../secure-assets'; import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; import flagEn from '../../assets/images/flag_icon/flag_icon_en.png'; @@ -1054,7 +1055,7 @@ const RegisterDialog: FC = props => { if(step !== 'avatar' || hotLooks.length) return; let cancelled = false; - fetch('hotlooks.json', { credentials: 'omit' }) + fetch(configFileUrl('hotlooks.json', true), { credentials: 'omit' }) .then(r => r.ok ? r.json() : null) .then((json: unknown) => { diff --git a/src/secure-assets.ts b/src/secure-assets.ts index 31018ac..5cdfdc3 100644 --- a/src/secure-assets.ts +++ b/src/secure-assets.ts @@ -204,7 +204,7 @@ const getPlainAssetBase = (kind: 'config' | 'gamedata'): string => if(typeof configured === 'string' && configured.length) return configured.endsWith('/') ? configured : `${ configured }/`; - if(kind === 'config') return `${ window.location.origin }/`; + if(kind === 'config') return `${ window.location.origin }/configuration/`; return `${ window.location.origin }/nitro/gamedata/`; }; @@ -239,6 +239,17 @@ export const secureUrl = (kind: 'config' | 'gamedata', file: string, cacheBust = return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }${ version }`; }; +export const configFileUrl = (file: string, cacheBust = false): string => +{ + if(getClientMode().secureAssetsEnabled) return secureUrl('config', file, cacheBust); + + const plainUrl = new URL(`configuration/${ file.replace(/^\/+/, '') }`, `${ window.location.origin }/`); + + if(cacheBust) plainUrl.searchParams.set('v', Date.now().toString(36)); + + return plainUrl.toString(); +}; + const createSecureSession = async (): Promise => { setDebugState('secure: generating ECDH session'); diff --git a/vite.config.mjs b/vite.config.mjs index 5922a33..7bf9554 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -16,18 +16,7 @@ export default defineConfig({ rendererRoot, ] }, - proxy: { - '/api': { - target: process.env.AUTH_PROXY_TARGET || 'https://nitro.example.com:2096/', - changeOrigin: true, - ws: true, - }, - '/nitro-sec': { - target: process.env.NITRO_PROXY_TARGET || 'https://nitro.example.com:2096/', - changeOrigin: true, - ws: true, - } - } + }, resolve: { tsconfigPaths: true, From 851d82f93f33f68d2239ca031f02fb3549e0c4cc Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Wed, 6 May 2026 06:27:40 +0200 Subject: [PATCH 11/11] Document secure runtime configuration --- docs/local-development-setup.en.md | 279 +++++++++++++ docs/local-development-setup.md | 279 +++++++++++++ docs/secure-production-setup.en.md | 365 ++++++++++++++++ docs/secure-production-setup.md | 365 ++++++++++++++++ public/configuration/news.example | 12 + public/configuration/renderer-config.example | 140 ++++--- public/configuration/ui-config.example | 414 +++++++++++++------ src/bootstrap.ts | 24 ++ src/components/login/LoginView.tsx | 4 + 9 files changed, 1701 insertions(+), 181 deletions(-) create mode 100644 docs/local-development-setup.en.md create mode 100644 docs/local-development-setup.md create mode 100644 docs/secure-production-setup.en.md create mode 100644 docs/secure-production-setup.md create mode 100644 public/configuration/news.example diff --git a/docs/local-development-setup.en.md b/docs/local-development-setup.en.md new file mode 100644 index 0000000..b873850 --- /dev/null +++ b/docs/local-development-setup.en.md @@ -0,0 +1,279 @@ +# Local Development Setup with `yarn start` + +This guide explains how to run Nitro locally with Vite, using: + +- local UI on `http://localhost:5173`; +- local API/emulator on `http://localhost:2096`; +- local WebSocket on `ws://localhost:2096`; +- remote plain assets and gamedata, so you do not need to copy the full `client/nitro` folder locally. + +## 1. Start the emulator + +Inside `Arcturus-Morningstar-Extended/Emulator`, start the emulator with WebSocket enabled. + +Recommended local `config.ini` values: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=* +ws.ip.header= + +crypto.ws.enabled=0 + +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +For local development, it is easier to disable: + +- `crypto.ws.enabled`; +- `nitro.secure.assets.enabled`; +- `nitro.secure.api.enabled`. + +This keeps debugging simple and avoids the secure runtime layer. + +## 2. `public/configuration/client-mode.json` + +File: + +```txt +Nitro-V3/public/configuration/client-mode.json +``` + +Recommended local config: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "http://localhost:2096", + "plainConfigBaseUrl": "http://localhost:5173/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Notes: + +- `secureAssetsEnabled=false` avoids `/nitro-sec/file`. +- `secureApiEnabled=false` avoids encrypted `/api/*` requests. +- `apiBaseUrl` must point to your local emulator. +- `plainGamedataBaseUrl` can stay remote if you do not have gamedata copied locally. + +If you want everything local, use: + +```json +"plainGamedataBaseUrl": "http://localhost:5173/client/nitro/gamedata/" +``` + +but the files must really exist under: + +```txt +Nitro-V3/public/client/nitro/gamedata/ +``` + +## 3. `public/configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/public/configuration/renderer-config.json +``` + +Minimum local values: + +```json +{ + "socket.url": "ws://localhost:2096", + "api.url": "http://localhost:2096", + "crypto.ws.enabled": false, + "gamedata.url": "https://hotel.example.com/client/nitro/gamedata", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "login.endpoint": "${api.url}/api/auth/login", + "login.register.endpoint": "${api.url}/api/auth/register", + "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", + "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.health.endpoint": "${api.url}/api/health", + "login.health.method": "GET", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.register.imaging.url": "${api.url}/api/avatar/imaging", + "login.news.url": "${api.url}/api/auth/news", + "badges.custom.list.endpoint": "${api.url}/api/badges/custom", + "badges.custom.create.endpoint": "${api.url}/api/badges/custom", + "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts" +} +``` + +Important: + +- Do not use `https://localhost:2096/nitro-sec/file` locally if `secureAssetsEnabled=false`. +- Do not use `ws://192.168.x.x/:2096`; it is malformed. Use `ws://localhost:2096` or `ws://192.168.x.x:2096`. + +## 4. `public/configuration/ui-config.json` + +File: + +```txt +Nitro-V3/public/configuration/ui-config.json +``` + +For the login view, you can use remote plain images: + +```json +{ + "loginview": { + "images": { + "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", + "background.colour": "#6eadc8", + "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", + "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" + } + } +} +``` + +If you see `ERR_NAME_NOT_RESOLVED`, the configured domain does not exist or is not reachable. + +## 5. Database-backed news + +Login news should come from the database through the emulator. + +In renderer config use: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +The emulator reads from: + +```txt +ui_news +``` + +Reference SQL: + +```txt +Arcturus-Morningstar-Extended/Database Updates/013_UI_Client_News.sql +``` + +Main columns: + +- `title` +- `body` +- `image` +- `link_text` +- `link_url` +- `enabled` +- `sort_order` + +`public/configuration/news.json` can stay as a mock/fallback only, but it is not the correct production flow. + +## 6. Start Nitro + +Inside `Nitro-V3`: + +```bash +yarn start +``` + +Open: + +```txt +http://localhost:5173 +``` + +Recommendation: use `localhost`, not `192.168.x.x`, because cookies and API sessions are host-based and can otherwise cause `401 Unauthorized`. + +## 7. Common errors + +### `Unable to load renderer-config.json` + +Check: + +```txt +public/configuration/client-mode.json +``` + +It must contain: + +```json +"secureAssetsEnabled": false +``` + +### `Invalid JSON ... Unexpected token '<'` + +The client requested JSON, but Vite returned HTML. + +This happens when a URL points to a file that does not exist, for example: + +```txt +http://localhost:5173/client/nitro/gamedata/ExternalTexts.json +``` + +Fix: + +- use remote plain gamedata; +- or copy the gamedata files into `public/client/nitro/gamedata`. + +### WebSocket `1006` + +Check: + +```json +"socket.url": "ws://localhost:2096" +``` + +and emulator config: + +```ini +ws.enabled=true +ws.port=2096 +``` + +### Custom badges `401 Unauthorized` + +This is normal if you are not logged in or if you open Nitro from a different host. + +Use: + +```txt +http://localhost:5173 +``` + +and API: + +```txt +http://localhost:2096 +``` + +## 8. Difference from production + +Local `yarn start`: + +```html + +``` + +Production build: + +```html + +``` + +Do not mix the two flows. diff --git a/docs/local-development-setup.md b/docs/local-development-setup.md new file mode 100644 index 0000000..b788e2e --- /dev/null +++ b/docs/local-development-setup.md @@ -0,0 +1,279 @@ +# Setup locale con `yarn start` + +Questa guida serve per avviare Nitro in locale con Vite, usando: + +- UI locale su `http://localhost:5173`; +- API/emulatore locale su `http://localhost:2096`; +- WebSocket locale su `ws://localhost:2096`; +- asset e gamedata remoti plain, così non devi copiare tutta la cartella `client/nitro`. + +## 1. Avvia l'emulatore + +Nel repo `Arcturus-Morningstar-Extended/Emulator`, avvia l'emulatore con WebSocket attivo. + +Nel tuo `config.ini` locale usa valori tipo: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=* +ws.ip.header= + +crypto.ws.enabled=0 + +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +Per il locale è meglio tenere spenti: + +- `crypto.ws.enabled`; +- `nitro.secure.assets.enabled`; +- `nitro.secure.api.enabled`. + +Così puoi debuggare senza layer secure in mezzo. + +## 2. `public/configuration/client-mode.json` + +File: + +```txt +Nitro-V3/public/configuration/client-mode.json +``` + +Config locale consigliato: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "http://localhost:2096", + "plainConfigBaseUrl": "http://localhost:5173/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Note: + +- `secureAssetsEnabled=false` evita `/nitro-sec/file`. +- `secureApiEnabled=false` evita cifratura `/api/*`. +- `apiBaseUrl` deve puntare all'emulatore locale. +- `plainGamedataBaseUrl` può rimanere remoto se non hai gamedata copiato in locale. + +Se vuoi tutto locale, usa: + +```json +"plainGamedataBaseUrl": "http://localhost:5173/client/nitro/gamedata/" +``` + +ma devi avere davvero i file sotto: + +```txt +Nitro-V3/public/client/nitro/gamedata/ +``` + +## 3. `public/configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/public/configuration/renderer-config.json +``` + +Valori minimi locali: + +```json +{ + "socket.url": "ws://localhost:2096", + "api.url": "http://localhost:2096", + "crypto.ws.enabled": false, + "gamedata.url": "https://hotel.example.com/client/nitro/gamedata", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "login.endpoint": "${api.url}/api/auth/login", + "login.register.endpoint": "${api.url}/api/auth/register", + "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", + "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.health.endpoint": "${api.url}/api/health", + "login.health.method": "GET", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.register.imaging.url": "${api.url}/api/avatar/imaging", + "login.news.url": "${api.url}/api/auth/news", + "badges.custom.list.endpoint": "${api.url}/api/badges/custom", + "badges.custom.create.endpoint": "${api.url}/api/badges/custom", + "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts" +} +``` + +Importante: + +- Non usare `https://localhost:2096/nitro-sec/file` in locale se `secureAssetsEnabled=false`. +- Non usare `ws://192.168.x.x/:2096`: è malformato. Usa `ws://localhost:2096` oppure `ws://192.168.x.x:2096`. + +## 4. `public/configuration/ui-config.json` + +File: + +```txt +Nitro-V3/public/configuration/ui-config.json +``` + +Per la login view puoi usare immagini remote plain: + +```json +{ + "loginview": { + "images": { + "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", + "background.colour": "#6eadc8", + "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", + "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" + } + } +} +``` + +Se vedi `ERR_NAME_NOT_RESOLVED`, il dominio configurato non esiste o non è raggiungibile. + +## 5. News dal database + +Le news della login devono arrivare dal database tramite l'emulatore. + +Nel renderer config usa: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +L'emulatore legge dalla tabella: + +```txt +ui_news +``` + +SQL di riferimento: + +```txt +Arcturus-Morningstar-Extended/Database Updates/013_UI_Client_News.sql +``` + +Colonne principali: + +- `title` +- `body` +- `image` +- `link_text` +- `link_url` +- `enabled` +- `sort_order` + +`public/configuration/news.json` può rimanere solo come mock/fallback, ma non è il flow corretto. + +## 6. Avvio Nitro + +Nel repo `Nitro-V3`: + +```bash +yarn start +``` + +Apri: + +```txt +http://localhost:5173 +``` + +Consiglio: usa `localhost`, non `192.168.x.x`, perché cookie e sessioni API possono cambiare host e causare `401 Unauthorized`. + +## 7. Errori comuni + +### `Unable to load renderer-config.json` + +Controlla: + +```txt +public/configuration/client-mode.json +``` + +Deve avere: + +```json +"secureAssetsEnabled": false +``` + +### `Invalid JSON ... Unexpected token '<'` + +Vuol dire che il client ha chiesto un JSON, ma Vite ha risposto HTML. + +Succede quando un URL punta a un file che non esiste, per esempio: + +```txt +http://localhost:5173/client/nitro/gamedata/ExternalTexts.json +``` + +Soluzione: + +- usa gamedata remoto plain; +- oppure copia davvero i gamedata in `public/client/nitro/gamedata`. + +### WebSocket `1006` + +Controlla: + +```json +"socket.url": "ws://localhost:2096" +``` + +e nel config emulator: + +```ini +ws.enabled=true +ws.port=2096 +``` + +### Custom badges `401 Unauthorized` + +È normale se non sei loggato o se apri Nitro da un host diverso. + +Usa: + +```txt +http://localhost:5173 +``` + +e API: + +```txt +http://localhost:2096 +``` + +## 8. Differenza con produzione + +Locale con `yarn start`: + +```html + +``` + +Produzione buildata: + +```html + +``` + +Non mischiare i due flow. diff --git a/docs/secure-production-setup.en.md b/docs/secure-production-setup.en.md new file mode 100644 index 0000000..ab86bfd --- /dev/null +++ b/docs/secure-production-setup.en.md @@ -0,0 +1,365 @@ +# Secure Runtime Production Setup + +Quick setup guide for running Nitro with: + +- configuration and gamedata served through `/nitro-sec/file`; +- encrypted runtime `/api/*` calls; +- obfuscated production bundles loaded as `.dat`. + +Replace the example domains with your real domains: + +- `https://hotel.example.com` +- `https://nitro.example.com:2096` + +## 1. Build Nitro + +Inside the `Nitro-V3` repository: + +```bash +yarn build +``` + +Then publish the `dist` folder to your web server, for example: + +```txt +C:/inetpub/wwwroot/hotel/nitro +``` + +The deployed folder should contain at least: + +```txt +configuration/ +assets/ +asset-loader.js +index.html +src/ +``` + +## 2. `configuration/client-mode.json` + +File: + +```txt +Nitro-V3/dist/configuration/client-mode.json +``` + +Secure production configuration: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Meaning: + +- `distObfuscationEnabled: true` loads `app.js.dat` and `app.css.dat`. +- `secureAssetsEnabled: true` loads `renderer-config.json`, `ui-config.json`, and gamedata through `/nitro-sec/file`. +- `secureApiEnabled: true` automatically encrypts `/api/*` requests. +- `apiBaseUrl` must point to the emulator/API. +- `plainConfigBaseUrl` and `plainGamedataBaseUrl` are fallbacks when secure assets are disabled. + +## 3. `configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/renderer-config.json +``` + +Important values: + +```json +{ + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "crypto.ws.enabled": true +} +``` + +If you are not using WebSocket crypto yet, use: + +```json +"crypto.ws.enabled": false +``` + +## 4. `configuration/ui-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/ui-config.json +``` + +Static image and camera URLs can remain plain: + +```json +{ + "camera.url": "https://hotel.example.com/client/camera/", + "thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png" +} +``` + +Non-sensitive images can stay static. JSON configuration and gamedata should go through the secure endpoint. + +## 5. Emulator `config.ini` + +Inside `Arcturus-Morningstar-Extended`, edit the emulator config: + +```txt +Emulator/config.ini +``` + +Production example: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=https://hotel.example.com +ws.ip.header=CF-Connecting-IP + +crypto.ws.enabled=1 + +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +nitro.secure.gamedata.root=C:/inetpub/wwwroot/hotel/nitro/client/nitro/gamedata +nitro.secure.master_key=change-this-to-a-long-random-secret + +login.remember.enabled=true +login.remember.duration.days=30 +login.remember.jwt.secret=change-this-too-if-you-use-remember-me +``` + +Notes: + +- `nitro.secure.config.root` must point to the folder containing `renderer-config.json`, `ui-config.json`, and `client-mode.json`. +- `nitro.secure.gamedata.root` must point to the live gamedata folder. +- Files are read live from disk: if you update a JSON file, a new browser refresh reads the new version. +- `nitro.secure.master_key` must be secret and stable. Never put it in public files. + +## 6. Cloudflare + +If you use Cloudflare: + +1. Keep the proxy enabled for the website domain `hotel.example.com`. +2. Make sure Cloudflare supports/proxies the port used by `nitro.example.com:2096`. +3. Always use HTTPS/WSS in the browser: + +```json +"api.url": "https://nitro.example.com:2096", +"socket.url": "wss://nitro.example.com:2096" +``` + +If you get CORS errors, check: + +```ini +ws.whitelist=https://hotel.example.com +``` + +## 7. IIS / `.dat` MIME type + +If obfuscated `.dat` assets are enabled, IIS must serve them correctly. + +Add this MIME type: + +```txt +Extension: .dat +MIME type: application/octet-stream +``` + +Without it, the browser can receive 404 even when the file exists. + +## 8. Final checklist + +- `client-mode.json` has `secureAssetsEnabled=true`. +- `client-mode.json` has `secureApiEnabled=true`. +- `renderer-config.json` uses `/nitro-sec/file?kind=gamedata&file=`. +- `api.url` points to `https://nitro.example.com:2096`. +- `socket.url` points to `wss://nitro.example.com:2096`. +- `config.ini` has the correct `nitro.secure.config.root`. +- `config.ini` has the correct `nitro.secure.gamedata.root`. +- `config.ini` has a stable `nitro.secure.master_key`. +- IIS knows the `.dat` MIME type. +- Restart the emulator after changing `config.ini`. +- Refresh the browser after changing JSON files in `configuration` or `gamedata`. + +## 9. Temporarily disable secure mode + +For quick debugging, only change `client-mode.json`: + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Then hard refresh the browser. + +## 10. `configuration/bootstrap.js` + +File: + +```txt +Nitro-V3/dist/configuration/bootstrap.js +``` + +This is the first loader when you use the external secure mode. + +It does three things: + +1. opens an ECDH session with the emulator through `/nitro-sec/bootstrap`; +2. downloads encrypted `client-mode.json` through `/nitro-sec/file?kind=config`; +3. downloads encrypted `asset-loader.js` and imports it as a JavaScript module. + +### Value to check + +Inside `bootstrap.js` there is: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +It must point to your public emulator/API URL. + +In production: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +In local development: + +```js +const API_BASE = "http://localhost:2096"; +``` + +If `bootstrap.js` fails, it automatically falls back to the plain loader: + +```txt +configuration/asset-loader.js +``` + +So `asset-loader.js` must always exist inside the `configuration` folder. + +## 11. `configuration/asset-loader.js` + +File: + +```txt +Nitro-V3/dist/configuration/asset-loader.js +``` + +This loader loads the actual bundle: + +- if `distObfuscationEnabled=true` + - it loads `app.css.dat`; + - it loads `app.js.dat`; + - it decodes, decompresses, and imports the bundle from a blob. + +- if `distObfuscationEnabled=false` + - it loads `assets/app.css`; + - it loads `assets/app.js`. + +### Required files in production + +With obfuscation enabled, these files must exist: + +```txt +assets/app.css.dat +assets/app.js.dat +configuration/asset-loader.js +configuration/bootstrap.js +configuration/client-mode.json +``` + +With obfuscation disabled, these files must exist: + +```txt +assets/app.css +assets/app.js +configuration/asset-loader.js +configuration/client-mode.json +``` + +## 12. `index.html` + +`index.html` should stay minimal. + +Secure production example: + +```html +
+ +``` + +Vite development example: + +```html +
+ +``` + +Do not mix the two flows: + +- production build: use `configuration/bootstrap.js`; +- `yarn start` development: use `/src/bootstrap.ts`. + +## 13. Files inside `/configuration` + +The `configuration` folder should contain: + +```txt +asset-loader.js +bootstrap.js +client-mode.json +renderer-config.json +ui-config.json +adsense.json optional +hotlooks.json if register hot looks are enabled +UITexts.json if separate UI texts are enabled +``` + +Login news should not live in `news.json` in production. They come from the database through: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +The emulator reads from the `ui_news` table. + +With `secureAssetsEnabled=true`, client-loaded files go through: + +```txt +https://nitro.example.com:2096/nitro-sec/file?kind=config&file=... +``` + +The emulator reads them from: + +```ini +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +``` + +If you add new JSON/JS files inside `configuration` and want to protect them, they must be requested through the secure endpoint or loaded through `bootstrap.js`. diff --git a/docs/secure-production-setup.md b/docs/secure-production-setup.md new file mode 100644 index 0000000..eb76a6a --- /dev/null +++ b/docs/secure-production-setup.md @@ -0,0 +1,365 @@ +# Setup Secure Runtime in produzione + +Guida rapida per avviare Nitro con: + +- configurazioni e gamedata serviti da `/nitro-sec/file`; +- API `/api/*` cifrate dal wrapper runtime; +- bundle buildati offuscati come `.dat`. + +Negli esempi usa i tuoi domini reali al posto di: + +- `https://hotel.example.com` +- `https://nitro.example.com:2096` + +## 1. Build Nitro + +Nel repo `Nitro-V3`: + +```bash +yarn build +``` + +Poi pubblica la cartella `dist` nel web server del sito, ad esempio: + +```txt +C:/inetpub/wwwroot/hotel/nitro +``` + +La struttura pubblicata deve contenere almeno: + +```txt +configuration/ +assets/ +asset-loader.js +index.html +src/ +``` + +## 2. `configuration/client-mode.json` + +File: + +```txt +Nitro-V3/dist/configuration/client-mode.json +``` + +Configurazione produzione secure: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Significato: + +- `distObfuscationEnabled: true` carica `app.js.dat` e `app.css.dat`. +- `secureAssetsEnabled: true` carica `renderer-config.json`, `ui-config.json` e gamedata da `/nitro-sec/file`. +- `secureApiEnabled: true` cifra automaticamente le chiamate `/api/*`. +- `apiBaseUrl` deve puntare all'emulatore/API. +- `plainConfigBaseUrl` e `plainGamedataBaseUrl` restano fallback quando spegni secure assets. + +## 3. `configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/renderer-config.json +``` + +Valori importanti: + +```json +{ + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "crypto.ws.enabled": true +} +``` + +Se non usi ancora WebSocket crypto, metti: + +```json +"crypto.ws.enabled": false +``` + +## 4. `configuration/ui-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/ui-config.json +``` + +Qui puoi lasciare immagini e camera su URL statici normali: + +```json +{ + "camera.url": "https://hotel.example.com/client/camera/", + "thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png" +} +``` + +Le immagini non sensibili possono rimanere statiche. I JSON/gamedata invece passano dal secure endpoint. + +## 5. `config.ini` dell'emulatore + +Nel repo `Arcturus-Morningstar-Extended`, file usato dall'emulatore: + +```txt +Emulator/config.ini +``` + +Esempio produzione: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=https://hotel.example.com +ws.ip.header=CF-Connecting-IP + +crypto.ws.enabled=1 + +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +nitro.secure.gamedata.root=C:/inetpub/wwwroot/hotel/nitro/client/nitro/gamedata +nitro.secure.master_key=change-this-to-a-long-random-secret + +login.remember.enabled=true +login.remember.duration.days=30 +login.remember.jwt.secret=change-this-too-if-you-use-remember-me +``` + +Note: + +- `nitro.secure.config.root` deve puntare alla cartella dove ci sono `renderer-config.json`, `ui-config.json`, `client-mode.json`. +- `nitro.secure.gamedata.root` deve puntare alla cartella live dei gamedata. +- I file vengono letti live da disco: se cambi un JSON, un nuovo refresh pagina legge la nuova versione. +- `nitro.secure.master_key` deve restare segreta e stabile. Non metterla nei file pubblici. + +## 6. Cloudflare + +Se usi Cloudflare: + +1. Lascia la nuvoletta attiva sul dominio web `hotel.example.com`. +2. Per `nitro.example.com:2096`, assicurati che Cloudflare supporti/proxy il traffico sulla porta usata. +3. Usa sempre HTTPS/WSS lato browser: + +```json +"api.url": "https://nitro.example.com:2096", +"socket.url": "wss://nitro.example.com:2096" +``` + +Se vedi errori CORS, controlla: + +```ini +ws.whitelist=https://hotel.example.com +``` + +## 7. IIS / MIME `.dat` + +Se usi gli asset offuscati `.dat`, IIS deve servirli. + +Aggiungi MIME type: + +```txt +Extension: .dat +MIME type: application/octet-stream +``` + +Senza questo, il browser può dare 404 anche se il file esiste davvero. + +## 8. Checklist finale + +- `client-mode.json` ha `secureAssetsEnabled=true`. +- `client-mode.json` ha `secureApiEnabled=true`. +- `renderer-config.json` usa `/nitro-sec/file?kind=gamedata&file=`. +- `api.url` punta a `https://nitro.example.com:2096`. +- `socket.url` punta a `wss://nitro.example.com:2096`. +- `config.ini` ha `nitro.secure.config.root` corretto. +- `config.ini` ha `nitro.secure.gamedata.root` corretto. +- `config.ini` ha `nitro.secure.master_key` stabile. +- IIS conosce il MIME `.dat`. +- Dopo modifiche a `config.ini`, riavvia l'emulatore. +- Dopo modifiche ai JSON in `configuration` o `gamedata`, basta refresh pagina. + +## 9. Spegnere temporaneamente secure + +Per debug rapido, cambia solo `client-mode.json`: + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Poi fai hard refresh. + +## 10. `configuration/bootstrap.js` + +File: + +```txt +Nitro-V3/dist/configuration/bootstrap.js +``` + +Questo è il primo loader quando usi la modalità secure esterna. + +Fa tre cose: + +1. apre una sessione ECDH con l'emulatore tramite `/nitro-sec/bootstrap`; +2. scarica `client-mode.json` cifrato da `/nitro-sec/file?kind=config`; +3. scarica `asset-loader.js` cifrato e lo importa come modulo JavaScript. + +### Valore da controllare + +Dentro `bootstrap.js` esiste: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +Deve puntare all'emulatore/API pubblico. + +In produzione: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +In locale: + +```js +const API_BASE = "http://localhost:2096"; +``` + +Se `bootstrap.js` fallisce, prova automaticamente fallback plain su: + +```txt +configuration/asset-loader.js +``` + +Quindi `asset-loader.js` deve esistere sempre nella cartella `configuration`. + +## 11. `configuration/asset-loader.js` + +File: + +```txt +Nitro-V3/dist/configuration/asset-loader.js +``` + +Questo loader carica il bundle vero: + +- se `distObfuscationEnabled=true` + - carica `app.css.dat`; + - carica `app.js.dat`; + - decodifica, decomprime e importa il bundle da blob. + +- se `distObfuscationEnabled=false` + - carica `assets/app.css`; + - carica `assets/app.js`. + +### File richiesti in produzione + +Con offuscamento attivo devono esistere: + +```txt +assets/app.css.dat +assets/app.js.dat +configuration/asset-loader.js +configuration/bootstrap.js +configuration/client-mode.json +``` + +Con offuscamento spento devono esistere: + +```txt +assets/app.css +assets/app.js +configuration/asset-loader.js +configuration/client-mode.json +``` + +## 12. `index.html` + +Il file `index.html` deve rimanere minimale. + +Esempio secure: + +```html +
+ +``` + +Esempio dev Vite: + +```html +
+ +``` + +Non mischiare i due flow: + +- produzione buildata: usa `configuration/bootstrap.js`; +- sviluppo con `yarn start`: usa `/src/bootstrap.ts`. + +## 13. File dentro `/configuration` + +La cartella `configuration` deve contenere: + +```txt +asset-loader.js +bootstrap.js +client-mode.json +renderer-config.json +ui-config.json +adsense.json opzionale +hotlooks.json se usi register hot looks +UITexts.json se usi testi UI separati +``` + +Le news login non devono stare in `news.json` in produzione: arrivano dal database tramite: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +L'emulatore legge dalla tabella `ui_news`. + +Con `secureAssetsEnabled=true`, i file letti dal client passano da: + +```txt +https://nitro.example.com:2096/nitro-sec/file?kind=config&file=... +``` + +Quindi l'emulatore li legge da: + +```ini +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +``` + +Se aggiungi nuovi file JSON/JS in `configuration` e vuoi proteggerli, devono essere richiesti passando dal secure endpoint o caricati tramite `bootstrap.js`. diff --git a/public/configuration/news.example b/public/configuration/news.example new file mode 100644 index 0000000..4003700 --- /dev/null +++ b/public/configuration/news.example @@ -0,0 +1,12 @@ +{ + "news": [ + { + "id": 1, + "title": "Welcome to Nitro", + "body": "This news entry is loaded from configuration/news.json.", + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "link": "", + "linkText": "Read more" + } + ] +} diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index 4a294f2..8ee77b6 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -1,53 +1,91 @@ { - "socket.url": "wss://nitro.example.com:2096", - "api.url": "https://nitro.example.com:2096", - "asset.url": "https://hotel.example.com/client/nitro/bundled", - "image.library.url": "https://hotel.example.com/client/c_images/", - "hof.furni.url": "https://hotel.example.com/client/c_images/dcr/hof_furni", - "images.url": "https://hotel.example.com/client/nitro/images", - "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", - "sounds.url": "${asset.url}/sounds/%sample%.mp3", - "external.texts.url": [ - "${gamedata.url}/ExternalTexts.json", - "${gamedata.url}/UITexts.json" - ], - "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", - "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", - "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", - "furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%", - "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", - "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", - "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", - "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", - "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", - "avatar.asset.url": "${asset.url}/figure/%libname%.nitro", - "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", - "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", - "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", - "pet.asset.url": "${asset.url}/pets/%libname%.nitro", - "generic.asset.url": "${asset.url}/generic/%libname%.nitro", - "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", - "furni.rotation.bounce.steps": 20, - "furni.rotation.bounce.height": 0.0625, - "enable.avatar.arrow": false, - "system.log.debug": true, - "system.log.warn": true, - "system.log.error": true, - "system.log.events": false, - "system.log.packets": true, - "system.fps.animation": 24, - "system.fps.max": 60, - "system.pong.manually": true, - "system.pong.interval.ms": 20000, - "room.color.skip.transition": true, - "room.landscapes.enabled": true, - "room.zoom.enabled": true, - "login.screen.enabled": true, - "login.endpoint": "${api.url}/api/auth/login", - "login.register.endpoint": "${api.url}/api/auth/register", - "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", - "login.logout.endpoint": "${api.url}/api/auth/logout", - "login.remember.endpoint": "${api.url}/api/auth/remember", - "login.turnstile.enabled": false, - "login.turnstile.sitekey": "" + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "asset.url": "https://hotel.example.com/client/nitro/bundled", + "image.library.url": "https://hotel.example.com/client/c_images/", + "hof.furni.url": "https://hotel.example.com/client/c_images/dcr/hof_furni", + "images.url": "https://hotel.example.com/client/nitro/images", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", + "sounds.url": "${asset.url}/sounds/%sample%.mp3", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", + "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "avatar.asset.url": "${asset.url}/figure/%libname%.nitro", + "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", + "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", + "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", + "pet.asset.url": "${asset.url}/pets/%libname%.nitro", + "generic.asset.url": "${asset.url}/generic/%libname%.nitro", + "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", + "furni.rotation.bounce.steps": 20, + "furni.rotation.bounce.height": 0.0625, + "enable.avatar.arrow": false, + "system.log.debug": true, + "system.log.warn": true, + "system.log.error": true, + "system.log.events": false, + "system.log.packets": true, + "system.fps.animation": 24, + "system.fps.max": 60, + "system.pong.manually": true, + "system.pong.interval.ms": 20000, + "room.color.skip.transition": true, + "room.landscapes.enabled": true, + "room.zoom.enabled": true, + "login.screen.enabled": true, + "login.endpoint": "${api.url}/api/auth/login", + "login.register.endpoint": "${api.url}/api/auth/register", + "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", + "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.turnstile.enabled": false, + "login.turnstile.sitekey": "", + "avatar.mandatory.libraries": [ + "bd:1", + "li:0" + ], + "avatar.mandatory.effect.libraries": [ + "dance.1", + "dance.2", + "dance.3", + "dance.4" + ], + "avatar.default.figuredata": { + "palettes": [], + "setTypes": [] + }, + "avatar.default.actions": { + "actions": [] + }, + "pet.types": [], + "preload.assets.urls": [ + "${asset.url}/generic/avatar_additions.nitro", + "${asset.url}/generic/group_badge.nitro", + "${asset.url}/generic/floor_editor.nitro", + "${images.url}/loading_icon.png", + "${images.url}/clear_icon.png", + "${images.url}/big_arrow.png" + ], + "login.health.endpoint": "${api.url}/api/health", + "login.health.method": "GET", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.register.imaging.url": "${api.url}/api/avatar/imaging", + "crypto.ws.enabled": true, + "login.news.url": "${api.url}/api/auth/news", + "badges.custom.list.endpoint": "${api.url}/api/badges/custom", + "badges.custom.create.endpoint": "${api.url}/api/badges/custom", + "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%", + "badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts" } diff --git a/public/configuration/ui-config.example b/public/configuration/ui-config.example index 7510024..5503c2c 100644 --- a/public/configuration/ui-config.example +++ b/public/configuration/ui-config.example @@ -37,145 +37,202 @@ "left": "${asset.url}/c_images/reception/ts.png", "right": "${asset.url}/c_images/reception/US_right.png", "right.repeat": "${asset.url}/c_images/reception/US_top_right.png" + }, + "widgets": { + "slot.1.widget": "promoarticle", + "slot.1.conf": {}, + "slot.2.widget": "widgetcontainer", + "slot.2.conf": { + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "texts": "2021NitroPromo", + "btnLink": "" + }, + "slot.3.widget": "", + "slot.3.conf": {}, + "slot.4.widget": "", + "slot.4.conf": {}, + "slot.5.widget": "", + "slot.5.conf": {}, + "slot.6.widget": "", + "slot.6.conf": { + "campaign": "" + }, + "slot.7.widget": "", + "slot.7.conf": {} } }, - "navigator.room.models": [{ + "navigator.room.models": [ + { "clubLevel": 0, "tileSize": 104, "name": "a" - }, { + }, + { "clubLevel": 0, "tileSize": 94, "name": "b" - }, { + }, + { "clubLevel": 0, "tileSize": 36, "name": "c" - }, { + }, + { "clubLevel": 0, "tileSize": 84, "name": "d" - }, { + }, + { "clubLevel": 0, "tileSize": 80, "name": "e" - }, { + }, + { "clubLevel": 0, "tileSize": 80, "name": "f" - }, { + }, + { "clubLevel": 0, "tileSize": 416, "name": "i" - }, { + }, + { "clubLevel": 0, "tileSize": 320, "name": "j" - }, { + }, + { "clubLevel": 0, "tileSize": 448, "name": "k" - }, { + }, + { "clubLevel": 0, "tileSize": 352, "name": "l" - }, { + }, + { "clubLevel": 0, "tileSize": 384, "name": "m" - }, { + }, + { "clubLevel": 0, "tileSize": 372, "name": "n" - }, { + }, + { "clubLevel": 1, "tileSize": 80, "name": "g" - }, { + }, + { "clubLevel": 1, "tileSize": 74, "name": "h" - }, { + }, + { "clubLevel": 1, "tileSize": 416, "name": "o" - }, { + }, + { "clubLevel": 1, "tileSize": 352, "name": "p" - }, { + }, + { "clubLevel": 1, "tileSize": 304, "name": "q" - }, { + }, + { "clubLevel": 1, "tileSize": 336, "name": "r" - }, { + }, + { "clubLevel": 1, "tileSize": 748, "name": "u" - }, { + }, + { "clubLevel": 1, "tileSize": 438, "name": "v" - }, { + }, + { "clubLevel": 2, "tileSize": 540, "name": "t" - }, { + }, + { "clubLevel": 2, "tileSize": 512, "name": "w" - }, { + }, + { "clubLevel": 2, "tileSize": 396, "name": "x" - }, { + }, + { "clubLevel": 2, "tileSize": 440, "name": "y" - }, { + }, + { "clubLevel": 2, "tileSize": 456, "name": "z" - }, { + }, + { "clubLevel": 2, "tileSize": 208, "name": "0" - }, { + }, + { "clubLevel": 2, "tileSize": 1009, "name": "1" - }, { + }, + { "clubLevel": 2, "tileSize": 1044, "name": "2" - }, { + }, + { "clubLevel": 2, "tileSize": 183, "name": "3" - }, { + }, + { "clubLevel": 2, "tileSize": 254, "name": "4" - }, { + }, + { "clubLevel": 2, "tileSize": 1024, "name": "5" - }, { + }, + { "clubLevel": 2, "tileSize": 801, "name": "6" - }, { + }, + { "clubLevel": 2, "tileSize": 354, "name": "7" - }, { + }, + { "clubLevel": 2, "tileSize": 888, "name": "8" - }, { + }, + { "clubLevel": 2, "tileSize": 926, "name": "9" @@ -260,325 +317,379 @@ "catalog.headers": false, "chat.input.maxlength": 100, "chat.styles.disabled": [], - "chat.styles": [{ + "chat.styles": [ + { "styleId": 0, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 1, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 2, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 3, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 4, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 5, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 6, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 7, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 8, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 9, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 10, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 11, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 12, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 13, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 14, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 15, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 16, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 17, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 18, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 19, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 20, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 21, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 22, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 23, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 24, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 25, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 26, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 27, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 28, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 29, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 30, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 31, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 32, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 33, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 34, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 35, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 36, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 37, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 38, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 39, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 40, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 41, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 42, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 43, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 44, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 45, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 46, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 47, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 48, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 49, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 50, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 51, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 52, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 53, "minRank": 5, "isSystemStyle": false, @@ -586,7 +697,8 @@ "isAmbassadorOnly": true } ], - "camera.available.effects": [{ + "camera.available.effects": [ + { "name": "dark_sepia", "colorMatrix": [ 0.4, @@ -612,7 +724,8 @@ ], "minLevel": 0, "enabled": true - }, { + }, + { "name": "increase_saturation", "colorMatrix": [ 2, @@ -638,7 +751,8 @@ ], "minLevel": 0, "enabled": true - }, { + }, + { "name": "increase_contrast", "colorMatrix": [ 1.5, @@ -664,13 +778,15 @@ ], "minLevel": 0, "enabled": true - }, { + }, + { "name": "shadow_multiply_02", "colorMatrix": [], "minLevel": 0, "blendMode": 2, "enabled": true - }, { + }, + { "name": "color_1", "colorMatrix": [ 0.393, @@ -696,7 +812,8 @@ ], "minLevel": 1, "enabled": true - }, { + }, + { "name": "hue_bright_sat", "colorMatrix": [ 1, @@ -722,25 +839,29 @@ ], "minLevel": 1, "enabled": true - }, { + }, + { "name": "hearts_hardlight_02", "colorMatrix": [], "minLevel": 1, "blendMode": 9, "enabled": true - }, { + }, + { "name": "texture_overlay", "colorMatrix": [], "minLevel": 1, "blendMode": 4, "enabled": true - }, { + }, + { "name": "pinky_nrm", "colorMatrix": [], "minLevel": 1, "blendMode": 0, "enabled": true - }, { + }, + { "name": "color_2", "colorMatrix": [ 0.333, @@ -766,7 +887,8 @@ ], "minLevel": 2, "enabled": true - }, { + }, + { "name": "night_vision", "colorMatrix": [ 0, @@ -792,37 +914,43 @@ ], "minLevel": 2, "enabled": true - }, { + }, + { "name": "stars_hardlight_02", "colorMatrix": [], "minLevel": 2, "blendMode": 9, "enabled": true - }, { + }, + { "name": "coffee_mpl", "colorMatrix": [], "minLevel": 2, "blendMode": 2, "enabled": true - }, { + }, + { "name": "security_hardlight", "colorMatrix": [], "minLevel": 3, "blendMode": 9, "enabled": true - }, { + }, + { "name": "bluemood_mpl", "colorMatrix": [], "minLevel": 3, "blendMode": 2, "enabled": true - }, { + }, + { "name": "rusty_mpl", "colorMatrix": [], "minLevel": 3, "blendMode": 2, "enabled": true - }, { + }, + { "name": "decr_conrast", "colorMatrix": [ 0.5, @@ -848,7 +976,8 @@ ], "minLevel": 4, "enabled": true - }, { + }, + { "name": "green_2", "colorMatrix": [ 0.5, @@ -874,13 +1003,15 @@ ], "minLevel": 4, "enabled": true - }, { + }, + { "name": "alien_hrd", "colorMatrix": [], "minLevel": 4, "blendMode": 9, "enabled": true - }, { + }, + { "name": "color_3", "colorMatrix": [ 0.609, @@ -906,7 +1037,8 @@ ], "minLevel": 5, "enabled": true - }, { + }, + { "name": "color_4", "colorMatrix": [ 0.8, @@ -932,13 +1064,15 @@ ], "minLevel": 5, "enabled": true - }, { + }, + { "name": "toxic_hrd", "colorMatrix": [], "minLevel": 5, "blendMode": 9, "enabled": true - }, { + }, + { "name": "hypersaturated", "colorMatrix": [ 2, @@ -964,7 +1098,8 @@ ], "minLevel": 6, "enabled": true - }, { + }, + { "name": "Yellow", "colorMatrix": [ 1, @@ -990,13 +1125,15 @@ ], "minLevel": 6, "enabled": true - }, { + }, + { "name": "misty_hrd", "colorMatrix": [], "minLevel": 6, "blendMode": 9, "enabled": true - }, { + }, + { "name": "x_ray", "colorMatrix": [ 0, @@ -1022,7 +1159,8 @@ ], "minLevel": 7, "enabled": true - }, { + }, + { "name": "decrease_saturation", "colorMatrix": [ 0.7, @@ -1048,55 +1186,64 @@ ], "minLevel": 7, "enabled": true - }, { + }, + { "name": "drops_mpl", "colorMatrix": [], "minLevel": 8, "blendMode": 2, "enabled": true - }, { + }, + { "name": "shiny_hrd", "colorMatrix": [], "minLevel": 9, "blendMode": 9, "enabled": true - }, { + }, + { "name": "glitter_hrd", "colorMatrix": [], "minLevel": 10, "blendMode": 9, "enabled": true - }, { + }, + { "name": "frame_gold", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "frame_gray_4", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "frame_black_2", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "frame_wood_2", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "finger_nrm", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "color_5", "colorMatrix": [ 3.309, @@ -1122,7 +1269,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "black_white_negative", "colorMatrix": [ -0.5, @@ -1148,7 +1296,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "blue", "colorMatrix": [ 0.5, @@ -1174,7 +1323,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "red", "colorMatrix": [ 0.5, @@ -1200,7 +1350,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "green", "colorMatrix": [ 0.5, @@ -1336,5 +1487,8 @@ "display": "BUBBLE", "image": "${image.library.url}/album1584/X1517.gif" } - } + }, + "backgrounds.data": [], + "stands.data": [], + "overlays.data": [] } diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 395f3b2..7513077 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -16,6 +16,30 @@ const setBootDebug = (message: string) => setBootDebug('boot: secure fetch installed'); +const loadClientMode = async () => +{ + try + { + if((window as any).__nitroClientMode) return; + + const url = new URL('configuration/client-mode.json', `${ window.location.origin }/`); + url.searchParams.set('v', Date.now().toString(36)); + + const response = await fetch(url.toString()); + + if(!response.ok) throw new Error(`HTTP ${ response.status }`); + + (window as any).__nitroClientMode = await response.json(); + setBootDebug('boot: client-mode loaded'); + } + catch(error) + { + setBootDebug(`boot: client-mode fallback ${ error?.message || error }`); + } +}; + +await loadClientMode(); + const search = new URLSearchParams(window.location.search); const clientMode = getClientMode(); const cacheBustUrl = (path: string): string => diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index dd08aed..2da9120 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -13,6 +13,7 @@ import flagNl from '../../assets/images/flag_icon/flag_icon_nl.png'; import flagSelected from '../../assets/images/flag_icon/flag_icon_selected.png'; import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png'; import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation'; +import { NewsWindow } from './components/NewsWindow'; import { TurnstileWidget } from './TurnstileWidget'; type DialogMode = 'login' | 'register' | 'forgot'; @@ -229,6 +230,7 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); + const newsUrl = interpolate(GetConfigurationValue('login.news.url', '')); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); const turnstileEnabled = (rawTurnstileEnabled === true @@ -678,6 +680,8 @@ export const LoginView: FC = ({ onAuthenticated, isEntering = fa }) }
} + { newsUrl && } +
Choose your language