Skip to content

Commit

Permalink
feat: google drive integration w/ resume book 📚 (#387)
Browse files Browse the repository at this point in the history
  • Loading branch information
ramiAbdou authored Jul 20, 2024
1 parent c41f8d3 commit cb3e329
Show file tree
Hide file tree
Showing 9 changed files with 346 additions and 27 deletions.
22 changes: 21 additions & 1 deletion apps/api/src/routers/oauth.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import express from 'express';
import { match } from 'ts-pattern';
import { z } from 'zod';

import { loginWithOAuth, OAuthCodeState } from '@oyster/core/api';
import {
loginWithOAuth,
OAuthCodeState,
saveGoogleDriveCredentials,
} from '@oyster/core/api';

export const oauthRouter = express.Router();

Expand All @@ -23,6 +27,22 @@ oauthRouter.get('/oauth/google', async (req, res) => {
}
});

// This route is used to save the credentials to access the Google Drive API
// on behalf of the user.
oauthRouter.get('/oauth/google/drive', async (req, res) => {
try {
const query = AuthorizationCodeQuery.pick({ code: true }).parse(req.query);

await saveGoogleDriveCredentials(query.code);

return res.json({ ok: true });
} catch (e) {
return res.status(500).json({
message: (e as Error).message,
});
}
});

oauthRouter.get('/oauth/slack', async (req, res) => {
const query = AuthorizationCodeQuery.parse(req.query);

Expand Down
1 change: 1 addition & 0 deletions apps/member-profile/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ STUDENT_PROFILE_URL=http://localhost:3000
# GITHUB_OAUTH_CLIENT_ID=
# GITHUB_OAUTH_CLIENT_SECRET=
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# GOOGLE_MAPS_API_KEY=
# MIXPANEL_TOKEN=
# POSTMARK_API_TOKEN=
Expand Down
2 changes: 2 additions & 0 deletions apps/member-profile/app/shared/constants.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const BaseEnvironmentConfig = z.object({
GITHUB_OAUTH_CLIENT_ID: EnvironmentVariable,
GITHUB_OAUTH_CLIENT_SECRET: EnvironmentVariable,
GOOGLE_CLIENT_ID: EnvironmentVariable,
GOOGLE_CLIENT_SECRET: EnvironmentVariable,
GOOGLE_MAPS_API_KEY: EnvironmentVariable,
JWT_SECRET: EnvironmentVariable,
MIXPANEL_TOKEN: EnvironmentVariable,
Expand Down Expand Up @@ -42,6 +43,7 @@ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [
GITHUB_OAUTH_CLIENT_ID: true,
GITHUB_OAUTH_CLIENT_SECRET: true,
GOOGLE_CLIENT_ID: true,
GOOGLE_CLIENT_SECRET: true,
GOOGLE_MAPS_API_KEY: true,
MIXPANEL_TOKEN: true,
R2_ACCESS_KEY_ID: true,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { educationWorker } from './modules/education/education.worker';
export { workExperienceWorker } from './modules/employment/employment.worker';
export { eventWorker } from './modules/event/event.worker';
export { gamificationWorker } from './modules/gamification/gamification.worker';
export { saveGoogleDriveCredentials } from './modules/google-drive';
export { emailMarketingWorker } from './modules/mailchimp/email-marketing.worker';
export { memberEmailWorker } from './modules/member/member-email.worker';
export { memberWorker } from './modules/member/member.worker';
Expand Down
229 changes: 229 additions & 0 deletions packages/core/src/modules/google-drive/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { redis } from '@/infrastructure/redis';
import { OAuthTokenResponse } from '@/modules/authentication/oauth.service';
import { ColorStackError } from '@/shared/errors';
import { validate } from '@/shared/utils/zod.utils';

// Environment Variables

const API_URL = process.env.API_URL as string;
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID as string;
const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET as string;

// Core

type UploadFileInput = {
/**
* The file to upload to Google Drive, which is sent in a `multipart/form-data`
* request.
*/
file: File;

/**
* If provided, this is the ID of the file to update. This will send a `PATCH`
* request instead of a `POST` request to the Google Drive API.
*/
fileId?: string;

/**
* The name of the file to upload. If not provided, the file's original name
* will be used.
*/
fileName?: string;

/**
* The ID of the folder to upload the file to. If not provided, the file will
* be uploaded to the root of the user's Google Drive.
*/
folderId: string;
};

/**
* Uploads a file to Google Drive. If a `fileId` is provided, this will update
* the file instead of creating a new one.
*
* Returns the ID of the file that was uploaded.
*
* @see https://developers.google.com/drive/api/v3/manage-uploads
*/
export async function uploadFileToGoogleDrive({
file,
fileId,
fileName,
folderId,
}: UploadFileInput) {
const accessToken = await retrieveAccessToken();

const isUpdate = !!fileId;

const metadata = {
mimeType: file.type,
name: fileName || file.name,
parents: !!folderId && !isUpdate ? [folderId] : undefined,
};

const form = new FormData();

form.set(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' })
);

form.set('file', file);

const response = await fetch(
isUpdate
? `https://www.googleapis.com/upload/drive/v3/files/${fileId}?uploadType=multipart`
: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
{
body: form,
method: isUpdate ? 'PATCH' : 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
);

const json = await response.json();

if (!response.ok) {
throw new ColorStackError()
.withMessage('Failed to upload file to Google Drive.')
.withContext({ fileId, metadata, response: json })
.report();
}

return json.id as string;
}

// Authentication

const ACCESS_TOKEN_KEY = 'google_drive:access_token';
const EXPIRES_AT_KEY = 'google_drive:expires_at';
const REFRESH_TOKEN_KEY = 'google_drive:refresh_token';

/**
* Exchanges the authorization code for Google Drive credentials. Saves the
* credentials in Redis.
*
* @see https://developers.google.com/identity/protocols/oauth2/web-server#exchange-authorization-code
*/
export async function saveGoogleDriveCredentials(code: string) {
const body = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: API_URL + '/oauth/google/drive',
});

const response = await fetch('https://oauth2.googleapis.com/token', {
body,
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});

const json = await response.json();

if (!response.ok) {
throw new ColorStackError()
.withMessage('Failed to exchange code for Google Drive credentials.')
.withContext(json);
}

const data = validate(
OAuthTokenResponse.pick({
access_token: true,
expires_in: true,
refresh_token: true,
}),
json
);

const expiresAt = Date.now() + data.expires_in * 1000;

await Promise.all([
redis.set(ACCESS_TOKEN_KEY, data.access_token),
redis.set(EXPIRES_AT_KEY, expiresAt),
redis.set(REFRESH_TOKEN_KEY, data.refresh_token),
]);
}

async function retrieveAccessToken() {
const [accessToken = '', expiresAt = '', refreshToken = ''] =
await Promise.all([
redis.get(ACCESS_TOKEN_KEY),
redis.get(EXPIRES_AT_KEY),
redis.get(REFRESH_TOKEN_KEY),
]);

if (!refreshToken) {
throw new ColorStackError().withMessage(
'Failed to find the Google Drive refresh token in Redis.'
);
}

// We track the expiration time of the access token, so if it has yet to
// expire, we can use it!
if (!!expiresAt && Date.now() < parseInt(expiresAt)) {
return accessToken;
}

// Otherwise, we need to refresh the access token.
const newAccessToken = await refreshCredentials(refreshToken);

return newAccessToken;
}

/**
* Refreshes the Google Drive credentials by using the refresh token. This
* does NOT update the refresh token, which should never change unless the user
* revokes access. Saves the updated credentials in Redis.
*
* Returns the new access token.
*
* @see https://developers.google.com/identity/protocols/oauth2/web-server#offline
*/
async function refreshCredentials(refreshToken: string) {
const body = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: refreshToken,
});

const response = await fetch('https://oauth2.googleapis.com/token', {
body,
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});

const json = await response.json();

if (!response.ok) {
throw new ColorStackError()
.withMessage('Failed to refresh Google Drive credentials.')
.withContext(json)
.report();
}

const data = validate(
OAuthTokenResponse.pick({
access_token: true,
expires_in: true,
}),
json
);

const expiresAt = Date.now() + data.expires_in * 1000;

await Promise.all([
redis.set(ACCESS_TOKEN_KEY, data.access_token),
redis.set(EXPIRES_AT_KEY, expiresAt),
]);

return data.access_token;
}
Loading

0 comments on commit cb3e329

Please sign in to comment.