Skip to content

Commit 3f26e9e

Browse files
committed
feat(apps): create the code of conduct managing functions (#1109)
Create the Firebase functions used for managing the code of conduct application. PR Close #1109
1 parent 08f8d67 commit 3f26e9e

File tree

13 files changed

+332
-5
lines changed

13 files changed

+332
-5
lines changed

apps/firestore.rules

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
rules_version = '2';
12
service cloud.firestore {
23
match /databases/{database}/documents {
34
match /{document=**} {
4-
allow read, write: if false
5+
allow read, create: if request.auth != null;
6+
allow update: if request.auth != null && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['blockUntil', 'comments']);
57
}
68
}
7-
}
9+
}

apps/functions/BUILD.bazel

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("//tools:defaults.bzl", "esbuild_esm_bundle", "ts_library")
1+
load("//tools:defaults.bzl", "esbuild_cjs_bundle", "ts_library")
22
load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin")
33

44
package(default_visibility = ["//visibility:private"])
@@ -17,13 +17,14 @@ ts_library(
1717
"index.ts",
1818
],
1919
deps = [
20+
"//apps/functions/code-of-conduct",
2021
"//apps/functions/githubWebhook",
2122
"//apps/functions/ng-dev",
2223
"@npm//firebase-admin",
2324
],
2425
)
2526

26-
esbuild_esm_bundle(
27+
esbuild_cjs_bundle(
2728
name = "functions_compiled",
2829
entry_points = [
2930
"index.ts",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
package(default_visibility = ["//visibility:private"])
4+
5+
ts_library(
6+
name = "code-of-conduct",
7+
srcs = [
8+
"index.ts",
9+
],
10+
visibility = [
11+
"//apps/functions:__pkg__",
12+
],
13+
deps = [
14+
":lib",
15+
"@npm//firebase-admin",
16+
],
17+
)
18+
19+
ts_library(
20+
name = "lib",
21+
srcs = [
22+
"blockUser.ts",
23+
"shared.ts",
24+
"syncUsers.ts",
25+
"unblockUser.ts",
26+
],
27+
deps = [
28+
"@npm//@octokit/auth-app",
29+
"@npm//@octokit/request-error",
30+
"@npm//@octokit/rest",
31+
"@npm//@octokit/webhooks-types",
32+
"@npm//@types/node",
33+
"@npm//firebase-admin",
34+
"@npm//firebase-functions",
35+
],
36+
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as functions from 'firebase-functions';
2+
import * as admin from 'firebase-admin';
3+
import {
4+
checkAuthenticationAndAccess,
5+
BlockUserParams,
6+
blockedUsersCollection,
7+
getAuthenticatedGithubClient,
8+
} from './shared.js';
9+
import {RequestError} from '@octokit/request-error';
10+
11+
/** Blocks the requested user from Github for the prescribed amount of time. */
12+
export const blockUser = functions
13+
.runWith({
14+
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
15+
})
16+
.https.onCall(async ({comments, blockUntil, context, username}: BlockUserParams, request) => {
17+
// Ensure that the request was authenticated.
18+
checkAuthenticationAndAccess(request);
19+
20+
/** The Github client for performing Github actions. */
21+
const github = await getAuthenticatedGithubClient();
22+
/** The user performing the block action */
23+
const actor = await admin.auth().getUser(request.auth.uid);
24+
/** The display name of the user. */
25+
const actorName = actor.displayName || actor.email || 'Unknown User';
26+
/** The Firestore Document for the user being blocked. */
27+
const userDoc = await blockedUsersCollection().doc(username).get();
28+
29+
if (userDoc.exists) {
30+
throw Error();
31+
}
32+
33+
await github.orgs.blockUser({org: 'angular', username: username}).catch((err: RequestError) => {
34+
// If a user is already blocked, we can continue silently failing as action still "succeeded".
35+
if (err.message === 'Blocked user has already been blocked' && err.status === 422) {
36+
return;
37+
}
38+
throw err;
39+
});
40+
41+
await userDoc.ref.create({
42+
comments: comments,
43+
context: context,
44+
username: username,
45+
blockedBy: actorName,
46+
blockedOn: new Date(),
47+
blockUntil: new Date(blockUntil),
48+
});
49+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export {blockUser} from './blockUser.js';
2+
export {unblockUser, dailyUnblock} from './unblockUser.js';
3+
export {dailySync, syncUsersFromGithub} from './syncUsers.js';
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import * as admin from 'firebase-admin';
2+
import {Octokit} from '@octokit/rest';
3+
import {createAppAuth} from '@octokit/auth-app';
4+
import * as functions from 'firebase-functions';
5+
6+
/** Parameters for blocking a user. */
7+
export interface BlockUserParams {
8+
username: string;
9+
blockUntil: string;
10+
context: string;
11+
comments: string;
12+
}
13+
14+
/** Parameters for unblocking a user. */
15+
export interface UnblockUserParams {
16+
username: string;
17+
}
18+
19+
/**
20+
* Convertor to ensure the data types for javascript and firestore storage are in sync.
21+
*/
22+
export const converter: admin.firestore.FirestoreDataConverter<BlockedUser> = {
23+
toFirestore: (user: BlockedUser) => {
24+
return user;
25+
},
26+
fromFirestore: (data: admin.firestore.QueryDocumentSnapshot<BlockedUser>) => {
27+
return {
28+
username: data.get('username'),
29+
context: data.get('context'),
30+
comments: data.get('comments'),
31+
blockedBy: data.get('blockedBy'),
32+
blockUntil: new Date(data.get('blockUntil')),
33+
blockedOn: new Date(data.get('blockedOn')),
34+
};
35+
},
36+
};
37+
38+
/** Get the firestore collection for the blocked users, with the converter already set up. */
39+
export const blockedUsersCollection = () =>
40+
admin.firestore().collection('blockedUsers').withConverter(converter);
41+
42+
/** A blocked user stored in Firestore. */
43+
export interface BlockedUser extends admin.firestore.DocumentData {
44+
blockedBy: string;
45+
blockedOn: Date;
46+
username: string;
47+
blockUntil: Date;
48+
context: string;
49+
comments: string;
50+
}
51+
52+
/** A CallableContext which is confirmed to already have an authorized user. */
53+
interface AuthenticatedCallableContext extends functions.https.CallableContext {
54+
auth: NonNullable<functions.https.CallableContext['auth']>;
55+
}
56+
57+
/** Verify that the incoming request is authenticated and authorized for access. */
58+
export function checkAuthenticationAndAccess(
59+
context: functions.https.CallableContext,
60+
): asserts context is AuthenticatedCallableContext {
61+
// Authentication is managed by firebase as this occurs within the Firebase functions context.
62+
// If the user is unauthenticted, the authorization object will be undefined.
63+
if (context.auth == undefined) {
64+
// Throwing an HttpsError so that the client gets the error details.
65+
throw new functions.https.HttpsError('unauthenticated', 'This action requires authentication');
66+
}
67+
}
68+
69+
/** Retrieves a Github client instance authenticated as the Angular Robot Github App. */
70+
export async function getAuthenticatedGithubClient() {
71+
const GITHUB_APP_PEM = Buffer.from(
72+
process.env['ANGULAR_ROBOT_APP_PRIVATE_KEY']!,
73+
'base64',
74+
).toString('utf-8');
75+
76+
const applicationGithub = new Octokit({
77+
authStrategy: createAppAuth,
78+
auth: {appId: process.env['ANGULAR_ROBOT_APP_ID']!, privateKey: GITHUB_APP_PEM},
79+
});
80+
/** The specific installation id for the provided repository. */
81+
const {id: installation_id} = (await applicationGithub.apps.getOrgInstallation({org: 'angular'}))
82+
.data;
83+
/** A temporary github access token. */
84+
const {token} = (
85+
await applicationGithub.rest.apps.createInstallationAccessToken({installation_id})
86+
).data;
87+
88+
return new Octokit({auth: token});
89+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
blockedUsersCollection as getBlockedUsersCollection,
3+
getAuthenticatedGithubClient,
4+
checkAuthenticationAndAccess,
5+
} from './shared.js';
6+
import * as functions from 'firebase-functions';
7+
8+
/** Runs the synchronization of blocked users from Github to the blocked users once per day. */
9+
export const dailySync = functions
10+
.runWith({
11+
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
12+
})
13+
.pubsub.schedule('every day 08:00')
14+
.timeZone('America/Los_Angeles')
15+
.onRun(syncUsers);
16+
17+
/** Runs the synchronization of blocked users from Github to the blocked users list on demand. */
18+
export const syncUsersFromGithub = functions
19+
.runWith({
20+
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
21+
})
22+
.https.onCall(async (_: void, context) => {
23+
await checkAuthenticationAndAccess(context);
24+
await syncUsers();
25+
});
26+
27+
async function syncUsers() {
28+
/** The authenticated Github client for performing actions. */
29+
const github = await getAuthenticatedGithubClient();
30+
/** The firestore collection for blocked users */
31+
const blockedUsersCollection = getBlockedUsersCollection();
32+
/** A Firestore batch, allowing for atomic updating of the blocked users. */
33+
const writeBatch = blockedUsersCollection.firestore.batch();
34+
35+
/**
36+
* A Date object one year from today, the default block length applied for users discovered
37+
* from Githubs listing.
38+
*/
39+
const oneYearFromToday = (() => {
40+
const date = new Date();
41+
date.setFullYear(date.getFullYear() + 1);
42+
return date;
43+
})();
44+
45+
/** All of the currently blocked users for the Angular organization. */
46+
const blockedUsers = await github.paginate(github.orgs.listBlockedUsers, {
47+
org: 'angular',
48+
per_page: 100,
49+
});
50+
51+
for (let blockedUser of blockedUsers) {
52+
const firebaseUser = await blockedUsersCollection.doc(blockedUser.login).get();
53+
// For users we already know about from Github, we skip their records.
54+
if (firebaseUser.exists) {
55+
continue;
56+
}
57+
58+
writeBatch.create(firebaseUser.ref, {
59+
blockedBy: 'Imported From Github',
60+
blockedOn: new Date(),
61+
blockUntil: oneYearFromToday,
62+
comments: 'This record was automatically imported from Github',
63+
context: 'Unknown',
64+
username: blockedUser.login,
65+
});
66+
}
67+
await writeBatch.commit();
68+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {
2+
checkAuthenticationAndAccess,
3+
blockedUsersCollection,
4+
UnblockUserParams,
5+
BlockedUser,
6+
getAuthenticatedGithubClient,
7+
} from './shared.js';
8+
import {Octokit} from '@octokit/rest';
9+
import * as admin from 'firebase-admin';
10+
import * as functions from 'firebase-functions';
11+
12+
/** Unblocks the provided user from Github, clearing their records from our listing. */
13+
export const unblockUser = functions
14+
.runWith({
15+
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
16+
})
17+
.https.onCall(async ({username}: UnblockUserParams, request) => {
18+
await checkAuthenticationAndAccess(request);
19+
/** The authenticated Github client for performing actions. */
20+
const github = await getAuthenticatedGithubClient();
21+
/** The Firestore record of the user to be unblocked */
22+
const doc = await blockedUsersCollection().doc(username).get();
23+
24+
await performUnblock(github, doc);
25+
});
26+
27+
/** Unblocks the all listed users who's block has expired, runs daily. */
28+
export const dailyUnblock = functions
29+
.runWith({
30+
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
31+
})
32+
.pubsub.schedule('every day 08:00')
33+
.timeZone('America/Los_Angeles')
34+
.onRun(async () => {
35+
/** The authenticated Github client for performing actions. */
36+
const github = await getAuthenticatedGithubClient();
37+
/** The Firestore records for all users who's block has expired. */
38+
const usersToUnblock = await blockedUsersCollection()
39+
.where('blockUntil', '<', new Date())
40+
.get();
41+
42+
await Promise.all(usersToUnblock.docs.map(async (user) => performUnblock(github, user)));
43+
});
44+
45+
async function performUnblock(github: Octokit, doc: admin.firestore.DocumentSnapshot<BlockedUser>) {
46+
await github.orgs
47+
.unblockUser({org: 'angular', username: doc.get('username')})
48+
.then(() => doc.ref.delete());
49+
}

apps/functions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './githubWebhook/index.js';
22
export * from './ng-dev/index.js';
3+
export * from './code-of-conduct/index.js';
34
import * as admin from 'firebase-admin';
45

56
admin.initializeApp();

apps/functions/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"engines": {
44
"node": "18"
55
},
6-
"main": "functions_compiled/index.mjs",
6+
"main": "functions_compiled/index.cjs",
77
"type": "module",
88
"private": true
99
}

0 commit comments

Comments
 (0)