diff --git a/src/docs/game-channel-api.docs.ts b/src/docs/game-channel-api.docs.ts new file mode 100644 index 00000000..7b88896c --- /dev/null +++ b/src/docs/game-channel-api.docs.ts @@ -0,0 +1,240 @@ +import GameChannelAPIService from '../services/api/game-channel-api.service' +import APIDocs from './api-docs' + +const GameChannelAPIDocs: APIDocs = { + index: { + description: 'List game channels', + samples: [ + { + title: 'Sample response', + sample: { + channels: [ + { + id: 1, + name: 'general-chat', + owner: null, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + }, + { + id: 2, + name: 'guild-chat', + owner: { + id: 1, + service: 'username', + identifier: 'guild_admin_bob', + player: { + id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', + props: [ + { key: 'currentLevel', value: '58' }, + { key: 'xPos', value: '13.29' }, + { key: 'yPos', value: '26.44' }, + { key: 'zoneId', value: '3' } + ], + aliases: [ + '/* [Circular] */' + ], + devBuild: false, + createdAt: '2022-01-15T13:20:32.133Z', + lastSeenAt: '2022-04-12T15:09:43.066Z', + groups: [ + { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } + ] + } + }, + props: [ + { key: 'channelType', value: 'guild' }, + { key: 'guildId', value: '5912' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + } + ] + } + } + ] + }, + post: { + description: 'Create a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + body: { + name: 'The name of the channel', + props: 'An array of @type(Props:prop)' + } + }, + samples: [ + { + title: 'Sample request', + sample: { + name: 'general-chat', + props: [ + { key: 'channelType', value: 'public' } + ] + } + }, + { + title: 'Sample response', + sample: { + channel: { + id: 1, + name: 'general-chat', + owner: { + id: 1, + service: 'username', + identifier: 'guild_admin_bob', + player: { + id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', + props: [ + { key: 'currentLevel', value: '58' }, + { key: 'xPos', value: '13.29' }, + { key: 'yPos', value: '26.44' }, + { key: 'zoneId', value: '3' } + ], + aliases: [ + '/* [Circular] */' + ], + devBuild: false, + createdAt: '2022-01-15T13:20:32.133Z', + lastSeenAt: '2022-04-12T15:09:43.066Z', + groups: [ + { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } + ] + } + }, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + } + } + } + ] + }, + join: { + description: 'Join a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + route: { + id: 'The ID of the channel' + } + }, + samples: [ + { + title: 'Sample request', + sample: { + name: 'general-chat' + } + }, + { + title: 'Sample response', + sample: { + channel: { + id: 1, + name: 'general-chat', + owner: null, + props: [ + { key: 'channelType', value: 'public' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:00:00Z' + } + } + } + ] + }, + leave: { + description: 'Leave a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + route: { + id: 'The ID of the channel' + } + } + }, + put: { + description: 'Update a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + body: { + name: 'The new name of the channel', + props: 'An array of @type(Props:prop)', + ownerAliasId: 'The ID of the new owner of the channel' + }, + route: { + id: 'The ID of the channel' + } + }, + samples: [ + { + title: 'Sample request', + sample: { + name: 'new-general-chat', + props: [ + { key: 'channelType', value: 'public' }, + { key: 'recentlyUpdated', value: 'true' } + ], + ownerAliasId: 2 + } + }, + { + title: 'Sample response', + sample: { + channel: { + id: 1, + name: 'new-general-chat', + owner: { + id: 2, + service: 'username', + identifier: 'new_admin_john', + player: { + id: '7a4e70ec-6ee6-418e-923d-b3a45051b7f9', + props: [], + aliases: [ + '/* [Circular] */' + ], + devBuild: false, + createdAt: '2022-01-15T13:20:32.133Z', + lastSeenAt: '2022-04-12T15:09:43.066Z', + groups: [ + { id: '5826ca71-1964-4a1b-abcb-a61ffbe003be', name: 'Winners' } + ] + } + }, + props: [ + { key: 'channelType', value: 'public' }, + { key: 'recentlyUpdated', value: 'true' } + ], + createdAt: '2024-12-09T12:00:00Z', + updatedAt: '2024-12-09T12:01:00Z' + } + } + } + ] + }, + delete: { + description: 'Delete a game channel', + params: { + headers: { + 'x-talo-alias': 'The ID of the player\'s alias' + }, + route: { + id: 'The ID of the channel' + } + } + } +} + +export default GameChannelAPIDocs diff --git a/src/docs/player-api.docs.ts b/src/docs/player-api.docs.ts index b6bbf308..cc0938db 100644 --- a/src/docs/player-api.docs.ts +++ b/src/docs/player-api.docs.ts @@ -5,6 +5,9 @@ const PlayerAPIDocs: APIDocs = { identify: { description: 'Identify a player', params: { + headers: { + 'x-talo-session': 'The session token (required if using Talo player authentication)' + }, query: { service: 'The name of the service where the identity of the player comes from (e.g. "steam", "epic" or "username")', identifier: 'The unique identifier of the player. This can be their username, an email or a numeric ID' diff --git a/src/entities/game-channel.ts b/src/entities/game-channel.ts index de331c83..5a602aea 100644 --- a/src/entities/game-channel.ts +++ b/src/entities/game-channel.ts @@ -29,12 +29,19 @@ export default class GameChannel { @Property() name: string - @ManyToOne(() => PlayerAlias, { nullable: false, cascade: [Cascade.REMOVE] }) + @ManyToOne(() => PlayerAlias, { cascade: [Cascade.REMOVE] }) owner: PlayerAlias @ManyToMany(() => PlayerAlias, (alias) => alias.channels, { owner: true }) members = new Collection(this) + @Property() + totalMessages: number = 0 + + @Required() + @Property() + autoCleanup: boolean = false + @ManyToOne(() => Game) game: Game @@ -56,6 +63,7 @@ export default class GameChannel { id: this.id, name: this.name, owner: this.owner, + totalMessages: this.totalMessages, props: this.props, createdAt: this.createdAt, updatedAt: this.updatedAt diff --git a/src/migrations/.snapshot-gs_dev.json b/src/migrations/.snapshot-gs_dev.json index 708387a0..992e2ec2 100644 --- a/src/migrations/.snapshot-gs_dev.json +++ b/src/migrations/.snapshot-gs_dev.json @@ -2496,10 +2496,32 @@ "unsigned": true, "autoincrement": false, "primary": false, + "nullable": true, + "length": null, + "mappedType": "integer" + }, + "total_messages": { + "name": "total_messages", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, "nullable": false, "length": null, + "default": "0", "mappedType": "integer" }, + "auto_cleanup": { + "name": "auto_cleanup", + "type": "tinyint(1)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 1, + "default": "false", + "mappedType": "boolean" + }, "game_id": { "name": "game_id", "type": "int", diff --git a/src/migrations/20241206233511CreateGameChannelTables.ts b/src/migrations/20241206233511CreateGameChannelTables.ts index 10065382..4e248def 100644 --- a/src/migrations/20241206233511CreateGameChannelTables.ts +++ b/src/migrations/20241206233511CreateGameChannelTables.ts @@ -3,7 +3,7 @@ import { Migration } from '@mikro-orm/migrations' export class CreateGameChannelTables extends Migration { override async up(): Promise { - this.addSql('create table `game_channel` (`id` int unsigned not null auto_increment primary key, `name` varchar(255) not null, `owner_id` int unsigned not null, `game_id` int unsigned not null, `props` json not null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') + this.addSql('create table `game_channel` (`id` int unsigned not null auto_increment primary key, `name` varchar(255) not null, `owner_id` int unsigned null, `game_id` int unsigned not null, `total_messages` int not null default 0, `auto_cleanup` tinyint(1) not null default false, `props` json not null, `created_at` datetime not null, `updated_at` datetime not null) default character set utf8mb4 engine = InnoDB;') this.addSql('alter table `game_channel` add index `game_channel_owner_id_index`(`owner_id`);') this.addSql('alter table `game_channel` add index `game_channel_game_id_index`(`game_id`);') diff --git a/src/policies/api/game-channel-api.policy.ts b/src/policies/api/game-channel-api.policy.ts index bc1d1a92..57b11956 100644 --- a/src/policies/api/game-channel-api.policy.ts +++ b/src/policies/api/game-channel-api.policy.ts @@ -1,21 +1,63 @@ import Policy from '../policy' -import { PolicyResponse } from 'koa-clay' +import { PolicyDenial, PolicyResponse } from 'koa-clay' import { APIKeyScope } from '../../entities/api-key' +import PlayerAlias from '../../entities/player-alias' +import { EntityManager } from '@mikro-orm/mysql' export default class GameChannelAPIPolicy extends Policy { + async getAlias(): Promise { + const em: EntityManager = this.ctx.em + return await em.getRepository(PlayerAlias).findOne({ + id: this.ctx.state.currentAliasId, + player: { + game: this.ctx.state.game + } + }) + } + async index(): Promise { return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) } + async subscriptions(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) + } + async post(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } async join(): Promise { - return await this.hasScope(APIKeyScope.READ_GAME_CHANNELS) + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } async leave(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) + } + + async put(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) + } + + async delete(): Promise { + this.ctx.state.alias = await this.getAlias() + if (!this.ctx.state.alias) return new PolicyDenial({ message: 'Player not found' }, 404) + return await this.hasScope(APIKeyScope.WRITE_GAME_CHANNELS) } } diff --git a/src/services/api/game-channel-api.service.ts b/src/services/api/game-channel-api.service.ts index e65178f7..37506a88 100644 --- a/src/services/api/game-channel-api.service.ts +++ b/src/services/api/game-channel-api.service.ts @@ -1,35 +1,86 @@ -import { forwardRequest, ForwardTo, HasPermission, Request, Response, Validate } from 'koa-clay' +import { forwardRequest, ForwardTo, HasPermission, Request, Response, Routes, Validate } from 'koa-clay' import GameChannelAPIPolicy from '../../policies/api/game-channel-api.policy' import APIService from './api-service' import GameChannel from '../../entities/game-channel' import { EntityManager } from '@mikro-orm/mysql' -import PlayerAlias from '../../entities/player-alias' import sanitiseProps from '../../lib/props/sanitiseProps' import Socket from '../../socket' import { sendMessage, sendMessages, SocketMessageResponse } from '../../socket/messages/socketMessage' +import GameChannelAPIDocs from '../../docs/game-channel-api.docs' +import PlayerAlias from '../../entities/player-alias' -async function getAlias(req: Request): Promise { - const em: EntityManager = req.ctx.em - return await em.getRepository(PlayerAlias).findOne({ - id: req.ctx.state.currentAliasId, - player: { - game: req.ctx.state.game - } - }) -} - -async function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { +function sendMessageToChannelMembers(req: Request, channel: GameChannel, res: SocketMessageResponse, data: T) { const socket: Socket = req.ctx.wss const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) sendMessages(conns, res, data) } +function canModifyChannel(channel: GameChannel, alias: PlayerAlias): boolean { + return channel.owner ? channel.owner.id === alias.id : true +} + +@Routes([ + { + method: 'GET', + handler: 'index', + docs: GameChannelAPIDocs.index + }, + { + method: 'GET', + handler: 'subscriptions', + docs: GameChannelAPIDocs.subscriptions + }, + { + method: 'POST', + handler: 'post', + docs: GameChannelAPIDocs.post + }, + { + method: 'POST', + path: '/:id/join', + handler: 'join', + docs: GameChannelAPIDocs.join + }, + { + method: 'POST', + path: '/:id/leave', + handler: 'leave', + docs: GameChannelAPIDocs.leave + }, + { + method: 'PUT', + path: '/:id', + handler: 'put', + docs: GameChannelAPIDocs.put + }, + { + method: 'DELETE', + path: '/:id', + handler: 'delete', + docs: GameChannelAPIDocs.delete + } +]) export default class GameChannelAPIService extends APIService { @ForwardTo('games.game-channels', 'index') async index(req: Request): Promise { return forwardRequest(req) } + @Validate({ + headers: ['x-talo-alias'] + }) + @HasPermission(GameChannelAPIPolicy, 'subscriptions') + async subscriptions(req: Request): Promise { + const channels = await req.ctx.state.alias.channels.loadItems() + + return { + status: 200, + body: { + channels + } + } + } + @Validate({ headers: ['x-talo-alias'], body: [GameChannel] @@ -39,20 +90,10 @@ export default class GameChannelAPIService extends APIService { const { name, props } = req.body const em: EntityManager = req.ctx.em - const alias = await getAlias(req) - if (!alias) { - return { - status: 404, - body: { - error: 'Player not found' - } - } - } - const channel = new GameChannel(req.ctx.state.game) channel.name = name - channel.owner = alias - channel.members.add(alias) + channel.owner = req.ctx.state.alias + channel.members.add(req.ctx.state.alias) if (props) { channel.props = sanitiseProps(props) @@ -61,7 +102,7 @@ export default class GameChannelAPIService extends APIService { await em.persistAndFlush(channel) const socket: Socket = req.ctx.wss - const conn = socket.findConnections((conn) => conn.playerAlias.id === alias.id)[0] + const conn = socket.findConnections((conn) => conn.playerAlias.id === req.ctx.state.alias.id)[0] if (conn) { sendMessage(conn, 'v1.channels.player-joined', { channel }) } @@ -83,16 +124,6 @@ export default class GameChannelAPIService extends APIService { const { name } = req.body const em: EntityManager = req.ctx.em - const alias = await getAlias(req) - if (!alias) { - return { - status: 404, - body: { - error: 'Player not found' - } - } - } - const channel = await em.getRepository(GameChannel).findOne({ game: req.ctx.state.game, name }) if (!channel) { return { @@ -103,11 +134,12 @@ export default class GameChannelAPIService extends APIService { } } - await channel.members.loadItems() - channel.members.add(alias) - await em.flush() + if (!(await channel.members.load()).getIdentifiers().includes(req.ctx.state.alias.id)) { + channel.members.add(req.ctx.state.alias) + await em.flush() + } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + sendMessageToChannelMembers(req, channel, 'v1.channels.player-joined', { channel }) return { status: 200, @@ -123,20 +155,49 @@ export default class GameChannelAPIService extends APIService { }) @HasPermission(GameChannelAPIPolicy, 'leave') async leave(req: Request): Promise { - const { name } = req.body + const { id } = req.params const em: EntityManager = req.ctx.em - const alias = await getAlias(req) - if (!alias) { + const channel = await em.getRepository(GameChannel).findOne(Number(id)) + if (!channel) { return { status: 404, body: { - error: 'Player not found' + error: 'Channel not found' } } } - const channel = await em.getRepository(GameChannel).findOne({ game: req.ctx.state.game, name }) + if (channel.autoCleanup && channel.owner.id === req.ctx.state.alias.id) { + await channel.members.removeAll() + await em.removeAndFlush(channel) + + return { + status: 204 + } + } + + (await channel.members.load()).remove(req.ctx.state.alias) + await em.flush() + + sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + + return { + status: 204 + } + } + + @Validate({ + headers: ['x-talo-alias'], + body: [GameChannel] + }) + @HasPermission(GameChannelAPIPolicy, 'put') + async put(req: Request): Promise { + const { id } = req.params + const { name, props, ownerAliasId } = req.body + const em: EntityManager = req.ctx.em + + const channel = await em.getRepository(GameChannel).findOne(Number(id)) if (!channel) { return { status: 404, @@ -146,11 +207,44 @@ export default class GameChannelAPIService extends APIService { } } - await channel.members.loadItems() - channel.members.remove(alias) - await em.flush() + if (canModifyChannel(channel, req.ctx.state.alias)) { + return { + status: 403, + body: { + error: 'This player is not the owner of the channel' + } + } + } - sendMessageToChannelMembers(req, channel, 'v1.channels.player-left', { channel }) + if (name) { + channel.name = name + } + + if (props) { + channel.props = sanitiseProps(props) + } + + if (ownerAliasId) { + const newOwner = await em.getRepository(PlayerAlias).findOne({ + id: ownerAliasId, + player: { + game: req.ctx.state.game + } + }) + + if (!newOwner) { + return { + status: 404, + body: { + error: 'Owner alias not found' + } + } + } + + channel.owner = newOwner + } + + await em.flush() return { status: 200, @@ -159,4 +253,39 @@ export default class GameChannelAPIService extends APIService { } } } + + @Validate({ + headers: ['x-talo-alias'] + }) + @HasPermission(GameChannelAPIPolicy, 'delete') + async delete(req: Request): Promise { + const { id } = req.params + const em: EntityManager = req.ctx.em + + const channel = await em.getRepository(GameChannel).findOne(Number(id)) + if (!channel) { + return { + status: 404, + body: { + error: 'Channel not found' + } + } + } + + if (canModifyChannel(channel, req.ctx.state.alias)) { + return { + status: 403, + body: { + error: 'This player is not the owner of the channel' + } + } + } + + await channel.members.removeAll() + await em.removeAndFlush(channel) + + return { + status: 204 + } + } } diff --git a/src/socket/listeners/gameChannelListeners.ts b/src/socket/listeners/gameChannelListeners.ts index 8fc871ef..6c1a8620 100644 --- a/src/socket/listeners/gameChannelListeners.ts +++ b/src/socket/listeners/gameChannelListeners.ts @@ -30,12 +30,18 @@ const gameChannelListeners: SocketMessageListener[] = [ throw new Error('Player not in channel') } - const conns = socket.findConnections((conn) => channel.members.getIdentifiers().includes(conn.playerAlias.id)) + const conns = socket.findConnections((conn) => { + return conn.scopes.includes(APIKeyScope.READ_GAME_CHANNELS) && + channel.members.getIdentifiers().includes(conn.playerAlias.id) + }) sendMessages(conns, 'v1.channels.message', { channelName: channel.name, message: data.message, fromPlayerAlias: conn.playerAlias }) + + channel.totalMessages++ + await RequestContext.getEntityManager().flush() }, { apiKeyScopes: [APIKeyScope.WRITE_GAME_CHANNELS]