// image.js // Copyright (C) 2024 DTP Technologies, LLC // All Rights Reserved 'use strict'; import path from 'node:path'; import fs from 'node:fs'; import mongoose from 'mongoose'; const ChatImage = mongoose.model('Image'); import sharp from 'sharp'; import { SiteService, SiteAsync } from '../../lib/site-lib.js'; export default class ImageService extends SiteService { static get name ( ) { return 'ImageService'; } static get slug ( ) { return 'image'; } constructor (dtp) { super(dtp, ImageService); } async start ( ) { await super.start(); const { user: userService } = this.dtp.services; this.populateImage = [ { path: 'owner', select: userService.USER_SELECT, }, ]; await fs.promises.mkdir(process.env.IMAGE_WORK_PATH, { recursive: true }); } async create (owner, imageDefinition, file) { const NOW = new Date(); const { minio: minioService } = this.dtp.services; this.log.debug('processing uploaded image', { imageDefinition, file }); const sharpImage = sharp(file.path); const metadata = await sharpImage.metadata(); // create an Image model instance, but leave it here in application memory. // we don't persist it to the db until MinIO accepts the binary data. const image = new ChatImage(); image.created = NOW; image.owner = owner._id; image.type = file.mimetype; image.size = file.size; image.file.bucket = process.env.MINIO_IMAGE_BUCKET; image.metadata = this.makeImageMetadata(metadata); const imageId = image._id.toString(); const ownerId = owner._id.toString(); const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/${imageId.slice(0, 3)}/${imageId}`; image.file.key = fileKey; // upload the image file to MinIO const response = await minioService.uploadFile({ bucket: image.file.bucket, key: image.file.key, filePath: file.path, metadata: { 'Content-Type': file.mimetype, 'Content-Length': file.size, }, }); // store the eTag from MinIO in the Image model image.file.etag = response.etag; // save the Image model to the db await image.save(); this.log.info('processed uploaded image', { ownerId, imageId, fileKey }); return image.toObject(); } async getImageById (imageId) { const image = await ChatImage .findById(imageId) .populate(this.populateImage); return image; } async getRecentImagesForOwner (owner) { const images = await ChatImage .find({ owner: owner._id }) .sort({ created: -1 }) .limit(10) .populate(this.populateImage) .lean(); return images; } async deleteImage (image) { const { minio: minioService } = this.dtp.services; try { if (image.file && image.file.bucket && image.file.key) { // this.log.debug('removing image from storage', { file: image.file }); await minioService.removeObject(image.file.bucket, image.file.key); } } catch (error) { this.log.error('failed to remove image from storage', { error }); // fall through } await ChatImage.deleteOne({ _id: image._id }); } async removeForUser (user) { this.log.info('removing all images for user', { user: { _id: user._id, username: user.username, }, }); await ChatImage .find({ owner: user._id }) .populate(this.populateImage) .cursor() .eachAsync(async (image) => { await this.deleteImage(image); }); } async processImageFile (owner, file, outputs, options) { this.log.debug('processing image file', { owner, file, outputs }); const sharpImage = sharp(file.path); return this.processImage(owner, sharpImage, outputs, options); } async processImage (owner, sharpImage, outputs, options) { const NOW = new Date(); const service = this; const { minio: minioService } = this.dtp.services; options = Object.assign({ removeWorkFiles: true, }, options); const imageWorkPath = process.env.IMAGE_WORK_PATH || '/tmp'; const metadata = await sharpImage.metadata(); async function processOutputImage (output) { const outputMetadata = service.makeImageMetadata(metadata); outputMetadata.width = output.width; outputMetadata.height = output.height; service.log.debug('processing image', { output, outputMetadata }); const image = new ChatImage(); image.created = NOW; image.owner = owner._id; image.type = `image/${output.format}`; image.metadata = outputMetadata; try { let chain = sharpImage.clone().resize({ width: output.width, height: output.height }); chain = chain[output.format](output.formatParameters); output.filePath = path.join(imageWorkPath, `${image._id}.${output.width}x${output.height}.${output.format}`); output.mimetype = `image/${output.format}`; await chain.toFile(output.filePath); output.stat = await fs.promises.stat(output.filePath); } catch (error) { service.log.error('failed to process output image', { output, error }); throw error; } try { const imageId = image._id.toString(); const ownerId = owner._id.toString(); const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/images/${imageId.slice(0, 3)}/${imageId}.${output.format}`; image.file.bucket = process.env.MINIO_IMAGE_BUCKET; image.file.key = fileKey; image.size = output.stat.size; // upload the image file to MinIO const response = await minioService.uploadFile({ bucket: image.file.bucket, key: image.file.key, filePath: output.filePath, metadata: { 'Content-Type': output.mimetype, 'Content-Length': output.stat.size, }, }); // store the eTag from MinIO in the Image model image.file.etag = response.etag; // save the Image model to the db await image.save(); service.log.info('processed uploaded image', { ownerId, imageId, fileKey }); if (options.removeWorkFiles) { service.log.debug('removing work file', { path: output.filePath }); await fs.promises.unlink(output.filePath); delete output.filePath; } output.image = { _id: image._id, bucket: image.file.bucket, key: image.file.key, }; } catch (error) { service.log.error('failed to persist output image', { output, error }); if (options.removeWorkFiles) { service.log.debug('removing work file', { path: output.filePath }); await SiteAsync.each(outputs, async (output) => { await fs.promises.unlink(output.filePath); delete output.filePath; }, 4); } throw error; } } await SiteAsync.each(outputs, processOutputImage, 4); } makeImageMetadata (metadata) { return { format: metadata.format, size: metadata.size, width: metadata.width, height: metadata.height, space: metadata.space, channels: metadata.channels, depth: metadata.depth, density: metadata.density, hasAlpha: metadata.hasAlpha, orientation: metadata.orientation, }; } async reportStats ( ) { const chart = await ChatImage.aggregate([ { $match: { }, }, { $group: { _id: { $dateToString: { format: '%Y-%m', date: '$created' } }, count: { $sum: 1 }, bytes: { $sum: '$size' }, }, }, { $sort: { _id: 1 }, }, ]); const stats = { }; stats.count = chart.reduce((prev, value) => { return prev + value.count; }, 0); stats.bytes = chart.reduce((prev, value) => { return prev + value.bytes; }, 0); return { chart, stats }; } }