diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index b8fa08739..3bcc48715 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -258,12 +258,6 @@ public class AppConfig { return false; } - @Bean(name = "GoogleDriveEnabled") - @Profile("default") - public boolean googleDriveEnabled() { - return false; - } - @Bean(name = "license") @Profile("default") public String licenseType() { diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 7eeace787..14704d825 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -530,7 +530,6 @@ public class ApplicationProperties { private boolean ssoAutoLogin; private boolean database; private CustomMetadata customMetadata = new CustomMetadata(); - private GoogleDrive googleDrive = new GoogleDrive(); @Data public static class CustomMetadata { @@ -549,26 +548,6 @@ public class ApplicationProperties { : producer; } } - - @Data - public static class GoogleDrive { - private boolean enabled; - private String clientId; - private String apiKey; - private String appId; - - public String getClientId() { - return clientId == null || clientId.trim().isEmpty() ? "" : clientId; - } - - public String getApiKey() { - return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey; - } - - public String getAppId() { - return appId == null || appId.trim().isEmpty() ? "" : appId; - } - } } @Data diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java index da83fd462..66078099f 100644 --- a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -109,22 +109,6 @@ class ApplicationPropertiesLogicTest { assertTrue(ex.getMessage().toLowerCase().contains("not supported")); } - @Test - void premium_google_drive_getters_return_empty_string_on_null_or_blank() { - Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive(); - - assertEquals("", gd.getClientId()); - assertEquals("", gd.getApiKey()); - assertEquals("", gd.getAppId()); - - gd.setClientId(" id "); - gd.setApiKey(" key "); - gd.setAppId(" app "); - assertEquals(" id ", gd.getClientId()); - assertEquals(" key ", gd.getApiKey()); - assertEquals(" app ", gd.getAppId()); - } - @Test void ui_getters_return_null_for_blank() { ApplicationProperties.Ui ui = new ApplicationProperties.Ui(); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 6d9263270..072471e5c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -98,11 +98,6 @@ public class ConfigController { if (applicationContext.containsBean("license")) { configData.put("license", applicationContext.getBean("license", String.class)); } - if (applicationContext.containsBean("GoogleDriveEnabled")) { - configData.put( - "GoogleDriveEnabled", - applicationContext.getBean("GoogleDriveEnabled", Boolean.class)); - } if (applicationContext.containsBean("SSOAutoLogin")) { configData.put( "SSOAutoLogin", diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 465f95fb6..8143ba4c2 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -76,11 +76,6 @@ premium: author: username creator: Stirling-PDF producer: Stirling-PDF - googleDrive: - enabled: false - clientId: '' - apiKey: '' - appId: '' enterpriseFeatures: audit: enabled: true # Enable audit logging diff --git a/app/core/src/main/resources/templates/fragments/common.html b/app/core/src/main/resources/templates/fragments/common.html index d3b888a1d..973822b58 100644 --- a/app/core/src/main/resources/templates/fragments/common.html +++ b/app/core/src/main/resources/templates/fragments/common.html @@ -422,10 +422,6 @@ -
- -
- google drive
@@ -443,16 +439,4 @@ - -
- - - - - -
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java index 215b82347..2e71b670d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/EEAppConfig.java @@ -12,7 +12,6 @@ import org.springframework.core.annotation.Order; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.ApplicationProperties.EnterpriseEdition; import stirling.software.common.model.ApplicationProperties.Premium; -import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive; @Configuration @Order(Ordered.HIGHEST_PRECEDENCE) @@ -55,19 +54,6 @@ public class EEAppConfig { return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin(); } - @Profile("security") - @Bean(name = "GoogleDriveEnabled") - @Primary - public boolean googleDriveEnabled() { - return runningProOrHigher() - && applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled(); - } - - @Bean(name = "GoogleDriveConfig") - public GoogleDrive googleDriveConfig() { - return applicationProperties.getPremium().getProFeatures().getGoogleDrive(); - } - // TODO: Remove post migration @SuppressWarnings("deprecation") public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) { diff --git a/docker/README.md b/docker/README.md index df07e6b9e..99b86b53e 100644 --- a/docker/README.md +++ b/docker/README.md @@ -50,7 +50,14 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build - **Custom Ports**: Modify port mappings in docker-compose files - **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat) +### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview) + +- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create) +- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis) +- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings + ## Development vs Production - **Development**: Keep backend port 8080 exposed for debugging -- **Production**: Remove backend port exposure, use only frontend proxy \ No newline at end of file +- **Production**: Remove backend port exposure, use only frontend proxy + diff --git a/docker/compose/docker-compose.fat.yml b/docker/compose/docker-compose.fat.yml index 1757782d5..d0242eb3c 100644 --- a/docker/compose/docker-compose.fat.yml +++ b/docker/compose/docker-compose.fat.yml @@ -47,6 +47,9 @@ services: - "3000:80" environment: BACKEND_URL: http://backend:8080 + #VITE_GOOGLE_DRIVE_CLIENT_ID: + #VITE_GOOGLE_DRIVE_API_KEY: + #VITE_GOOGLE_DRIVE_APP_ID: depends_on: - backend networks: diff --git a/docker/compose/docker-compose.ultra-lite.yml b/docker/compose/docker-compose.ultra-lite.yml index bfbf55861..0639b53ac 100644 --- a/docker/compose/docker-compose.ultra-lite.yml +++ b/docker/compose/docker-compose.ultra-lite.yml @@ -44,6 +44,9 @@ services: - "3000:80" environment: BACKEND_URL: http://backend:8080 + #VITE_GOOGLE_DRIVE_CLIENT_ID: + #VITE_GOOGLE_DRIVE_API_KEY: + #VITE_GOOGLE_DRIVE_APP_ID: depends_on: - backend networks: diff --git a/docker/compose/docker-compose.yml b/docker/compose/docker-compose.yml index b0061f785..6f8b1ace8 100644 --- a/docker/compose/docker-compose.yml +++ b/docker/compose/docker-compose.yml @@ -46,6 +46,9 @@ services: - "3000:80" environment: BACKEND_URL: http://backend:8080 + #VITE_GOOGLE_DRIVE_CLIENT_ID: + #VITE_GOOGLE_DRIVE_API_KEY: + #VITE_GOOGLE_DRIVE_APP_ID: depends_on: - backend networks: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f73143e83..a27c2c936 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -66,6 +66,10 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/gapi": "^0.0.47", + "@types/gapi.client.drive-v3": "^0.0.5", + "@types/google.accounts": "^0.0.18", + "@types/google.picker": "^0.0.51", "@types/node": "^24.5.2", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", @@ -2009,6 +2013,28 @@ "react": "^18.x || ^19.x" } }, + "node_modules/@maxim_mazurok/gapi.client.discovery-v1": { + "version": "0.4.20200806", + "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.4.20200806.tgz", + "integrity": "sha512-Jeo/KZqK39DI6ExXHcJ4lqnn1O/wEqboQ6eQ8WnNpu5eJ7wUnX/C5KazOgs1aRhnIB/dVzDe8wm62nmtkMIoaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/gapi.client": "*", + "@types/gapi.client.discovery-v1": "*" + } + }, + "node_modules/@maxim_mazurok/gapi.client.drive-v3": { + "version": "0.1.20250930", + "resolved": "https://registry.npmjs.org/@maxim_mazurok/gapi.client.drive-v3/-/gapi.client.drive-v3-0.1.20250930.tgz", + "integrity": "sha512-zNR7HtaFl2Pvf8Ck2zP8cppUst7ouY2isKn7hrGf6hQ4/0ULsu19qMRSQgRb0HxBYcGjak7kGK4pZI4a2z4CWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/gapi.client": "*", + "@types/gapi.client.discovery-v1": "*" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.2.tgz", @@ -3595,6 +3621,54 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/gapi": { + "version": "0.0.47", + "resolved": "https://registry.npmjs.org/@types/gapi/-/gapi-0.0.47.tgz", + "integrity": "sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gapi.client": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/gapi.client/-/gapi.client-1.0.8.tgz", + "integrity": "sha512-qJQUmmumbYym3Amax0S8CVzuSngcXsC1fJdwRS2zeW5lM63zXkw4wJFP+bG0jzgi0R6EsJKoHnGNVTDbOyG1ng==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gapi.client.discovery-v1": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@types/gapi.client.discovery-v1/-/gapi.client.discovery-v1-0.0.4.tgz", + "integrity": "sha512-uevhRumNE65F5mf2gABLaReOmbFSXONuzFZjNR3dYv6BmkHg+wciubHrfBAsp3554zNo3Dcg6dUAlwMqQfpwjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@maxim_mazurok/gapi.client.discovery-v1": "latest" + } + }, + "node_modules/@types/gapi.client.drive-v3": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@types/gapi.client.drive-v3/-/gapi.client.drive-v3-0.0.5.tgz", + "integrity": "sha512-yYBxiqMqJVBg4bns4Q28+f2XdJnd3tVA9dxQX1lXMVmzT2B+pZdyCi1u9HLwGveVlookSsAXuqfLfS9KO6MF6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@maxim_mazurok/gapi.client.drive-v3": "latest" + } + }, + "node_modules/@types/google.accounts": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@types/google.accounts/-/google.accounts-0.0.18.tgz", + "integrity": "sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/google.picker": { + "version": "0.0.51", + "resolved": "https://registry.npmjs.org/@types/google.picker/-/google.picker-0.0.51.tgz", + "integrity": "sha512-z6o2J4PQTcXvlW1rtgQx65d5uEF+rMI1hzrnazKQxBONdEuYAr4AeOSH2KZy12WHPmqMX+aWYyfcZ0uktBBhhA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 319653af1..901704500 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -105,6 +105,10 @@ "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/gapi": "^0.0.47", + "@types/gapi.client.drive-v3": "^0.0.5", + "@types/google.accounts": "^0.0.18", + "@types/google.picker": "^0.0.51", "@types/node": "^24.5.2", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx index fb26a3a3a..96185902b 100644 --- a/frontend/src/components/FileManager.tsx +++ b/frontend/src/components/FileManager.tsx @@ -9,6 +9,8 @@ import MobileLayout from './fileManager/MobileLayout'; import DesktopLayout from './fileManager/DesktopLayout'; import DragOverlay from './fileManager/DragOverlay'; import { FileManagerProvider } from '../contexts/FileManagerContext'; +import { isGoogleDriveConfigured } from '../services/googleDrivePickerService'; +import { loadScript } from '../utils/scriptLoader'; interface FileManagerProps { selectedTool?: Tool | null; @@ -84,6 +86,29 @@ const FileManager: React.FC = ({ selectedTool }) => { }; }, []); + // Preload Google Drive scripts if configured + useEffect(() => { + if (isGoogleDriveConfigured()) { + // Load scripts in parallel without blocking + Promise.all([ + loadScript({ + src: 'https://apis.google.com/js/api.js', + id: 'gapi-script', + async: true, + defer: true, + }), + loadScript({ + src: 'https://accounts.google.com/gsi/client', + id: 'gis-script', + async: true, + defer: true, + }), + ]).catch((error) => { + console.warn('Failed to preload Google Drive scripts:', error); + }); + } + }, []); + // Modal size constants for consistent scaling const modalHeight = '80vh'; const modalWidth = isMobile ? '100%' : '80vw'; diff --git a/frontend/src/components/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx index d2d28e09e..78ab8ce39 100644 --- a/frontend/src/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/components/fileManager/FileSourceButtons.tsx @@ -5,6 +5,7 @@ import UploadIcon from '@mui/icons-material/Upload'; import CloudIcon from '@mui/icons-material/Cloud'; import { useTranslation } from 'react-i18next'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; +import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker'; interface FileSourceButtonsProps { horizontal?: boolean; @@ -13,8 +14,20 @@ interface FileSourceButtonsProps { const FileSourceButtons: React.FC = ({ horizontal = false }) => { - const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext(); + const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext(); const { t } = useTranslation(); + const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker(); + + const handleGoogleDriveClick = async () => { + try { + const files = await openGoogleDrivePicker({ multiple: true }); + if (files.length > 0) { + onGoogleDriveSelect(files); + } + } catch (error) { + console.error('Failed to pick files from Google Drive:', error); + } + }; const buttonProps = { variant: (source: string) => activeSource === source ? 'filled' : 'subtle', @@ -67,15 +80,24 @@ const FileSourceButtons: React.FC = ({ diff --git a/frontend/src/components/shared/config/configSections/Overview.tsx b/frontend/src/components/shared/config/configSections/Overview.tsx index e591655e0..d3f250f49 100644 --- a/frontend/src/components/shared/config/configSections/Overview.tsx +++ b/frontend/src/components/shared/config/configSections/Overview.tsx @@ -51,7 +51,6 @@ const Overview: React.FC = () => { } : null; const integrationConfig = config ? { - GoogleDriveEnabled: config.GoogleDriveEnabled, SSOAutoLogin: config.SSOAutoLogin, } : null; diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx index 28a30fc20..8c7f6182d 100644 --- a/frontend/src/contexts/FileManagerContext.tsx +++ b/frontend/src/contexts/FileManagerContext.tsx @@ -39,6 +39,7 @@ interface FileManagerContextValue { onAddToRecents: (file: StirlingFileStub) => void; onUnzipFile: (file: StirlingFileStub) => Promise; onNewFilesSelect: (files: File[]) => void; + onGoogleDriveSelect: (files: File[]) => void; // External props recentFiles: StirlingFileStub[]; @@ -546,6 +547,19 @@ export const FileManagerProvider: React.FC = ({ } }, [refreshRecentFiles]); + const handleGoogleDriveSelect = useCallback(async (files: File[]) => { + if (files.length > 0) { + try { + // Process Google Drive files same as local files + onNewFilesSelect(files); + await refreshRecentFiles(); + onClose(); + } catch (error) { + console.error('Failed to process Google Drive files:', error); + } + } + }, [onNewFilesSelect, refreshRecentFiles, onClose]); + const handleUnzipFile = useCallback(async (file: StirlingFileStub) => { try { // Load the full file from storage @@ -623,6 +637,7 @@ export const FileManagerProvider: React.FC = ({ onAddToRecents: handleAddToRecents, onUnzipFile: handleUnzipFile, onNewFilesSelect, + onGoogleDriveSelect: handleGoogleDriveSelect, // External props recentFiles, @@ -656,6 +671,7 @@ export const FileManagerProvider: React.FC = ({ handleAddToRecents, handleUnzipFile, onNewFilesSelect, + handleGoogleDriveSelect, recentFiles, isFileSupported, modalHeight, diff --git a/frontend/src/hooks/useAppConfig.ts b/frontend/src/hooks/useAppConfig.ts index cb23cfb60..cfca99b57 100644 --- a/frontend/src/hooks/useAppConfig.ts +++ b/frontend/src/hooks/useAppConfig.ts @@ -21,7 +21,6 @@ export interface AppConfig { runningProOrHigher?: boolean; runningEE?: boolean; license?: string; - GoogleDriveEnabled?: boolean; SSOAutoLogin?: boolean; serverCertificateEnabled?: boolean; error?: string; diff --git a/frontend/src/hooks/useGoogleDrivePicker.ts b/frontend/src/hooks/useGoogleDrivePicker.ts new file mode 100644 index 000000000..5c9ead572 --- /dev/null +++ b/frontend/src/hooks/useGoogleDrivePicker.ts @@ -0,0 +1,98 @@ +/** + * React hook for Google Drive file picker + */ + +import { useState, useCallback, useEffect } from 'react'; +import { + getGoogleDrivePickerService, + isGoogleDriveConfigured, + getGoogleDriveConfig, +} from '../services/googleDrivePickerService'; + +interface UseGoogleDrivePickerOptions { + multiple?: boolean; + mimeTypes?: string; +} + +interface UseGoogleDrivePickerReturn { + isEnabled: boolean; + isLoading: boolean; + error: string | null; + openPicker: (options?: UseGoogleDrivePickerOptions) => Promise; +} + +/** + * Hook to use Google Drive file picker + */ +export function useGoogleDrivePicker(): UseGoogleDrivePickerReturn { + const [isEnabled, setIsEnabled] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + + // Check if Google Drive is configured on mount + useEffect(() => { + const configured = isGoogleDriveConfigured(); + setIsEnabled(configured); + }, []); + + /** + * Initialize the Google Drive service (lazy initialization) + */ + const initializeService = useCallback(async () => { + if (isInitialized) return; + + const config = getGoogleDriveConfig(); + if (!config) { + throw new Error('Google Drive is not configured'); + } + + const service = getGoogleDrivePickerService(); + await service.initialize(config); + setIsInitialized(true); + }, [isInitialized]); + + /** + * Open the Google Drive picker + */ + const openPicker = useCallback( + async (options: UseGoogleDrivePickerOptions = {}): Promise => { + if (!isEnabled) { + setError('Google Drive is not configured'); + return []; + } + + try { + setIsLoading(true); + setError(null); + + // Initialize service if needed + await initializeService(); + + // Open picker + const service = getGoogleDrivePickerService(); + const files = await service.openPicker({ + multiple: options.multiple ?? true, + mimeTypes: options.mimeTypes, + }); + + return files; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to open Google Drive picker'; + setError(errorMessage); + console.error('Google Drive picker error:', err); + return []; + } finally { + setIsLoading(false); + } + }, + [isEnabled, initializeService] + ); + + return { + isEnabled, + isLoading, + error, + openPicker, + }; +} diff --git a/frontend/src/services/googleDrivePickerService.ts b/frontend/src/services/googleDrivePickerService.ts new file mode 100644 index 000000000..cb5b02b87 --- /dev/null +++ b/frontend/src/services/googleDrivePickerService.ts @@ -0,0 +1,295 @@ +/** + * Google Drive Picker Service + * Handles Google Drive file picker integration + */ + +import { loadScript } from '../utils/scriptLoader'; + +const SCOPES = 'https://www.googleapis.com/auth/drive.readonly'; +const SESSION_STORAGE_ID = 'googleDrivePickerAccessToken'; + +interface GoogleDriveConfig { + clientId: string; + apiKey: string; + appId: string; +} + +interface PickerOptions { + multiple?: boolean; + mimeTypes?: string | null; +} + +// Expandable mime types for Google Picker +const expandableMimeTypes: Record = { + 'image/*': ['image/jpeg', 'image/png', 'image/svg+xml'], +}; + +/** + * Convert file input accept attribute to Google Picker mime types + */ +function fileInputToGooglePickerMimeTypes(accept?: string): string | null { + if (!accept || accept === '' || accept.includes('*/*')) { + // Setting null will accept all supported mimetypes + return null; + } + + const mimeTypes: string[] = []; + accept.split(',').forEach((part) => { + const trimmedPart = part.trim(); + if (!(trimmedPart in expandableMimeTypes)) { + mimeTypes.push(trimmedPart); + return; + } + + expandableMimeTypes[trimmedPart].forEach((mimeType) => { + mimeTypes.push(mimeType); + }); + }); + + return mimeTypes.join(',').replace(/\s+/g, ''); +} + +class GoogleDrivePickerService { + private config: GoogleDriveConfig | null = null; + private tokenClient: any = null; + private accessToken: string | null = null; + private gapiLoaded = false; + private gisLoaded = false; + + constructor() { + this.accessToken = sessionStorage.getItem(SESSION_STORAGE_ID); + } + + /** + * Initialize the service with credentials + */ + async initialize(config: GoogleDriveConfig): Promise { + this.config = config; + + // Load Google APIs + await Promise.all([ + this.loadGapi(), + this.loadGis(), + ]); + } + + /** + * Load Google API client + */ + private async loadGapi(): Promise { + if (this.gapiLoaded) return; + + await loadScript({ + src: 'https://apis.google.com/js/api.js', + id: 'gapi-script', + }); + + return new Promise((resolve) => { + window.gapi.load('client:picker', async () => { + await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'); + this.gapiLoaded = true; + resolve(); + }); + }); + } + + /** + * Load Google Identity Services + */ + private async loadGis(): Promise { + if (this.gisLoaded) return; + + await loadScript({ + src: 'https://accounts.google.com/gsi/client', + id: 'gis-script', + }); + + if (!this.config) { + throw new Error('Google Drive config not initialized'); + } + + this.tokenClient = window.google.accounts.oauth2.initTokenClient({ + client_id: this.config.clientId, + scope: SCOPES, + callback: () => {}, // Will be overridden during picker creation + }); + + this.gisLoaded = true; + } + + /** + * Open the Google Drive picker + */ + async openPicker(options: PickerOptions = {}): Promise { + if (!this.config) { + throw new Error('Google Drive service not initialized'); + } + + // Request access token + await this.requestAccessToken(); + + // Create and show picker + return this.createPicker(options); + } + + /** + * Request access token from Google + */ + private requestAccessToken(): Promise { + return new Promise((resolve, reject) => { + if (!this.tokenClient) { + reject(new Error('Token client not initialized')); + return; + } + + this.tokenClient.callback = (response: any) => { + if (response.error !== undefined) { + reject(new Error(response.error)); + return; + } + if(response.access_token == null){ + reject(new Error("No acces token in response")); + } + + this.accessToken = response.access_token; + sessionStorage.setItem(SESSION_STORAGE_ID, this.accessToken ?? ""); + resolve(); + }; + + this.tokenClient.requestAccessToken({ + prompt: this.accessToken === null ? 'consent' : '', + }); + }); + } + + /** + * Create and display the Google Picker + */ + private createPicker(options: PickerOptions): Promise { + return new Promise((resolve, reject) => { + if (!this.config || !this.accessToken) { + reject(new Error('Not initialized or no access token')); + return; + } + + const mimeTypes = fileInputToGooglePickerMimeTypes(options.mimeTypes || undefined); + + const view1 = new window.google.picker.DocsView().setIncludeFolders(true); + if (mimeTypes !== null) { + view1.setMimeTypes(mimeTypes); + } + + const view2 = new window.google.picker.DocsView() + .setIncludeFolders(true) + .setEnableDrives(true); + if (mimeTypes !== null) { + view2.setMimeTypes(mimeTypes); + } + + const builder = new window.google.picker.PickerBuilder() + .setDeveloperKey(this.config.apiKey) + .setAppId(this.config.appId) + .setOAuthToken(this.accessToken) + .addView(view1) + .addView(view2) + .setCallback((data: any) => this.pickerCallback(data, resolve, reject)); + + if (options.multiple) { + builder.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED); + } + + const picker = builder.build(); + picker.setVisible(true); + }); + } + + /** + * Handle picker selection callback + */ + private async pickerCallback( + data: any, + resolve: (files: File[]) => void, + reject: (error: Error) => void + ): Promise { + if (data.action === window.google.picker.Action.PICKED) { + try { + const files = await Promise.all( + data[window.google.picker.Response.DOCUMENTS].map(async (pickedFile: any) => { + const fileId = pickedFile[window.google.picker.Document.ID]; + const res = await window.gapi.client.drive.files.get({ + fileId: fileId, + alt: 'media', + }); + + // Convert response body to File object + const file = new File( + [new Uint8Array(res.body.length).map((_: any, i: number) => res.body.charCodeAt(i))], + pickedFile.name, + { + type: pickedFile.mimeType, + lastModified: pickedFile.lastModified, + } + ); + return file; + }) + ); + + resolve(files); + } catch (error) { + reject(error instanceof Error ? error : new Error('Failed to download files')); + } + } else if (data.action === window.google.picker.Action.CANCEL) { + resolve([]); // User cancelled, return empty array + } + } + + /** + * Sign out and revoke access token + */ + signOut(): void { + if (this.accessToken) { + sessionStorage.removeItem(SESSION_STORAGE_ID); + window.google?.accounts.oauth2.revoke(this.accessToken, () => {}); + this.accessToken = null; + } + } +} + +// Singleton instance +let serviceInstance: GoogleDrivePickerService | null = null; + +/** + * Get or create the Google Drive picker service instance + */ +export function getGoogleDrivePickerService(): GoogleDrivePickerService { + if (!serviceInstance) { + serviceInstance = new GoogleDrivePickerService(); + } + return serviceInstance; +} + +/** + * Check if Google Drive credentials are configured + */ +export function isGoogleDriveConfigured(): boolean { + const clientId = import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID; + const apiKey = import.meta.env.VITE_GOOGLE_DRIVE_API_KEY; + const appId = import.meta.env.VITE_GOOGLE_DRIVE_APP_ID; + + return !!(clientId && apiKey && appId); +} + +/** + * Get Google Drive configuration from environment variables + */ +export function getGoogleDriveConfig(): GoogleDriveConfig | null { + if (!isGoogleDriveConfigured()) { + return null; + } + + return { + clientId: import.meta.env.VITE_GOOGLE_DRIVE_CLIENT_ID, + apiKey: import.meta.env.VITE_GOOGLE_DRIVE_API_KEY, + appId: import.meta.env.VITE_GOOGLE_DRIVE_APP_ID, + }; +} diff --git a/frontend/src/utils/scriptLoader.ts b/frontend/src/utils/scriptLoader.ts new file mode 100644 index 000000000..bf0b64ded --- /dev/null +++ b/frontend/src/utils/scriptLoader.ts @@ -0,0 +1,55 @@ +/** + * Utility for dynamically loading external scripts + */ + +interface ScriptLoadOptions { + src: string; + id?: string; + async?: boolean; + defer?: boolean; + onLoad?: () => void; +} + +const loadedScripts = new Set(); + +export function loadScript({ src, id, async = true, defer = false, onLoad }: ScriptLoadOptions): Promise { + return new Promise((resolve, reject) => { + // Check if already loaded + const scriptId = id || src; + if (loadedScripts.has(scriptId)) { + resolve(); + return; + } + + // Check if script already exists in DOM + const existingScript = id ? document.getElementById(id) : document.querySelector(`script[src="${src}"]`); + if (existingScript) { + loadedScripts.add(scriptId); + resolve(); + return; + } + + // Create and append script + const script = document.createElement('script'); + script.src = src; + if (id) script.id = id; + script.async = async; + script.defer = defer; + + script.onload = () => { + loadedScripts.add(scriptId); + if (onLoad) onLoad(); + resolve(); + }; + + script.onerror = () => { + reject(new Error(`Failed to load script: ${src}`)); + }; + + document.head.appendChild(script); + }); +} + +export function isScriptLoaded(idOrSrc: string): boolean { + return loadedScripts.has(idOrSrc); +}