diff --git a/lib/project/critical-services.yml b/lib/project/critical-services.yml index 6e06677120..1c0c0e4581 100644 --- a/lib/project/critical-services.yml +++ b/lib/project/critical-services.yml @@ -34,6 +34,7 @@ paulmelnikow: - hexpm - jenkins - luarocks + - matrix - maven-central - node - nom diff --git a/logo/matrix.svg b/logo/matrix.svg new file mode 100644 index 0000000000..594b97ed72 --- /dev/null +++ b/logo/matrix.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/services/matrix/matrix.service.js b/services/matrix/matrix.service.js new file mode 100644 index 0000000000..a6199a35ec --- /dev/null +++ b/services/matrix/matrix.service.js @@ -0,0 +1,147 @@ +'use strict' + +const Joi = require('joi') +const BaseJsonService = require('../base-json') + +const matrixRegisterSchema = Joi.object({ + access_token: Joi.string().required(), +}).required() + +const matrixStateSchema = Joi.array() + .items( + Joi.object({ + content: Joi.object({ + membership: Joi.string().optional(), + }).required(), + type: Joi.string().required(), + sender: Joi.string().required(), + state_key: Joi.string() + .allow('') + .required(), + }) + ) + .required() + +const documentation = ` +

+ In order for this badge to work, the host of your room must allow guest accounts or dummy accounts to register, and the room must be world readable (chat history visible to anyone). +
+ The following steps will show you how to setup the badge URL using the Riot.im Matrix client. +
+

+

+ ` + +module.exports = class Matrix extends BaseJsonService { + async registerAccount({ host, guest }) { + return this._requestJson({ + url: `https://${host}/_matrix/client/r0/register`, + schema: matrixRegisterSchema, + options: { + method: 'POST', + qs: guest + ? { + kind: 'guest', + } + : {}, + body: JSON.stringify({ + password: '', + auth: { type: 'm.login.dummy' }, + }), + }, + errorMessages: { + 401: 'auth failed', + 403: 'guests not allowed', + 429: 'rate limited by rooms host', + }, + }) + } + + async fetch({ host, roomId }) { + let auth + try { + auth = await this.registerAccount({ host, guest: true }) + } catch (e) { + if (e.prettyMessage === 'guests not allowed') { + // attempt fallback method + auth = await this.registerAccount({ host, guest: false }) + } else throw e + } + const data = await this._requestJson({ + url: `https://${host}/_matrix/client/r0/rooms/${roomId}/state`, + schema: matrixStateSchema, + options: { + qs: { + access_token: auth.access_token, + }, + }, + errorMessages: { + 400: 'unknown request', + 401: 'bad auth token', + 403: 'room not world readable or is invalid', + }, + }) + return Array.isArray(data) + ? data.filter( + m => + m.type === 'm.room.member' && + m.sender === m.state_key && + m.content.membership === 'join' + ).length + : 0 + } + + static get _cacheLength() { + return 30 + } + + static render({ members }) { + return { + message: `${members} users`, + color: 'brightgreen', + } + } + + async handle({ roomId, host, authServer }) { + const members = await this.fetch({ + host, + roomId: `${roomId}:${host}`, + }) + return this.constructor.render({ members }) + } + + static get defaultBadgeData() { + return { label: 'chat' } + } + + static get category() { + return 'chat' + } + + static get route() { + return { + base: 'matrix', + format: '([^/]+)/([^/]+)', + capture: ['roomId', 'host'], + } + } + + static get examples() { + return [ + { + title: 'Matrix', + exampleUrl: '!ltIjvaLydYAWZyihee/matrix.org', + pattern: ':roomId/:host', + staticExample: this.render({ members: 42 }), + documentation, + }, + ] + } +} diff --git a/services/matrix/matrix.tester.js b/services/matrix/matrix.tester.js new file mode 100644 index 0000000000..225b452577 --- /dev/null +++ b/services/matrix/matrix.tester.js @@ -0,0 +1,233 @@ +'use strict' + +const Joi = require('joi') +const ServiceTester = require('../service-tester') +const { colorScheme } = require('../test-helpers') + +const t = new ServiceTester({ id: 'matrix', title: 'Matrix' }) +module.exports = t + +t.create('get room state as guest') + .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .intercept(nock => + nock('https://DUMMY.dumb/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .reply( + 200, + JSON.stringify([ + { + // valid user 1 + type: 'm.room.member', + sender: '@user1:DUMMY.dumb', + state_key: '@user1:DUMMY.dumb', + content: { + membership: 'join', + }, + }, + { + // valid user 2 + type: 'm.room.member', + sender: '@user2:DUMMY.dumb', + state_key: '@user2:DUMMY.dumb', + content: { + membership: 'join', + }, + }, + { + // should exclude banned/invited/left members + type: 'm.room.member', + sender: '@user3:DUMMY.dumb', + state_key: '@user3:DUMMY.dumb', + content: { + membership: 'leave', + }, + }, + { + // exclude events like the room name + type: 'm.room.name', + sender: '@user4:DUMMY.dumb', + state_key: '@user4:DUMMY.dumb', + content: { + membership: 'fake room', + }, + }, + ]) + ) + ) + .expectJSON({ + name: 'chat', + value: '2 users', + colorB: colorScheme.brightgreen, + }) + +t.create('get room state as member (backup method)') + .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .intercept(nock => + nock('https://DUMMY.dumb/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 403, + JSON.stringify({ + errcode: 'M_GUEST_ACCESS_FORBIDDEN', // i think this is the right one + error: 'Guest access not allowed', + }) + ) + .post('/_matrix/client/r0/register') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .reply( + 200, + JSON.stringify([ + { + // valid user 1 + type: 'm.room.member', + sender: '@user1:DUMMY.dumb', + state_key: '@user1:DUMMY.dumb', + content: { + membership: 'join', + }, + }, + { + // valid user 2 + type: 'm.room.member', + sender: '@user2:DUMMY.dumb', + state_key: '@user2:DUMMY.dumb', + content: { + membership: 'join', + }, + }, + { + // should exclude banned/invited/left members + type: 'm.room.member', + sender: '@user3:DUMMY.dumb', + state_key: '@user3:DUMMY.dumb', + content: { + membership: 'leave', + }, + }, + { + // exclude events like the room name + type: 'm.room.name', + sender: '@user4:DUMMY.dumb', + state_key: '@user4:DUMMY.dumb', + content: { + membership: 'fake room', + }, + }, + ]) + ) + ) + .expectJSON({ + name: 'chat', + value: '2 users', + colorB: colorScheme.brightgreen, + }) + +t.create('bad server or connection') + .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .networkOff() + .expectJSON({ + name: 'chat', + value: 'inaccessible', + colorB: colorScheme.lightgray, + }) + +t.create('invalid room') + .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .intercept(nock => + nock('https://DUMMY.dumb/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .reply( + 403, + JSON.stringify({ + errcode: 'M_GUEST_ACCESS_FORBIDDEN', + error: 'Guest access not allowed', + }) + ) + ) + .expectJSON({ + name: 'chat', + value: 'room not world readable or is invalid', + colorB: colorScheme.lightgray, + }) + +t.create('invalid token') + .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .intercept(nock => + nock('https://DUMMY.dumb/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .reply( + 401, + JSON.stringify({ + errcode: 'M_UNKNOWN_TOKEN', + error: 'Unrecognised access token.', + }) + ) + ) + .expectJSON({ + name: 'chat', + value: 'bad auth token', + colorB: colorScheme.lightgray, + }) + +t.create('unknown request') + .get('/ROOM/DUMMY.dumb.json?style=_shields_test') + .intercept(nock => + nock('https://DUMMY.dumb/') + .post('/_matrix/client/r0/register?kind=guest') + .reply( + 200, + JSON.stringify({ + access_token: 'TOKEN', + }) + ) + .get('/_matrix/client/r0/rooms/ROOM:DUMMY.dumb/state?access_token=TOKEN') + .reply( + 400, + JSON.stringify({ + errcode: 'M_UNRECOGNIZED', + error: 'Unrecognized request', + }) + ) + ) + .expectJSON({ + name: 'chat', + value: 'unknown request', + colorB: colorScheme.lightgray, + }) + +t.create('test on real matrix room for API compliance') + .get('/!ltIjvaLydYAWZyihee/matrix.org.json?style=_shields_test') + .expectJSONTypes( + Joi.object().keys({ + name: 'chat', + value: Joi.string().regex(/^[0-9]+ users$/), + colorB: colorScheme.brightgreen, + }) + )