/*
* Author: Vlad Seryakov vseryakov@gmail.com
* backendjs 2018
*/
/**
* @module api/images
*/
const path = require('path');
const fs = require('fs');
const https = require('https');
const modules = require(__dirname + '/../modules');
const lib = require(__dirname + '/../lib');
const api = require(__dirname + '/../api');
const logger = require(__dirname + '/../logger');
const mod = {
name: "api.images",
args: [
{ name: "url", descr: "URL where images are stored, for cases of central image server(s), must be full URL with optional path" },
{ name: "s3", descr: "S3 bucket name where to store and retrieve images" },
{ name: "raw", type: "bool", descr: "Return raw urls for the images, requires images-url to be configured. The path will reflect the actual 2 level structure and user id in the image name" },
{ name: "s3-options", type: "json", logger: "warn", descr: "S3 options to sign images urls, may have expires:, key:, secret: properties" },
{ name: "ext", descr: "Default image extension to use when saving images" },
{ name: "mod", descr: "Images scaling module, sharp" },
{ name: "path", descr: "Path to store images" },
],
ext: "jpg",
};
/**
* Saving and serving images
*/
module.exports = mod;
/**
* Scale image return err if failed.
*
* If image module is not set (default) then the input data is returned or saved as is.
*
* @param {string} infile can be a string with file name or a Buffer with actual image data
* @param {object} options
* @param {string} [options.outfile] - if not empty is a file name where to store scaled image or if empty the new image contents will be returned in the callback as a buffer
* @param {int} [options.width] - new width
* @param {int} [options.height] - new image height
* - if width or height is negative this means do not perform upscale, keep the original size if smaller than given positive value,
* - if any is 0 that means keep the original size
* @param {string} [options.ext] - image format: png, gif, jpg, svg
* @param {function} callback takes 3 arguments: function(err, data, info)
* where `data` will contain a new image data and `info` is an object with the info about the new or unmodified image: ext, width, height.
* @memberof module:api/images
* @method scale
*/
mod.scale = function(infile, options, callback)
{
if (typeof options == "function") callback = options, options = null;
if (!options) options = {};
var data, info = {};
options.ext = options.ext || this.ext || "jpg";
switch (this.mod) {
case "sharp":
if (!api.sharp) api.sharp = lib.tryRequire('sharp');
if (!api.sharp) {
return lib.tryCall(callback, { status: 500, message: "service unavailable" });
}
var img = api.sharp(infile);
lib.series([
function(next) {
img.metadata((err, inf) => {
if (err) return next(err);
if (!options.height) delete options.height; else
if (options.height < 0) {
options.height = inf.height < Math.abs(options.height) ? inf.height : Math.abs(options.height);
}
if (!options.width) delete options.width; else
if (options.width < 0) {
options.width = inf.width < Math.abs(options.width) ? inf.width : Math.abs(options.width);
}
try {
img.toFormat(options.ext).resize(options);
} catch (e) {
err = e;
}
next(err);
});
},
function(next) {
if (options.outfile) {
img.toFile(options.outfile, (err, inf) => {
info = inf;
next(err);
});
} else {
img.toBuffer((err, buf, inf) => {
data = buf;
info = inf;
next(err);
});
}
},
], (err) => {
if (!err) info.ext = info.format == "jpeg" ? "jpg" : info.format;
logger[err ? "error": "debug"]('scaleIcon:', Buffer.isBuffer(infile) ? "Buffer:" + infile.length : infile, options, info, err);
lib.tryCall(callback, err, data, info);
}, true);
break;
default:
info.ext = options.ext || this.ext || "jpg";
if (Buffer.isBuffer(infile)) {
if (options.outfile) {
fs.writeFile(options.outfile, infile, (err) => {
lib.tryCall(callback, err, data, info);
});
} else {
lib.tryCall(callback, null, infile, info);
}
} else {
if (options.outfile) {
lib.copyFile(infile, options.outfile, (err) => {
lib.tryCall(callback, err, data, info);
});
} else {
fs.readFile(infile, (err, data) => {
lib.tryCall(callback, err, data, info);
});
}
}
}
}
/**
* Full path to the icon, perform necessary hashing and sharding, id can be a number or any string.
* @param {string} id
* @param {object} options
* @param {string} [options.type] may contain special placeholders:
* - @uuid@ - will be replaced with a unique UUID and placed back to the options.type
* - @now@ - will be replaced with the current timestamp
* - @filename@ - will be replaced with the basename of the uploaded file from the filename property if present
* @returns {string}
* @memberof module:api/images
* @method getPath
*/
mod.getPath = function(id, options)
{
// Convert into string and remove all chars except numbers, this will support UUIDs as well as regular integers
var num = lib.toDigits(id);
var ext = String(options?.ext || this.ext || "jpg").toLowerCase();
var type = String(options?.type || "");
if (type.indexOf("@") > -1) {
if (type.indexOf("@uuid@") > -1) {
type = type.replace("@uuid@", lib.uuid());
}
if (type.indexOf("@now@") > -1) {
type = type.replace("@now@", Date.now());
}
if (type.indexOf("@filename@") > -1 && options?.filename) {
type = type.replace("@filename@", path.basename(options?.filename, path.extname(options?.filename)));
}
}
var name = (type ? type + '-' : "") + id + (ext[0] == '.' ? "" : ".") + ext;
return lib.normalize(this.path, options?.prefix || "user", num.substr(-2), num.substr(-4, 2), name);
}
/**
* Returns constructed icon url from the icon record
* @param {string} file
* @param {object} options
* @returns {string}
* @memberof module:api/images
* @method getUrl
*/
mod.getUrl = function(file, options)
{
if (file) {
if (lib.isObject(file)) {
options = file;
file = this.getPath(file.id, file);
}
var s3url = options?.imagesS3 || this.s3;
var s3opts = options?.imagesS3Options || this.s3Options;
if (s3url && s3opts) {
s3opts.url = true;
file = modules.aws.signS3("GET", s3url, path.normalize(file), "", s3opts);
} else {
var imagesUrl = options?.imagesUrl || this.url;
if (imagesUrl) {
file = imagesUrl + path.normalize(file);
}
}
}
return file || "";
}
/**
* Send an icon to the client, only handles files
* @param {Request} req
* @param {string} id
* @param {object} options
* @memberof module:api/images
* @method send
*/
mod.send = function(req, id, options)
{
var icon = this.getPath(id, options);
logger.debug('sendImage:', icon, id, options);
if (options?.imagesS3 || this.s3) {
var opts = {};
var params = URL.parse(modules.aws.signS3("GET", options?.imagesS3 || this.s3, icon, "", opts)) || {};
params.headers = opts.headers;
var s3req = https.request(params, (s3res) => {
req.res.writeHead(s3res.statusCode, s3res.headers);
s3res.pipe(req.res, { end: true });
});
s3req.on("error", (err) => {
logger.error('sendImage:', err);
s3req.abort();
});
s3req.end();
} else {
api.sendFile(req, icon);
}
}
/**
* Store an icon for user, the options are the same as for the `path` method
* @param {Request} req
* @param {string} name is the name property to look for in the multipart body or in the request body or query
* @param {string} id is used in `iconPath` along with the options to build the icon absolute path
* @param {object} options
* @param {boolean} [options.autodel] - if true, auto delete the base64 icon property from the query or the body after it is decoded, this is to
* mark it for deallocation while the icon is being processed, the worker queue is limited so with large number of requests
* all these query objects will remain in the query wasting memory
* @param {boolean} [options.verify] - check the given image of file header for known image types
* @param {regexp} [options.extkeep] - a regexp with image types to preserve, not to convert into the specified image type
* @param {string} [options.ext] - the output file extention without dot, ex: jpg, png, gif....
* @param {function} callback
* @memberof module:api/images
* @method put
*/
mod.put = function(req, name, id, options, callback)
{
if (typeof options == "function") callback = options, options = null;
if (!options) options = {};
logger.debug("putImage:", name, id, options);
var ext, icon;
if (req.files && req.files[name]) {
options.filesize = req.files[name].size;
options.filename = path.basename(req.files[name].name);
if (options.verify) {
lib.readFile(req.files[name].path, { length: 4, encoding: "binary" }, function(err, data) {
if (!err) ext = mod.isImage(data);
if (!err && !ext) err = "unknown image";
if (err) logger.debug("putImage:", name, id, req.files[name].path, err, data);
if (err) return lib.tryCall(callback, err);
if (lib.testRegexp(ext, options.extkeep)) options.ext = ext;
mod.save(req.files[name].path, id, options, callback);
});
} else {
ext = path.extname(req.files[name].path);
if (lib.testRegexp(ext, options.extkeep)) options.ext = ext.substr(1);
mod.save(req.files[name].path, id, options, callback);
}
} else
// JSON object submitted with the property `name`
if (typeof req.body == "object" && req.body[name]) {
icon = Buffer.from(req.body[name], "base64");
ext = mod.isImage(icon);
if (options.autodel) delete req.body[name];
if (options.verify && !ext) return lib.tryCall(callback, "unknown image");
if (lib.testRegexp(ext, options.extkeep)) options.ext = ext;
mod.save(icon, id, options, callback);
} else
// Query base64 encoded parameter
if (req.query && req.query[name]) {
icon = Buffer.from(req.query[name], "base64");
ext = mod.isImage(icon);
if (options.autodel) delete req.query[name];
if (options.verify && !ext) return lib.tryCall(callback, "unknown image");
if (lib.testRegexp(ext, options.extkeep)) options.ext = ext;
mod.save(icon, id, options, callback);
} else {
lib.tryCall(callback);
}
}
/**
* Save the icon data to the destination, if mod.s3 or options.imagesS3 specified then plave the image on the S3 drive.
* Store in the proper location according to the types for given id, this function is used after downloading new image or
* when moving images from other places. On success the callback will be called with the second argument set to the output
* file name where the image has been saved.
* @param {string} file
* @param {string} id
* @param {object} options - same properties as in {@link module:api/images.scale}: width, height, filter, ext, quality
* @param {string} [options.type] - icon type, this will be prepended to the name of the icon, there are several special types:
* - @uuid@ - auto generate an UUID
* - @now@ - use current timestamp
* - @filename@ - if filename is present the basename without extension will be used
* @param {string} [options.prefix] - top level subdirectory under images/
* @param {int} [options.filesize] - file size if available
* @param {string} [options.filename] - name of a file uploaded if available
* @param {function} callback
* @memberof module:api/images
* @method save
*/
mod.save = function(file, id, options, callback)
{
if (typeof options == "function") callback = options, options = null;
if (!options) options = {};
var outfile = this.getPath(id, options);
logger.debug('saveImage:', id, file, outfile, options);
if (this.s3 || options.imagesS3) {
delete options.outfile;
mod.scale(file, options, (err, data, info) => {
if (err) return lib.tryCall(callback, err);
if (info?.ext) {
outfile = path.join(path.dirname(outfile), path.basename(outfile, path.extname(outfile)) + "." + info.ext);
}
outfile = "s3://" + (options.imagesS3 || mod.s3) + "/" + outfile;
modules.aws.s3PutFile(outfile, data, { postsize: options.filesize }, (err) => {
if (!err) return lib.tryCall(callback, err, outfile, info);
logger.error("saveImage:", outfile, err, options);
lib.tryCall(callback, err);
});
});
} else {
// To auto append the extension we need to pass file name without it
lib.makePath(path.dirname(outfile), (err) => {
options.outfile = outfile;
mod.scale(file, options, (err, data, info) => {
if (!err) return lib.tryCall(callback, err, outfile, info);
logger.error("saveImage:", outfile, err, options);
lib.tryCall(callback, err);
});
});
}
}
/**
* Delete an icon for user, .type defines icon prefix
* @param {string} id
* @param {object} options
* @param {function} callback
* @memberof module:api/images
* @method del
*/
api.del = function(id, options, callback)
{
if (typeof options == "function") callback = options, options = null;
var icon = this.getPath(id, options);
logger.debug('delImage:', id, options);
if (this.s3 || options?.imagesS3) {
modules.aws.queryS3(options?.imagesS3 || this.s3, icon, { method: "DELETE" }, (err, params) => {
lib.tryCall(callback, err);
});
} else {
fs.unlink(icon, (err) => {
if (err) logger.error('delImage:', id, err, options);
lib.tryCall(callback, err);
});
}
}
/**
* Return true if the given file or url point ot an image
* @param {string} url
* @returns {boolean}
* @memberof module:api/images
* @method isUrl
*/
api.isUrl = function(url)
{
if (typeof url != "string") return false;
if (url.indexOf("?") > -1) url = url.split("?")[0];
return /\.(png|jpg|jpeg|gif)$/i.test(url);
}
/**
* Returns detected image type if the given buffer contains an image, it checks the header only
* @param {buffer} buf
* @Returns {string}
* @memberof module:api/images
* @method isImage
*/
api.isImage = function(buf)
{
if (!Buffer.isBuffer(buf)) return;
var hdr = buf.slice(0, 4).toString("hex");
if (hdr === "ffd8ffe0") return "jpg";
if (hdr === "ffd8ffe1") return "jpg";
if (hdr === "89504e47") return "png";
if (hdr === "47494638") return "gif";
if (hdr.slice(0, 4) === "424d") return "bmp";
}