streamflix-api / src /shared /modules /upload /google-drive /google-drive.service.ts
Akshar2325
feat(google-drive-upload): add Google Drive integration for file uploads and HLS conversion
ffebaa3
Raw
History Blame
13.9 kB
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<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';
}
}