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();
Source