// image.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const path = require('path'); const fs = require('fs'); const mongoose = require('mongoose'); const SiteImage = mongoose.model('Image'); const sharp = require('sharp'); const { SiteService, SiteAsync } = require('../../lib/site-lib'); class ImageService extends SiteService { constructor(dtp) { super(dtp, module.exports); this.populateImage = [ { path: 'owner', select: '_id username username_lc displayName picture' }, ]; } async start() { await super.start(); await fs.promises.mkdir(process.env.DTP_IMAGE_WORK_PATH, { recursive: true }); } async create(owner, imageDefinition, file) { const NOW = new Date(); const { minio: minioService } = this.dtp.services; try { 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 SiteImage(); 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(); } catch (error) { this.log.error('failed to process image', { error }); throw error; } finally { this.log.info('removing uploaded image from local file system', { file: file.path }); await fs.promises.rm(file.path); } } async getImageById(imageId) { const image = await SiteImage .findById(imageId) .populate(this.populateImage); return image; } async getRecentImagesForOwner(owner) { const images = await SiteImage .find({ owner: owner._id }) .sort({ created: -1 }) .limit(10) .populate(this.populateImage) .lean(); return images; } async deleteImage(image) { const { minio: minioService } = this.dtp.services; this.log.debug('removing image from storage', { bucket: image.file.bucket, key: image.file.key }); await minioService.removeObject(image.file.bucket, image.file.key); this.log.debug('removing image from MongoDB', { _id: image._id }); await SiteImage.deleteOne({ _id: image._id }); } 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.DTP_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 SiteImage(); 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, options: output.resizeOptions, }) ; 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); } async getSiteIconInfo() { const siteDomain = this.dtp.config.site.domainKey; const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); const siteIconDir = path.join(siteImagesDir, 'icon', siteDomain); let icon; try { await fs.promises.access(siteIconDir); const iconMetadata = await sharp(path.join(siteIconDir, 'icon-512x512.png')).metadata(); icon = { metadata: iconMetadata, path: `/img/icon/${siteDomain}/icon-512x512.png`, }; } catch (error) { icon = null; } return icon; } async getPostImageInfo() { const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); let icon; try { await fs.promises.access(siteImagesDir); const iconMetadata = await sharp(path.join(siteImagesDir, 'default-poster.jpg')).metadata(); icon = { metadata: iconMetadata, path: `/img/default-poster.jpg`, }; } catch (error) { icon = null; } return icon; } async updatePostImage(imageDefinition, file) { this.log.debug('updating site icon', { imageDefinition, file }); try { const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); const sourceIconFilePath = file.path; await sharp(sourceIconFilePath).resize({ fit: sharp.fit.inside, width: 540, height: 960, }).jpeg() .toFile(path.join(siteImagesDir, `default-poster.jpg`)); return path.join(siteImagesDir, 'default-poster.jpg'); } catch (error) { this.log.error('failed to update site icon', { error }); throw error; } finally { this.log.info('removing uploaded image from local file system', { file: file.path }); await fs.promises.rm(file.path); } } async updateSiteIcon(imageDefinition, file) { this.log.debug('updating site icon', { imageDefinition, file }); try { const siteDomain = this.dtp.config.site.domainKey; const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); const siteIconDir = path.join(siteImagesDir, 'icon', siteDomain); const sourceIconFilePath = file.path; const sizes = [16, 32, 36, 48, 57, 60, 70, 72, 76, 96, 114, 120, 144, 150, 152, 180, 192, 256, 310, 384, 512]; await fs.promises.mkdir(siteIconDir, { force: true, recursive: true }); for (var size of sizes) { await sharp(sourceIconFilePath).resize({ fit: sharp.fit.inside, width: size, height: size, }).png() .toFile(path.join(siteIconDir, `icon-${size}x${size}.png`)); } await fs.promises.cp(sourceIconFilePath, path.join(siteImagesDir, 'social-cards', `${siteDomain}.png`)); return path.join(siteIconDir, 'icon-512x512.png'); } catch (error) { this.log.error('failed to update site icon', { error }); throw error; } finally { this.log.info('removing uploaded image from local file system', { file: file.path }); await fs.promises.rm(file.path); } } 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, }; } } module.exports = { slug: 'image', name: 'image', create: (dtp) => { return new ImageService(dtp); }, };