diff --git a/mx-tester.yml b/mx-tester.yml index 12e9e5b8..1e903e89 100644 --- a/mx-tester.yml +++ b/mx-tester.yml @@ -6,7 +6,7 @@ up: # Wait until postgresql is ready - until psql postgres://mjolnir-tester:mjolnir-test@127.0.0.1:8083/mjolnir-test-db -c ""; do echo "Waiting for psql..."; sleep 1s; done # Launch the reverse proxy, listening for connections *only* on the local host. - - docker run --rm --network host --name mjolnir-test-reverse-proxy -p 127.0.0.1:8081:80 -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx + - docker run --rm --network host --name mjolnir-test-reverse-proxy -v $MX_TEST_CWD/test/nginx.conf:/etc/nginx/nginx.conf:ro -d nginx - yarn install - npx ts-node src/appservice/cli.ts -r -u "http://host.docker.internal:9000" - cp mjolnir-registration.yaml $MX_TEST_SYNAPSE_DIR/data/ diff --git a/src/commands/CommandHandler.ts b/src/commands/CommandHandler.ts index f2212db3..229acbbd 100644 --- a/src/commands/CommandHandler.ts +++ b/src/commands/CommandHandler.ts @@ -73,6 +73,7 @@ import "./Rules"; import "./WatchUnwatchCommand"; import "./Help"; import "./SetDisplayNameCommand"; +import "./QueryAdminDetails"; export const COMMAND_PREFIX = "!mjolnir"; diff --git a/src/commands/QueryAdminDetails.tsx b/src/commands/QueryAdminDetails.tsx new file mode 100644 index 00000000..c64645b9 --- /dev/null +++ b/src/commands/QueryAdminDetails.tsx @@ -0,0 +1,128 @@ +import { Permalinks, UserID, getRequestFn } from "matrix-bot-sdk"; +import { MjolnirContext } from "./CommandHandler"; +import { CommandError, CommandResult } from "./interface-manager/Validation"; +import { defineInterfaceCommand, findTableCommand } from "./interface-manager/InterfaceCommand"; +import { findPresentationType, makePresentationType, parameters, ParsedKeywords, simpleTypeValidator, union } from "./interface-manager/ParameterParsing"; +import "./interface-manager/MatrixPresentations"; +import { JSXFactory } from "./interface-manager/JSXFactory"; +import { defineMatrixInterfaceAdaptor } from "./interface-manager/MatrixInterfaceAdaptor"; +import { renderMatrixAndSend } from "./interface-manager/DeadDocumentMatrix"; +import { DocumentNode } from "./interface-manager/DeadDocument"; +import { ReadItem } from "./interface-manager/CommandReader"; + +const MatrixHomeserverSecret = Symbol("MatrixHomeserverSecret"); +export type MatrixHomeserver = string & { [MatrixHomeserverSecret]: true }; + +function isMatrixHomeserver(string: string): string is MatrixHomeserver { + return !string.includes('#') && !string.includes('!') +} + +makePresentationType({ + name: "MatrixHomeserver", + // This is a very very crude way to detect a url. + validator: simpleTypeValidator("MatrixHomeserver", (readItem: ReadItem) => (typeof readItem === 'string') && isMatrixHomeserver(readItem)) +}) + +interface SupportJson { + contacts?: { + matrix_id?: string, + email_address?: string; + role: "admin" | "security"; + }[], + support_page?: string; +} + +async function queryAdminDetails( + this: MjolnirContext, + _keywords: ParsedKeywords, + entity: UserID | MatrixHomeserver | string +): Promise> { + let domain: string; + if (entity instanceof UserID) { + domain = `https://${entity.domain}`; + } else { + // Doing some cleanup on the url + if (!entity.startsWith("https://") && !entity.startsWith("http://")) { + domain = `https://${entity}`; + } else { + domain = entity; + } + } + + + try { + const resp: SupportJson = await new Promise((resolve, reject) => { + getRequestFn()(`${domain}/.well-known/matrix/support`, (error: any, response: any, resBody: unknown) => { + if (error) { + reject(new CommandError(`The request failed with an error: ${error}.`)); + } else if (response.statusCode !== 200) { + reject(new CommandError(`The server didn't reply with a valid response code: ${response.statusCode}.`)); + } else if (typeof resBody === 'object' && resBody !== null && ('contacts' in resBody || 'support_page' in resBody)) { + resolve(resBody as SupportJson) + } else if (resBody === null) { + reject(new CommandError(`The response was empty.`)); + } else { + reject(new CommandError(`Don't know what to do with response body ${resBody}. Assuming its not a json`)); + } + }); + }); + return CommandResult.Ok([entity, resp]); + } catch (error: any) { + return CommandResult.Err(error); + } +} + +defineInterfaceCommand({ + designator: ["query", "admin"], + table: "mjolnir", + parameters: parameters([ + { + name: "entity", + acceptor: union( + findPresentationType("UserID"), + findPresentationType("MatrixHomeserver"), + findPresentationType("string") + ) + } + ]), + command: queryAdminDetails, + summary: "Queries the admin of the Homeserver or user using MSC1929 if available", +}) + +function renderSupportJson([entity, support_json]: [UserID | MatrixHomeserver | string, SupportJson],): DocumentNode { + if (!support_json.support_page) { + return + Support info for ({entity}):
+ +
+ } else if (!support_json.contacts) { + return + Support Page for ({entity}):
+

+ Support Page: {support_json.support_page} +

+
+ } else { + return + Support info for ({entity}):
+

+ Support Page: {support_json.support_page} +


+ +
+ } +} + +defineMatrixInterfaceAdaptor({ + interfaceCommand: findTableCommand("mjolnir", "query", "admin"), + renderer: async function (client, commandRoomId, event, result) { + await renderMatrixAndSend( + renderSupportJson(result.ok), + commandRoomId, event, client + ); + } +}) diff --git a/test/integration/commands/queryAdminDetailsTest.ts b/test/integration/commands/queryAdminDetailsTest.ts new file mode 100644 index 00000000..4f536090 --- /dev/null +++ b/test/integration/commands/queryAdminDetailsTest.ts @@ -0,0 +1,53 @@ +import { strict as assert } from "assert"; + +import { newTestUser } from "../clientHelper"; +import { getFirstReply } from "./commandUtils"; + +describe("Test: The queryAdmin command", function () { + // If a test has a timeout while awaitng on a promise then we never get given control back. + afterEach(function () { this.moderator?.stop(); }); + + it('Mjölnir can query and display the query results for a complete json.', async function () { + const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + + const reply_event = await getFirstReply(this.mjolnir.matrixEmitter, this.mjolnir.managementRoomId, async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir query admin http://localhost:8081` }); + }); + assert.equal(reply_event.content.body, "**Support info for (http://localhost:8081):**\nSupport Page: http://localhost\n\n\n\n * **admin** - [@admin:localhost](https://matrix.to/#/@admin:localhost)\n\n", `Draupnir did not parse the json as expected.`); + }) + + it('Mjölnir can query and display the query results for a partial contacts-only json.', async function () { + const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + + const reply_event = await getFirstReply(this.mjolnir.matrixEmitter, this.mjolnir.managementRoomId, async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir query admin http://localhost:7072` }); + }); + assert.equal(reply_event.content.body, "**Support info for (http://localhost:7072):**\n\n * **admin** - [@admin:localhost](https://matrix.to/#/@admin:localhost)\n\n", `Draupnir did not parse the json as expected.`); + }) + + it('Mjölnir can query and display the query results for a partial support_page-only json.', async function () { + const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + + const reply_event = await getFirstReply(this.mjolnir.matrixEmitter, this.mjolnir.managementRoomId, async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir query admin http://localhost:7071` }); + }); + assert.equal(reply_event.content.body, "**Support Page for (http://localhost:7071):**\nSupport Page: http://localhost\n\n", `Draupnir did not parse the json as expected.`); + }) + + it('Mjölnir can query and display an error for a non well-formed json.', async function () { + const moderator = await newTestUser(this.config.homeserverUrl, { name: { contains: "moderator" } }); + this.moderator = moderator; + await moderator.joinRoom(this.config.managementRoom); + + const reply_event = await getFirstReply(this.mjolnir.matrixEmitter, this.mjolnir.managementRoomId, async () => { + return await moderator.sendMessage(this.mjolnir.managementRoomId, { msgtype: 'm.text', body: `!mjolnir query admin http://localhost:7070` }); + }); + assert.equal(reply_event.content.body.includes("The request failed with an error: Error: Error during MatrixClient request GET /.well-known/matrix/support:"), true, `Draupnir did not print an error as.`); + }) +}); diff --git a/test/nginx.conf b/test/nginx.conf index 23c73c76..2885f153 100644 --- a/test/nginx.conf +++ b/test/nginx.conf @@ -4,29 +4,74 @@ events { http { server { - listen [::]:8081 ipv6only=off; - - location ~ ^/_matrix/client/(r0|v3)/rooms/([^/]*)/report/(.*)$ { - # Abuse reports should be sent to Mjölnir. - # The r0 endpoint is deprecated but still used by many clients. - # As of this writing, the v3 endpoint is the up-to-date version. - - # Add CORS, otherwise a browser will refuse this request. - add_header 'Access-Control-Allow-Origin' '*' always; # Note: '*' is for testing purposes. For your own server, you probably want to tighten this. - add_header 'Access-Control-Allow-Credentials' 'true' always; - add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since' always; - add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; - add_header 'Access-Control-Max-Age' 1728000; # cache preflight value for 20 days - - # Alias the regexps, to ensure that they're not rewritten. - set $room_id $2; - set $event_id $3; - proxy_pass http://127.0.0.1:8082/api/1/report/$room_id/$event_id; + listen [::]:8081 ipv6only=off; + + location ~ ^/_matrix/client/(r0|v3)/rooms/([^/\s]*)/report/(.*)$ { + # Abuse reports should be sent to Mjölnir. + # The r0 endpoint is deprecated but still used by many clients. + # As of this writing, the v3 endpoint is the up-to-date version. + + # Add CORS, otherwise a browser will refuse this request. + add_header 'Access-Control-Allow-Origin' '*' always; # Note: '*' is for testing purposes. For your own server, you probably want to tighten this. + add_header 'Access-Control-Allow-Credentials' 'true' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Authorization,Content-Type,Accept,Origin,User-Agent,DNT,Cache-Control,X-Mx-ReqToken,Keep-Alive,X-Requested-With,If-Modified-Since' always; + add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; + add_header 'Access-Control-Max-Age' 1728000; # cache preflight value for 20 days + + # Alias the regexps, to ensure that they're not rewritten. + set $room_id $2; + set $event_id $3; + proxy_pass http://127.0.0.1:8082/api/1/report/$room_id/$event_id; + } + # MSC1929 Test in best-case + location /.well-known/matrix/support { + default_type application/json; + return 200 '{ + "contacts": [{ + "matrix_id": "@admin:localhost", + "role": "admin" + }], + "support_page": "http://localhost" + }'; + } + location / { + # Everything else should be sent to Synapse. + proxy_pass http://127.0.0.1:9999; + } + } + server { + listen [::]:7070 ipv6only=off; + + # MSC1929 Test in worst-case + location /.well-known/matrix/support { + default_type application/json; + return 404 '{}'; + } + } + server { + listen [::]:7071 ipv6only=off; + + # MSC1929 Test in supportpage_only-case + location /.well-known/matrix/support { + default_type application/json; + return 200 '{ + "support_page": "http://localhost" + }'; + } } - location / { - # Everything else should be sent to Synapse. - proxy_pass http://127.0.0.1:9999; + server { + listen [::]:7072 ipv6only=off; + + # MSC1929 Test in contacts-case + location /.well-known/matrix/support { + default_type application/json; + return 200 '{ + "contacts": [{ + "matrix_id": "@admin:localhost", + "role": "admin" + }] + }'; } } } diff --git a/tsconfig.json b/tsconfig.json index 046c87d5..b9ddfff7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,5 +33,6 @@ "./test/integration/protectionSettingsTest.ts", "./test/integration/banPropagationTest.ts", "./test/integration/protectedRoomsConfigTest.ts", + "./test/integration/commands/queryAdminDetailsTest.ts", ] }