diff --git a/docs/dev/adminAPI.md b/docs/dev/adminAPI.md new file mode 100644 index 00000000..aae1e1fb --- /dev/null +++ b/docs/dev/adminAPI.md @@ -0,0 +1,85 @@ +# Implement your own admin API +## Why +Create your own Admin API and connect it to WorkAdventure if: + +- you want to connect WorkAdventure to your own database +- you want some of your users to have special privileges (tags) +- you want to create rooms dynamically +- you want to have "pretty URLs" for your rooms + +{.alert.alert-warning} +Be aware that WorkAdventure is licensed under "AGPL-3 restricted by the commons clause". It means in particular you are not allowed to sell a version of WorkAdventure online as a service. If in doubt, please contact us at hello@workadventu.re. We can offer special licenses depending on your use-case. + +## Architecture +```mermaid +graph LR + style API stroke:#ff475a,stroke-width:2px,stroke: 5 5 + subgraph WorkAdventure + F(Front) <--> P("Pusher") --> B(Back) + end + subgraph YW["Your website"] + API("Admin API") + P ---> API + end +``` + +First you need to understand how Work Adventure architecture is made. +WorkAdventure is divided in 3 sections : +- **Front**
+ The front is the visible part of Work Adventure, the one that serves the game. +- **Pusher**
+ The pusher is the one that centralizes the connections and makes exchanges through WebSocket tunnels with the clients of the Front. + In addition, he speaks with the Back and the admin API if it's determinate. +- **Back**
+ The back is the service that allows all metrics, movements, bubbles to persist. + +Finally, the Admin API is the part where the members are managed. This part is fully optional. +If you are reading this documentation this is surely because you want to implement your own admin API. + +## Principles +**Important!** It is not your site that will call the pusher but the reverse.
+The pusher will directly ask your admin API for the information and authorizations it needs. + +```mermaid +sequenceDiagram + participant F as Front + participant P as Pusher + participant API as Admin API + autonumber + F ->>+ P: /map + P ->>+ API: /api/map + API -->>- P: MapDetailsData + P -->>- F: MapDetailsData + F ->>+ P: /room/access + P ->>+ API: /api/room/access + API -->>- P: FetchMemberDataByUuidResponse + P -->>- F: FetchMemberDataByUuidResponse + F ->>+ P: /woka/list + P ->>+ API: /api/woka/list + API -->>- P: WokaList + P -->>- F: WokaList +``` + +The most important endpoints are: +- `/api/map`
+ _On the sequence diagram this is the call n°2._
+ This end point maps the URL of the map to the map info (in particular the URL to the Tiled JSON file. + It will process the playURI and the uuid to return the information of the map if the user can access it.
+ In case of success, this endpoint returns a `MapDetailsData` object. +- `/api/room/access`
+ _On the sequence diagram this is the call n°6._
+ This end point returns the member's information if he can access this room.
+ In case of success, this endpoint returns a `FetchMemberDataByUuidResponse` object. +- `/api/woka/list`
+ _On the sequence diagram this is the call n°10._
+ This end point returns a list of all the woka from the world specified.
+ In case of success, this endpoint returns a `WokaList` object. + +## What to do +1. You will need to implement, in your website, all the URLs that are listed in this swagger documentation : [WA Pusher](https://pusher.workadventu.re/swagger-ui/). +2. In the `.env` file : + * Set the URL of your admin API, set the environment variable : + `ADMIN_API_URL=http://example.com` + * Set the token of the API to check if each request is authenticated by this token : + `ADMIN_API_TOKEN=myapitoken` + If the call is not correctly authenticated by the Bearer token in the header, make sure to answer with a 403 response. diff --git a/front/src/Connexion/ConnectionManager.ts b/front/src/Connexion/ConnectionManager.ts index ba182ad7..ab7ed177 100644 --- a/front/src/Connexion/ConnectionManager.ts +++ b/front/src/Connexion/ConnectionManager.ts @@ -19,6 +19,7 @@ import { gameManager } from "../Phaser/Game/GameManager"; import { locales } from "../i18n/i18n-util"; import type { Locales } from "../i18n/i18n-types"; import { setCurrentLocale } from "../i18n/locales"; +import { isErrorApiData } from "../Messages/JsonMessages/ErrorApiData"; import { AvailabilityStatus } from "../Messages/ts-proto-generated/protos/messages"; class ConnectionManager { @@ -125,6 +126,12 @@ class ConnectionManager { await this.checkAuthUserConnexion(); analyticsClient.loggedWithSso(); } catch (err) { + if (Axios.isAxiosError(err)) { + const errorType = isErrorApiData.safeParse(err?.response?.data); + if (errorType.success) { + throw err; + } + } console.error(err); const redirect = this.loadOpenIDScreen(); if (redirect === null) { diff --git a/messages/JsonMessages/PlayerTextures.ts b/messages/JsonMessages/PlayerTextures.ts index f5d218d3..48bee8f3 100644 --- a/messages/JsonMessages/PlayerTextures.ts +++ b/messages/JsonMessages/PlayerTextures.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { extendApi } from "@anatine/zod-openapi"; /* * WARNING! The original file is in /messages/JsonMessages. @@ -7,18 +8,27 @@ import { z } from "zod"; //The list of all the player textures, both the default models and the partial textures used for customization -const wokaTexture = z.object({ - id: z.string(), - name: z.string(), - url: z.string(), - tags: z.array(z.string()).optional(), - tintable: z.boolean().optional(), +export const wokaTexture = z.object({ + id: extendApi(z.string(), { + description: "A unique identifier for this texture.", + example: "03395306-5dee-4b16-a034-36f2c5f2324a", + }), + name: extendApi(z.string(), { description: "The name of the texture.", example: "Hair 1" }), + url: extendApi(z.string(), { + description: "The URL of the image of the texture.", + example: "http://example.com/resources/customisation/character_hairs/character_hairs1.png", + }), + tags: extendApi(z.array(z.string()).optional(), { deprecated: true }), + tintable: extendApi(z.boolean().optional(), { + description: "Whether the color is customizable or not. Not used yet.", + example: true, + }), }); export type WokaTexture = z.infer; const wokaTextureCollection = z.object({ - name: z.string(), + name: extendApi(z.string(), { description: "Name of the collection", example: "Hair" }), textures: z.array(wokaTexture), }); @@ -38,9 +48,15 @@ export type WokaList = z.infer; export const wokaPartNames = ["woka", "body", "eyes", "hair", "clothes", "hat", "accessory"]; export const isWokaDetail = z.object({ - id: z.string(), - url: z.optional(z.string()), - layer: z.optional(z.string()), + id: extendApi(z.string(), { + description: "The unique identifier of the Woka.", + example: "03395306-5dee-4b16-a034-36f2c5f2324a", + }), + url: extendApi(z.optional(z.string()), { + description: "The URL of the image of the woka.", + example: "http://example.com/resources/characters/pipoya/male.png", + }), + layer: extendApi(z.optional(z.string()), { description: "The layer of where the woka will be rendered." }), }); export type WokaDetail = z.infer; diff --git a/pusher/src/Controller/AuthenticateController.ts b/pusher/src/Controller/AuthenticateController.ts index 6eee2e5d..0ca3af18 100644 --- a/pusher/src/Controller/AuthenticateController.ts +++ b/pusher/src/Controller/AuthenticateController.ts @@ -7,6 +7,8 @@ import { openIDClient } from "../Services/OpenIDClient"; import { DISABLE_ANONYMOUS } from "../Enum/EnvironmentVariable"; import { RegisterData } from "../Messages/JsonMessages/RegisterData"; import { adminService } from "../Services/AdminService"; +import Axios from "axios"; +import { isErrorApiData } from "../Messages/JsonMessages/ErrorApiData"; export interface TokenInterface { userUuid: string; @@ -197,6 +199,13 @@ export class AuthenticateController extends BaseHttpController { locale: authTokenData?.locale, }); } catch (err) { + if (Axios.isAxiosError(err)) { + const errorType = isErrorApiData.safeParse(err?.response?.data); + if (errorType.success) { + res.sendStatus(err?.response?.status ?? 500); + return res.json(errorType.data); + } + } console.info("User was not connected", err); } } diff --git a/pusher/src/Controller/SwaggerController.ts b/pusher/src/Controller/SwaggerController.ts index e1e50ef3..7f8cc46a 100644 --- a/pusher/src/Controller/SwaggerController.ts +++ b/pusher/src/Controller/SwaggerController.ts @@ -52,13 +52,166 @@ export class SwaggerController extends BaseHttpController { in: "header", }, }, - ...SwaggerGenerator.definitions(), + ...SwaggerGenerator.definitions(null), }, apis: ["./src/Services/*.ts"], }; res.json(swaggerJsdoc(options)); }); + this.app.get("/openapi/external-admin", (req, res) => { + // Let's load the module dynamically (it may not exist in prod because part of the -dev packages) + const options = { + swagger: "2.0", + //openapi: "3.0.0", + info: { + title: "WorkAdventure External Admin", + version: "1.0.0", + description: + "This is a documentation about the external endpoints called by the pusher (aka the Admin API). \n Those endpoints should be implemented by the Admin API. The pusher will access those endpoints (just like webhooks). You can find out more about WorkAdventure and the Admin API on [GitHub](https://github.com/thecodingmachine/workadventure/blob/develop/docs/dev/adminAPI.md).", + contact: { + email: "hello@workadventu.re", + }, + }, + tags: [ + { + name: "ExternalAdminAPI", + description: "Access to end points of the external admin from the pusher", + }, + ], + securityDefinitions: { + Header: { + type: "apiKey", + name: "Authorization", + in: "header", + }, + }, + ...SwaggerGenerator.definitions("external"), + paths: { + "/api/mapinformation": { + get: { + security: [ + { + Header: [], + }, + ], + tags: ["ExternalAdminAPI"], + parameters: [ + { + name: "playUri", + in: "query", + description: "The full URL of WorkAdventure", + required: true, + type: "string", + example: "http://example.com/@/teamSlug/worldSLug/roomSlug", + }, + ], + responses: { + 200: { + description: "The details of the map", + schema: { + $ref: "#/definitions/MapDetailsData", + }, + }, + 401: { + description: "Error while retrieving the data because you are not authorized", + schema: { + $ref: "#/definitions/ErrorApiUnauthorizedData", + }, + }, + }, + }, + }, + "/api/roomaccess": { + get: { + security: [ + { + Header: [], + }, + ], + tags: ["ExternalAdminAPI"], + parameters: [ + { + name: "playUri", + in: "query", + description: "The full URL of WorkAdventure", + required: true, + type: "string", + example: "http://example.com/@/teamSlug/worldSLug/roomSlug", + }, + { + name: "ipAddress", + in: "query", + description: + "IP Address of the user logged in, allows you to check whether a user has been banned or not", + required: true, + type: "string", + example: "127.0.0.1", + }, + { + name: "userIdentifier", + in: "query", + description: + "The identifier of the current user \n It can be null or an uuid or an email", + type: "string", + example: "998ce839-3dea-4698-8b41-ebbdf7688ad9", + }, + ], + responses: { + 200: { + description: "The details of the member if he can access this room", + schema: { + $ref: "#/definitions/FetchMemberDataByUuidResponse", + }, + }, + 401: { + description: "Error while retrieving the data because you are not authorized", + schema: { + $ref: "#/definitions/ErrorApiUnauthorizedData", + }, + }, + }, + }, + }, + "/api/loginurl/{organizationMemberToken}": { + get: { + security: [ + { + Header: [], + }, + ], + description: "Returns a member from the token", + tags: ["ExternalAdminAPI"], + parameters: [ + { + name: "organizationMemberToken", + in: "path", + description: "The token of member in the organization", + required: true, + type: "string", + }, + ], + responses: { + 200: { + description: "The details of the member", + schema: { + $ref: "#/definitions/AdminApiData", + }, + }, + 401: { + description: "Error while retrieving the data because you are not authorized", + schema: { + $ref: "#/definitions/ErrorApiUnauthorizedData", + }, + }, + }, + }, + }, + }, + }; + res.json(options); + }); + // Create a LiveDirectory instance to virtualize directory with our assets // @ts-ignore const LiveDirectory = require("live-directory"); @@ -80,13 +233,14 @@ export class SwaggerController extends BaseHttpController { } const urls = [ - { url: "/openapi/pusher", name: "Front -> Pusher" }, - { url: "/openapi/admin", name: "Pusher <- Admin" }, + { url: "/openapi/pusher", name: "Front <- Pusher" }, + { url: "/openapi/admin", name: "Pusher -> Admin" }, + { url: "/openapi/external-admin", name: "Admin -> External Admin" }, ]; const result = data.replace( /url: "https:\/\/petstore\.swagger\.io\/v2\/swagger.json"/g, - `urls: ${JSON.stringify(urls)}, "urls.primaryName": "Pusher <- Admin"` + `urls: ${JSON.stringify(urls)}, "urls.primaryName": "Admin -> External Admin"` ); response.send(result); diff --git a/pusher/src/Services/AdminApi.ts b/pusher/src/Services/AdminApi.ts index 6fbf0a8f..86f4fa56 100644 --- a/pusher/src/Services/AdminApi.ts +++ b/pusher/src/Services/AdminApi.ts @@ -34,11 +34,17 @@ export const isFetchMemberDataByUuidResponse = z.object({ description: "URL of the visitCard of the user fetched.", example: "https://mycompany.com/contact/me", }), - textures: extendApi(z.array(isWokaDetail), { $ref: "#/definitions/WokaDetail" }), - messages: extendApi(z.array(z.unknown()), { description: "List of user's messages." }), + textures: extendApi(z.array(isWokaDetail), { + description: "This data represents the textures (WOKA) that will be available to users.", + $ref: "#/definitions/WokaDetail", + }), + messages: extendApi(z.array(z.unknown()), { + description: + "Sets messages that will be displayed when the user logs in to the WA room. These messages are used for ban or ban warning.", + }), anonymous: extendApi(z.optional(z.boolean()), { - description: "Whether the user if logged as anonymous or not", + description: "Defines whether it is possible to login as anonymous on a WorkAdventure room.", example: false, }), userRoomToken: extendApi(z.optional(z.string()), { description: "", example: "" }), @@ -110,7 +116,7 @@ class AdminApi implements AdminInterface { * example: "998ce839-3dea-4698-8b41-ebbdf7688ad9" * responses: * 200: - * description: The details of the member + * description: The details of the map * schema: * $ref: "#/definitions/MapDetailsData" * 401: @@ -162,7 +168,7 @@ class AdminApi implements AdminInterface { * /api/room/access: * get: * tags: ["AdminAPI"] - * description: Returns member's informations if he can access this room + * description: Returns the member's information if he can access this room * security: * - Bearer: [] * produces: @@ -181,6 +187,7 @@ class AdminApi implements AdminInterface { * example: "http://play.workadventure.localhost/@/teamSlug/worldSLug/roomSlug" * - name: "ipAddress" * in: "query" + * description: "IP Address of the user logged in, allows you to check whether a user has been banned or not" * required: true * type: "string" * example: "127.0.0.1" diff --git a/pusher/src/Services/AdminWokaService.ts b/pusher/src/Services/AdminWokaService.ts index a7b3237f..2bfe5437 100644 --- a/pusher/src/Services/AdminWokaService.ts +++ b/pusher/src/Services/AdminWokaService.ts @@ -8,6 +8,41 @@ class AdminWokaService implements WokaServiceInterface { * Returns the list of all available Wokas for the current user. */ getWokaList(roomUrl: string, token: string): Promise { + /** + * @openapi + * /api/woka/list: + * get: + * tags: ["AdminAPI"] + * description: Get all the woka from the world specified + * security: + * - Bearer: [] + * produces: + * - "application/json" + * parameters: + * - name: "roomUrl" + * in: "query" + * description: "The slug of the room" + * type: "string" + * required: true + * example: "/@/teamSlug/worldSlug/roomSlug" + * - name: "uuid" + * in: "query" + * description: "The uuid of the user \n It can be an uuid or an email" + * type: "string" + * required: true + * example: "998ce839-3dea-4698-8b41-ebbdf7688ad8" + * responses: + * 200: + * description: The list of the woka + * schema: + * type: array + * items: + * $ref: '#/definitions/WokaList' + * 404: + * description: Error while retrieving the data + * schema: + * $ref: '#/definitions/ErrorApiErrorData' + */ return axios .get>(`${ADMIN_API_URL}/api/woka/list`, { headers: { Authorization: `${ADMIN_API_TOKEN}` }, diff --git a/pusher/src/Services/SocketManager.ts b/pusher/src/Services/SocketManager.ts index 6c14b67a..aaa74c12 100644 --- a/pusher/src/Services/SocketManager.ts +++ b/pusher/src/Services/SocketManager.ts @@ -658,6 +658,8 @@ export class SocketManager implements ZoneEventListener { errorMessage.setSubtitle(new StringValue().setValue(errorApi.subtitle)); errorMessage.setDetails(new StringValue().setValue(errorApi.details)); errorMessage.setImage(new StringValue().setValue(errorApi.image)); + if (errorApi.type == "unauthorized" && errorApi.buttonTitle) + errorMessage.setButtontitle(new StringValue().setValue(errorApi.buttonTitle)); } if (errorApi.type == "retry") { if (errorApi.buttonTitle) errorMessage.setButtontitle(new StringValue().setValue(errorApi.buttonTitle)); diff --git a/pusher/src/Services/SwaggerGenerator.ts b/pusher/src/Services/SwaggerGenerator.ts index fcda50d0..c31a39d8 100644 --- a/pusher/src/Services/SwaggerGenerator.ts +++ b/pusher/src/Services/SwaggerGenerator.ts @@ -8,10 +8,22 @@ import { } from "../Messages/JsonMessages/ErrorApiData"; import { isMapDetailsData } from "../Messages/JsonMessages/MapDetailsData"; import { isFetchMemberDataByUuidResponse } from "./AdminApi"; -import { isWokaDetail } from "../Messages/JsonMessages/PlayerTextures"; +import { isWokaDetail, wokaList, wokaTexture } from "../Messages/JsonMessages/PlayerTextures"; class SwaggerGenerator { - definitions() { + definitions(type: string | null) { + const definitions = { + definitions: { + AdminApiData: generateSchema(isAdminApiData), + ErrorApiUnauthorizedData: generateSchema(isErrorApiUnauthorizedData), + FetchMemberDataByUuidResponse: generateSchema(isFetchMemberDataByUuidResponse), + MapDetailsData: generateSchema(isMapDetailsData), + WokaDetail: generateSchema(isWokaDetail), + }, + }; + if (type === "external") { + return definitions; + } return { definitions: { AdminApiData: generateSchema(isAdminApiData), @@ -27,6 +39,8 @@ class SwaggerGenerator { //RoomRedirect: generateSchema(isRoomRedirect), //UserMessageAdminMessageInterface: generateSchema(isUserMessageAdminMessageInterface), WokaDetail: generateSchema(isWokaDetail), + WokaList: generateSchema(wokaList), + WokaTexture: generateSchema(wokaTexture), }, }; }