Skip to content

Commit

Permalink
feat: google drive support (#387)
Browse files Browse the repository at this point in the history
* feat: add store export/import

* chore: update types for export

* chore: lint & build

* chore: add basic backup tests

* chore: lint

* chore: add unit tests

* feat: add state backup encryption

* chore: remove duplicate call

* chore: add test google login to dapp

* feat: add google drive service

* chore: add basic gdrive rpc methods

* chore: build

* fix: rename method in test

* chore: update google service

* chore: update snap service methods

* chore: add setGoogleToken connector method

* chore: lint

* chore: add connector methods

* feat: improve PR (added UI, refactor code)

* chore: rename Crypto.service to Encryption.service

* chore: lint

* chore: add validate google token method

* chore: update google button to store token

* fix: dapp & connector bugs

* feat: add google api route

* feat: refactor google drive buttons

* chore: lint and build

* chore: remove old drive implementation

* fix: remove old google test suite

* fix: typo in QR scanner view

* fix: narrow google scopes

* fix: add backup success toast

* fix: google button spacing

* fix: menu popover errors

* chore: update .env.example

Signed-off-by: Urban Vidovič <[email protected]>

* fix: use translations for google drive import/export buttons

Signed-off-by: Urban Vidovič <[email protected]>

* chore: add changeset

Signed-off-by: Urban Vidovič <[email protected]>

* chore: remove console.log and use switch statement

Signed-off-by: Urban Vidovič <[email protected]>

* chore: format index.tsx

Signed-off-by: Urban Vidovič <[email protected]>

* fix: error [object object]

* fix: google error handling refactor

* fix: google delete button style

* chore: lint

---------

Signed-off-by: Urban Vidovič <[email protected]>
Co-authored-by: martines3000 <[email protected]>
Co-authored-by: Andraz <[email protected]>
Co-authored-by: andyv09 <[email protected]>
Co-authored-by: Urban Vidovič <[email protected]>
  • Loading branch information
5 people authored Sep 1, 2023
1 parent 7edfa44 commit f18ef41
Show file tree
Hide file tree
Showing 17 changed files with 1,148 additions and 95 deletions.
5 changes: 5 additions & 0 deletions .changeset/fifty-dodos-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blockchain-lab-um/dapp': patch
---

Adds support for backing up data to Google Drive.
2 changes: 1 addition & 1 deletion packages/connector/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,6 @@ export async function enableMasca(

return ResultObject.success(snap);
} catch (err: unknown) {
return ResultObject.error((err as Error).toString());
return ResultObject.error((err as Error).message);
}
}
26 changes: 13 additions & 13 deletions packages/connector/src/snap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,33 +454,33 @@ export async function validateStoredCeramicSession(
}

/**
* Import encrypted Masca state
* Export Masca state
* @param this - Masca instance
* @param params - Encrypted Masca state
* @returns Result<boolean> - true if successful
* @returns Result<string> - Encrypted Masca state
*/
export async function importStateBackup(
this: Masca,
params: ImportStateBackupRequestParams
): Promise<Result<boolean>> {
export async function exportStateBackup(this: Masca): Promise<Result<string>> {
return sendSnapMethod(
{
method: 'importStateBackup',
params,
method: 'exportStateBackup',
},
this.snapId
);
}

/**
* Export Masca state
* Import encrypted Masca state
* @param this - Masca instance
* @returns Result<string> - Encrypted Masca state
* @param params - Encrypted Masca state
* @returns Result<boolean> - true if successful
*/
export async function exportStateBackup(this: Masca): Promise<Result<string>> {
export async function importStateBackup(
this: Masca,
params: ImportStateBackupRequestParams
): Promise<Result<boolean>> {
return sendSnapMethod(
{
method: 'exportStateBackup',
method: 'importStateBackup',
params,
},
this.snapId
);
Expand Down
3 changes: 3 additions & 0 deletions packages/dapp/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
NEXT_PUBLIC_DEMO_ISSUER=http://localhost:3003
NEXT_PUBLIC_DEMO_VERIFIER=http://localhost:3004
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
NEXT_PUBLIC_GOOGLE_SCOPES=
GOOGLE_DRIVE_FILE_NAME=

# Prisma
DATABASE_URL=
2 changes: 2 additions & 0 deletions packages/dapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@metamask/providers": "^10.2.0",
"@prisma/client": "^5.1.1",
"@radix-ui/react-toast": "^1.1.4",
"@react-oauth/google": "^0.11.1",
"@tanstack/react-table": "^8.9.3",
"@veramo/core": "5.4.1",
"@veramo/utils": "5.4.1",
Expand All @@ -36,6 +37,7 @@
"clsx": "^2.0.0",
"did-jwt-vc": "^3.2.5",
"file-saver": "^2.0.5",
"googleapis": "^126.0.1",
"headless-stepper": "^1.8.3",
"html5-qrcode": "^2.3.8",
"luxon": "^3.3.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/dapp/src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default async function LocaleLayout({
}

return (
<html lang={params.locale}>
<html suppressHydrationWarning lang={params.locale}>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
Expand Down
191 changes: 191 additions & 0 deletions packages/dapp/src/app/api/google/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { NextRequest, NextResponse } from 'next/server';
import { drive_v3, google } from 'googleapis';

const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};

const actions = ['import', 'backup', 'delete'];

async function createDriveInstance(accessToken: string) {
if (!accessToken) throw new Error('Missing accessToken');
const oauth2Client = new google.auth.OAuth2();
oauth2Client.setCredentials({ access_token: accessToken });
google.options({ auth: oauth2Client });
const drive = google.drive({
version: 'v3',
auth: oauth2Client,
});
return drive;
}

async function verifyAccessToken(accessToken: string) {
const oauth2Client = new google.auth.OAuth2();
const tokenInfo = await oauth2Client.getTokenInfo(accessToken);
return tokenInfo;
}

async function createDriveFile(drive: drive_v3.Drive, content: string) {
const mimeType = 'text/plain';
const fileMetadata = {
parents: ['appDataFolder'],
name: process.env.GOOGLE_DRIVE_FILE_NAME,
mimeType,
};
const media = {
mimeType,
body: content,
};
const res = await drive.files.create({
requestBody: fileMetadata,
media,
});

if (!res.data || res.status !== 200) throw new Error('Error creating file');
return res.data;
}

async function getBackupFileId(drive: drive_v3.Drive) {
let id;
const list = await drive.files.list({
q: `name='${process.env.GOOGLE_DRIVE_FILE_NAME}'`,
spaces: 'appDataFolder',
});

if (list.data.files?.length) {
id = list.data.files[0].id!;
}

return id;
}

async function getBackupFileContent(drive: drive_v3.Drive) {
const id = await getBackupFileId(drive);
if (!id) throw new Error('Backup file not found');

const res = await drive.files.get({
fileId: `${id}`,
alt: 'media',
});

if (!res.data || res.status !== 200)
throw new Error('Error getting file content');
return res.data as string;
}

async function updateDriveFile(drive: drive_v3.Drive, content: string) {
const fileId = await getBackupFileId(drive);
if (!fileId) {
const res = createDriveFile(drive, content);
return res;
}
const media = {
mimeType: 'text/plains',
body: content,
};
const res = await drive.files.update({
fileId,
media,
});

if (!res.data || res.status !== 200) throw new Error('Error updating file');
return res.data;
}

async function deleteDriveFile(drive: drive_v3.Drive) {
const fileId = await getBackupFileId(drive);
if (!fileId) throw new Error('Backup file not found');

const res = await drive.files.delete({
fileId,
});

return res.data;
}

export async function POST(request: NextRequest) {
try {
const body = await request.json();

if (!body.data) {
return NextResponse.json(
{ success: false, error: 'Missing data parameter' },
{ status: 400, headers: { ...CORS_HEADERS } }
);
}

const { accessToken, action, content } = body.data;

if (!actions.includes(action)) {
return NextResponse.json(
{ success: false, error: 'Invalid action' },
{ status: 400, headers: { ...CORS_HEADERS } }
);
}

if (action === 'backup' && !content) {
return NextResponse.json(
{ success: false, error: 'Missing content parameter' },
{ status: 400, headers: { ...CORS_HEADERS } }
);
}

// Verify access token
const tokenInfo = await verifyAccessToken(accessToken);

const scopes = process.env.NEXT_PUBLIC_GOOGLE_SCOPES?.split(' ');
if (
!tokenInfo.scopes ||
!scopes?.every((scope) => tokenInfo.scopes.includes(scope))
) {
return NextResponse.json(
{ success: false, error: 'Invalid access token' },
{ status: 400, headers: { ...CORS_HEADERS } }
);
}

const drive = await createDriveInstance(accessToken);

if (!drive) {
return NextResponse.json(
{ success: false, error: 'Error creating drive instance' },
{ status: 500, headers: { ...CORS_HEADERS } }
);
}
switch (action) {
case 'import': {
const fileContent = await getBackupFileContent(drive);
return NextResponse.json(
{ success: true, data: fileContent },
{ status: 200, headers: { ...CORS_HEADERS } }
);
}
case 'backup': {
await updateDriveFile(drive, content);
return NextResponse.json(
{ success: true },
{ status: 200, headers: { ...CORS_HEADERS } }
);
}
case 'delete': {
await deleteDriveFile(drive);
return NextResponse.json(
{ success: true },
{ status: 200, headers: { ...CORS_HEADERS } }
);
}
default:
return NextResponse.json(
{ success: false, error: 'Invalid action' },
{ status: 400, headers: { ...CORS_HEADERS } }
);
}
} catch (e) {
return NextResponse.json(
{ success: false, error: (e as Error).message },
{ status: 500, headers: { ...CORS_HEADERS } }
);
}
}
Loading

0 comments on commit f18ef41

Please sign in to comment.