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'; @Injectable() 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( 'googleDriveStorage.folderImages', )!; this.folderVideos = this.configService.get( 'googleDriveStorage.folderVideos', )!; this.folderDocuments = this.configService.get( 'googleDriveStorage.folderDocuments', )!; // Check if OAuth2 credentials are available const oauth2ClientId = this.configService.get( '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( 'googleDriveStorage.oauth2ClientId', ); const clientSecret = this.configService.get( 'googleDriveStorage.oauth2ClientSecret', ); const redirectUri = this.configService.get( 'googleDriveStorage.oauth2RedirectUri', ); const refreshToken = this.configService.get( '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( 'googleDriveStorage.privateKey', ); const clientEmail = this.configService.get( '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 { 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 { 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 { 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 { 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'; } }