Spaces:
Runtime error
Runtime error
Akshar2325
feat(google-drive-upload): add Google Drive integration for file uploads and HLS conversion
ffebaa3 | import { Injectable, Logger } from '@nestjs/common'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { google } from 'googleapis'; | |
| import { Readable } from 'stream'; | |
| import { OAuth2Client } from 'google-auth-library'; | |
| () | |
| export class GoogleDriveService { | |
| private readonly logger = new Logger(GoogleDriveService.name); | |
| private drive: any; | |
| private oauth2Client: OAuth2Client; | |
| private readonly folderImages: string; | |
| private readonly folderVideos: string; | |
| private readonly folderDocuments: string; | |
| private readonly useOAuth2: boolean; | |
| constructor(private configService: ConfigService) { | |
| this.folderImages = this.configService.get<string>( | |
| 'googleDriveStorage.folderImages', | |
| )!; | |
| this.folderVideos = this.configService.get<string>( | |
| 'googleDriveStorage.folderVideos', | |
| )!; | |
| this.folderDocuments = this.configService.get<string>( | |
| 'googleDriveStorage.folderDocuments', | |
| )!; | |
| // Check if OAuth2 credentials are available | |
| const oauth2ClientId = this.configService.get<string>( | |
| 'googleDriveStorage.oauth2ClientId', | |
| ); | |
| this.useOAuth2 = !!oauth2ClientId; | |
| if (this.useOAuth2) { | |
| this.logger.log( | |
| '✅ Initializing Google Drive with OAuth2 (uses YOUR account quota)', | |
| ); | |
| this.initializeOAuth2(); | |
| } else { | |
| this.logger.warn( | |
| '⚠️ Using Service Account (will NOT work with personal accounts)', | |
| ); | |
| this.initializeDrive(); | |
| } | |
| } | |
| private initializeOAuth2() { | |
| try { | |
| const clientId = this.configService.get<string>( | |
| 'googleDriveStorage.oauth2ClientId', | |
| ); | |
| const clientSecret = this.configService.get<string>( | |
| 'googleDriveStorage.oauth2ClientSecret', | |
| ); | |
| const redirectUri = this.configService.get<string>( | |
| 'googleDriveStorage.oauth2RedirectUri', | |
| ); | |
| const refreshToken = this.configService.get<string>( | |
| 'googleDriveStorage.oauth2RefreshToken', | |
| ); | |
| this.oauth2Client = new google.auth.OAuth2( | |
| clientId, | |
| clientSecret, | |
| redirectUri, | |
| ); | |
| if (refreshToken) { | |
| this.oauth2Client.setCredentials({ | |
| refresh_token: refreshToken, | |
| }); | |
| this.logger.log('✅ OAuth2 client initialized with refresh token'); | |
| } else { | |
| this.logger.warn( | |
| '⚠️ No refresh token found. You need to authenticate first!', | |
| ); | |
| } | |
| this.drive = google.drive({ version: 'v3', auth: this.oauth2Client }); | |
| this.logger.log('Google Drive OAuth2 service initialized successfully'); | |
| } catch (error) { | |
| this.logger.error( | |
| 'Failed to initialize Google Drive OAuth2:', | |
| error.message, | |
| ); | |
| throw error; | |
| } | |
| } | |
| private initializeDrive() { | |
| try { | |
| const privateKey = this.configService.get<string>( | |
| 'googleDriveStorage.privateKey', | |
| ); | |
| const clientEmail = this.configService.get<string>( | |
| 'googleDriveStorage.clientEmail', | |
| ); | |
| const auth = new google.auth.GoogleAuth({ | |
| credentials: { | |
| type: 'service_account', | |
| private_key: privateKey, | |
| client_email: clientEmail, | |
| }, | |
| scopes: [ | |
| 'https://www.googleapis.com/auth/drive.file', | |
| 'https://www.googleapis.com/auth/drive', | |
| ], | |
| }); | |
| this.drive = google.drive({ version: 'v3', auth }); | |
| this.logger.log('Google Drive service initialized successfully'); | |
| } catch (error) { | |
| this.logger.error( | |
| 'Failed to initialize Google Drive service:', | |
| error.message, | |
| ); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Get OAuth2 authorization URL for user to grant access | |
| */ | |
| getAuthorizationUrl(): string { | |
| if (!this.useOAuth2) { | |
| throw new Error('OAuth2 is not configured'); | |
| } | |
| const authUrl = this.oauth2Client.generateAuthUrl({ | |
| access_type: 'offline', | |
| scope: [ | |
| 'https://www.googleapis.com/auth/drive.file', | |
| 'https://www.googleapis.com/auth/drive', | |
| ], | |
| prompt: 'consent', // Force to get refresh token | |
| }); | |
| return authUrl; | |
| } | |
| /** | |
| * Exchange authorization code for tokens | |
| */ | |
| async getTokensFromCode(code: string): Promise<any> { | |
| if (!this.useOAuth2) { | |
| throw new Error('OAuth2 is not configured'); | |
| } | |
| const { tokens } = await this.oauth2Client.getToken(code); | |
| this.oauth2Client.setCredentials(tokens); | |
| this.logger.log('✅ Successfully exchanged code for tokens'); | |
| this.logger.log( | |
| `📝 Save this refresh token to .env: ${tokens.refresh_token}`, | |
| ); | |
| return tokens; | |
| } | |
| /** | |
| * Handle OAuth callback - exchange code for tokens | |
| */ | |
| async handleOAuthCallback(code: string): Promise<{ | |
| success: boolean; | |
| refreshToken?: string; | |
| error?: string; | |
| }> { | |
| try { | |
| if (!code) { | |
| return { success: false, error: 'No authorization code provided' }; | |
| } | |
| const tokens = await this.getTokensFromCode(code); | |
| return { | |
| success: true, | |
| refreshToken: tokens.refresh_token, | |
| }; | |
| } catch (error) { | |
| this.logger.error('OAuth2 callback failed:', error.message); | |
| return { | |
| success: false, | |
| error: error.message, | |
| }; | |
| } | |
| } | |
| /** | |
| * Upload file from buffer to Google Drive | |
| * Note: When using personal Google accounts (not Workspace), files uploaded by the service account | |
| * will count against the SERVICE ACCOUNT's quota, not the personal account's quota. | |
| * For Workspace accounts with Shared Drives, files count against the organization's quota. | |
| */ | |
| private async uploadToGoogleDrive( | |
| fileBuffer: Buffer, | |
| fileName: string, | |
| folderId: string, | |
| mimeType?: string, | |
| ): Promise<string> { | |
| try { | |
| // Convert buffer to readable stream | |
| const bufferStream = new Readable(); | |
| bufferStream.push(fileBuffer); | |
| bufferStream.push(null); | |
| const fileMetadata = { | |
| name: fileName, | |
| parents: [folderId], | |
| }; | |
| const media = { | |
| mimeType: mimeType || 'application/octet-stream', | |
| body: bufferStream, | |
| }; | |
| this.logger.log( | |
| `Uploading file: ${fileName} (${fileBuffer.length} bytes) to folder: ${folderId}`, | |
| ); | |
| const response = await this.drive.files.create({ | |
| requestBody: fileMetadata, | |
| media: media, | |
| fields: 'id, name, size, webViewLink, webContentLink', | |
| }); | |
| // Make file publicly accessible | |
| try { | |
| await this.drive.permissions.create({ | |
| fileId: response.data.id, | |
| requestBody: { | |
| role: 'reader', | |
| type: 'anyone', | |
| }, | |
| }); | |
| this.logger.log(`File made public: ${response.data.id}`); | |
| } catch (permError) { | |
| this.logger.warn(`Could not make file public: ${permError.message}`); | |
| } | |
| this.logger.log( | |
| `File uploaded successfully: ${fileName} (ID: ${response.data.id}, Size: ${response.data.size} bytes)`, | |
| ); | |
| return response.data.id; | |
| } catch (error) { | |
| // Log the complete error details for debugging | |
| this.logger.error(`Google Drive API Error Details:`, { | |
| message: error.message, | |
| code: error.code, | |
| status: error.status, | |
| errors: error.errors, | |
| statusText: error.statusText, | |
| response: error.response?.data, | |
| }); | |
| // Extract actual error message from Google Drive API | |
| const apiError = error.response?.data?.error; | |
| const errorMessage = apiError?.message || error.message; | |
| const errorCode = error.code || apiError?.code || error.status; | |
| this.logger.error( | |
| `Upload failed for ${fileName}: [${errorCode}] ${errorMessage}`, | |
| ); | |
| // Check for specific error types and provide helpful messages | |
| if (errorCode === 403) { | |
| const isQuotaError = | |
| errorMessage.toLowerCase().includes('quota') || | |
| errorMessage.toLowerCase().includes('storage') || | |
| errorMessage.toLowerCase().includes('limit exceeded'); | |
| if (isQuotaError) { | |
| throw new Error( | |
| `Storage quota exceeded. ` + | |
| `Google Drive API Error: ${errorMessage}. ` + | |
| `\n\nIMPORTANT: With personal Google accounts, files uploaded by service accounts count against the SERVICE ACCOUNT's 15GB quota, not your personal account quota. ` + | |
| `\n\nSolutions:\n` + | |
| `1. Delete unused files from service account storage (contact Google Cloud support to check usage)\n` + | |
| `2. Use Google Workspace with Shared Drives (files count against organization quota)\n` + | |
| `3. Switch to OAuth2 authentication to upload files as YOUR account (uses your 15GB quota)\n` + | |
| `4. Use alternative storage: Backblaze B2, IDrive e2, Supabase (already configured in this project)`, | |
| ); | |
| } | |
| throw new Error( | |
| `Permission denied. Google Drive API Error: ${errorMessage}. ` + | |
| `Ensure the service account has Editor permission on folder ${folderId}.`, | |
| ); | |
| } | |
| if (errorCode === 404) { | |
| throw new Error( | |
| `Folder not found. Google Drive API Error: ${errorMessage}. ` + | |
| `Folder ID: ${folderId}. Ensure it exists and is shared with: ${this.configService.get('googleDriveStorage.clientEmail')}`, | |
| ); | |
| } | |
| if (errorCode === 401) { | |
| throw new Error( | |
| `Authentication failed. Google Drive API Error: ${errorMessage}. ` + | |
| `Check your service account credentials in .env file.`, | |
| ); | |
| } | |
| if (errorCode === 400) { | |
| throw new Error( | |
| `Bad request. Google Drive API Error: ${errorMessage}. ` + | |
| `This usually indicates invalid file metadata or folder ID.`, | |
| ); | |
| } | |
| // Generic error with actual Google Drive API message | |
| throw new Error( | |
| `Upload failed. Google Drive API Error [${errorCode}]: ${errorMessage}`, | |
| ); | |
| } | |
| } | |
| /** | |
| * Get public URL for a file | |
| */ | |
| getPublicUrl(fileId: string): string { | |
| return `https://drive.google.com/uc?id=${fileId}`; | |
| } | |
| /** | |
| * Get direct download URL for images | |
| */ | |
| getImageUrl(fileId: string): string { | |
| return `https://drive.usercontent.google.com/download?id=${fileId}&export=view&authuser=0`; | |
| } | |
| /** | |
| * Delete file from Google Drive by file ID | |
| */ | |
| private async deleteFile(fileId: string): Promise<void> { | |
| try { | |
| await this.drive.files.delete({ | |
| fileId: fileId, | |
| }); | |
| this.logger.log(`File deleted: ${fileId}`); | |
| } catch (error) { | |
| this.logger.error(`Failed to delete file ${fileId}:`, error.message); | |
| throw new Error(`Delete failed: ${error.message}`); | |
| } | |
| } | |
| /** | |
| * Extract file ID from Google Drive public URL | |
| */ | |
| private extractFileIdFromUrl(url: string): string | null { | |
| try { | |
| // Handle URL format: https://drive.google.com/uc?id=FILE_ID | |
| const match = url.match(/id=([^&]+)/); | |
| if (match && match[1]) { | |
| return match[1]; | |
| } | |
| // Handle alternative format: https://drive.usercontent.google.com/download?id=FILE_ID | |
| const match2 = url.match(/[?&]id=([^&]+)/); | |
| if (match2 && match2[1]) { | |
| return match2[1]; | |
| } | |
| return null; | |
| } catch (error) { | |
| this.logger.error('Failed to extract file ID from URL:', error.message); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Handle file deletion by public URL | |
| */ | |
| async handleFileDelete(publicUrl: string): Promise<any> { | |
| try { | |
| const fileId = this.extractFileIdFromUrl(publicUrl); | |
| if (!fileId) { | |
| throw new Error('Invalid public URL - could not extract file ID'); | |
| } | |
| await this.deleteFile(fileId); | |
| return { | |
| success: true, | |
| message: 'File deleted successfully', | |
| fileId, | |
| }; | |
| } catch (error) { | |
| this.logger.error('Delete failed:', error.message); | |
| throw new Error(`Delete failed: ${error.message}`); | |
| } | |
| } | |
| /** | |
| * Get folder ID based on file type | |
| */ | |
| getFolderId(fileType: string): string { | |
| switch (fileType.toLowerCase()) { | |
| case 'image': | |
| return this.folderImages; | |
| case 'video': | |
| return this.folderVideos; | |
| case 'document': | |
| return this.folderDocuments; | |
| default: | |
| return this.folderImages; | |
| } | |
| } | |
| /** | |
| * Upload file to Google Drive (main entry point from controller) | |
| */ | |
| async uploadFile(params: { | |
| fileBuffer: Buffer; | |
| filename: string; | |
| fileType?: string; | |
| customFilename?: string; | |
| mimetype?: string; | |
| }) { | |
| const { fileBuffer, filename, fileType, customFilename, mimetype } = params; | |
| const finalFilename = customFilename || filename; | |
| const subfolder = this.getFolderPath(fileType || 'IMAGE'); | |
| const folderId = this.getFolderId(subfolder); | |
| const timestamp = Date.now(); | |
| const sanitizedFilename = finalFilename.replace(/[^a-zA-Z0-9.-]/g, '_'); | |
| const finalName = `${timestamp}-${sanitizedFilename}`; | |
| try { | |
| const fileId = await this.uploadToGoogleDrive( | |
| fileBuffer, | |
| finalName, | |
| folderId, | |
| mimetype, | |
| ); | |
| const publicUrl = | |
| fileType === 'IMAGE' | |
| ? this.getImageUrl(fileId) | |
| : this.getPublicUrl(fileId); | |
| this.logger.log(`File uploaded: ${finalName}`); | |
| return { | |
| success: true, | |
| name: finalName, | |
| driveFileId: fileId, | |
| driveFolderId: folderId, | |
| size: fileBuffer.length, | |
| publicUrl: publicUrl, | |
| }; | |
| } catch (error) { | |
| this.logger.error('Upload failed:', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Get folder path based on file type | |
| */ | |
| private getFolderPath(fileType: string): string { | |
| const folderMap = { | |
| IMAGE: 'image', | |
| VIDEO: 'video', | |
| DOCUMENT: 'document', | |
| }; | |
| return folderMap[fileType.toUpperCase()] || 'image'; | |
| } | |
| } | |