342 lines
11 KiB
TypeScript
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();
|