Skip to content

Commit

Permalink
Fix #982 Enable developers to customize the "/slack/install" webpage …
Browse files Browse the repository at this point in the history
…content
  • Loading branch information
seratch committed Aug 25, 2021
1 parent bcca0ee commit b5938e6
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 7 deletions.
9 changes: 7 additions & 2 deletions src/receivers/ExpressReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,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 { renderHtmlForInstallPath as 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.
Expand Down Expand Up @@ -44,6 +44,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'];
Expand Down Expand Up @@ -151,7 +152,11 @@ 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);
Expand Down
46 changes: 45 additions & 1 deletion src/receivers/HTTPReceiver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,51 @@ describe('HTTPReceiver', function () {
assert(installProviderStub.generateInstallUrl.calledWith(sinon.match({ metadata, scopes, userScopes })));
assert.isTrue(writeHead.calledWith(200));
});
it('should rediect 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(
Expand Down
11 changes: 9 additions & 2 deletions src/receivers/HTTPReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { renderHtmlForInstallPath as defaultRenderHtmlForInstallPath } from './render-html-for-install-path';
import {
ReceiverMultipleAckError,
ReceiverInconsistentStateError,
Expand All @@ -34,6 +34,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'
Expand Down Expand Up @@ -66,6 +67,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
Expand Down Expand Up @@ -129,6 +132,10 @@ 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);
Expand Down Expand Up @@ -402,7 +409,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:
Expand Down
43 changes: 43 additions & 0 deletions src/receivers/SocketModeReceiver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,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);
Expand Down
9 changes: 7 additions & 2 deletions src/receivers/SocketModeReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,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 { renderHtmlForInstallPath as 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.
Expand All @@ -29,6 +29,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'];
Expand Down Expand Up @@ -120,7 +121,11 @@ 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) {
throw new Error(err);
Expand Down

0 comments on commit b5938e6

Please sign in to comment.