From ac6b5bb65afed784b24ef49d4b50801e7c7d48eb Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 25 Aug 2021 14:26:02 +0900 Subject: [PATCH] Fix #982 Enable developers to customize the "/slack/install" webpage content --- src/receivers/ExpressReceiver.ts | 8 +++- src/receivers/HTTPReceiver.spec.ts | 46 ++++++++++++++++++- src/receivers/HTTPReceiver.ts | 10 +++- src/receivers/SocketModeReceiver.spec.ts | 43 +++++++++++++++++ src/receivers/SocketModeReceiver.ts | 8 +++- src/receivers/render-html-for-install-path.ts | 7 ++- 6 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/receivers/ExpressReceiver.ts b/src/receivers/ExpressReceiver.ts index 53207b204..920d2d778 100644 --- a/src/receivers/ExpressReceiver.ts +++ b/src/receivers/ExpressReceiver.ts @@ -11,7 +11,7 @@ import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOpt import App from '../App'; import { ReceiverAuthenticityError, ReceiverMultipleAckError, ReceiverInconsistentStateError } from '../errors'; import { AnyMiddlewareArgs, Receiver, ReceiverEvent } from '../types'; -import renderHtmlForInstallPath from './render-html-for-install-path'; +import defaultRenderHtmlForInstallPath from './render-html-for-install-path'; // Option keys for tls.createServer() and tls.createSecureContext(), exclusive of those for http.createServer() const httpsOptionKeys = [ @@ -94,6 +94,7 @@ interface InstallerOptions { metadata?: InstallURLOptions['metadata']; installPath?: string; directInstall?: boolean; // see https://api.slack.com/start/distributing/directory#direct_install + renderHtmlForInstallPath?: (url: string) => string; redirectUriPath?: string; callbackOptions?: CallbackOptions; userScopes?: InstallURLOptions['userScopes']; @@ -202,7 +203,10 @@ export default class ExpressReceiver implements Receiver { res.redirect(url); } else { // The installation starts from a landing page served by this app. - res.send(renderHtmlForInstallPath(url)); + const renderHtml = installerOptions.renderHtmlForInstallPath !== undefined ? + installerOptions.renderHtmlForInstallPath : + defaultRenderHtmlForInstallPath; + res.send(renderHtml(url)); } } catch (error) { next(error); diff --git a/src/receivers/HTTPReceiver.spec.ts b/src/receivers/HTTPReceiver.spec.ts index 92ca46784..661d1ee29 100644 --- a/src/receivers/HTTPReceiver.spec.ts +++ b/src/receivers/HTTPReceiver.spec.ts @@ -160,7 +160,51 @@ describe('HTTPReceiver', function () { assert(installProviderStub.generateInstallUrl.calledWith(sinon.match({ metadata, scopes, userScopes }))); assert.isTrue(writeHead.calledWith(200)); }); - it('should redirect installers if directInstallEnabled is true', async function () { + it('should use a custom HTML renderer for the install path webpage', async function () { + // Arrange + const installProviderStub = sinon.createStubInstance(InstallProvider); + const overrides = mergeOverrides( + withHttpCreateServer(this.fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); + const HTTPReceiver = await importHTTPReceiver(overrides); + + const metadata = 'this is bat country'; + const scopes = ['channels:read']; + const userScopes = ['chat:write']; + const receiver = new HTTPReceiver({ + logger: noopLogger, + clientId: 'my-clientId', + clientSecret: 'my-client-secret', + signingSecret: 'secret', + stateSecret: 'state-secret', + scopes, + installerOptions: { + authVersion: 'v2', + installPath: '/hiya', + renderHtmlForInstallPath: (_) => 'Hello world!', + metadata, + userScopes, + }, + }); + assert.isNotNull(receiver); + receiver.installer = installProviderStub as unknown as InstallProvider; + const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + fakeReq.url = '/hiya'; + fakeReq.headers = { host: 'localhost' }; + fakeReq.method = 'GET'; + const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const writeHead = sinon.fake(); + const end = sinon.fake(); + fakeRes.writeHead = writeHead; + fakeRes.end = end; + /* eslint-disable-next-line @typescript-eslint/await-thenable */ + await receiver.requestListener(fakeReq, fakeRes); + assert(installProviderStub.generateInstallUrl.calledWith(sinon.match({ metadata, scopes, userScopes }))); + assert.isTrue(writeHead.calledWith(200)); + assert.isTrue(end.calledWith('Hello world!')); + }); + it('should rediect installers if directInstall is true', async function () { // Arrange const installProviderStub = sinon.createStubInstance(InstallProvider); const overrides = mergeOverrides( diff --git a/src/receivers/HTTPReceiver.ts b/src/receivers/HTTPReceiver.ts index 7d297f793..844aa6d05 100644 --- a/src/receivers/HTTPReceiver.ts +++ b/src/receivers/HTTPReceiver.ts @@ -9,7 +9,7 @@ import { URL } from 'url'; import { verify as verifySlackAuthenticity, BufferedIncomingMessage } from './verify-request'; import App from '../App'; import { Receiver, ReceiverEvent } from '../types'; -import renderHtmlForInstallPath from './render-html-for-install-path'; +import defaultRenderHtmlForInstallPath from './render-html-for-install-path'; import { ReceiverMultipleAckError, ReceiverInconsistentStateError, @@ -70,6 +70,7 @@ export interface HTTPReceiverOptions { export interface HTTPReceiverInstallerOptions { installPath?: string; directInstall?: boolean; // see https://api.slack.com/start/distributing/directory#direct_install + renderHtmlForInstallPath?: (url: string) => string; redirectUriPath?: string; stateStore?: InstallProviderOptions['stateStore']; // default ClearStateStore authVersion?: InstallProviderOptions['authVersion']; // default 'v2' @@ -102,6 +103,8 @@ export default class HTTPReceiver implements Receiver { private directInstall?: boolean; // always defined when installer is defined + private renderHtmlForInstallPath: (url: string) => string; + private installRedirectUriPath?: string; // always defined when installer is defined private installUrlOptions?: InstallURLOptions; // always defined when installer is defined @@ -164,6 +167,9 @@ export default class HTTPReceiver implements Receiver { }; this.installCallbackOptions = installerOptions.callbackOptions ?? {}; } + this.renderHtmlForInstallPath = installerOptions.renderHtmlForInstallPath !== undefined ? + installerOptions.renderHtmlForInstallPath : + defaultRenderHtmlForInstallPath; // Assign the requestListener property by binding the unboundRequestListener to this instance this.requestListener = this.unboundRequestListener.bind(this); @@ -438,7 +444,7 @@ export default class HTTPReceiver implements Receiver { } else { // The installation starts from a landing page served by this app. // Generate HTML response body - const body = renderHtmlForInstallPath(url); + const body = this.renderHtmlForInstallPath(url); // Serve a basic HTML page including the "Add to Slack" button. // Regarding headers: diff --git a/src/receivers/SocketModeReceiver.spec.ts b/src/receivers/SocketModeReceiver.spec.ts index 52f51d7b6..9d82daaea 100644 --- a/src/receivers/SocketModeReceiver.spec.ts +++ b/src/receivers/SocketModeReceiver.spec.ts @@ -201,6 +201,49 @@ describe('SocketModeReceiver', function () { assert(fakeRes.writeHead.calledWith(200, sinon.match.object)); assert(fakeRes.end.called); }); + it('should use a custom HTML renderer for the install path webpage', async function () { + // Arrange + const installProviderStub = sinon.createStubInstance(InstallProvider); + const overrides = mergeOverrides( + withHttpCreateServer(this.fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); + const SocketModeReceiver = await importSocketModeReceiver(overrides); + + const metadata = 'this is bat country'; + const scopes = ['channels:read']; + const userScopes = ['chat:write']; + const receiver = new SocketModeReceiver({ + appToken: 'my-secret', + logger: noopLogger, + clientId: 'my-clientId', + clientSecret: 'my-client-secret', + stateSecret: 'state-secret', + scopes, + installerOptions: { + authVersion: 'v2', + installPath: '/hiya', + renderHtmlForInstallPath: (_) => 'Hello world!', + metadata, + userScopes, + }, + }); + assert.isNotNull(receiver); + receiver.installer = installProviderStub as unknown as InstallProvider; + const fakeReq = { + url: '/hiya', + }; + const fakeRes = { + writeHead: sinon.fake(), + end: sinon.fake(), + }; + /* eslint-disable-next-line @typescript-eslint/await-thenable */ + await this.listener(fakeReq, fakeRes); + assert(installProviderStub.generateInstallUrl.calledWith(sinon.match({ metadata, scopes, userScopes }))); + assert(fakeRes.writeHead.calledWith(200, sinon.match.object)); + assert(fakeRes.end.called); + assert.isTrue(fakeRes.end.calledWith('Hello world!')); + }); it('should redirect installers if directInstall is true', async function () { // Arrange const installProviderStub = sinon.createStubInstance(InstallProvider); diff --git a/src/receivers/SocketModeReceiver.ts b/src/receivers/SocketModeReceiver.ts index 72483cf99..bdac640a2 100644 --- a/src/receivers/SocketModeReceiver.ts +++ b/src/receivers/SocketModeReceiver.ts @@ -5,7 +5,7 @@ import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOpt import { AppsConnectionsOpenResponse } from '@slack/web-api'; import App from '../App'; import { Receiver, ReceiverEvent } from '../types'; -import renderHtmlForInstallPath from './render-html-for-install-path'; +import defaultRenderHtmlForInstallPath from './render-html-for-install-path'; // TODO: we throw away the key names for endpoints, so maybe we should use this interface. is it better for migrations? // if that's the reason, let's document that with a comment. @@ -28,6 +28,7 @@ interface InstallerOptions { metadata?: InstallURLOptions['metadata']; installPath?: string; directInstall?: boolean; // see https://api.slack.com/start/distributing/directory#direct_install + renderHtmlForInstallPath?: (url: string) => string; redirectUriPath?: string; callbackOptions?: CallbackOptions; userScopes?: InstallURLOptions['userScopes']; @@ -118,7 +119,10 @@ export default class SocketModeReceiver implements Receiver { res.end(''); } else { res.writeHead(200, {}); - res.end(renderHtmlForInstallPath(url)); + const renderHtml = installerOptions.renderHtmlForInstallPath !== undefined ? + installerOptions.renderHtmlForInstallPath : + defaultRenderHtmlForInstallPath; + res.end(renderHtml(url)); } } catch (err) { const e = err as any; diff --git a/src/receivers/render-html-for-install-path.ts b/src/receivers/render-html-for-install-path.ts index 8e0a97a24..bcdeac5eb 100644 --- a/src/receivers/render-html-for-install-path.ts +++ b/src/receivers/render-html-for-install-path.ts @@ -1,4 +1,4 @@ -export default function renderHtmlForInstallPath(addToSlackUrl: string): string { +export default function defaultRenderHtmlForInstallPath(addToSlackUrl: string): string { return ` @@ -13,3 +13,8 @@ export default function renderHtmlForInstallPath(addToSlackUrl: string): string `; } + +// For backward-compatibility +export function renderHtmlForInstallPath(addToSlackUrl: string): string { + return defaultRenderHtmlForInstallPath(addToSlackUrl); +}