Skip to content

Commit

Permalink
feat(apps): create the code of conduct managing functions (#1109)
Browse files Browse the repository at this point in the history
Create the Firebase functions used for managing the code of conduct application.

PR Close #1109
  • Loading branch information
josephperrott committed Apr 21, 2023
1 parent 08f8d67 commit 3f26e9e
Show file tree
Hide file tree
Showing 13 changed files with 332 additions and 5 deletions.
6 changes: 4 additions & 2 deletions apps/firestore.rules
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if false
allow read, create: if request.auth != null;
allow update: if request.auth != null && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['blockUntil', 'comments']);
}
}
}
}
5 changes: 3 additions & 2 deletions apps/functions/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
load("//tools:defaults.bzl", "esbuild_esm_bundle", "ts_library")
load("//tools:defaults.bzl", "esbuild_cjs_bundle", "ts_library")
load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin")

package(default_visibility = ["//visibility:private"])
Expand All @@ -17,13 +17,14 @@ ts_library(
"index.ts",
],
deps = [
"//apps/functions/code-of-conduct",
"//apps/functions/githubWebhook",
"//apps/functions/ng-dev",
"@npm//firebase-admin",
],
)

esbuild_esm_bundle(
esbuild_cjs_bundle(
name = "functions_compiled",
entry_points = [
"index.ts",
Expand Down
36 changes: 36 additions & 0 deletions apps/functions/code-of-conduct/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
load("//tools:defaults.bzl", "ts_library")

package(default_visibility = ["//visibility:private"])

ts_library(
name = "code-of-conduct",
srcs = [
"index.ts",
],
visibility = [
"//apps/functions:__pkg__",
],
deps = [
":lib",
"@npm//firebase-admin",
],
)

ts_library(
name = "lib",
srcs = [
"blockUser.ts",
"shared.ts",
"syncUsers.ts",
"unblockUser.ts",
],
deps = [
"@npm//@octokit/auth-app",
"@npm//@octokit/request-error",
"@npm//@octokit/rest",
"@npm//@octokit/webhooks-types",
"@npm//@types/node",
"@npm//firebase-admin",
"@npm//firebase-functions",
],
)
49 changes: 49 additions & 0 deletions apps/functions/code-of-conduct/blockUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import {
checkAuthenticationAndAccess,
BlockUserParams,
blockedUsersCollection,
getAuthenticatedGithubClient,
} from './shared.js';
import {RequestError} from '@octokit/request-error';

/** Blocks the requested user from Github for the prescribed amount of time. */
export const blockUser = functions
.runWith({
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
})
.https.onCall(async ({comments, blockUntil, context, username}: BlockUserParams, request) => {
// Ensure that the request was authenticated.
checkAuthenticationAndAccess(request);

/** The Github client for performing Github actions. */
const github = await getAuthenticatedGithubClient();
/** The user performing the block action */
const actor = await admin.auth().getUser(request.auth.uid);
/** The display name of the user. */
const actorName = actor.displayName || actor.email || 'Unknown User';
/** The Firestore Document for the user being blocked. */
const userDoc = await blockedUsersCollection().doc(username).get();

if (userDoc.exists) {
throw Error();
}

await github.orgs.blockUser({org: 'angular', username: username}).catch((err: RequestError) => {
// If a user is already blocked, we can continue silently failing as action still "succeeded".
if (err.message === 'Blocked user has already been blocked' && err.status === 422) {
return;
}
throw err;
});

await userDoc.ref.create({
comments: comments,
context: context,
username: username,
blockedBy: actorName,
blockedOn: new Date(),
blockUntil: new Date(blockUntil),
});
});
3 changes: 3 additions & 0 deletions apps/functions/code-of-conduct/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {blockUser} from './blockUser.js';
export {unblockUser, dailyUnblock} from './unblockUser.js';
export {dailySync, syncUsersFromGithub} from './syncUsers.js';
89 changes: 89 additions & 0 deletions apps/functions/code-of-conduct/shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import * as admin from 'firebase-admin';
import {Octokit} from '@octokit/rest';
import {createAppAuth} from '@octokit/auth-app';
import * as functions from 'firebase-functions';

/** Parameters for blocking a user. */
export interface BlockUserParams {
username: string;
blockUntil: string;
context: string;
comments: string;
}

/** Parameters for unblocking a user. */
export interface UnblockUserParams {
username: string;
}

/**
* Convertor to ensure the data types for javascript and firestore storage are in sync.
*/
export const converter: admin.firestore.FirestoreDataConverter<BlockedUser> = {
toFirestore: (user: BlockedUser) => {
return user;
},
fromFirestore: (data: admin.firestore.QueryDocumentSnapshot<BlockedUser>) => {
return {
username: data.get('username'),
context: data.get('context'),
comments: data.get('comments'),
blockedBy: data.get('blockedBy'),
blockUntil: new Date(data.get('blockUntil')),
blockedOn: new Date(data.get('blockedOn')),
};
},
};

/** Get the firestore collection for the blocked users, with the converter already set up. */
export const blockedUsersCollection = () =>
admin.firestore().collection('blockedUsers').withConverter(converter);

/** A blocked user stored in Firestore. */
export interface BlockedUser extends admin.firestore.DocumentData {
blockedBy: string;
blockedOn: Date;
username: string;
blockUntil: Date;
context: string;
comments: string;
}

/** A CallableContext which is confirmed to already have an authorized user. */
interface AuthenticatedCallableContext extends functions.https.CallableContext {
auth: NonNullable<functions.https.CallableContext['auth']>;
}

/** Verify that the incoming request is authenticated and authorized for access. */
export function checkAuthenticationAndAccess(
context: functions.https.CallableContext,
): asserts context is AuthenticatedCallableContext {
// Authentication is managed by firebase as this occurs within the Firebase functions context.
// If the user is unauthenticted, the authorization object will be undefined.
if (context.auth == undefined) {
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('unauthenticated', 'This action requires authentication');
}
}

/** Retrieves a Github client instance authenticated as the Angular Robot Github App. */
export async function getAuthenticatedGithubClient() {
const GITHUB_APP_PEM = Buffer.from(
process.env['ANGULAR_ROBOT_APP_PRIVATE_KEY']!,
'base64',
).toString('utf-8');

const applicationGithub = new Octokit({
authStrategy: createAppAuth,
auth: {appId: process.env['ANGULAR_ROBOT_APP_ID']!, privateKey: GITHUB_APP_PEM},
});
/** The specific installation id for the provided repository. */
const {id: installation_id} = (await applicationGithub.apps.getOrgInstallation({org: 'angular'}))
.data;
/** A temporary github access token. */
const {token} = (
await applicationGithub.rest.apps.createInstallationAccessToken({installation_id})
).data;

return new Octokit({auth: token});
}
68 changes: 68 additions & 0 deletions apps/functions/code-of-conduct/syncUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
blockedUsersCollection as getBlockedUsersCollection,
getAuthenticatedGithubClient,
checkAuthenticationAndAccess,
} from './shared.js';
import * as functions from 'firebase-functions';

/** Runs the synchronization of blocked users from Github to the blocked users once per day. */
export const dailySync = functions
.runWith({
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
})
.pubsub.schedule('every day 08:00')
.timeZone('America/Los_Angeles')
.onRun(syncUsers);

/** Runs the synchronization of blocked users from Github to the blocked users list on demand. */
export const syncUsersFromGithub = functions
.runWith({
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
})
.https.onCall(async (_: void, context) => {
await checkAuthenticationAndAccess(context);
await syncUsers();
});

async function syncUsers() {
/** The authenticated Github client for performing actions. */
const github = await getAuthenticatedGithubClient();
/** The firestore collection for blocked users */
const blockedUsersCollection = getBlockedUsersCollection();
/** A Firestore batch, allowing for atomic updating of the blocked users. */
const writeBatch = blockedUsersCollection.firestore.batch();

/**
* A Date object one year from today, the default block length applied for users discovered
* from Githubs listing.
*/
const oneYearFromToday = (() => {
const date = new Date();
date.setFullYear(date.getFullYear() + 1);
return date;
})();

/** All of the currently blocked users for the Angular organization. */
const blockedUsers = await github.paginate(github.orgs.listBlockedUsers, {
org: 'angular',
per_page: 100,
});

for (let blockedUser of blockedUsers) {
const firebaseUser = await blockedUsersCollection.doc(blockedUser.login).get();
// For users we already know about from Github, we skip their records.
if (firebaseUser.exists) {
continue;
}

writeBatch.create(firebaseUser.ref, {
blockedBy: 'Imported From Github',
blockedOn: new Date(),
blockUntil: oneYearFromToday,
comments: 'This record was automatically imported from Github',
context: 'Unknown',
username: blockedUser.login,
});
}
await writeBatch.commit();
}
49 changes: 49 additions & 0 deletions apps/functions/code-of-conduct/unblockUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
checkAuthenticationAndAccess,
blockedUsersCollection,
UnblockUserParams,
BlockedUser,
getAuthenticatedGithubClient,
} from './shared.js';
import {Octokit} from '@octokit/rest';
import * as admin from 'firebase-admin';
import * as functions from 'firebase-functions';

/** Unblocks the provided user from Github, clearing their records from our listing. */
export const unblockUser = functions
.runWith({
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
})
.https.onCall(async ({username}: UnblockUserParams, request) => {
await checkAuthenticationAndAccess(request);
/** The authenticated Github client for performing actions. */
const github = await getAuthenticatedGithubClient();
/** The Firestore record of the user to be unblocked */
const doc = await blockedUsersCollection().doc(username).get();

await performUnblock(github, doc);
});

/** Unblocks the all listed users who's block has expired, runs daily. */
export const dailyUnblock = functions
.runWith({
secrets: ['ANGULAR_ROBOT_APP_PRIVATE_KEY', 'ANGULAR_ROBOT_APP_ID'],
})
.pubsub.schedule('every day 08:00')
.timeZone('America/Los_Angeles')
.onRun(async () => {
/** The authenticated Github client for performing actions. */
const github = await getAuthenticatedGithubClient();
/** The Firestore records for all users who's block has expired. */
const usersToUnblock = await blockedUsersCollection()
.where('blockUntil', '<', new Date())
.get();

await Promise.all(usersToUnblock.docs.map(async (user) => performUnblock(github, user)));
});

async function performUnblock(github: Octokit, doc: admin.firestore.DocumentSnapshot<BlockedUser>) {
await github.orgs
.unblockUser({org: 'angular', username: doc.get('username')})
.then(() => doc.ref.delete());
}
1 change: 1 addition & 0 deletions apps/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './githubWebhook/index.js';
export * from './ng-dev/index.js';
export * from './code-of-conduct/index.js';
import * as admin from 'firebase-admin';

admin.initializeApp();
2 changes: 1 addition & 1 deletion apps/functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"engines": {
"node": "18"
},
"main": "functions_compiled/index.mjs",
"main": "functions_compiled/index.cjs",
"type": "module",
"private": true
}
20 changes: 20 additions & 0 deletions bazel/esbuild/index.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ const require = __cjsCompatRequire(import.meta.url);
**kwargs
)

def esbuild_cjs_bundle(name, **kwargs):
"""ESBuild macro supports an ESM/CJS interop.
Args:
name: Name of the target
**kwargs: Other arguments passed to the `esbuild` rule.
"""

args = dict(
resolveExtensions = [".cjs", ".js", ".json"],
outExtension = {".js": ".cjs"},
)

esbuild(
name = name,
format = "cjs",
args = args,
**kwargs
)

def esbuild_amd(name, entry_point, module_name, testonly = False, config = None, deps = [], **kwargs):
"""Generates an AMD bundle for the specified entry-point with the given AMD module name."""
expand_template(
Expand Down
2 changes: 2 additions & 0 deletions tools/defaults.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ load(
"//tools:esbuild.bzl",
_esbuild = "esbuild",
_esbuild_checked_in = "esbuild_checked_in",
_esbuild_cjs_bundle = "esbuild_cjs_bundle",
_esbuild_config = "esbuild_config",
_esbuild_esm_bundle = "esbuild_esm_bundle",
)
Expand All @@ -15,6 +16,7 @@ esbuild = _esbuild
esbuild_config = _esbuild_config
esbuild_esm_bundle = _esbuild_esm_bundle
esbuild_checked_in = _esbuild_checked_in
esbuild_cjs_bundle = _esbuild_cjs_bundle

jasmine_node_test = _jasmine_node_test

Expand Down
Loading

0 comments on commit 3f26e9e

Please sign in to comment.