diff --git a/front/src/Components/App.svelte b/front/src/Components/App.svelte index c596c8ee..da1ca6f0 100644 --- a/front/src/Components/App.svelte +++ b/front/src/Components/App.svelte @@ -2,6 +2,7 @@ import type { Game } from "../Phaser/Game/Game"; import { chatVisibilityStore } from "../Stores/ChatStore"; import { errorStore } from "../Stores/ErrorStore"; + import { errorScreenStore } from "../Stores/ErrorScreenStore"; import { loginSceneVisibleStore } from "../Stores/LoginSceneStore"; import { enableCameraSceneVisibilityStore } from "../Stores/MediaStore"; import { selectCharacterSceneVisibleStore } from "../Stores/SelectCharacterStore"; @@ -13,11 +14,16 @@ import SelectCharacterScene from "./selectCharacter/SelectCharacterScene.svelte"; import SelectCompanionScene from "./SelectCompanion/SelectCompanionScene.svelte"; import ErrorDialog from "./UI/ErrorDialog.svelte"; + import ErrorScreen from "./UI/ErrorScreen.svelte"; export let game: Game; -{#if $errorStore.length > 0} +{#if $errorScreenStore !== undefined} +
+ +
+{:else if $errorStore.length > 0}
diff --git a/front/src/Components/UI/ErrorScreen.svelte b/front/src/Components/UI/ErrorScreen.svelte new file mode 100644 index 00000000..bac59594 --- /dev/null +++ b/front/src/Components/UI/ErrorScreen.svelte @@ -0,0 +1,144 @@ + + +
+
+ +
+ {#if $errorScreenStore.type !== "retry"}

{$errorScreenStore.title}

{/if} +

{$errorScreenStore.subtitle}

+ {#if $errorScreenStore.type !== "retry"}

Code : {$errorScreenStore.code}

{/if} +

+ {detailsStylized}{#if $errorScreenStore.type === "retry"}

{/if} +

+ {#if $errorScreenStore.type === "retry" && $errorScreenStore.canRetryManual} + + {/if} +
+
+ + diff --git a/front/src/Components/images/logo-min-white.png b/front/src/Components/images/logo-min-white.png new file mode 100644 index 00000000..7e5796b0 Binary files /dev/null and b/front/src/Components/images/logo-min-white.png differ diff --git a/front/src/Components/images/reload.png b/front/src/Components/images/reload.png new file mode 100644 index 00000000..414af00c Binary files /dev/null and b/front/src/Components/images/reload.png differ diff --git a/front/src/Connexion/RoomConnection.ts b/front/src/Connexion/RoomConnection.ts index 04993226..ccc56acc 100644 --- a/front/src/Connexion/RoomConnection.ts +++ b/front/src/Connexion/RoomConnection.ts @@ -25,6 +25,7 @@ import { TokenExpiredMessage, WorldConnexionMessage, ErrorMessage as ErrorMessageTsProto, + ErrorScreenMessage as ErrorScreenMessageTsProto, UserMovedMessage as UserMovedMessageTsProto, GroupUpdateMessage as GroupUpdateMessageTsProto, GroupDeleteMessage as GroupDeleteMessageTsProto, @@ -47,6 +48,7 @@ import { Subject } from "rxjs"; import { selectCharacterSceneVisibleStore } from "../Stores/SelectCharacterStore"; import { gameManager } from "../Phaser/Game/GameManager"; import { SelectCharacterScene, SelectCharacterSceneName } from "../Phaser/Login/SelectCharacterScene"; +import { errorScreenStore } from "../Stores/ErrorScreenStore"; const manualPingDelay = 20000; @@ -62,6 +64,9 @@ export class RoomConnection implements RoomConnection { private readonly _errorMessageStream = new Subject(); public readonly errorMessageStream = this._errorMessageStream.asObservable(); + private readonly _errorScreenMessageStream = new Subject(); + public readonly errorScreenMessageStream = this._errorScreenMessageStream.asObservable(); + private readonly _roomJoinedMessageStream = new Subject<{ connection: RoomConnection; room: RoomJoinedMessageInterface; @@ -477,6 +482,13 @@ export class RoomConnection implements RoomConnection { console.error("An error occurred server side: " + message.errorMessage.message); break; } + case "errorScreenMessage": { + this._errorScreenMessageStream.next(message.errorScreenMessage); + if (message.errorScreenMessage.code !== "retry") this.closed = true; + console.error("An error occurred server side: " + message.errorScreenMessage.code); + errorScreenStore.setError(message.errorScreenMessage); + break; + } default: { // Security check: if we forget a "case", the line below will catch the error at compile-time. // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/front/src/Phaser/Login/EntryScene.ts b/front/src/Phaser/Login/EntryScene.ts index 41910f26..dce7216d 100644 --- a/front/src/Phaser/Login/EntryScene.ts +++ b/front/src/Phaser/Login/EntryScene.ts @@ -1,12 +1,12 @@ import { gameManager } from "../Game/GameManager"; import { Scene } from "phaser"; import { ErrorScene } from "../Reconnecting/ErrorScene"; -import { WAError } from "../Reconnecting/WAError"; import { waScaleManager } from "../Services/WaScaleManager"; import { ReconnectingTextures } from "../Reconnecting/ReconnectingScene"; -import LL from "../../i18n/i18n-svelte"; -import { get } from "svelte/store"; import { localeDetector } from "../../i18n/locales"; +import { errorScreenStore } from "../../Stores/ErrorScreenStore"; +import { isErrorApiData } from "../../Messages/JsonMessages/ErrorApiData"; +import { connectionManager } from "../../Connexion/ConnectionManager"; export const EntrySceneName = "EntryScene"; @@ -47,27 +47,13 @@ export class EntryScene extends Scene { this.scene.start(nextSceneName); }) .catch((err) => { - const $LL = get(LL); - if (err.response && err.response.status == 404) { - ErrorScene.showError( - new WAError( - $LL.error.accessLink.title(), - $LL.error.accessLink.subTitle(), - $LL.error.accessLink.details() - ), - this.scene - ); - } else if (err.response && err.response.status == 403) { - ErrorScene.showError( - new WAError( - $LL.error.connectionRejected.title(), - $LL.error.connectionRejected.subTitle({ - error: err.response.data ? ". \n\r \n\r" + `${err.response.data}` : "", - }), - $LL.error.connectionRejected.details() - ), - this.scene - ); + const errorType = isErrorApiData.safeParse(err?.response?.data); + if (errorType.success) { + if (errorType.data.type === "unauthorized") { + void connectionManager.logout(); + } else if (errorType.data.type === "redirect") { + window.location.assign(errorType.data.urlToRedirect); + } else errorScreenStore.setError(err?.response?.data); } else { ErrorScene.showError(err, this.scene); } diff --git a/front/src/Phaser/Reconnecting/ErrorScene.ts b/front/src/Phaser/Reconnecting/ErrorScene.ts index ea593c45..2e86983e 100644 --- a/front/src/Phaser/Reconnecting/ErrorScene.ts +++ b/front/src/Phaser/Reconnecting/ErrorScene.ts @@ -3,7 +3,6 @@ import Image = Phaser.GameObjects.Image; import Sprite = Phaser.GameObjects.Sprite; import Text = Phaser.GameObjects.Text; import ScenePlugin = Phaser.Scenes.ScenePlugin; -import { WAError } from "./WAError"; import Axios from "axios"; export const ErrorSceneName = "ErrorScene"; @@ -88,12 +87,6 @@ export class ErrorScene extends Phaser.Scene { title: "An error occurred", subTitle: error, }); - } else if (error instanceof WAError) { - scene.start(ErrorSceneName, { - title: error.title, - subTitle: error.subTitle, - message: error.details, - }); } else if (Axios.isAxiosError(error) && error.response) { // Axios HTTP error // client received an error response (5xx, 4xx) diff --git a/front/src/Phaser/Reconnecting/WAError.ts b/front/src/Phaser/Reconnecting/WAError.ts index abc71f6c..1481c54a 100644 --- a/front/src/Phaser/Reconnecting/WAError.ts +++ b/front/src/Phaser/Reconnecting/WAError.ts @@ -1,26 +1,65 @@ export class WAError extends Error { + private _type: string; + private _code: string; private _title: string; - private _subTitle: string; + private _subtitle: string; private _details: string; + private _timeToRetry: number; + private _canRetryManual: boolean; + private _urlToRedirect: string; + private _buttonTitle: string; - constructor(title: string, subTitle: string, details: string) { - super(title + " - " + subTitle + " - " + details); + constructor( + type: string, + code: string, + title: string, + subtitle: string, + details: string, + timeToRetry: number, + canRetryManual: boolean, + urlToRedirect: string, + buttonTitle: string + ) { + super(title + " - " + subtitle + " - " + details); + + this._type = type; + this._code = code; this._title = title; - this._subTitle = subTitle; + this._subtitle = subtitle; this._details = details; + this._timeToRetry = timeToRetry; + this._canRetryManual = canRetryManual; + this._urlToRedirect = urlToRedirect; + this._buttonTitle = buttonTitle; // Set the prototype explicitly. Object.setPrototypeOf(this, WAError.prototype); } + get type(): string { + return this._type; + } + get code(): string { + return this._code; + } get title(): string { return this._title; } - - get subTitle(): string { - return this._subTitle; + get subtitle(): string { + return this._subtitle; } - get details(): string { return this._details; } + get timeToRetry(): number { + return this._timeToRetry; + } + get buttonTitle(): string { + return this._buttonTitle; + } + get urlToRedirect(): string { + return this._urlToRedirect; + } + get canRetryManual(): boolean { + return this._canRetryManual; + } } diff --git a/front/src/Stores/ErrorScreenStore.ts b/front/src/Stores/ErrorScreenStore.ts new file mode 100644 index 00000000..564afb92 --- /dev/null +++ b/front/src/Stores/ErrorScreenStore.ts @@ -0,0 +1,18 @@ +import { writable } from "svelte/store"; +import { ErrorScreenMessage } from "../Messages/ts-proto-generated/protos/messages"; + +/** + * A store that contains one error of type WAError to be displayed. + */ +function createErrorScreenStore() { + const { subscribe, set } = writable(undefined); + + return { + subscribe, + setError: (e: ErrorScreenMessage): void => { + set(e); + }, + }; +} + +export const errorScreenStore = createErrorScreenStore(); diff --git a/messages/JsonMessages/ErrorApiData.ts b/messages/JsonMessages/ErrorApiData.ts new file mode 100644 index 00000000..bb029159 --- /dev/null +++ b/messages/JsonMessages/ErrorApiData.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +/* + * WARNING! The original file is in /messages/JsonMessages. + * All other files are automatically copied from this file on container startup / build + */ + +export const isErrorApiErrorData = z.object({ + // @ts-ignore + type: z.literal("error"), + code: z.string(), + title: z.string(), + subtitle: z.string(), + details: z.string(), + image: z.string(), +}); + +export const isErrorApiRetryData = z.object({ + type: z.literal("retry"), + code: z.string(), + title: z.string(), + subtitle: z.string(), + details: z.string(), + image: z.string(), + buttonTitle: z.optional(z.nullable(z.string())), + timeToRetry: z.number(), + canRetryManual: z.boolean(), +}); + +export const isErrorApiRedirectData = z.object({ + type: z.literal("redirect"), + urlToRedirect: z.string(), +}); + +export const isErrorApiUnauthorizedData = z.object({ + type: z.literal("unauthorized"), +}); + +export const isErrorApiData = z.discriminatedUnion("type", [ + isErrorApiErrorData, + isErrorApiRetryData, + isErrorApiRedirectData, + isErrorApiUnauthorizedData, +]); + +export type ErrorApiData = z.infer; diff --git a/messages/protos/messages.proto b/messages/protos/messages.proto index 8317d829..68099982 100644 --- a/messages/protos/messages.proto +++ b/messages/protos/messages.proto @@ -217,10 +217,29 @@ message UserLeftMessage { int32 userId = 1; } +/* + * ErrorMessage is only used to console.error the message in the front + */ message ErrorMessage { string message = 1; } +/* + * ErrorScreenMessage is used to show the ErrorScreen in the front + */ +message ErrorScreenMessage { + string type = 1; + google.protobuf.StringValue code = 2; + google.protobuf.StringValue title = 3; + google.protobuf.StringValue subtitle = 4; + google.protobuf.StringValue details = 5; + google.protobuf.Int32Value timeToRetry = 6; + google.protobuf.BoolValue canRetryManual = 7; + google.protobuf.StringValue urlToRedirect = 8; + google.protobuf.StringValue buttonTitle = 9; + google.protobuf.StringValue image = 10; +} + message ItemStateMessage { int32 itemId = 1; string stateJson = 2; @@ -332,6 +351,7 @@ message ServerToClientMessage { FollowAbortMessage followAbortMessage = 23; InvalidTextureMessage invalidTextureMessage = 24; GroupUsersUpdateMessage groupUsersUpdateMessage = 25; + ErrorScreenMessage errorScreenMessage = 26; } } diff --git a/pusher/package.json b/pusher/package.json index 22f6960e..cbd5375e 100644 --- a/pusher/package.json +++ b/pusher/package.json @@ -40,6 +40,7 @@ }, "homepage": "https://github.com/thecodingmachine/workadventure#readme", "dependencies": { + "@anatine/zod-openapi": "^1.3.0", "axios": "^0.21.2", "circular-json": "^0.5.9", "debug": "^4.3.1", @@ -48,6 +49,7 @@ "hyper-express": "^5.8.1", "jsonwebtoken": "^8.5.1", "mkdirp": "^1.0.4", + "openapi3-ts": "^2.0.2", "openid-client": "^4.7.4", "prom-client": "^12.0.0", "qs": "^6.10.3", diff --git a/pusher/src/Controller/AuthenticateController.ts b/pusher/src/Controller/AuthenticateController.ts index e965194f..62daef9a 100644 --- a/pusher/src/Controller/AuthenticateController.ts +++ b/pusher/src/Controller/AuthenticateController.ts @@ -1,6 +1,6 @@ import { v4 } from "uuid"; import { BaseHttpController } from "./BaseHttpController"; -import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; +import { FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { AuthTokenData, jwtTokenManager } from "../Services/JWTTokenManager"; import { parse } from "query-string"; import { openIDClient } from "../Services/OpenIDClient"; @@ -172,7 +172,8 @@ export class AuthenticateController extends BaseHttpController { authTokenData.identifier, playUri as string, IPAddress, - [] + [], + req.header("accept-language") ); if (authTokenData.accessToken == undefined) { @@ -224,7 +225,13 @@ export class AuthenticateController extends BaseHttpController { //Get user data from Admin Back Office //This is very important to create User Local in LocalStorage in WorkAdventure - const data = await adminService.fetchMemberDataByUuid(email, playUri as string, IPAddress, []); + const data = await adminService.fetchMemberDataByUuid( + email, + playUri as string, + IPAddress, + [], + req.header("accept-language") + ); return res.json({ ...data, authToken, username: userInfo?.username, locale: userInfo?.locale }); } catch (e) { @@ -327,13 +334,18 @@ export class AuthenticateController extends BaseHttpController { try { if (typeof organizationMemberToken != "string") throw new Error("No organization token"); - const data = await adminApi.fetchMemberDataByToken(organizationMemberToken, playUri); + const data = await adminService.fetchMemberDataByToken( + organizationMemberToken, + playUri, + req.header("accept-language") + ); const userUuid = data.userUuid; const email = data.email; const roomUrl = data.roomUrl; const mapUrlStart = data.mapUrlStart; const authToken = jwtTokenManager.createAuthToken(email || userUuid); + res.json({ authToken, userUuid, @@ -419,7 +431,7 @@ export class AuthenticateController extends BaseHttpController { //get login profile res.status(302); - res.setHeader("Location", adminApi.getProfileUrl(authTokenData.accessToken)); + res.setHeader("Location", adminService.getProfileUrl(authTokenData.accessToken)); res.send(""); return; } catch (error) { @@ -485,7 +497,8 @@ export class AuthenticateController extends BaseHttpController { * @param email * @param playUri * @param IPAddress - * @return FetchMemberDataByUuidResponse|object + * @return + |object * @private */ private async getUserByUserIdentifier( @@ -503,7 +516,7 @@ export class AuthenticateController extends BaseHttpController { userRoomToken: undefined, }; try { - data = await adminApi.fetchMemberDataByUuid(email, playUri, IPAddress, []); + data = await adminService.fetchMemberDataByUuid(email, playUri, IPAddress, []); } catch (err) { console.error("openIDCallback => fetchMemberDataByUuid", err); } diff --git a/pusher/src/Controller/BaseHttpController.ts b/pusher/src/Controller/BaseHttpController.ts index a15f7529..b48e1457 100644 --- a/pusher/src/Controller/BaseHttpController.ts +++ b/pusher/src/Controller/BaseHttpController.ts @@ -1,6 +1,7 @@ import { Server } from "hyper-express"; import Response from "hyper-express/types/components/http/Response"; import axios from "axios"; +import { isErrorApiData } from "../Messages/JsonMessages/ErrorApiData"; export class BaseHttpController { constructor(protected app: Server) { @@ -31,12 +32,15 @@ export class BaseHttpController { if (axios.isAxiosError(e) && e.response) { res.status(e.response.status); - res.send( - "An error occurred: " + - e.response.status + - " " + - (e.response.data && e.response.data.message ? e.response.data.message : e.response.statusText) - ); + const errorType = isErrorApiData.safeParse(e?.response?.data); + if (!errorType.success) { + res.send( + "An error occurred: " + + e.response.status + + " " + + (e.response.data && e.response.data.message ? e.response.data.message : e.response.statusText) + ); + } else res.json(errorType.data); return; } else { res.status(500); diff --git a/pusher/src/Controller/IoSocketController.ts b/pusher/src/Controller/IoSocketController.ts index 4c5137b1..304eb1e8 100644 --- a/pusher/src/Controller/IoSocketController.ts +++ b/pusher/src/Controller/IoSocketController.ts @@ -25,7 +25,7 @@ import { import { UserMovesMessage } from "../Messages/generated/messages_pb"; import { parse } from "query-string"; import { AdminSocketTokenData, jwtTokenManager, tokenInvalidException } from "../Services/JWTTokenManager"; -import { adminApi, FetchMemberDataByUuidResponse } from "../Services/AdminApi"; +import { FetchMemberDataByUuidResponse } from "../Services/AdminApi"; import { socketManager } from "../Services/SocketManager"; import { emitInBatch } from "../Services/IoSocketHelpers"; import { ADMIN_API_URL, ADMIN_SOCKETS_TOKEN, DISABLE_ANONYMOUS, SOCKET_IDLE_TIMER } from "../Enum/EnvironmentVariable"; @@ -39,6 +39,8 @@ import { localWokaService } from "../Services/LocalWokaService"; import { WebSocket } from "uWebSockets.js"; import { WokaDetail } from "../Messages/JsonMessages/PlayerTextures"; import { z } from "zod"; +import { adminService } from "../Services/AdminService"; +import { ErrorApiData, isErrorApiData } from "../Messages/JsonMessages/ErrorApiData"; /** * The object passed between the "open" and the "upgrade" methods when opening a websocket @@ -66,13 +68,21 @@ interface UpgradeData { }; } -interface UpgradeFailedData { +interface UpgradeFailedInvalidData { rejected: true; reason: "tokenInvalid" | "textureInvalid" | null; message: string; roomId: string; } +interface UpgradeFailedErrorData { + rejected: true; + reason: "error"; + error: ErrorApiData; +} + +type UpgradeFailedData = UpgradeFailedErrorData | UpgradeFailedInvalidData; + export class IoSocketController { private nextUserId: number = 1; @@ -234,6 +244,7 @@ export class IoSocketController { const websocketProtocol = req.getHeader("sec-websocket-protocol"); const websocketExtensions = req.getHeader("sec-websocket-extensions"); const IPAddress = req.getHeader("x-forwarded-for"); + const locale = req.getHeader("accept-language"); const roomId = query.roomId; try { @@ -302,41 +313,46 @@ export class IoSocketController { if (ADMIN_API_URL) { try { try { - userData = await adminApi.fetchMemberDataByUuid( + userData = await adminService.fetchMemberDataByUuid( userIdentifier, roomId, IPAddress, - characterLayers + characterLayers, + locale ); } catch (err) { if (Axios.isAxiosError(err)) { - if (err?.response?.status == 404) { - // If we get an HTTP 404, the token is invalid. Let's perform an anonymous login! - - console.warn( - 'Cannot find user with email "' + - (userIdentifier || "anonymous") + - '". Performing an anonymous login instead.' - ); - } else if (err?.response?.status == 403) { - // If we get an HTTP 403, the world is full. We need to broadcast a special error to the client. - // we finish immediately the upgrade then we will close the socket as soon as it starts opening. + const errorType = isErrorApiData.safeParse(err?.response?.data); + if (errorType.success) { return res.upgrade( { rejected: true, - message: err?.response?.data.message, + reason: "error", status: err?.response?.status, - roomId, - }, + error: errorType.data, + } as UpgradeFailedData, + websocketKey, + websocketProtocol, + websocketExtensions, + context + ); + } else { + return res.upgrade( + { + rejected: true, + reason: null, + status: 500, + message: err?.response?.data, + roomId: roomId, + } as UpgradeFailedData, websocketKey, websocketProtocol, websocketExtensions, context ); } - } else { - throw err; } + throw err; } memberMessages = userData.messages; memberTags = userData.tags; @@ -480,8 +496,8 @@ export class IoSocketController { socketManager.emitTokenExpiredMessage(ws); } else if (ws.reason === "textureInvalid") { socketManager.emitInvalidTextureMessage(ws); - } else if (ws.message === "World is full") { - socketManager.emitWorldFullMessage(ws); + } else if (ws.reason === "error") { + socketManager.emitErrorScreenMessage(ws, ws.error); } else { socketManager.emitConnexionErrorMessage(ws, ws.message); } diff --git a/pusher/src/Controller/MapController.ts b/pusher/src/Controller/MapController.ts index 19131a24..0dd98b6a 100644 --- a/pusher/src/Controller/MapController.ts +++ b/pusher/src/Controller/MapController.ts @@ -1,11 +1,8 @@ -import { adminApi } from "../Services/AdminApi"; -import { ADMIN_API_URL, DISABLE_ANONYMOUS } from "../Enum/EnvironmentVariable"; -import { GameRoomPolicyTypes } from "../Model/PusherRoom"; -import { isMapDetailsData, MapDetailsData } from "../Messages/JsonMessages/MapDetailsData"; -import { AuthTokenData, jwtTokenManager } from "../Services/JWTTokenManager"; -import { InvalidTokenError } from "./InvalidTokenError"; +import { DISABLE_ANONYMOUS } from "../Enum/EnvironmentVariable"; +import { isMapDetailsData } from "../Messages/JsonMessages/MapDetailsData"; import { parse } from "query-string"; import { BaseHttpController } from "./BaseHttpController"; +import { adminService } from "../Services/AdminService"; export class MapController extends BaseHttpController { // Returns a map mapping map name to file name of the map @@ -107,66 +104,17 @@ export class MapController extends BaseHttpController { return; } - // If no admin URL is set, let's react on '/_/[instance]/[map url]' URLs - if (!ADMIN_API_URL) { - const roomUrl = new URL(query.playUri); - - const match = /\/_\/[^/]+\/(.+)/.exec(roomUrl.pathname); - if (!match) { - res.status(404); - res.json({}); - return; - } - - const mapUrl = roomUrl.protocol + "//" + match[1]; - - res.json({ - mapUrl, - policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY, - roomSlug: null, // Deprecated - group: null, - tags: [], - contactPage: null, - authenticationMandatory: DISABLE_ANONYMOUS, - } as MapDetailsData); - - return; - } - (async () => { try { - let userId: string | undefined = undefined; - if (query.authToken != undefined) { - let authTokenData: AuthTokenData; - try { - authTokenData = jwtTokenManager.verifyJWTToken(query.authToken as string); - userId = authTokenData.identifier; - } catch (e) { - try { - // Decode token, in this case we don't need to create new token. - authTokenData = jwtTokenManager.verifyJWTToken(query.authToken as string, true); - userId = authTokenData.identifier; - console.info("JWT expire, but decoded", userId); - } catch (e) { - if (e instanceof InvalidTokenError) { - // The token was not good, redirect user on login page - res.status(401); - res.send("Token decrypted error"); - return; - } else { - this.castErrorToResponse(e, res); - return; - } - } - } - } const mapDetails = isMapDetailsData.parse( - await adminApi.fetchMapDetails(query.playUri as string, userId) + await adminService.fetchMapDetails( + query.playUri as string, + query.authToken as string, + req.header("accept-language") + ) ); - if (DISABLE_ANONYMOUS) { - mapDetails.authenticationMandatory = true; - } + if (DISABLE_ANONYMOUS) mapDetails.authenticationMandatory = true; res.json(mapDetails); return; diff --git a/pusher/src/Services/AdminApi.ts b/pusher/src/Services/AdminApi.ts index 5e7fff37..caefd8e2 100644 --- a/pusher/src/Services/AdminApi.ts +++ b/pusher/src/Services/AdminApi.ts @@ -7,6 +7,8 @@ import { z } from "zod"; import { isWokaDetail } from "../Messages/JsonMessages/PlayerTextures"; import qs from "qs"; import { AdminInterface } from "./AdminInterface"; +import { AuthTokenData, jwtTokenManager } from "./JWTTokenManager"; +import { InvalidTokenError } from "../Controller/InvalidTokenError"; export interface AdminBannedData { is_banned: boolean; @@ -27,14 +29,37 @@ export const isFetchMemberDataByUuidResponse = z.object({ export type FetchMemberDataByUuidResponse = z.infer; class AdminApi implements AdminInterface { - /** - * @var playUri: is url of the room - * @var userId: can to be undefined or email or uuid - * @return MapDetailsData|RoomRedirect - */ - async fetchMapDetails(playUri: string, userId?: string): Promise { - if (!ADMIN_API_URL) { - return Promise.reject(new Error("No admin backoffice set!")); + async fetchMapDetails( + playUri: string, + authToken?: string, + locale?: string + ): Promise { + let userId: string | undefined = undefined; + if (authToken != undefined) { + let authTokenData: AuthTokenData; + try { + authTokenData = jwtTokenManager.verifyJWTToken(authToken); + userId = authTokenData.identifier; + } catch (e) { + try { + // Decode token, in this case we don't need to create new token. + authTokenData = jwtTokenManager.verifyJWTToken(authToken, true); + userId = authTokenData.identifier; + console.info("JWT expire, but decoded", userId); + } catch (e) { + if (e instanceof InvalidTokenError) { + throw new Error("Token decrypted error"); + // The token was not good, redirect user on login page + //res.status(401); + //res.send("Token decrypted error"); + //return; + } else { + throw new Error("Error on decryption of token :" + e); + //this.castErrorToResponse(e, res); + //return; + } + } + } } const params: { playUri: string; userId?: string } = { @@ -43,7 +68,7 @@ class AdminApi implements AdminInterface { }; const res = await Axios.get>(ADMIN_API_URL + "/api/map", { - headers: { Authorization: `${ADMIN_API_TOKEN}` }, + headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" }, params, }); @@ -69,11 +94,9 @@ class AdminApi implements AdminInterface { userIdentifier: string, playUri: string, ipAddress: string, - characterLayers: string[] + characterLayers: string[], + locale?: string ): Promise { - if (!ADMIN_API_URL) { - return Promise.reject(new Error("No admin backoffice set!")); - } const res = await Axios.get>(ADMIN_API_URL + "/api/room/access", { params: { userIdentifier, @@ -81,7 +104,7 @@ class AdminApi implements AdminInterface { ipAddress, characterLayers, }, - headers: { Authorization: `${ADMIN_API_TOKEN}` }, + headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" }, paramsSerializer: (p) => { return qs.stringify(p, { arrayFormat: "brackets" }); }, @@ -100,14 +123,15 @@ class AdminApi implements AdminInterface { ); } - async fetchMemberDataByToken(organizationMemberToken: string, playUri: string | null): Promise { - if (!ADMIN_API_URL) { - return Promise.reject(new Error("No admin backoffice set!")); - } + async fetchMemberDataByToken( + organizationMemberToken: string, + playUri: string | null, + locale?: string + ): Promise { //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. const res = await Axios.get(ADMIN_API_URL + "/api/login-url/" + organizationMemberToken, { params: { playUri }, - headers: { Authorization: `${ADMIN_API_TOKEN}` }, + headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" }, }); const adminApiData = isAdminApiData.safeParse(res.data); @@ -125,11 +149,9 @@ class AdminApi implements AdminInterface { reportedUserUuid: string, reportedUserComment: string, reporterUserUuid: string, - reportWorldSlug: string + reportWorldSlug: string, + locale?: string ) { - if (!ADMIN_API_URL) { - return Promise.reject(new Error("No admin backoffice set!")); - } return Axios.post( `${ADMIN_API_URL}/api/report`, { @@ -139,15 +161,17 @@ class AdminApi implements AdminInterface { reportWorldSlug, }, { - headers: { Authorization: `${ADMIN_API_TOKEN}` }, + headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" }, } ); } - async verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string): Promise { - if (!ADMIN_API_URL) { - return Promise.reject(new Error("No admin backoffice set!")); - } + async verifyBanUser( + userUuid: string, + ipAddress: string, + roomUrl: string, + locale?: string + ): Promise { //todo: this call can fail if the corresponding world is not activated or if the token is invalid. Handle that case. return Axios.get( ADMIN_API_URL + @@ -158,28 +182,20 @@ class AdminApi implements AdminInterface { encodeURIComponent(userUuid) + "&roomUrl=" + encodeURIComponent(roomUrl), - { headers: { Authorization: `${ADMIN_API_TOKEN}` } } + { headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" } } ).then((data) => { return data.data; }); } - async getUrlRoomsFromSameWorld(roomUrl: string): Promise { - if (!ADMIN_API_URL) { - return Promise.reject(new Error("No admin backoffice set!")); - } - + async getUrlRoomsFromSameWorld(roomUrl: string, locale?: string): Promise { return Axios.get(ADMIN_API_URL + "/api/room/sameWorld" + "?roomUrl=" + encodeURIComponent(roomUrl), { - headers: { Authorization: `${ADMIN_API_TOKEN}` }, + headers: { Authorization: `${ADMIN_API_TOKEN}`, "Accept-Language": locale ?? "en" }, }).then((data) => { return data.data; }); } - /** - * - * @param accessToken - */ getProfileUrl(accessToken: string): string { if (!OPID_PROFILE_SCREEN_PROVIDER) { throw new Error("No admin backoffice set!"); @@ -187,7 +203,7 @@ class AdminApi implements AdminInterface { return `${OPID_PROFILE_SCREEN_PROVIDER}?accessToken=${accessToken}`; } - async logoutOauth(token: string) { + async logoutOauth(token: string): Promise { await Axios.get(ADMIN_API_URL + `/oauth/logout?token=${token}`); } } diff --git a/pusher/src/Services/AdminInterface.ts b/pusher/src/Services/AdminInterface.ts index 66121316..e70a653a 100644 --- a/pusher/src/Services/AdminInterface.ts +++ b/pusher/src/Services/AdminInterface.ts @@ -1,10 +1,82 @@ -import { FetchMemberDataByUuidResponse } from "./AdminApi"; +import { AdminBannedData, FetchMemberDataByUuidResponse } from "./AdminApi"; +import { MapDetailsData } from "../Messages/JsonMessages/MapDetailsData"; +import { RoomRedirect } from "../Messages/JsonMessages/RoomRedirect"; +import { AdminApiData } from "../Messages/JsonMessages/AdminApiData"; export interface AdminInterface { + /** + * @var playUri: is url of the room + * @var userIdentifier: can to be undefined or email or uuid + * @var ipAddress + * @var characterLayers + * @return MapDetailsData|RoomRedirect + */ fetchMemberDataByUuid( userIdentifier: string, playUri: string, ipAddress: string, - characterLayers: string[] + characterLayers: string[], + locale?: string ): Promise; + + /** + * @var playUri: is url of the room + * @var userId: can to be undefined or email or uuid + * @return MapDetailsData|RoomRedirect + */ + fetchMapDetails(playUri: string, authToken?: string, locale?: string): Promise; + + /** + * @param locale + * @param organizationMemberToken + * @param playUri + * @return AdminApiData + */ + fetchMemberDataByToken( + organizationMemberToken: string, + playUri: string | null, + locale?: string + ): Promise; + + /** + * @param locale + * @param reportedUserUuid + * @param reportedUserComment + * @param reporterUserUuid + * @param reportWorldSlug + */ + reportPlayer( + reportedUserUuid: string, + reportedUserComment: string, + reporterUserUuid: string, + reportWorldSlug: string, + locale?: string + ): Promise; + + /** + * @param locale + * @param userUuid + * @param ipAddress + * @param roomUrl + * @return AdminBannedData + */ + verifyBanUser(userUuid: string, ipAddress: string, roomUrl: string, locale?: string): Promise; + + /** + * @param locale + * @param roomUrl + * @return string[] + */ + getUrlRoomsFromSameWorld(roomUrl: string, locale?: string): Promise; + + /** + * @param accessToken + * @return string + */ + getProfileUrl(accessToken: string): string; + + /** + * @param token + */ + logoutOauth(token: string): Promise; } diff --git a/pusher/src/Services/LocalAdmin.ts b/pusher/src/Services/LocalAdmin.ts index 4e2c41a0..d4c8bcba 100644 --- a/pusher/src/Services/LocalAdmin.ts +++ b/pusher/src/Services/LocalAdmin.ts @@ -1,5 +1,10 @@ -import { FetchMemberDataByUuidResponse } from "./AdminApi"; +import { AdminBannedData, FetchMemberDataByUuidResponse } from "./AdminApi"; import { AdminInterface } from "./AdminInterface"; +import { MapDetailsData } from "../Messages/JsonMessages/MapDetailsData"; +import { RoomRedirect } from "../Messages/JsonMessages/RoomRedirect"; +import { GameRoomPolicyTypes } from "../Model/PusherRoom"; +import { DISABLE_ANONYMOUS } from "../Enum/EnvironmentVariable"; +import { AdminApiData } from "../Messages/JsonMessages/AdminApiData"; /** * A local class mocking a real admin if no admin is configured. @@ -12,7 +17,9 @@ class LocalAdmin implements AdminInterface { // eslint-disable-next-line @typescript-eslint/no-unused-vars ipAddress: string, // eslint-disable-next-line @typescript-eslint/no-unused-vars - characterLayers: string[] + characterLayers: string[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + locale?: string ): Promise { return Promise.resolve({ email: userIdentifier, @@ -24,6 +31,99 @@ class LocalAdmin implements AdminInterface { userRoomToken: undefined, }); } + + fetchMapDetails( + playUri: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + authToken?: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + locale?: string + ): Promise { + const roomUrl = new URL(playUri); + + const match = /\/_\/[^/]+\/(.+)/.exec(roomUrl.pathname); + if (!match) { + throw new Error("URL format is not good"); + } + + const mapUrl = roomUrl.protocol + "//" + match[1]; + + return Promise.resolve({ + mapUrl, + policy_type: GameRoomPolicyTypes.ANONYMOUS_POLICY, + tags: [], + authenticationMandatory: DISABLE_ANONYMOUS, + roomSlug: null, + contactPage: null, + group: null, + iframeAuthentication: null, + loadingLogo: null, + loginSceneLogo: null, + }); + } + + async fetchMemberDataByToken( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + organizationMemberToken: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + playUri: string | null, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + locale?: string + ): Promise { + return Promise.reject(new Error("No admin backoffice set!")); + } + + reportPlayer( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reportedUserUuid: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reportedUserComment: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reporterUserUuid: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + reportWorldSlug: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + locale?: string + ) { + return Promise.reject(new Error("No admin backoffice set!")); + } + + async verifyBanUser( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + userUuid: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + ipAddress: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + roomUrl: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + locale?: string + ): Promise { + return Promise.reject(new Error("No admin backoffice set!")); + } + + async getUrlRoomsFromSameWorld( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + roomUrl: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + locale?: string + ): Promise { + return Promise.reject(new Error("No admin backoffice set!")); + } + + getProfileUrl( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + accessToken: string + ): string { + new Error("No admin backoffice set!"); + return ""; + } + + async logoutOauth( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + token: string + ): Promise { + return Promise.reject(new Error("No admin backoffice set!")); + } } export const localAdmin = new LocalAdmin(); diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 8b1d2216..08afedac 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -39,10 +39,10 @@ import { PlayerDetailsUpdatedMessage, LockGroupPromptMessage, InvalidTextureMessage, + ErrorScreenMessage, } from "../Messages/generated/messages_pb"; import { ProtobufUtils } from "../Model/Websocket/ProtobufUtils"; import { ADMIN_API_URL, JITSI_ISS, JITSI_URL, SECRET_JITSI_KEY } from "../Enum/EnvironmentVariable"; -import { adminApi } from "./AdminApi"; import { emitInBatch } from "./IoSocketHelpers"; import Jwt from "jsonwebtoken"; import { clientEventsEmitter } from "./ClientEventsEmitter"; @@ -53,6 +53,9 @@ import Debug from "debug"; import { ExAdminSocketInterface } from "../Model/Websocket/ExAdminSocketInterface"; import { compressors } from "hyper-express"; import { isMapDetailsData } from "../Messages/JsonMessages/MapDetailsData"; +import { adminService } from "./AdminService"; +import { ErrorApiData } from "../Messages/JsonMessages/ErrorApiData"; +import { BoolValue, Int32Value, StringValue } from "google-protobuf/google/protobuf/wrappers_pb"; const debug = Debug("socket"); @@ -375,7 +378,8 @@ export class SocketManager implements ZoneEventListener { async handleReportMessage(client: ExSocketInterface, reportPlayerMessage: ReportPlayerMessage) { try { - await adminApi.reportPlayer( + await adminService.reportPlayer( + "en", reportPlayerMessage.getReporteduseruuid(), reportPlayerMessage.getReportcomment(), client.userUuid, @@ -461,7 +465,7 @@ export class SocketManager implements ZoneEventListener { } public async updateRoomWithAdminData(room: PusherRoom): Promise { - const data = await adminApi.fetchMapDetails(room.roomUrl); + const data = await adminService.fetchMapDetails(room.roomUrl); const mapDetailsData = isMapDetailsData.safeParse(data); if (mapDetailsData.success) { @@ -661,6 +665,34 @@ export class SocketManager implements ZoneEventListener { client.send(serverToClientMessage.serializeBinary().buffer, true); } + public emitErrorScreenMessage(client: compressors.WebSocket, errorApi: ErrorApiData) { + const errorMessage = new ErrorScreenMessage(); + errorMessage.setType(errorApi.type); + if (errorApi.type == "retry" || errorApi.type == "error") { + errorMessage.setCode(new StringValue().setValue(errorApi.code)); + errorMessage.setTitle(new StringValue().setValue(errorApi.title)); + errorMessage.setSubtitle(new StringValue().setValue(errorApi.subtitle)); + errorMessage.setDetails(new StringValue().setValue(errorApi.details)); + errorMessage.setImage(new StringValue().setValue(errorApi.image)); + } + if (errorApi.type == "retry") { + if (errorApi.buttonTitle) errorMessage.setButtontitle(new StringValue().setValue(errorApi.buttonTitle)); + if (errorApi.canRetryManual !== undefined) + errorMessage.setCanretrymanual(new BoolValue().setValue(errorApi.canRetryManual)); + if (errorApi.timeToRetry) + errorMessage.setTimetoretry(new Int32Value().setValue(Number(errorApi.timeToRetry))); + } + if (errorApi.type == "redirect" && errorApi.urlToRedirect) + errorMessage.setUrltoredirect(new StringValue().setValue(errorApi.urlToRedirect)); + + const serverToClientMessage = new ServerToClientMessage(); + serverToClientMessage.setErrorscreenmessage(errorMessage); + + //if (!client.disconnecting) { + client.send(serverToClientMessage.serializeBinary().buffer, true); + //} + } + private refreshRoomData(roomId: string, versionNumber: number): void { const room = this.rooms.get(roomId); //this function is run for every users connected to the room, so we need to make sure the room wasn't already refreshed. @@ -688,7 +720,7 @@ export class SocketManager implements ZoneEventListener { let tabUrlRooms: string[]; if (playGlobalMessageEvent.getBroadcasttoworld()) { - tabUrlRooms = await adminApi.getUrlRoomsFromSameWorld(clientRoomUrl); + tabUrlRooms = await adminService.getUrlRoomsFromSameWorld("en", clientRoomUrl); } else { tabUrlRooms = [clientRoomUrl]; }