const fs = require('fs/promises') const path = require('path') const crypto = require('crypto') const sharp = require('sharp') const { nanoid } = require('nanoid') const { findImageAssetByHash, createImageAsset, createImageOptimizationJob, updateImageOptimizationJobStatus, } = require('../db') const UPLOAD_ROOT = path.join(__dirname, '..', '..', 'uploads') const OPTIMIZED_DIR = 'assets' const OPTIMIZATION_CONCURRENCY = Math.max(1, Number(process.env.IMAGE_OPTIMIZATION_CONCURRENCY || 1)) let activeCount = 0 const pendingJobs = [] function ensureImageMimeType(file) { return typeof file?.mimetype === 'string' && file.mimetype.startsWith('image/') } function createMemoryUpload(multer, { fileSize = 6 * 1024 * 1024, maxCount } = {}) { return multer({ storage: multer.memoryStorage(), limits: { fileSize, ...(typeof maxCount === 'number' ? { files: maxCount } : {}), }, fileFilter: (req, file, cb) => { if (ensureImageMimeType(file)) return cb(null, true) cb(new Error('image_file_required')) }, }) } function scheduleQueue() { while (activeCount < OPTIMIZATION_CONCURRENCY && pendingJobs.length) { const job = pendingJobs.shift() activeCount += 1 processQueuedJob(job) .then(job.resolve) .catch(job.reject) .finally(() => { activeCount = Math.max(0, activeCount - 1) scheduleQueue() }) } } async function optimizeAndPersist({ file, width, height, fit, quality }) { const { data, info } = await sharp(file.buffer, { failOn: 'none' }) .rotate() .resize({ width, height, fit, withoutEnlargement: true, }) .webp({ quality }) .toBuffer({ resolveWithObject: true }) const contentHash = crypto.createHash('sha256').update(data).digest('hex') const existing = await findImageAssetByHash(contentHash) if (existing) { return { src: existing.src, size: existing.byteSize, originalSize: existing.originalByteSize, width: existing.width, height: existing.height, contentHash: existing.contentHash, reused: true, } } const basename = nanoid() const shardDirectory = basename.slice(0, 2) const filename = basename + '.webp' const relativeDir = path.join(OPTIMIZED_DIR, shardDirectory) const absoluteDir = path.join(UPLOAD_ROOT, relativeDir) const absolutePath = path.join(absoluteDir, filename) const src = '/uploads/' + relativeDir.split(path.sep).join('/') + '/' + filename await fs.mkdir(absoluteDir, { recursive: true }) await fs.writeFile(absolutePath, data) try { const asset = await createImageAsset({ id: nanoid(), contentHash, src, mimeType: 'image/webp', byteSize: data.length, originalByteSize: file.size || file.buffer.length, width: info.width || 0, height: info.height || 0, }) return { src: asset.src, size: asset.byteSize, originalSize: asset.originalByteSize, width: asset.width, height: asset.height, contentHash: asset.contentHash, reused: false, } } catch (error) { try { await fs.unlink(absolutePath) } catch (unlinkError) { if (unlinkError?.code !== 'ENOENT') throw unlinkError } if (error?.code === 'ER_DUP_ENTRY') { const asset = await findImageAssetByHash(contentHash) if (asset) { return { src: asset.src, size: asset.byteSize, originalSize: asset.originalByteSize, width: asset.width, height: asset.height, contentHash: asset.contentHash, reused: true, } } } throw error } } async function processQueuedJob(job) { await updateImageOptimizationJobStatus({ id: job.jobId, status: 'processing', startedAt: Date.now(), }) try { const result = await optimizeAndPersist(job) await updateImageOptimizationJobStatus({ id: job.jobId, status: 'completed', optimizedByteSize: result.size, reusedAsset: result.reused, finishedAt: Date.now(), }) return result } catch (error) { await updateImageOptimizationJobStatus({ id: job.jobId, status: 'failed', errorMessage: error?.message || 'optimization_failed', finishedAt: Date.now(), }) throw error } } async function writeOptimizedImage({ file, directory, width, height, fit = 'inside', quality = 82, }) { if (!file?.buffer?.length) { const error = new Error('file_required') error.code = 'file_required' throw error } if (!ensureImageMimeType(file)) { const error = new Error('image_file_required') error.code = 'image_file_required' throw error } const jobId = nanoid() await createImageOptimizationJob({ id: jobId, sourceCategory: directory, targetDirectory: OPTIMIZED_DIR, originalByteSize: file.size || file.buffer.length, }) return new Promise((resolve, reject) => { pendingJobs.push({ jobId, file, directory, width, height, fit, quality, resolve: (result) => resolve({ ...result, directory }), reject, }) scheduleQueue() }) } function getImageOptimizationQueueState() { return { concurrency: OPTIMIZATION_CONCURRENCY, activeCount, pendingCount: pendingJobs.length, } } module.exports = { createMemoryUpload, ensureImageMimeType, writeOptimizedImage, getImageOptimizationQueueState, }