Source

services/gcs.service.js

import { env } from "@config/env.js";
import { logger } from "@config/logger.js";
import { Storage } from "@google-cloud/storage";
import crypto from "crypto";
import sharp from "sharp";
/**
 * Google Cloud Storage Service
 * Handles file uploads and management in Google Cloud Storage with automatic image optimization.
 * @category Services
 */
export class GcsService {
    storage;
    bucketName;
    bucket;
    /**
     * Instantiate the GCS client with configured credentials and bucket.
     * @category Services
     */
    constructor() {
        // Initialize Google Cloud Storage client
        // Credentials file should be located at: secrets/gcp-service-account.json
        const keyFilename = "secrets/gcp-service-account.json";
        this.storage = new Storage({
            projectId: env.GCS_PROJECT_ID,
            keyFilename,
        });
        this.bucketName = env.GCS_BUCKET_NAME;
        this.bucket = this.storage.bucket(this.bucketName);
        logger.info({
            bucketName: this.bucketName,
            keyFile: keyFilename,
        }, "GCS service initialized");
    }
    /**
     * Upload an avatar image to GCS with resizing and compression.
     * @param {string} userId The user ID.
     * @param {string} base64Image Base64 encoded image string.
     * @returns {Promise<string>} The public URL of the uploaded avatar.
     */
    async uploadAvatar(userId, base64Image) {
        try {
            logger.info({ userId }, "Starting avatar upload to GCS");
            // Validate base64 image format
            const matches = base64Image.match(/^data:image\/(jpeg|jpg|png|gif|webp);base64,(.+)$/);
            if (!matches || matches.length !== 3) {
                throw new Error("Invalid base64 image format. Supported: jpeg, jpg, png, gif, webp");
            }
            const imageData = matches[2];
            if (!imageData) {
                throw new Error("Invalid image data");
            }
            // Convert base64 to buffer
            const originalBuffer = Buffer.from(imageData, "base64");
            // Validate original file size before processing
            const maxSizeBytes = env.AVATAR_MAX_SIZE_MB * 1024 * 1024;
            if (originalBuffer.length > maxSizeBytes) {
                throw new Error(`Original image size exceeds ${env.AVATAR_MAX_SIZE_MB}MB limit`);
            }
            logger.info({ userId, originalSize: originalBuffer.length }, "Processing image with Sharp");
            // Process image with Sharp
            // - Resize to configured size (from env)
            // - Convert to configured format (WebP by default)
            // - Auto-orient based on EXIF data
            // - Strip metadata for privacy
            let sharpPipeline = sharp(originalBuffer);
            // Apply format-specific compression
            if (env.AVATAR_FORMAT === "webp") {
                sharpPipeline = sharpPipeline.webp({
                    quality: env.AVATAR_QUALITY,
                    effort: env.AVATAR_COMPRESSION_EFFORT,
                });
            }
            else if (env.AVATAR_FORMAT === "jpeg") {
                sharpPipeline = sharpPipeline.jpeg({
                    quality: env.AVATAR_QUALITY,
                    progressive: true,
                    mozjpeg: true,
                });
            }
            else if (env.AVATAR_FORMAT === "png") {
                sharpPipeline = sharpPipeline.png({
                    quality: env.AVATAR_QUALITY,
                    compressionLevel: 9,
                    effort: env.AVATAR_COMPRESSION_EFFORT,
                });
            }
            const processedBuffer = await sharpPipeline.toBuffer();
            const finalSize = processedBuffer.length;
            const compressionRatio = ((1 - finalSize / originalBuffer.length) * 100).toFixed(1);
            logger.info({
                userId,
                originalSize: originalBuffer.length,
                finalSize,
                compressionRatio: `${compressionRatio}%`,
            }, "Image processed successfully");
            // Generate unique filename with appropriate extension
            const timestamp = Date.now();
            const randomString = crypto.randomBytes(8).toString("hex");
            const envPrefix = env.ENV_PREFIX ? `${env.ENV_PREFIX}/` : "";
            const filename = `${envPrefix}avatars/${userId}/${timestamp}-${randomString}.${env.AVATAR_FORMAT}`;
            // Create a file reference in GCS
            const file = this.bucket.file(filename);
            // Upload the processed file
            await file.save(processedBuffer, {
                metadata: {
                    contentType: `image/${env.AVATAR_FORMAT}`,
                    metadata: {
                        userId,
                        uploadedAt: new Date().toISOString(),
                        originalSize: originalBuffer.length.toString(),
                        processedSize: finalSize.toString(),
                        compressionRatio: `${compressionRatio}%`,
                        dimensions: `${env.AVATAR_SIZE}x${env.AVATAR_SIZE}`,
                        format: env.AVATAR_FORMAT,
                    },
                },
            });
            // Get the public URL
            const publicUrl = `https://storage.googleapis.com/${this.bucketName}/${filename}`;
            logger.info({
                userId,
                publicUrl,
                compressionRatio: `${compressionRatio}%`,
            }, "Avatar uploaded successfully to GCS");
            return publicUrl;
        }
        catch (error) {
            logger.error({
                error,
                userId,
                message: "Failed to upload avatar to GCS",
            });
            throw error;
        }
    }
    /**
     * Delete an avatar from GCS.
     * @param {string} avatarUrl The public URL of the avatar to delete.
     * @returns {Promise<boolean>} True when deleted successfully.
     */
    async deleteAvatar(avatarUrl) {
        try {
            logger.info({ avatarUrl }, "Deleting avatar from GCS");
            // Extract filename from URL
            // URL format: https://storage.googleapis.com/bucket-name/avatars/userId/filename.jpg
            const urlPattern = new RegExp(`https://storage\\.googleapis\\.com/${this.bucketName}/(.+)$`);
            const matches = avatarUrl.match(urlPattern);
            if (!matches || matches.length !== 2) {
                logger.warn({ avatarUrl }, "Invalid GCS URL format, skipping deletion");
                return false;
            }
            const filename = matches[1];
            if (!filename) {
                return false;
            }
            // Delete the file
            const file = this.bucket.file(filename);
            await file.delete();
            logger.info({ filename }, "Avatar deleted successfully from GCS");
            return true;
        }
        catch (error) {
            // If file doesn't exist (404), consider it a success
            if (error instanceof Error && error.message.includes("404")) {
                logger.warn({ avatarUrl }, "Avatar file not found in GCS, already deleted");
                return true;
            }
            logger.error({
                error,
                avatarUrl,
                message: "Failed to delete avatar from GCS",
            });
            // Don't throw error, just return false to avoid breaking the flow
            return false;
        }
    }
    /**
     * Delete all avatars for a specific user.
     * @param {string} userId The user ID.
     * @returns {Promise<number>} Number of files deleted.
     */
    async deleteUserAvatars(userId) {
        try {
            logger.info({ userId }, "Deleting all avatars for user from GCS");
            const prefix = `avatars/${userId}/`;
            // List all files with the prefix
            const [files] = await this.bucket.getFiles({ prefix });
            if (files.length === 0) {
                logger.info({ userId }, "No avatars found for user in GCS");
                return 0;
            }
            // Delete all files
            await Promise.all(files.map((file) => file.delete()));
            logger.info({ userId, count: files.length }, "All user avatars deleted from GCS");
            return files.length;
        }
        catch (error) {
            logger.error({
                error,
                userId,
                message: "Failed to delete user avatars from GCS",
            });
            throw error;
        }
    }
    /**
     * Check if an avatar exists in GCS.
     * @param {string} avatarUrl The public URL of the avatar.
     * @returns {Promise<boolean>} True if the file exists.
     */
    async avatarExists(avatarUrl) {
        try {
            // Extract filename from URL
            const urlPattern = new RegExp(`https://storage\\.googleapis\\.com/${this.bucketName}/(.+)$`);
            const matches = avatarUrl.match(urlPattern);
            if (!matches || matches.length !== 2) {
                return false;
            }
            const filename = matches[1];
            if (!filename) {
                return false;
            }
            const file = this.bucket.file(filename);
            const [exists] = await file.exists();
            return exists;
        }
        catch (error) {
            logger.error({
                error,
                avatarUrl,
                message: "Failed to check avatar existence in GCS",
            });
            return false;
        }
    }
    /**
     * Get avatar metadata.
     * @param {string} avatarUrl The public URL of the avatar.
     * @returns {Promise<FileMetadata | null>} File metadata response.
     */
    async getAvatarMetadata(avatarUrl) {
        try {
            // Extract filename from URL
            const urlPattern = new RegExp(`https://storage\\.googleapis\\.com/${this.bucketName}/(.+)$`);
            const matches = avatarUrl.match(urlPattern);
            if (!matches || matches.length !== 2) {
                throw new Error("Invalid GCS URL format");
            }
            const filename = matches[1];
            if (!filename) {
                return null;
            }
            const file = this.bucket.file(filename);
            const [metadata] = await file.getMetadata();
            return {
                name: metadata.name,
                size: metadata.size,
                contentType: metadata.contentType,
                timeCreated: metadata.timeCreated,
                updated: metadata.updated,
                md5Hash: metadata.md5Hash,
            };
        }
        catch (error) {
            logger.error({
                error,
                avatarUrl,
                message: "Failed to get avatar metadata from GCS",
            });
            throw error;
        }
    }
    /**
     * Health check for GCS connection.
     * @returns {Promise<boolean>} True if GCS is accessible.
     */
    async healthCheck() {
        try {
            // Try to access the bucket
            const [exists] = await this.bucket.exists();
            if (!exists) {
                logger.warn({ bucketName: this.bucketName }, "GCS bucket does not exist");
                return false;
            }
            logger.info({ bucketName: this.bucketName }, "GCS health check passed");
            return true;
        }
        catch (error) {
            logger.error({
                error,
                bucketName: this.bucketName,
                message: "GCS health check failed",
            });
            return false;
        }
    }
}
// Export singleton instance
export const gcsService = new GcsService();