Merge branch 'develop' of github.com:thecodingmachine/workadventure into develop

This commit is contained in:
_Bastler 2022-05-12 09:03:01 +02:00
commit 895499a9bc
10 changed files with 351 additions and 22 deletions

85
docs/dev/adminAPI.md Normal file
View File

@ -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**<br>
The front is the visible part of Work Adventure, the one that serves the game.
- **Pusher**<br>
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**<br>
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.<br>
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`<br>
_On the sequence diagram this is the call n°2._<br>
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.<br>
In case of success, this endpoint returns a `MapDetailsData` object.
- `/api/room/access`<br>
_On the sequence diagram this is the call n°6._<br>
This end point returns the member's information if he can access this room.<br>
In case of success, this endpoint returns a `FetchMemberDataByUuidResponse` object.
- `/api/woka/list`<br>
_On the sequence diagram this is the call n°10._<br>
This end point returns a list of all the woka from the world specified.<br>
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.

View File

@ -20,6 +20,7 @@ import { locales } from "../i18n/i18n-util";
import type { Locales } from "../i18n/i18n-types"; import type { Locales } from "../i18n/i18n-types";
import { setCurrentLocale } from "../i18n/locales"; import { setCurrentLocale } from "../i18n/locales";
import type { World } from "./World"; import type { World } from "./World";
import { isErrorApiData } from "../Messages/JsonMessages/ErrorApiData";
import { AvailabilityStatus } from "../Messages/ts-proto-generated/protos/messages"; import { AvailabilityStatus } from "../Messages/ts-proto-generated/protos/messages";
class ConnectionManager { class ConnectionManager {
@ -126,6 +127,12 @@ class ConnectionManager {
await this.checkAuthUserConnexion(); await this.checkAuthUserConnexion();
analyticsClient.loggedWithSso(); analyticsClient.loggedWithSso();
} catch (err) { } catch (err) {
if (Axios.isAxiosError(err)) {
const errorType = isErrorApiData.safeParse(err?.response?.data);
if (errorType.success) {
throw err;
}
}
console.error(err); console.error(err);
const redirect = this.loadOpenIDScreen(); const redirect = this.loadOpenIDScreen();
if (redirect === null) { if (redirect === null) {

View File

@ -54,7 +54,7 @@ export const isMapDetailsData = z.object({
description: "The URL of the image to be used on the LoginScene", description: "The URL of the image to be used on the LoginScene",
example: "https://example.com/logo_login.png", example: "https://example.com/logo_login.png",
}), }),
showPoweredBy: extendApi(z.boolean(), { showPoweredBy: extendApi(z.optional(z.nullable(z.boolean())), {
description: "The URL of the image to be used on the name scene", description: "The URL of the image to be used on the name scene",
example: "https://example.com/logo_login.png", example: "https://example.com/logo_login.png",
}), }),

View File

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { extendApi } from "@anatine/zod-openapi";
/* /*
* WARNING! The original file is in /messages/JsonMessages. * 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 //The list of all the player textures, both the default models and the partial textures used for customization
const wokaTexture = z.object({ export const wokaTexture = z.object({
id: z.string(), id: extendApi(z.string(), {
name: z.string(), description: "A unique identifier for this texture.",
url: z.string(), example: "03395306-5dee-4b16-a034-36f2c5f2324a",
tags: z.array(z.string()).optional(), }),
tintable: z.boolean().optional(), 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<typeof wokaTexture>; export type WokaTexture = z.infer<typeof wokaTexture>;
const wokaTextureCollection = z.object({ const wokaTextureCollection = z.object({
name: z.string(), name: extendApi(z.string(), { description: "Name of the collection", example: "Hair" }),
textures: z.array(wokaTexture), textures: z.array(wokaTexture),
}); });
@ -38,9 +48,15 @@ export type WokaList = z.infer<typeof wokaList>;
export const wokaPartNames = ["woka", "body", "eyes", "hair", "clothes", "hat", "accessory"]; export const wokaPartNames = ["woka", "body", "eyes", "hair", "clothes", "hat", "accessory"];
export const isWokaDetail = z.object({ export const isWokaDetail = z.object({
id: z.string(), id: extendApi(z.string(), {
url: z.optional(z.string()), description: "The unique identifier of the Woka.",
layer: z.optional(z.string()), 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<typeof isWokaDetail>; export type WokaDetail = z.infer<typeof isWokaDetail>;

View File

@ -7,6 +7,8 @@ import { openIDClient } from "../Services/OpenIDClient";
import { DISABLE_ANONYMOUS } from "../Enum/EnvironmentVariable"; import { DISABLE_ANONYMOUS } from "../Enum/EnvironmentVariable";
import { RegisterData } from "../Messages/JsonMessages/RegisterData"; import { RegisterData } from "../Messages/JsonMessages/RegisterData";
import { adminService } from "../Services/AdminService"; import { adminService } from "../Services/AdminService";
import Axios from "axios";
import { isErrorApiData } from "../Messages/JsonMessages/ErrorApiData";
export interface TokenInterface { export interface TokenInterface {
userUuid: string; userUuid: string;
@ -197,6 +199,13 @@ export class AuthenticateController extends BaseHttpController {
locale: authTokenData?.locale, locale: authTokenData?.locale,
}); });
} catch (err) { } 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); console.info("User was not connected", err);
} }
} }

View File

@ -52,13 +52,166 @@ export class SwaggerController extends BaseHttpController {
in: "header", in: "header",
}, },
}, },
...SwaggerGenerator.definitions(), ...SwaggerGenerator.definitions(null),
}, },
apis: ["./src/Services/*.ts"], apis: ["./src/Services/*.ts"],
}; };
res.json(swaggerJsdoc(options)); 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 // Create a LiveDirectory instance to virtualize directory with our assets
// @ts-ignore // @ts-ignore
const LiveDirectory = require("live-directory"); const LiveDirectory = require("live-directory");
@ -80,13 +233,14 @@ export class SwaggerController extends BaseHttpController {
} }
const urls = [ const urls = [
{ url: "/openapi/pusher", name: "Front -> Pusher" }, { url: "/openapi/pusher", name: "Front <- Pusher" },
{ url: "/openapi/admin", name: "Pusher <- Admin" }, { url: "/openapi/admin", name: "Pusher -> Admin" },
{ url: "/openapi/external-admin", name: "Admin -> External Admin" },
]; ];
const result = data.replace( const result = data.replace(
/url: "https:\/\/petstore\.swagger\.io\/v2\/swagger.json"/g, /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); response.send(result);

View File

@ -34,11 +34,17 @@ export const isFetchMemberDataByUuidResponse = z.object({
description: "URL of the visitCard of the user fetched.", description: "URL of the visitCard of the user fetched.",
example: "https://mycompany.com/contact/me", example: "https://mycompany.com/contact/me",
}), }),
textures: extendApi(z.array(isWokaDetail), { $ref: "#/definitions/WokaDetail" }), textures: extendApi(z.array(isWokaDetail), {
messages: extendApi(z.array(z.unknown()), { description: "List of user's messages." }), 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()), { 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, example: false,
}), }),
userRoomToken: extendApi(z.optional(z.string()), { description: "", example: "" }), userRoomToken: extendApi(z.optional(z.string()), { description: "", example: "" }),
@ -110,7 +116,7 @@ class AdminApi implements AdminInterface {
* example: "998ce839-3dea-4698-8b41-ebbdf7688ad9" * example: "998ce839-3dea-4698-8b41-ebbdf7688ad9"
* responses: * responses:
* 200: * 200:
* description: The details of the member * description: The details of the map
* schema: * schema:
* $ref: "#/definitions/MapDetailsData" * $ref: "#/definitions/MapDetailsData"
* 401: * 401:
@ -162,7 +168,7 @@ class AdminApi implements AdminInterface {
* /api/room/access: * /api/room/access:
* get: * get:
* tags: ["AdminAPI"] * 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: * security:
* - Bearer: [] * - Bearer: []
* produces: * produces:
@ -181,6 +187,7 @@ class AdminApi implements AdminInterface {
* example: "http://play.workadventure.localhost/@/teamSlug/worldSLug/roomSlug" * example: "http://play.workadventure.localhost/@/teamSlug/worldSLug/roomSlug"
* - name: "ipAddress" * - name: "ipAddress"
* in: "query" * in: "query"
* description: "IP Address of the user logged in, allows you to check whether a user has been banned or not"
* required: true * required: true
* type: "string" * type: "string"
* example: "127.0.0.1" * example: "127.0.0.1"

View File

@ -8,6 +8,41 @@ class AdminWokaService implements WokaServiceInterface {
* Returns the list of all available Wokas for the current user. * Returns the list of all available Wokas for the current user.
*/ */
getWokaList(roomUrl: string, token: string): Promise<WokaList | undefined> { getWokaList(roomUrl: string, token: string): Promise<WokaList | undefined> {
/**
* @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 return axios
.get<unknown, AxiosResponse<unknown>>(`${ADMIN_API_URL}/api/woka/list`, { .get<unknown, AxiosResponse<unknown>>(`${ADMIN_API_URL}/api/woka/list`, {
headers: { Authorization: `${ADMIN_API_TOKEN}` }, headers: { Authorization: `${ADMIN_API_TOKEN}` },

View File

@ -665,6 +665,8 @@ export class SocketManager implements ZoneEventListener {
errorMessage.setSubtitle(new StringValue().setValue(errorApi.subtitle)); errorMessage.setSubtitle(new StringValue().setValue(errorApi.subtitle));
errorMessage.setDetails(new StringValue().setValue(errorApi.details)); errorMessage.setDetails(new StringValue().setValue(errorApi.details));
errorMessage.setImage(new StringValue().setValue(errorApi.image)); 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.type == "retry") {
if (errorApi.buttonTitle) errorMessage.setButtontitle(new StringValue().setValue(errorApi.buttonTitle)); if (errorApi.buttonTitle) errorMessage.setButtontitle(new StringValue().setValue(errorApi.buttonTitle));

View File

@ -8,10 +8,22 @@ import {
} from "../Messages/JsonMessages/ErrorApiData"; } from "../Messages/JsonMessages/ErrorApiData";
import { isMapDetailsData } from "../Messages/JsonMessages/MapDetailsData"; import { isMapDetailsData } from "../Messages/JsonMessages/MapDetailsData";
import { isFetchMemberDataByUuidResponse } from "./AdminApi"; import { isFetchMemberDataByUuidResponse } from "./AdminApi";
import { isWokaDetail } from "../Messages/JsonMessages/PlayerTextures"; import { isWokaDetail, wokaList, wokaTexture } from "../Messages/JsonMessages/PlayerTextures";
class SwaggerGenerator { 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 { return {
definitions: { definitions: {
AdminApiData: generateSchema(isAdminApiData), AdminApiData: generateSchema(isAdminApiData),
@ -27,6 +39,8 @@ class SwaggerGenerator {
//RoomRedirect: generateSchema(isRoomRedirect), //RoomRedirect: generateSchema(isRoomRedirect),
//UserMessageAdminMessageInterface: generateSchema(isUserMessageAdminMessageInterface), //UserMessageAdminMessageInterface: generateSchema(isUserMessageAdminMessageInterface),
WokaDetail: generateSchema(isWokaDetail), WokaDetail: generateSchema(isWokaDetail),
WokaList: generateSchema(wokaList),
WokaTexture: generateSchema(wokaTexture),
}, },
}; };
} }