Newer
Older
WeComCompanyPlugin / image-processor.js
import { createHash } from "crypto";
import { readFile } from "fs/promises";
import { logger } from "./logger.js";

/**
 * Image Processing Module for WeCom
 *
 * Handles loading, validating, and encoding images for WeCom msg_item
 * Supports JPG and PNG formats up to 2MB
 */

// Image format signatures (magic bytes)
const IMAGE_SIGNATURES = {
  JPG: [0xff, 0xd8, 0xff],
  PNG: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
};

// 2MB size limit (before base64 encoding)
const MAX_IMAGE_SIZE = 2 * 1024 * 1024;

/**
 * Load image file from filesystem
 * @param {string} filePath - Absolute path to image file
 * @returns {Promise<Buffer>} Image data buffer
 * @throws {Error} If file not found or cannot be read
 */
export async function loadImageFromPath(filePath) {
  try {
    logger.debug("Loading image from path", { filePath });
    const buffer = await readFile(filePath);
    logger.debug("Image loaded successfully", {
      filePath,
      size: buffer.length,
    });
    return buffer;
  } catch (error) {
    if (error.code === "ENOENT") {
      throw new Error(`Image file not found: ${filePath}`, { cause: error });
    } else if (error.code === "EACCES") {
      throw new Error(`Permission denied reading image: ${filePath}`, { cause: error });
    } else {
      throw new Error(`Failed to read image file: ${error.message}`, { cause: error });
    }
  }
}

/**
 * Convert buffer to base64 string
 * @param {Buffer} buffer - Image data buffer
 * @returns {string} Base64-encoded string
 */
export function encodeImageToBase64(buffer) {
  return buffer.toString("base64");
}

/**
 * Calculate MD5 checksum of buffer
 * @param {Buffer} buffer - Image data buffer
 * @returns {string} MD5 hash in hexadecimal
 */
export function calculateMD5(buffer) {
  return createHash("md5").update(buffer).digest("hex");
}

/**
 * Validate image size is within limits
 * @param {Buffer} buffer - Image data buffer
 * @throws {Error} If size exceeds 2MB limit
 */
export function validateImageSize(buffer) {
  const sizeBytes = buffer.length;
  const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);

  if (sizeBytes > MAX_IMAGE_SIZE) {
    throw new Error(`Image size ${sizeMB}MB exceeds 2MB limit (actual: ${sizeBytes} bytes)`);
  }

  logger.debug("Image size validated", { sizeBytes, sizeMB });
}

/**
 * Detect image format from magic bytes
 * @param {Buffer} buffer - Image data buffer
 * @returns {string} Format: "JPG" or "PNG"
 * @throws {Error} If format is not supported
 */
export function detectImageFormat(buffer) {
  // Check PNG signature
  if (buffer.length >= IMAGE_SIGNATURES.PNG.length) {
    const isPNG = IMAGE_SIGNATURES.PNG.every((byte, index) => buffer[index] === byte);
    if (isPNG) {
      logger.debug("Image format detected: PNG");
      return "PNG";
    }
  }

  // Check JPG signature
  if (buffer.length >= IMAGE_SIGNATURES.JPG.length) {
    const isJPG = IMAGE_SIGNATURES.JPG.every((byte, index) => buffer[index] === byte);
    if (isJPG) {
      logger.debug("Image format detected: JPG");
      return "JPG";
    }
  }

  // Unknown format
  const header = buffer.subarray(0, 16).toString("hex");
  throw new Error(
    `Unsupported image format. Only JPG and PNG are supported. File header: ${header}`,
  );
}

/**
 * Complete image processing pipeline
 *
 * Loads image from filesystem, validates format and size,
 * then encodes to base64 and calculates MD5 checksum.
 *
 * @param {string} filePath - Absolute path to image file
 * @returns {Promise<Object>} Processed image data
 * @returns {string} return.base64 - Base64-encoded image data
 * @returns {string} return.md5 - MD5 checksum
 * @returns {string} return.format - Image format (JPG or PNG)
 * @returns {number} return.size - Original size in bytes
 *
 * @throws {Error} If any step fails (file not found, invalid format, size exceeded, etc.)
 *
 * @example
 * const result = await prepareImageForMsgItem('/path/to/image.jpg');
 * // Returns: { base64: "...", md5: "...", format: "JPG", size: 123456 }
 */
export async function prepareImageForMsgItem(filePath) {
  logger.debug("Starting image processing pipeline", { filePath });

  try {
    // Step 1: Load image
    const buffer = await loadImageFromPath(filePath);

    // Step 2: Validate size
    validateImageSize(buffer);

    // Step 3: Detect format
    const format = detectImageFormat(buffer);

    // Step 4: Encode to base64
    const base64 = encodeImageToBase64(buffer);

    // Step 5: Calculate MD5
    const md5 = calculateMD5(buffer);

    logger.info("Image processed successfully", {
      filePath,
      format,
      size: buffer.length,
      md5,
      base64Length: base64.length,
    });

    return {
      base64,
      md5,
      format,
      size: buffer.length,
    };
  } catch (error) {
    logger.error("Image processing failed", {
      filePath,
      error: error.message,
    });
    throw error;
  }
}