mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-17 17:21:48 +00:00
419 lines
36 KiB
Markdown
419 lines
36 KiB
Markdown
# Load from Google Drive
|
|
|
|
## Problem Statement
|
|
Currently, Topola Viewer operates primarily on local file uploads, external HTTP URLs, or integrations with APIs like WikiTree. However, users who maintain their genealogy files (such as `.ged` and `.gdz` files) on cloud storage platforms like Google Drive face significant friction when trying to view, share, or collaborate on their family trees. The lack of integrated cloud storage support makes sharing interactive trees between collaborators cumbersome, requiring them to manually download and re-upload files. Integrating Google Drive directly into Topola Viewer as a secure, read-only storage provider will allow users to seamlessly load their trees from the cloud and easily collaborate using direct shared links.
|
|
|
|
## The Technical Plan
|
|
|
|
To support Google Drive files, the application needs to orchestrate authentication, file selection, and download. The integration consists of five main components working together:
|
|
|
|
### Major Components
|
|
|
|
1. **Google Drive Service**: This is a helper component that manages the connection to Google's APIs. It loads the Google libraries, triggers the login popup, caches the authorization token, and opens the Google Picker file selector.
|
|
2. **Google Drive Data Source**: A data loader that is registered in Topola's data source system. When the app is asked to load a file ID, this component uses the active authorization token to download the raw file content directly from Google Drive's secure servers.
|
|
3. **App Router / Shell**: The main orchestrator of the application. It parses parameters from the URL (like `fileId` and `source=google-drive`) and initiates the loading process. If the file download fails due to access issues, it manages the state of the fallback dialog.
|
|
4. **Access Authorization Modal**: A fallback dialog that displays when a user clicks a shared link to a Google Drive file that they do not yet have permission to view. It guides them to authenticate and select the target file using the Google Picker, which dynamically grants the app permission to access that file.
|
|
5. **Google Drive Menu**: A button added to Topola Viewer's menu bar that allows users to connect their Google account and browse their Drive files.
|
|
|
|
## Alternatives Considered and Rejected
|
|
|
|
During the design phase, several alternative approaches were considered but rejected due to security, privacy, or complexity concerns:
|
|
|
|
### 1. Using the Broad `drive.readonly` Scope
|
|
* **Alternative**: Requesting permission to read any file in the user's Google Drive so that shared links could be opened immediately without additional user interaction.
|
|
* **Why Rejected**: Google classifies `drive.readonly` as a restricted scope. Publishing an app using this scope requires an annual third-party security assessment (CASA audit) which is prohibitively expensive for an open-source, non-profit project. Using the `drive.file` scope keeps the application within the non-sensitive tier while protecting user privacy by only granting access to files the user explicitly selects.
|
|
|
|
### 2. Anonymous Downloads for Publicly Shared Files
|
|
* **Alternative**: Attempting to download files shared with "anyone with the link" directly using standard public URLs without requiring a Google sign-in.
|
|
* **Why Rejected**: Public Google Drive URLs do not reliably support browser CORS headers for anonymous requests, and larger files trigger virus-warning pages that return HTML rather than raw file contents. Requiring a Google login for all Google Drive operations allows the app to fetch via the official API with an authorization token, which avoids both CORS and virus-scan issues and simplifies the code.
|
|
|
|
### 3. Routing Google Drive Traffic Through a CORS Proxy
|
|
* **Alternative**: Using the existing `topolaproxy.bieda.it` proxy to download files in order to bypass CORS restrictions.
|
|
* **Why Rejected**: Routing traffic through a third-party proxy poses a severe security risk because it would require transmitting the user's Google OAuth access token to an external service. Additionally, family tree files contain sensitive personal information, and routing them through an unauthenticated proxy violates privacy-first principles. Direct client-side requests to `googleapis.com` ensure all traffic remains securely encrypted between the user's browser and Google.
|
|
|
|
### 4. Implementing Write/Edit Support
|
|
* **Alternative**: Allowing users to modify their family trees and write back the changes directly to Google Drive.
|
|
* **Why Rejected**: Topola Viewer is architected as a read-only visualization tool. Implementing full write support would require developing a robust GEDCOM and GEDZIP serialization engine, state mutation synchronization, and conflict resolution mechanisms. Limiting this feature to read-only viewing aligns with the application's core purpose and avoids significant, high-risk complexity.
|
|
|
|
## Detailed Implementation Plan
|
|
|
|
This section outlines every file that will be created or modified to implement the Google Drive storage feature, along with the technical rationale for each change.
|
|
|
|
---
|
|
|
|
### 1. Build and Dependencies Setup
|
|
|
|
#### [MODIFY] [package.json](../package.json)
|
|
* **Rationale**: Type safety is critical for global variables introduced by Google scripts.
|
|
* **Changes**: Add the following type definitions to `devDependencies`:
|
|
* `@types/gapi` for the legacy Google API client.
|
|
* `@types/google.picker` for the Google Picker API.
|
|
* `@types/google.accounts` for the modern Google Identity Services (GIS) token client.
|
|
|
|
*Note: In contrast to the initial design where GAPI and GIS scripts were loaded statically in `index.html`, the final implementation loads these scripts dynamically on demand using the custom `loadScript()` helper function in `google_drive_service.ts` to improve performance and only download third-party assets when the user interacts with Google Drive.*
|
|
|
|
|
|
---
|
|
|
|
### 2. Data Source Layer
|
|
|
|
#### [MODIFY] [src/datasource/data_source.ts](../src/datasource/data_source.ts)
|
|
* **Rationale**: Topola Viewer abstracts data loading using the `DataSource` interface and `DataSourceEnum`. We must register the Google Drive type here.
|
|
* **Changes**: Add a `GOOGLE_DRIVE` entry to the `DataSourceEnum` export.
|
|
|
|
#### [MODIFY] [src/datasource/load_data.ts](../src/datasource/load_data.ts)
|
|
* **Rationale**: Restructure file loading logic so that Google Drive datasource can parse file content and perform initial caching without code duplication.
|
|
* **Changes**: Export a new helper function, `loadAndPrepareFile(blob: Blob, cacheId: string): Promise<TopolaData>`, which calls `loadFile()` and `prepareData()` inside a try-catch block, handling `revokeObjectUrls()` in case of parsing errors.
|
|
|
|
#### [NEW] [src/datasource/google_drive.ts](../src/datasource/google_drive.ts)
|
|
* **Rationale**: Defines the logic for checking and loading Google Drive files.
|
|
* **Changes**:
|
|
* Define the interface `GoogleDriveSourceSpec` that contains the `fileId` and a `source` field mapped to `DataSourceEnum.GOOGLE_DRIVE`.
|
|
* Create `GoogleDriveAuthError` (extends `Error`) to signal authentication failures or access denial back to the App controller.
|
|
* Implement the `GoogleDriveDataSource` class matching the `DataSource` interface. The `loadData` function:
|
|
1. Checks `sessionStorage` under `google-drive:{fileId}` to see if the parsed file is already cached (avoiding network requests on page refresh).
|
|
2. If not cached, gets the active access token from `googleDriveService`. If missing or expired, throws `GoogleDriveAuthError`.
|
|
3. Executes a direct `fetch` to Google's REST endpoint (`https://www.googleapis.com/drive/v3/files/{fileId}?alt=media`) using the token.
|
|
4. Inspects response status: if 401, 403, or 404, throws `GoogleDriveAuthError`; if any other non-200 code, throws a standard `Error`.
|
|
5. Parses the downloaded blob using `loadFile` (reusing existing GEDCOM/GEDZIP zip parsing).
|
|
|
|
#### [NEW] [src/datasource/google_drive_service.ts](../src/datasource/google_drive_service.ts)
|
|
* **Rationale**: Keeps all interactions with Google Identity Services and GAPI initialization isolated, protecting the core app from dependency leakage.
|
|
* **Changes**:
|
|
* Create a singleton `googleDriveService` class.
|
|
* Implement an initialization method `init()` that dynamically loads GAPI and GIS scripts using a helper function `loadScript(src)`. It then loads GAPI's `picker` library via `gapi.load('picker', ...)`. Access `window.gapi` and `window.google` using direct global references or `(window as any)` casting to prevent TypeScript compilation errors.
|
|
* Handle OAuth initialization via `google.accounts.oauth2.initTokenClient` and store the access token in memory. Cache the token and its computed absolute expiration timestamp (`Date.now() + expires_in * 1000`) in `sessionStorage` to survive page refreshes in the same tab.
|
|
* **Asynchronous Initialization**: Store the initialization Promise as `this.initPromise` (which resolves when global scripts are loaded and GAPI/GIS are initialized). All public service methods must await `this.initPromise` before executing to prevent race conditions and runtime errors.
|
|
* **Promise-based OAuth Request**: Since `initTokenClient` accepts a single static callback, `requestToken` should store the current Promise's `resolve` and `reject` handlers on the service instance (`this.pendingAuthResolve` and `this.pendingAuthReject`) and invoke them inside the GIS callback.
|
|
* Provide helper methods:
|
|
* `getAccessToken()`: Returns the cached token if it has not expired (checked against the absolute expiration timestamp stored in `sessionStorage`).
|
|
* `requestToken(forceAccountSelect)`: Returns a Promise that resolves when the Google Identity Services popup completes auth. If `forceAccountSelect` is true, sets the GIS configuration `prompt` parameter to `'select_account'` (critical for switching accounts on 403 errors).
|
|
* `showPicker(onPicked, onCancel)`: Calculates display dimensions based on the window size (`Math.min` bounds) to ensure responsiveness on mobile, then builds and opens the Google Picker.
|
|
* Set the developer API key via `.setDeveloperKey(import.meta.env.VITE_GOOGLE_API_KEY)` on the `PickerBuilder`.
|
|
* Configure `DocsView` using `google.picker.ViewId.DOCS` and restrict it to files matching `.setMimeTypes('application/x-gedcom,text/vnd.familysearch.gedcom,application/x-zip')` to cover `.ged` and `.gdz` formats.
|
|
* Set the origin using `.setOrigin(window.location.origin)` to prevent cross-origin issues between the Picker iframe and the viewer page.
|
|
* In the Picker callback, check for `data.action === google.picker.Action.PICKED` and retrieve the file ID via `data[google.picker.Response.DOCUMENTS][0][google.picker.Document.ID]`, triggering `onPicked`.
|
|
* If `data.action === google.picker.Action.CANCEL`, trigger the `onCancel` callback (if provided) to handle dialog closing gracefully.
|
|
* `signOut()`: Revokes the token using `google.accounts.oauth2.revoke`, clears memory and `sessionStorage` tokens, and resets local state.
|
|
|
|
#### [NEW] [src/datasource/test_helpers.ts](../src/datasource/test_helpers.ts)
|
|
* **Rationale**: Provides a mock `sessionStorage` utility for test environments.
|
|
* **Changes**:
|
|
* Define `mockSessionStorage()` which mocks `global.sessionStorage` with a key-value dictionary and returns it for inspection.
|
|
|
|
#### [NEW] [tests/import_meta_transformer.js](../tests/import_meta_transformer.js)
|
|
* **Rationale**: Translates Vite's `import.meta.env` to `process.env` so that tests running in Jest (a Node environment) can correctly access configuration properties.
|
|
|
|
#### [MODIFY] [jest.config.ts](../jest.config.ts)
|
|
* **Rationale**: Registers the custom `import_meta_transformer.js` for TS and TSX files.
|
|
|
|
#### [NEW] [src/datasource/google_drive.spec.ts](../src/datasource/google_drive.spec.ts)
|
|
* **Rationale**: Unit testing for the `GoogleDriveDataSource` class, verifying that the data source is instantiated, fetches, and parses data correctly, handles cached items, and throws authentication errors when expected.
|
|
|
|
#### [NEW] [src/datasource/google_drive_service.spec.ts](../src/datasource/google_drive_service.spec.ts)
|
|
* **Rationale**: Unit testing for the `GoogleDriveService` class including script loading, token caching, account selection, token revocation, and cache clearing.
|
|
|
|
|
|
---
|
|
|
|
### 3. User Interface Integration
|
|
|
|
#### [NEW] [src/menu/google_drive_menu.tsx](../src/menu/google_drive_menu.tsx)
|
|
* **Rationale**: Provides the user entry point in the navigation bar to open files.
|
|
* **Changes**:
|
|
* Implement `GoogleDriveMenu` using the `MenuItem` abstraction.
|
|
* When clicked, it requests an OAuth access token via `googleDriveService`. If successful, it launches the Google Picker.
|
|
* When a file is selected, it extracts the ID and navigates to `/view` with search query params set to `source=google-drive&fileId={id}`.
|
|
|
|
#### [MODIFY] [src/menu/top_bar.tsx](../src/menu/top_bar.tsx)
|
|
* **Rationale**: TopBar contains open actions for uploads, URL loading, and WikiTree. We must inject the Google Drive options here.
|
|
* **Changes**:
|
|
* Add props: `onGoogleSignOut?: () => void`, `hasGoogleToken: boolean`, and `onGoogleTokenAcquired?: () => void`.
|
|
* Check if `VITE_GOOGLE_CLIENT_ID` and `VITE_GOOGLE_API_KEY` exist in the build environment.
|
|
* If present, render `GoogleDriveMenu` alongside other menus in the desktop and mobile file selectors.
|
|
* Inject a **Google Drive Sign Out / Disconnect** button into the top bar in the same location where WikiTree login/logout options appear (on the right-aligned side of the menu for desktop, and list for mobile), rendering only if `hasGoogleToken` is true. Clicking it triggers a sign-out flow that:
|
|
1. Calls the `onGoogleSignOut` callback.
|
|
|
|
#### [NEW] [src/menu/google_auth_modal.tsx](../src/menu/google_auth_modal.tsx)
|
|
* **Rationale**: Essential for private file sharing support under `drive.file` and bypasses browser popup blockers.
|
|
* **Changes**:
|
|
* Define props interface:
|
|
* `failedFileId: string`: The ID of the file that failed to load.
|
|
* `onAuthSuccess: (fileId: string) => void`: Callback triggered when permission is successfully granted or a file is picked.
|
|
* `onCancel: () => void`: Callback triggered when the user cancels the modal.
|
|
* Create a modal view overlay displayed in the center of the screen.
|
|
* Prompt: "Google Drive Access Required".
|
|
* Provides a button to initiate connection. The modal implements a two-tier strategy tailored to `drive.file` constraints:
|
|
1. Clicking the button first runs a quick OAuth request (`googleDriveService.requestToken()`) and attempts to download the target file ID. Due to `drive.file` limitations, this direct request will fail (with 403 or 404) on the first try if the app has not been authorized for this file yet in the user's Google Drive.
|
|
2. In case of access failure (403/404), the modal UI will display detailed instructions guiding the user to select the file manually using the Google Picker. Selecting the file in the Picker explicitly grants the app permission to read it.
|
|
3. **Shared Link Constraint**: If the file was shared via "Anyone with the link can view", it may not appear in the user's Picker search or "Shared with me" list until they open the link once in Google Drive or add a shortcut to "My Drive". The modal should document this instruction to guide users opening shared links.
|
|
* **Popup Blocker Handling**: The OAuth popup is only triggered when the user explicitly clicks the "Connect" button (a direct user gesture). The modal must never attempt to open the OAuth popup automatically on mount.
|
|
* **Non-blocking Loader**: When the login flow is pending, show a spinner on the button itself or a cancelable loader. Do not show an un-cancelable full-screen loading overlay, as Google Identity Services does not notify the app if the user closes the sign-in popup. The connect button must not be permanently disabled while loading; it should allow the user to click it again to retry or provide a manual reset/timeout in case they closed the login popup.
|
|
* **Switch Account Button**: Provides a "Switch Account" button that allows users to authenticate with a different Google account directly from the modal dialog. In the implementation, this button is rendered conditionally once picker instructions are visible (after a failed direct download attempt).
|
|
* **Cancel Action**: Provide a "Cancel" button. If clicked, calls `props.onCancel()` which redirects the user back to the homepage `/` (Intro).
|
|
|
|
|
|
---
|
|
|
|
### 4. Main App Orchestration
|
|
|
|
#### [MODIFY] [src/app.tsx](../src/app.tsx)
|
|
* **Rationale**: The central application container responsible for routing, state tracking, error displaying, and data orchestration.
|
|
* **Changes**:
|
|
* **Type & Registration**: Add `GoogleDriveSourceSpec` to the `DataSourceSpec` union. Instantiate `googleDriveDataSource` via `const googleDriveDataSource = new GoogleDriveDataSource();` and register it in both the `loadData()` and `isNewData()` switch statements in `app.tsx`.
|
|
* **Credentials Check & Error Handling**: During data loading, if `source=google-drive` but `VITE_GOOGLE_CLIENT_ID` or `VITE_GOOGLE_API_KEY` is missing from the environment, throw an error signaling that Google Drive integration is not configured.
|
|
* **"Open with" Query Parameter**: Add a root-level `useEffect` or route handler to inspect the `state` query parameter using React Router's `location.search` hook. If present:
|
|
1. Wrap the query parsing logic in a `try/catch` block to handle cases where the `state` parameter is not valid JSON (e.g. from other OAuth providers).
|
|
2. Check that the parsed JSON contains Google Drive specific keys (`action === 'open'` and a non-empty `ids` array) before triggering the redirect.
|
|
3. If valid, extract the first file ID (`ids[0]`) from the array and ignore any other IDs. Perform a client-side redirect (soft navigate) to `/view?source=google-drive&fileId={fileId}` using `{ replace: true }` so it does not pollute the browser history and break the back button.
|
|
4. Clear the `state` query parameter from the URL by performing the client-side redirect using `{ replace: true }`, which replaces the URL in the history and implicitly clears the `state` parameter to prevent infinite redirect loops on subsequent rendering and routing cycles.
|
|
* **Argument Parsing**: Update `getArguments()` to support `source=google-drive` and `fileId` query params, returning a `GoogleDriveSourceSpec`.
|
|
* **Google Auth State**: Add React state for showing the `<GoogleAuthModal />`, storing the `failedFileId`, and tracking `hasGoogleToken` (updated upon successful login or logout to ensure proper reactivity in `TopBar`).
|
|
* **Catching Auth Errors**: Update the main `useEffect` data loading sequence. If `loadData()` throws `GoogleDriveAuthError`, do *not* set `state` to `AppState.ERROR` (which shows a scary red error banner). Instead, keep a clean background (e.g. keep `AppState.LOADING` or transition to a non-error background) and set `showAuthModal` to true.
|
|
* **Resolving Auth Fallback (Avoiding Deadlock)**:
|
|
* If the user selects a *new* file in the Picker, perform a soft `navigate` to update the URL parameters, triggering `isNewData` and initiating a normal reload.
|
|
* If the user successfully authorizes and selects the *same* file ID (matching `failedFileId`), do *not* navigate (as the URL matches and would result in a no-op). Instead, explicitly reset `state` to `AppState.INITIAL`. This forces the `useEffect` to execute the load sequence again using the newly acquired access token.
|
|
* If the user successfully selects the target file in the auth modal's callback, trigger url updating to refresh the page and successfully render the chart.
|
|
* **Disconnect Callback**: Pass `onGoogleSignOut` to `TopBar` that:
|
|
1. Revokes the token using `googleDriveService.signOut()`.
|
|
2. Clears the active `data` and `selection` state in `App` and revokes all media Object URLs.
|
|
3. Clears the active `sessionStorage` cache (all keys starting with `google-drive:`) to prevent unauthorized access by subsequent users on shared/public devices.
|
|
4. Redirects the user back to the home route `/` (Intro).
|
|
|
|
---
|
|
|
|
### 5. Localization Integration
|
|
|
|
#### [MODIFY] [src/translations/*.json](../src/translations) and [src/app.tsx](../src/app.tsx)
|
|
* **Rationale**: Ensure all user-facing Google Drive integration UI strings are fully translated and localized.
|
|
* **Changes**: Add translation keys for the new UI elements across all 7 localization files (`bg.json`, `cs.json`, `de.json`, `fr.json`, `it.json`, `pl.json`, `ru.json`). For the default English catalog, declare messages using the `defaultMessage` prop in components or as arguments in `intl.formatMessage` calls. Use a `TopolaError` with code `'GOOGLE_DRIVE_NOT_CONFIGURED'` to propagate configuration errors so they can be parsed and translated by `getI18nMessage()`.
|
|
* `menu.load_from_google_drive`: "Load from Google Drive" (or language equivalent)
|
|
* `menu.google_sign_out`: "Disconnect Google Drive" / "Sign out"
|
|
* `google_auth.title`: "Google Drive Access Required"
|
|
* `google_auth.instructions`: "To view this file, you must authenticate and select the file from your Google Drive to grant permissions."
|
|
* `google_auth.grant_button`: "Grant Access & Select File"
|
|
* `google_auth.cancel`: "Cancel"
|
|
* `google_auth.picker_instructions_header`: "Permissions Required"
|
|
* `google_auth.picker_instructions`: "The application does not have permission to read this file. Please select it in the file browser popup to grant access. If this is a shared file that doesn't show up, try adding a shortcut to your Drive first."
|
|
* `google_auth.switch_account_button`: "Switch Account"
|
|
* `error.GOOGLE_DRIVE_NOT_CONFIGURED`: "Google Drive integration is not configured."
|
|
|
|
---
|
|
|
|
## Google Cloud Platform Setup Guide
|
|
|
|
To support authentication and file picking, Topola Viewer must be connected to a Google Cloud Platform (GCP) project. Below are the step-by-step instructions to configure the GCP resources, credentials, and Workspace Marketplace listings.
|
|
|
|
### Step 1: Create a Google Cloud Project
|
|
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
|
2. Click the project dropdown in the top navigation and select **New Project**.
|
|
3. Enter a name (e.g., `Topola Viewer`) and click **Create**.
|
|
|
|
### Step 2: Enable Required APIs
|
|
1. Navigate to **APIs & Services** $\rightarrow$ **Library**.
|
|
2. Search for and enable the following APIs:
|
|
* **Google Drive API** (for file downloading).
|
|
* **Google Picker API** (for the file browser popup).
|
|
* **Google Workspace Marketplace SDK** (required to enable the "Open with" context menu inside Google Drive).
|
|
|
|
### Step 3: Configure the OAuth Consent Screen
|
|
1. Navigate to **APIs & Services** $\rightarrow$ **OAuth consent screen**.
|
|
2. Select **External** for User Type (so any Google user can log in) and click **Create**.
|
|
3. Complete the **App information** (App name, Support email, Developer contact information) and click **Save and Continue**.
|
|
4. In the **Scopes** step, click **Add or Remove Scopes**:
|
|
* Add the scope: `https://www.googleapis.com/auth/drive.file`
|
|
* This scope is classified as non-sensitive, meaning Topola Viewer does *not* require expensive security reviews or CASA audits to be verified by Google.
|
|
5. Save and continue to finish the configuration.
|
|
|
|
### Step 4: Create OAuth 2.0 Credentials
|
|
1. Navigate to **APIs & Services** $\rightarrow$ **Credentials**.
|
|
2. Click **Create Credentials** $\rightarrow$ **OAuth client ID**.
|
|
3. Set the **Application type** to **Web application**.
|
|
4. Under **Authorized JavaScript origins**, add the domains where the app is deployed, as well as local environments:
|
|
* `https://pewu.github.io` (Production)
|
|
* `https://apps.wikitree.com` (Wikitree Integration)
|
|
* `http://localhost:3000` (Local testing and development)
|
|
> [!IMPORTANT]
|
|
> Google's OAuth validation is strict. In local development, ensure you access the application via `http://localhost:3000` rather than `http://127.0.0.1:3000`, as using the raw IP address will result in origin authorization failures unless `http://127.0.0.1:3000` is also explicitly added to this list.
|
|
5. Click **Create**. Copy the generated **Client ID** to use in the app environment variables (`VITE_GOOGLE_CLIENT_ID`).
|
|
|
|
### Step 5: Create an API Key (for Google Picker)
|
|
1. On the **Credentials** screen, click **Create Credentials** $\rightarrow$ **API key**.
|
|
2. Edit the newly created API key to add restrictions:
|
|
* **Application restrictions**: Choose **HTTP referrers (web sites)**.
|
|
* Add the authorized referrers:
|
|
* `https://pewu.github.io/topola/viewer/*`
|
|
* `https://apps.wikitree.com/apps/wiech13/topola-viewer/*`
|
|
* `http://localhost:3000/*`
|
|
* **API restrictions**: Restrict the key to only allow requests to **both** the **Google Picker API** and the **Google Drive API** (the Picker component queries user files via the Drive API under the hood; restricting it strictly to the Picker API will cause requests to fail).
|
|
3. Save the key. Copy the generated **API Key** to use in the app environment variables (`VITE_GOOGLE_API_KEY`).
|
|
|
|
### Step 6: Configure the Workspace Marketplace SDK ("Open with" Integration)
|
|
1. Navigate to **APIs & Services** $\rightarrow$ **Enabled APIs & Services**, and select the **Google Workspace Marketplace SDK**.
|
|
2. Click **App Integration** $\rightarrow$ **Configuration**.
|
|
3. Enable the **Drive Extension** integration check.
|
|
4. Configure the **Open URL**:
|
|
* URL: `https://pewu.github.io/topola-viewer/` (or the corresponding deploy URL).
|
|
5. Set up **File Handlers**:
|
|
* Under **Default File Extensions**, register `.ged` and `.gdz`.
|
|
6. (Optional) Configure the **Store Listing** to publish the extension to the public Google Workspace Marketplace, making it searchable and installable for anyone.
|
|
|
|
## Testing Strategy
|
|
|
|
Due to the dependency on third-party external Google APIs and credentials, testing this feature is divided into automated mocking (for CI environments and unit tests) and manual verification (for local development).
|
|
|
|
### 1. Automated Unit Testing
|
|
|
|
All automated unit tests are run via **Jest**. Since Node environments do not load Google's external CDN scripts, we must isolate and mock these dependencies.
|
|
|
|
#### Mocking Global Scripts
|
|
Create a mock utility in the tests setup file to intercept calls to the global `google` and `gapi` interfaces:
|
|
```typescript
|
|
global.gapi = {
|
|
load: jest.fn((api: string, callback: () => void) => callback()),
|
|
};
|
|
|
|
global.google = {
|
|
accounts: {
|
|
oauth2: {
|
|
initTokenClient: jest.fn(() => ({
|
|
requestAccessToken: jest.fn(),
|
|
})),
|
|
},
|
|
},
|
|
picker: {
|
|
PickerBuilder: jest.fn(() => ({
|
|
addView: jest.fn().mockReturnThis(),
|
|
setOAuthToken: jest.fn().mockReturnThis(),
|
|
setDeveloperKey: jest.fn().mockReturnThis(),
|
|
setCallback: jest.fn().mockReturnThis(),
|
|
build: jest.fn(() => ({
|
|
setVisible: jest.fn(),
|
|
})),
|
|
})),
|
|
DocsView: jest.fn().mockImplementation(() => ({
|
|
setMimeTypes: jest.fn().mockReturnThis(),
|
|
setQuery: jest.fn().mockReturnThis(),
|
|
})),
|
|
ViewId: {
|
|
DOCS: 'doc',
|
|
SHARED_WITH_ME: 'shared-with-me',
|
|
},
|
|
},
|
|
};
|
|
```
|
|
|
|
#### Data Source Tests
|
|
In `src/datasource/google_drive.spec.ts` [NEW]:
|
|
* **Verify `isNewData()`**: Confirm it returns true only when the `fileId` changes.
|
|
* **Verify `loadData()` Success**: Mock `window.fetch` to return a mock blob, mock `getAccessToken()` to return a dummy token, and verify the data source parses the file stream correctly.
|
|
* **Verify `loadData()` Auth Failure**: Mock `getAccessToken()` to return `null` or configure `window.fetch` to return `403 Forbidden`, and assert that `GoogleDriveAuthError` is thrown.
|
|
|
|
#### URL Parsing & Orchestration Tests
|
|
In existing test suites or manual validation:
|
|
* Verify that URL query arguments containing `source=google-drive&fileId=XYZ` are parsed into the correct `GoogleDriveSourceSpec`.
|
|
* Verify that if `loadData()` throws `GoogleDriveAuthError`, the application transitions state, avoids setting a global error message, and triggers the rendering of the `<GoogleAuthModal />` fallback popup.
|
|
|
|
---
|
|
|
|
### 2. Manual Verification Checklist
|
|
|
|
Manual testing should be performed locally to verify the full OAuth loop and UI presentation:
|
|
|
|
| Test Case | Action | Expected Result |
|
|
| :--- | :--- | :--- |
|
|
| **Missing Credentials** | Run the app with empty `VITE_GOOGLE_CLIENT_ID` / `VITE_GOOGLE_API_KEY` values. | The Google Drive option is completely hidden from the TopBar menus; local file upload and WikiTree operations continue working normally. |
|
|
| **Picker Load** | Click "Load from Google Drive" in the menu, log in when prompted, select a `.ged`/`.gdz` file. | Google Picker popup closes, selected file is fetched directly from Google APIs, URL hash updates, and the family chart renders. |
|
|
| **Shared Link Auth Fallback** | Load: `http://localhost:3000/#/view?source=google-drive&fileId=UNAUTHORIZED_ID` | The "Google Drive Access Required" modal overlays the screen, preventing rendering. |
|
|
| **Grant Access Flow** | Click "Grant Access" in the fallback modal, authenticate, and pick the matching file from "Shared with me". | Modal closes, OAuth token gets authorized for the specific file ID, tree downloads and renders successfully. |
|
|
| **Google Drive UI Open** | Navigate to: `http://localhost:3000/?state={"ids":["FILE_ID"],"action":"open"}` | The app detects the `state` param, automatically prompts Google authentication, downloads the file, and opens the chart. |
|
|
| **Invalid Selection** | Pick a non-matching file from the fallback modal's picker. | App detects the mismatch, updates the URL to the new file's ID, downloads the newly picked file, and displays the tree. |
|
|
|
|
---
|
|
|
|
### 3. Continuous Integration (CI) Safety
|
|
|
|
* In CI pipelines (e.g. GitHub Actions workflows), Google API keys are not present in the environment.
|
|
* Because the feature checks for the presence of environment variables at compile/build time, the Google Drive menus are automatically omitted.
|
|
* This ensures that visual regression tests (`npm run test:visual`) and E2E test runs (`npm run test:e2e`) pass without requiring live OAuth secrets or mock servers.
|
|
|
|
---
|
|
|
|
## Build and Deployment CI/CD Configuration
|
|
|
|
To enable the Google Drive integration on the official public deployments, the Google API credentials must be injected during the automated build process in GitHub Actions.
|
|
|
|
### Required GitHub Secrets
|
|
You must configure the following repository secrets in your GitHub project settings:
|
|
1. `GOOGLE_CLIENT_ID`: The official OAuth Client ID for `pewu.github.io` / `apps.wikitree.com`.
|
|
2. `GOOGLE_API_KEY`: The official Google Picker API Key.
|
|
|
|
### Workflow Modifications
|
|
|
|
The build step (`npm run build`) in the deployment workflows must receive these secrets as environment variables.
|
|
|
|
#### 1. Deployment to GitHub Pages (`.github/workflows/deploy-gh-pages.yml`)
|
|
Pass the secrets to the build step:
|
|
```yaml
|
|
- run: npm run build
|
|
env:
|
|
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
|
VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
|
```
|
|
|
|
#### 2. Deployment to WikiTree Apps (`.github/workflows/deploy-wikitree-apps.yml`)
|
|
Pass the secrets to the build step:
|
|
```yaml
|
|
- run: npm run build
|
|
env:
|
|
VITE_GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
|
VITE_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
|
```
|
|
|
|
#### 3. Reusable Workflow Inheritance (`.github/workflows/deploy-everywhere.yml`)
|
|
Enable secret inheritance on jobs calling these workflows to ensure secrets are propagated:
|
|
```yaml
|
|
deploy-gh-pages:
|
|
uses: ./.github/workflows/deploy-gh-pages.yml
|
|
secrets: inherit
|
|
```
|
|
|
|
---
|
|
|
|
## Security and Privacy
|
|
|
|
Since Topola Viewer is a purely client-side application that manages sensitive genealogical data, security and user privacy are core design pillars for this integration.
|
|
|
|
### 1. Protection of Public API Credentials
|
|
The application's client-side architecture requires compiling the public Google `Client ID` and `API Key` directly into the JavaScript bundle. These values are protected from abuse using Google Cloud Platform constraints:
|
|
* **OAuth Domain Whitelisting**: The `Client ID` restricts **Authorized JavaScript Origins** and **Redirect URIs** to `https://pewu.github.io`, `https://apps.wikitree.com`, and `http://localhost:3000`. Google's authorization server will automatically block login attempts initiated from any other domains.
|
|
* **API Key Restrictions**: The `API Key` enforces **HTTP Referrer Restrictions** matching the authorized domains. Additionally, the key is scoped via **API Restrictions** to only permit requests to the **Google Picker API** and **Google Drive API**, preventing its use on other Google Cloud services.
|
|
* **No Secrets Compiled**: The application never utilizes or compiles an OAuth `Client Secret`, which is only required for server-side integrations.
|
|
|
|
### 2. Data Privacy & Zero-Server Architecture
|
|
* **Direct Client-to-Google Communication**: All requests to Google Drive APIs are initiated directly from the user's browser.
|
|
* **No Third-Party Proxies**: To prevent token leakage and maintain data confidentiality, Google Drive downloads **never** route through the `topolaproxy.bieda.it` CORS proxy.
|
|
* **Local Parsing**: The fetched GEDCOM or GEDZIP file content is parsed entirely within the browser using local JavaScript. No family data, files, or access tokens are ever transmitted to, processed by, or stored on external servers.
|
|
* **Short-Lived Auth Tokens**: The application uses short-lived access tokens that expire automatically. No persistent refresh tokens are requested or stored.
|
|
* **Session Storage Cache Security**: To optimize load times, parsed tree data is cached in `sessionStorage` under `google-drive:{fileId}`. To protect user privacy on shared or public computers, signing out or disconnecting Google Drive explicitly purges all `google-drive:*` keys from the browser's `sessionStorage`.
|
|
|
|
### 3. Principle of Least Privilege (OAuth Scopes)
|
|
Instead of requesting the broad `drive.readonly` scope, which grants visibility into a user's entire Google Drive, this feature utilizes the restrictive `drive.file` scope. Under this scope:
|
|
* The application only has permission to view files that the user has explicitly opened with the app (either by selecting the file in the Google Picker or opening it via the "Open with" Google Drive UI option).
|
|
* Topola Viewer remains blind to all other files and directories in the user's Google Drive.
|
|
|
|
## Known Limitations
|
|
|
|
1. **Image Loading from Plain `.ged` Files**: Only `.gdz` archives are supported for viewing media files from Google Drive. Plain `.ged` files will only display genealogical data, and any relative/external image paths will render as broken images because standard HTML `<img>` tags cannot transmit the `Authorization` header required for private Google Drive requests.
|
|
2. **Iframe Embedding (WikiTree)**: Running Google Drive authentication and loading files from Google Drive inside an embedding iframe (such as on `apps.wikitree.com`) can fail due to iframe popup sandboxing and origin mismatch restrictions. This configuration is not actively supported.
|
|
3. **Session Refresh Cache**: When a page is refreshed, zipped images cached in `sessionStorage` might have invalid Object URLs. We choose to ignore this problem until it becomes a visible UX issue.
|
|
4. **Access Modal Lockout**: If a user logs into a Google account that does not have permissions to read the file, they are prompted with the "Access Required" modal. Since this modal blocks the viewport, they cannot click the top-bar "Disconnect" button to switch Google accounts, requiring them to click "Cancel" to return to the homepage first.
|
|
|
|
---
|
|
|
|
## Future Improvements
|
|
|
|
1. **Self-Hosting Runtime Configuration Support**: Read `google-client-id` and `google-api-key` from HTML `<meta>` tags at runtime (similar to `topola-static-url`), allowing self-hosters to deploy and configure Google Drive support without rebuilding the static assets.
|
|
2. **Fetch Cancellation & Race Condition Mitigation**: Implement an `AbortController` or active boolean flag cancellation check in `app.tsx`'s `useEffect` loader to ensure slow background downloads do not overwrite newer datasets if a user changes the source/file before the previous request finishes.
|
|
|