Skip to content

Commit d1184a1

Browse files
committed
Merge branch 'main' into steeve/rnd-1947-send-trigger-timestamp-from-git-integrations
2 parents c101a6b + 9bd3b85 commit d1184a1

File tree

12 files changed

+521
-233
lines changed

12 files changed

+521
-233
lines changed

integrations/github/src/index.ts

+58-2
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ import {
2121
import { configBlock } from './components';
2222
import { getGitHubAppJWT } from './provider';
2323
import { triggerExport, updateCommitWithPreviewLinks } from './sync';
24-
import type { GithubRuntimeContext } from './types';
25-
import { BRANCH_REF_PREFIX } from './utils';
24+
import { handleIntegrationTask } from './tasks';
25+
import type { GithubRuntimeContext, IntegrationTask } from './types';
26+
import { arrayToHex, BRANCH_REF_PREFIX, safeCompare } from './utils';
2627
import { handlePullRequestEvents, handlePushEvent, verifyGitHubWebhookSignature } from './webhooks';
2728

2829
const logger = Logger('github');
@@ -38,6 +39,61 @@ const handleFetchEvent: FetchEventCallback<GithubRuntimeContext> = async (reques
3839
).pathname,
3940
});
4041

42+
async function verifyIntegrationSignature(
43+
payload: string,
44+
signature: string,
45+
secret: string
46+
): Promise<boolean> {
47+
if (!signature) {
48+
return false;
49+
}
50+
51+
const algorithm = { name: 'HMAC', hash: 'SHA-256' };
52+
const enc = new TextEncoder();
53+
const key = await crypto.subtle.importKey('raw', enc.encode(secret), algorithm, false, [
54+
'sign',
55+
'verify',
56+
]);
57+
const signed = await crypto.subtle.sign(algorithm.name, key, enc.encode(payload));
58+
const expectedSignature = arrayToHex(signed);
59+
60+
return safeCompare(expectedSignature, signature);
61+
}
62+
63+
/**
64+
* Handle integration tasks
65+
*/
66+
router.post('/tasks', async (request) => {
67+
const signature = request.headers.get('x-gitbook-integration-signature') ?? '';
68+
const payloadString = await request.text();
69+
70+
const verified = await verifyIntegrationSignature(
71+
payloadString,
72+
signature,
73+
environment.signingSecret!
74+
);
75+
76+
if (!verified) {
77+
return new Response('Invalid integration signature', {
78+
status: 400,
79+
});
80+
}
81+
82+
const { task } = JSON.parse(payloadString) as { task: IntegrationTask };
83+
logger.debug('verified & received integration task', task);
84+
85+
context.waitUntil(
86+
(async () => {
87+
await handleIntegrationTask(context, task);
88+
})()
89+
);
90+
91+
return new Response(JSON.stringify({ acknowledged: true }), {
92+
status: 200,
93+
headers: { 'content-type': 'application/json' },
94+
});
95+
});
96+
4197
/**
4298
* Handle GitHub App webhook events
4399
*/

integrations/github/src/installation.ts

+16-19
Original file line numberDiff line numberDiff line change
@@ -98,38 +98,35 @@ export async function saveSpaceConfiguration(
9898
}
9999

100100
/**
101-
* List space installations that match the given external ID. It takes
102-
* care of pagination and returns all space installations at once.
101+
* List space installations that match the given external ID.
103102
*/
104103
export async function querySpaceInstallations(
105104
context: GithubRuntimeContext,
106105
externalId: string,
107-
page?: string
108-
): Promise<Array<IntegrationSpaceInstallation>> {
106+
options: {
107+
page?: string;
108+
limit?: number;
109+
} = {}
110+
): Promise<{ data: Array<IntegrationSpaceInstallation>; nextPage?: string; total?: number }> {
109111
const { api, environment } = context;
112+
const { page, limit = 100 } = options;
110113

111-
logger.debug(`Querying space installations for external ID ${externalId} (page: ${page ?? 1})`);
114+
logger.debug(
115+
`Querying space installations for external ID ${externalId} (${JSON.stringify(options)})`
116+
);
112117

113118
const { data } = await api.integrations.listIntegrationSpaceInstallations(
114119
environment.integration.name,
115120
{
116-
limit: 100,
121+
limit,
117122
externalId,
118123
page,
119124
}
120125
);
121126

122-
const spaceInstallations = [...data.items];
123-
124-
// Recursively fetch next pages
125-
if (data.next) {
126-
const nextSpaceInstallations = await querySpaceInstallations(
127-
context,
128-
externalId,
129-
data.next.page
130-
);
131-
spaceInstallations.push(...nextSpaceInstallations);
132-
}
133-
134-
return spaceInstallations;
127+
return {
128+
data: data.items,
129+
total: data.count,
130+
nextPage: data.next?.page,
131+
};
135132
}

integrations/github/src/tasks.ts

+121
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { GitBookAPI } from '@gitbook/api';
2+
import { Logger } from '@gitbook/runtime';
3+
4+
import { querySpaceInstallations } from './installation';
5+
import { triggerImport } from './sync';
6+
import type { GithubRuntimeContext, IntegrationTask, IntegrationTaskImportSpaces } from './types';
7+
8+
const logger = Logger('github:tasks');
9+
10+
/**
11+
* Queue a task for the integration to import spaces.
12+
*/
13+
export async function queueTaskForImportSpaces(
14+
context: GithubRuntimeContext,
15+
task: IntegrationTaskImportSpaces
16+
): Promise<void> {
17+
const { api, environment } = context;
18+
await api.integrations.queueIntegrationTask(environment.integration.name, {
19+
task: {
20+
type: task.type,
21+
payload: task.payload,
22+
},
23+
});
24+
}
25+
26+
/**
27+
* Handle an integration task.
28+
*/
29+
export async function handleIntegrationTask(
30+
context: GithubRuntimeContext,
31+
task: IntegrationTask
32+
): Promise<void> {
33+
switch (task.type) {
34+
case 'import:spaces':
35+
await handleImportDispatchForSpaces(context, task.payload);
36+
break;
37+
default:
38+
throw new Error(`Unknown integration task type: ${task}`);
39+
}
40+
}
41+
42+
/**
43+
* This function is used to trigger an import for all the spaces that match the given config query.
44+
* It will handle pagination by queueing itself if there are more spaces to import.
45+
*
46+
* `NOTE`: It is important that the total number of external network calls in this function is less
47+
* than 50 as that is the limit imposed by Cloudflare workers.
48+
*/
49+
export async function handleImportDispatchForSpaces(
50+
context: GithubRuntimeContext,
51+
payload: IntegrationTaskImportSpaces['payload']
52+
): Promise<number | undefined> {
53+
const { configQuery, page, standaloneRef } = payload;
54+
55+
logger.debug(`handling import dispatch for spaces with payload: ${JSON.stringify(payload)}`);
56+
57+
const {
58+
data: spaceInstallations,
59+
nextPage,
60+
total,
61+
} = await querySpaceInstallations(context, configQuery, {
62+
limit: 10,
63+
page,
64+
});
65+
66+
await Promise.allSettled(
67+
spaceInstallations.map(async (spaceInstallation) => {
68+
try {
69+
// Obtain the installation API token needed to trigger the import
70+
const { data: installationAPIToken } =
71+
await context.api.integrations.createIntegrationInstallationToken(
72+
spaceInstallation.integration,
73+
spaceInstallation.installation
74+
);
75+
76+
// Set the token in the duplicated context to be used by the API client
77+
const installationContext: GithubRuntimeContext = {
78+
...context,
79+
api: new GitBookAPI({
80+
endpoint: context.environment.apiEndpoint,
81+
authToken: installationAPIToken.token,
82+
}),
83+
environment: {
84+
...context.environment,
85+
authToken: installationAPIToken.token,
86+
},
87+
};
88+
89+
await triggerImport(installationContext, spaceInstallation, {
90+
standalone: standaloneRef
91+
? {
92+
ref: standaloneRef,
93+
}
94+
: undefined,
95+
});
96+
} catch (error) {
97+
logger.error(
98+
`error while triggering ${
99+
standaloneRef ? `standalone (${standaloneRef})` : ''
100+
} import for space ${spaceInstallation.space}`,
101+
error
102+
);
103+
}
104+
})
105+
);
106+
107+
// Queue the next page if there is one
108+
if (nextPage) {
109+
logger.debug(`queueing next page ${nextPage} of import dispatch for spaces`);
110+
await queueTaskForImportSpaces(context, {
111+
type: 'import:spaces',
112+
payload: {
113+
page: nextPage,
114+
configQuery,
115+
standaloneRef,
116+
},
117+
});
118+
}
119+
120+
return total;
121+
}

integrations/github/src/types.ts

+18
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,21 @@ export type GithubConfigureState = Omit<
7979
withCustomTemplate?: boolean;
8080
commitMessagePreview?: string;
8181
};
82+
83+
export type IntegrationTaskType = 'import:spaces';
84+
85+
export type BaseIntegrationTask<Type extends IntegrationTaskType, Payload extends object> = {
86+
type: Type;
87+
payload: Payload;
88+
};
89+
90+
export type IntegrationTaskImportSpaces = BaseIntegrationTask<
91+
'import:spaces',
92+
{
93+
configQuery: string;
94+
page?: string;
95+
standaloneRef?: string;
96+
}
97+
>;
98+
99+
export type IntegrationTask = IntegrationTaskImportSpaces;

integrations/github/src/utils.ts

+26
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,29 @@ export function assertIsDefined<T>(
8383
throw new Error(`Expected value (${options.label}) to be defined, but received ${value}`);
8484
}
8585
}
86+
87+
/**
88+
* Convert an array buffer to a hex string
89+
*/
90+
export function arrayToHex(arr: ArrayBuffer) {
91+
return [...new Uint8Array(arr)].map((x) => x.toString(16).padStart(2, '0')).join('');
92+
}
93+
94+
/**
95+
* Constant-time string comparison. Equivalent of `crypto.timingSafeEqual`.
96+
**/
97+
export function safeCompare(expected: string, actual: string) {
98+
const lenExpected = expected.length;
99+
let result = 0;
100+
101+
if (lenExpected !== actual.length) {
102+
actual = expected;
103+
result = 1;
104+
}
105+
106+
for (let i = 0; i < lenExpected; i++) {
107+
result |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
108+
}
109+
110+
return result === 0;
111+
}

0 commit comments

Comments
 (0)