import { Storage } from '@google-cloud/storage'; import path from 'path'; import fs from 'fs'; import logger from '@utils/logger'; import { UPLOAD_DIR } from '@config/storage'; interface UploadFileOptions { filePath?: string; buffer?: Buffer; originalName: string; mimeType: string; requestNumber: string; // Request number (e.g., 'REQ-2025-12-0001') fileType: 'documents' | 'attachments'; // Type of file: documents or attachments } interface UploadResult { storageUrl: string; filePath: string; // GCS path fileName: string; // Generated file name in GCS } class GCSStorageService { private storage: Storage | null = null; private bucketName: string; private projectId: string; constructor() { this.projectId = process.env.GCP_PROJECT_ID || ''; this.bucketName = process.env.GCP_BUCKET_NAME || ''; const keyFilePath = process.env.GCP_KEY_FILE || ''; if (!this.projectId || !this.bucketName || !keyFilePath) { logger.warn('[GCS] GCP configuration missing. File uploads will fail.'); return; } try { // Resolve key file path (can be relative or absolute) const resolvedKeyPath = path.isAbsolute(keyFilePath) ? keyFilePath : path.resolve(process.cwd(), keyFilePath); if (!fs.existsSync(resolvedKeyPath)) { logger.error(`[GCS] Key file not found at: ${resolvedKeyPath}`); return; } this.storage = new Storage({ projectId: this.projectId, keyFilename: resolvedKeyPath, }); logger.info('[GCS] Initialized successfully', { projectId: this.projectId, bucketName: this.bucketName, }); } catch (error) { logger.error('[GCS] Failed to initialize:', error); } } /** * Ensure the bucket exists, create it if it doesn't * This is called lazily on first upload */ private async ensureBucketExists(): Promise { if (!this.storage) { throw new Error('GCS storage not initialized'); } try { const bucket = this.storage.bucket(this.bucketName); const [exists] = await bucket.exists(); if (!exists) { logger.info(`[GCS] Bucket "${this.bucketName}" does not exist. Creating...`); // Get region from env or default to asia-south1 (Mumbai) const region = process.env.GCP_BUCKET_REGION || 'asia-south1'; // Create bucket with default settings // Note: publicAccessPrevention is not set to allow public file access // If you need private buckets, set GCP_BUCKET_PUBLIC=false and use signed URLs const bucketOptions: any = { location: region, storageClass: 'STANDARD', uniformBucketLevelAccess: true, }; // Only enforce public access prevention if explicitly configured if (process.env.GCP_BUCKET_PUBLIC === 'false') { bucketOptions.publicAccessPrevention = 'enforced'; } await bucket.create(bucketOptions); logger.info(`[GCS] Bucket "${this.bucketName}" created successfully in region "${region}"`); } else { logger.debug(`[GCS] Bucket "${this.bucketName}" already exists`); } } catch (error) { logger.error(`[GCS] Failed to check/create bucket "${this.bucketName}":`, error); throw error; } } /** * Upload a file to Google Cloud Storage * @param options File upload options * @returns Upload result with storage URL and file path */ async uploadFile(options: UploadFileOptions): Promise { if (!this.storage) { throw new Error('GCS storage not initialized. Check GCP configuration.'); } const { filePath, buffer, originalName, mimeType, requestNumber, fileType } = options; if (!filePath && !buffer) { throw new Error('Either filePath or buffer must be provided'); } if (!requestNumber) { throw new Error('Request number is required for file upload'); } try { // Ensure bucket exists before uploading await this.ensureBucketExists(); // Generate unique file name const timestamp = Date.now(); const randomHash = Math.random().toString(36).substring(2, 8); const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); const extension = path.extname(originalName); const fileName = `${timestamp}-${randomHash}-${safeName}`; // Build GCS path: requests/{requestNumber}/{fileType}/{fileName} // Example: requests/REQ-2025-12-0001/documents/proposal.pdf // Example: requests/REQ-2025-12-0001/attachments/approval_note.pdf const gcsFilePath = `requests/${requestNumber}/${fileType}/${fileName}`; const bucket = this.storage.bucket(this.bucketName); const file = bucket.file(gcsFilePath); // Upload options const uploadOptions: any = { metadata: { contentType: mimeType, metadata: { originalName: originalName, uploadedAt: new Date().toISOString(), }, }, }; // Upload from buffer or file path if (buffer) { await file.save(buffer, uploadOptions); } else if (filePath) { await bucket.upload(filePath, { destination: gcsFilePath, metadata: uploadOptions.metadata, }); } // Make file publicly readable (or use signed URLs for private access) // Note: This will fail if bucket has publicAccessPrevention enabled let publicUrl: string; try { await file.makePublic(); // Get public URL publicUrl = `https://storage.googleapis.com/${this.bucketName}/${gcsFilePath}`; } catch (makePublicError: any) { // If making public fails (e.g., public access prevention), use signed URL if (makePublicError?.code === 400 || makePublicError?.message?.includes('publicAccessPrevention')) { logger.warn('[GCS] Cannot make file public (public access prevention enabled). Using signed URL.'); publicUrl = await this.getSignedUrl(gcsFilePath, 60 * 24 * 365); // 1 year expiry } else { throw makePublicError; } } logger.info('[GCS] File uploaded successfully', { fileName: originalName, gcsPath: gcsFilePath, storageUrl: publicUrl, size: buffer ? buffer.length : 'unknown', }); return { storageUrl: publicUrl, filePath: gcsFilePath, fileName: fileName, }; } catch (error) { logger.error('[GCS] Upload failed:', error); throw new Error(`Failed to upload file to GCS: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Delete a file from Google Cloud Storage * @param gcsFilePath The GCS file path (e.g., 'attachments/file-name.ext') */ async deleteFile(gcsFilePath: string): Promise { if (!this.storage) { throw new Error('GCS storage not initialized. Check GCP configuration.'); } try { const bucket = this.storage.bucket(this.bucketName); const file = bucket.file(gcsFilePath); await file.delete(); logger.info('[GCS] File deleted successfully', { gcsPath: gcsFilePath }); } catch (error) { logger.error('[GCS] Delete failed:', error); throw new Error(`Failed to delete file from GCS: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Get a signed URL for private file access (valid for 1 hour by default) * @param gcsFilePath The GCS file path * @param expiresInMinutes URL expiration time in minutes (default: 60) * @returns Signed URL */ async getSignedUrl(gcsFilePath: string, expiresInMinutes: number = 60): Promise { if (!this.storage) { throw new Error('GCS storage not initialized. Check GCP configuration.'); } try { const bucket = this.storage.bucket(this.bucketName); const file = bucket.file(gcsFilePath); const [url] = await file.getSignedUrl({ action: 'read', expires: Date.now() + expiresInMinutes * 60 * 1000, }); return url; } catch (error) { logger.error('[GCS] Failed to generate signed URL:', error); throw new Error(`Failed to generate signed URL: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Save file to local storage with the same folder structure as GCS * This is used as a fallback when GCS is not configured or fails * @param options File upload options * @returns Upload result with local storage URL and file path */ saveToLocalStorage(options: UploadFileOptions): UploadResult { const { buffer, originalName, requestNumber, fileType } = options; if (!buffer) { throw new Error('Buffer is required for local storage fallback'); } if (!requestNumber) { throw new Error('Request number is required for file upload'); } try { // Generate unique file name (same format as GCS) const timestamp = Date.now(); const randomHash = Math.random().toString(36).substring(2, 8); const safeName = originalName.replace(/[^a-zA-Z0-9._-]/g, '_'); const fileName = `${timestamp}-${randomHash}-${safeName}`; // Build local path: uploads/requests/{requestNumber}/{fileType}/{fileName} // This matches the GCS structure: requests/{requestNumber}/{fileType}/{fileName} const localDir = path.join(UPLOAD_DIR, 'requests', requestNumber, fileType); // Ensure directory exists if (!fs.existsSync(localDir)) { fs.mkdirSync(localDir, { recursive: true }); } const localFilePath = path.join(localDir, fileName); const relativePath = `requests/${requestNumber}/${fileType}/${fileName}`; // Save file to disk fs.writeFileSync(localFilePath, buffer); // Create URL path (will be served by express.static) const storageUrl = `/uploads/${relativePath}`; logger.info('[GCS] File saved to local storage (fallback)', { fileName: originalName, localPath: relativePath, storageUrl: storageUrl, requestNumber: requestNumber, }); return { storageUrl: storageUrl, filePath: relativePath, // Store relative path (same format as GCS path) fileName: fileName, }; } catch (error) { logger.error('[GCS] Local storage save failed:', error); throw new Error(`Failed to save file to local storage: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Upload file with automatic fallback to local storage * If GCS is configured and works, uploads to GCS. Otherwise, saves to local storage. * @param options File upload options * @returns Upload result with storage URL and file path */ async uploadFileWithFallback(options: UploadFileOptions): Promise { // If GCS is not configured, use local storage directly if (!this.isConfigured()) { logger.info('[GCS] GCS not configured, using local storage'); return this.saveToLocalStorage(options); } // Try GCS upload first try { return await this.uploadFile(options); } catch (gcsError) { logger.warn('[GCS] GCS upload failed, falling back to local storage', { error: gcsError }); // Fallback to local storage return this.saveToLocalStorage(options); } } /** * Check if GCS is properly configured */ isConfigured(): boolean { return this.storage !== null && this.bucketName !== '' && this.projectId !== ''; } } export const gcsStorageService = new GCSStorageService();