Re_Backend/src/services/gcsStorage.service.ts

342 lines
11 KiB
TypeScript

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<void> {
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<UploadResult> {
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<void> {
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<string> {
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<UploadResult> {
// 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();