Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ copy_to_bin(
],
)

copy_to_bin(
name = "firebase_assets",
srcs = [

# Firebase function files
"//apps/functions:functions_files",

# Firebase hosted application files
"//apps/code-of-conduct:application_files",
".firebaserc",
"deploy_wrapper_local.sh",
"firebase.json",
"firestore.indexes.json",
"firestore.rules",
],
)

firebase.firebase_binary(
name = "serve",
chdir = package_name(),
Expand Down
1 change: 1 addition & 0 deletions apps/code-of-conduct/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ ng_project(
":node_modules/@angular/compiler",
":node_modules/@angular/core",
":node_modules/@angular/fire",
":node_modules/@angular/material",
":node_modules/@angular/platform-browser",
":node_modules/@angular/router",
":node_modules/zone.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export class BlockUserComponent {
updateUser() {
this.dialogRef.disableClose = true;
this.blockService
.update(this.providedData.user!.username, this.blockUserForm.value)
.update({username: this.providedData.user!.username, data: this.blockUserForm.value})
.then(() => this.dialogRef.close(), console.error)
.finally(() => {
this.blockUserForm.enable();
Expand Down
101 changes: 51 additions & 50 deletions apps/code-of-conduct/app/block.service.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,8 @@
import {Injectable, inject} from '@angular/core';
import {
updateDoc,
collection,
QueryDocumentSnapshot,
FirestoreDataConverter,
collectionSnapshots,
Firestore,
doc,
getDoc,
} from '@angular/fire/firestore';
import {QueryDocumentSnapshot, FirestoreDataConverter} from '@angular/fire/firestore';
import {httpsCallable, Functions} from '@angular/fire/functions';
import {map, shareReplay} from 'rxjs';
import {map, shareReplay, from, switchMap, BehaviorSubject} from 'rxjs';
import {MatSnackBar} from '@angular/material/snack-bar';

export interface BlockUserParams {
/** The username of the user being blocked. */
Expand Down Expand Up @@ -46,55 +38,64 @@ export type BlockedUserFromFirestore = BlockedUser & {
export class BlockService {
/** Firebase functions instance, provided from the root. */
private functions = inject(Functions);
/** Firebase firestore instance, provided from the root. */
private firestore = inject(Firestore);
/** Snackbar for displaying failure alerts. */
private snackBar = inject(MatSnackBar);
/** Subject to trigger refreshing the blocked users list. */
private refreshBlockedUsers$ = new BehaviorSubject<void>(undefined);

/** Request a user to be blocked by the blocking service. */
readonly block = httpsCallable(this.functions, 'blockUser');

/** Request a user to be unblocked by the blocking service. */
readonly unblock = httpsCallable<UnblockUserParams>(this.functions, 'unblockUser');

/** Request a sync of all blocked users with the current Github blockings. */
readonly syncUsersFromGithub = httpsCallable<void>(this.functions, 'syncUsersFromGithub');
/** Request all blocked users. */
getBlockedUsers = this.asCallable<void, BlockedUserFromFirestore[]>('getBlockedUsers', true);

/** All blocked users current blocked by the blocking service. */
readonly blockedUsers = collectionSnapshots(
collection(this.firestore, 'blockedUsers').withConverter(converter),
).pipe(
readonly blockedUsers = this.refreshBlockedUsers$.pipe(
switchMap(() => from(this.getBlockedUsers())),
map((blockedUsers) =>
blockedUsers
.map((snapshot) => snapshot.data())
.map((user) => ({
...user,
blockUntil: user.blockUntil === false ? false : new Date(user.blockUntil),
blockedOn: new Date(user.blockedOn),
}))
.sort((a, b) => (a.username.toLowerCase() > b.username.toLowerCase() ? 1 : -1)),
),
shareReplay(1),
);

/** Request a user to be blocked. */
block = this.asCallable<BlockUserParams, void>('blockUser');

/** Request a user to be unblocked. */
unblock = this.asCallable<UnblockUserParams, void>('unblockUser');

/** Request a sync of all blocked users with the current Github blockings. */
syncUsersFromGithub = this.asCallable<void, void>('syncUsersFromGithub');

/** Update the metadata for a blocked user. */
async update(username: string, data: Partial<BlockedUser>) {
const userDoc = await getDoc(
doc(collection(this.firestore, 'blockedUsers').withConverter(converter), username),
);
if (userDoc.exists()) {
return await updateDoc(userDoc.ref, data);
}
throw Error(`The entry for ${username} does not exist`);
}
}
update = this.asCallable<{username: string; data: Partial<BlockedUser>}, void>('updateUser');

export const converter: FirestoreDataConverter<BlockedUser> = {
toFirestore: (user: BlockedUser) => {
return user;
},
fromFirestore: (data: QueryDocumentSnapshot<BlockedUser>) => {
return {
username: data.get('username'),
context: data.get('context'),
comments: data.get('comments'),
blockedBy: data.get('blockedBy'),
blockUntil:
data.get('blockUntil') === false ? false : new Date(data.get('blockUntil').seconds * 1000),
blockedOn: new Date(data.get('blockedOn').seconds * 1000),
/**
* Helper function to create a callable function that automatically refreshes the blocked users list.
* @param callableName The name of the callable function to create.
* @returns A function that can be called to invoke the callable function.
*/
private asCallable<T, R>(
callableName: string,
skipRefresh = false,
): (callableArg: T) => Promise<R> {
return async (callableArg: T) => {
try {
const result = await httpsCallable<T, R>(this.functions, callableName)(callableArg);
if (!skipRefresh) {
this.refreshBlockedUsers$.next();
}
return result.data;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.snackBar.open(`Failed to execute ${callableName}: ${message}`, 'Dismiss', {
duration: 5000,
});
throw error;
}
};
},
};
}
}
50 changes: 37 additions & 13 deletions apps/code-of-conduct/app/user-table/user-table.component.html
Original file line number Diff line number Diff line change
@@ -1,42 +1,66 @@
<mat-table [dataSource]="blockService.blockedUsers">
<mat-table [dataSource]="blockService.blockedUsers | async">
<ng-container matColumnDef="username">
<mat-header-cell *matHeaderCellDef> Username </mat-header-cell>
<mat-cell class="username-cell" *matCellDef="let user">
<img src="https://github.com/{{user.username}}.png" alt="username">
<span>{{user.username}}</span>
<img src="https://github.com/{{ user.username }}.png" alt="username" />
<span>{{ user.username }}</span>
</mat-cell>
</ng-container>

<ng-container matColumnDef="blockUntil">
<mat-header-cell *matHeaderCellDef> Blocked Until </mat-header-cell>
<mat-cell *matCellDef="let user"> {{user.blockUntil === false ? 'Blocked Indefinitely' : user.blockUntil | date}} </mat-cell>
<mat-cell *matCellDef="let user">
{{ user.blockUntil === false ? 'Blocked Indefinitely' : (user.blockUntil | date) }}
</mat-cell>
</ng-container>

<ng-container matColumnDef="blockedBy">
<mat-header-cell *matHeaderCellDef> Blocked By </mat-header-cell>
<mat-cell class="username-cell" *matCellDef="let user">
<span>{{user.blockedBy}}</span>
<span>{{ user.blockedBy }}</span>
</mat-cell>
</ng-container>

<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef>
<button matTooltip="Resync blocked users from Github into the app" [disabled]="forceSyncInProgress" mat-icon-button (click)="forceSync()">
<mat-progress-spinner *ngIf="forceSyncInProgress" mode="indeterminate" diameter="24"></mat-progress-spinner>
<button
matTooltip="Resync blocked users from Github into the app"
[disabled]="forceSyncInProgress"
mat-icon-button
(click)="forceSync()"
>
<mat-progress-spinner
*ngIf="forceSyncInProgress"
mode="indeterminate"
diameter="24"
></mat-progress-spinner>
<mat-icon *ngIf="!forceSyncInProgress">sync</mat-icon>
</button>
</mat-header-cell>
<mat-cell *matCellDef="let user;">
<button matTooltip="Unblock {{user.username }} immediately" [disabled]="user.inProgress" mat-icon-button (click)="unblock(user)">
<mat-progress-spinner *ngIf="user.inProgress" mode="indeterminate" diameter="24"></mat-progress-spinner>
<mat-cell *matCellDef="let user">
<button
matTooltip="Unblock {{ user.username }} immediately"
[disabled]="user.inProgress"
mat-icon-button
(click)="unblock(user)"
>
<mat-progress-spinner
*ngIf="user.inProgress"
mode="indeterminate"
diameter="24"
></mat-progress-spinner>
<mat-icon *ngIf="!user.inProgress">lock_open</mat-icon>
</button>
<button matTooltip="Edit information for {{user.username}}" mat-icon-button (click)="editUser(user)">
<button
matTooltip="Edit information for {{ user.username }}"
mat-icon-button
(click)="editUser(user)"
>
<mat-icon>edit</mat-icon>
</button>
</mat-cell>
</ng-container>

<mat-header-row *matHeaderRowDef="columns"></mat-header-row>
<mat-row *matRowDef="let row; columns: columns;"></mat-row>
</mat-table>
<mat-row *matRowDef="let row; columns: columns"></mat-row>
</mat-table>
3 changes: 2 additions & 1 deletion apps/code-of-conduct/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {routes} from './app/app.routes.js';
import {environment} from './environment.js';
import {provideFunctions, getFunctions} from '@angular/fire/functions';
import {provideFirestore, getFirestore} from '@angular/fire/firestore';
import {MatSnackBarModule} from '@angular/material/snack-bar';

if (environment.production) {
enableProdMode();
Expand All @@ -19,7 +20,7 @@ if (environment.production) {
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
importProvidersFrom([BrowserAnimationsModule]),
importProvidersFrom([BrowserAnimationsModule, MatSnackBarModule]),
provideFirebaseApp(() => initializeApp(environment.firebase)),
provideAuth(() => getAuth()),
provideFunctions(() => getFunctions()),
Expand Down
37 changes: 37 additions & 0 deletions apps/deploy_wrapper_local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -e

# Navigate to the directory where the script is running
cd "$(dirname "$0")"

if [ -L functions/node_modules/firebase-functions ]; then
TARGET=$(readlink functions/node_modules/firebase-functions)
VIRTUAL_NODE_MODULES="functions/node_modules/$(dirname "$TARGET")"
echo "Materializing firebase-functions symlink..."
rm functions/node_modules/firebase-functions
cp -rL "functions/node_modules/$TARGET" functions/node_modules/firebase-functions

echo "Creating symlinks for dependencies from $VIRTUAL_NODE_MODULES..."
for dep in "$VIRTUAL_NODE_MODULES"/*; do
dep_name=$(basename "$dep")
if [ "$dep_name" != "firebase-functions" ]; then
dep_target=$(readlink -f "$dep")
rm -rf "functions/node_modules/$dep_name"
ln -sf "$dep_target" "functions/node_modules/$dep_name"
fi
done
fi

# Create .bin directory and symlink for firebase-functions executable
# firebase-tools explicitly searches for .bin/firebase-functions to locate the SDK
echo "Creating .bin/firebase-functions symlink..."
mkdir -p functions/node_modules/.bin
ln -sf ../firebase-functions/lib/bin/firebase-functions.js functions/node_modules/.bin/firebase-functions
chmod +x functions/node_modules/.bin/firebase-functions

# Fix package.json to match CommonJS bundle format
echo "Removing type: module from package.json..."
sed -i '/"type": "module"/d' functions/package.json

echo "Running firebase deploy..."
npx -p firebase-tools firebase deploy --project internal-200822 --config firebase.json
11 changes: 9 additions & 2 deletions apps/firestore.rules
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /blockedUsers/{user} {
// Deny all reads from client, must use cloud functions for authorization
allow read: if false;
// Deny all writes from client, must use cloud functions
allow write: if false;
}

// Deny access to anything else by default
match /{document=**} {
allow read, create: if request.auth != null;
allow update: if request.auth != null && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['blockUntil', 'comments']);
allow read, write: if false;
}
}
}
22 changes: 20 additions & 2 deletions apps/functions/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,29 @@ package(default_visibility = ["//visibility:private"])

npm_link_all_packages()

# TODO: Figure out a different way to deal with the file linking issues.
genrule(
name = "fixed_bundle",
srcs = [":raw_bundle"],
outs = ["bundle.js"],
cmd = """
for f in $(locations :raw_bundle); do
if [[ $$f == *.js ]]; then
cp $$f $@
break
fi
done
chmod +w $@
sed -i 's/(0, import_node_module\\.createRequire)(import_meta\\.url)/(0, import_node_module.createRequire)(__filename)/g' $@
sed -i -E 's/import_([a-zA-Z0-9_]+)\\.default/(import_\\1.default || import_\\1)/g' $@
""",
)

copy_to_bin(
name = "functions_files",
srcs = [
"package.json",
":bundle",
":fixed_bundle",
"//apps/functions:node_modules/firebase-tools",
],
visibility = ["//apps:__pkg__"],
Expand All @@ -28,7 +46,7 @@ ts_project(
)

esbuild(
name = "bundle",
name = "raw_bundle",
srcs = [
":functions",
"//apps/functions:node_modules/firebase-functions",
Expand Down
2 changes: 2 additions & 0 deletions apps/functions/code-of-conduct/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@ ts_project(
name = "lib",
srcs = [
"blockUser.ts",
"getBlockedUsers.ts",
"shared.ts",
"syncUsers.ts",
"unblockUser.ts",
"updateUser.ts",
],
deps = [
"//apps/functions:node_modules/@octokit/auth-app",
Expand Down
8 changes: 4 additions & 4 deletions apps/functions/code-of-conduct/blockUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ export const blockUser = functions.https.onCall<BlockUserParams>(
},
async (request) => {
const {comments, blockUntil, context, username} = request.data;
// Ensure that the request was authenticated.
checkAuthenticationAndAccess(request);
// Ensure that the request was authenticated and authorized.
const authRequest = await 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);
const actor = await admin.auth().getUser(authRequest.auth.uid);
/** The display name of the user. */
const actorName = actor.displayName || actor.email || 'Unknown User';
/** The Firestore Document for the user being blocked. */
Expand All @@ -47,7 +47,7 @@ export const blockUser = functions.https.onCall<BlockUserParams>(
username: username,
blockedBy: actorName,
blockedOn: new Date(),
blockUntil: new Date(blockUntil),
blockUntil: blockUntil === false ? false : new Date(blockUntil),
});
},
);
Loading
Loading