var async = require('async'),
config = require('./config'),
child_process = require('child_process'),
etc = require('../etc'),
Muggle = etc.Muggle,
imagerDb = require('./db'),
index = require('./'),
formidable = require('formidable'),
fs = require('fs'),
jobs = require('./jobs'),
path = require('path'),
urlParse = require('url').parse,
util = require('util'),
winston = require('winston');
var IMAGE_EXTS = ['.png', '.jpg', '.gif'];
if (config.VIDEO && !config.DAEMON) {
console.warn("Please enable imager.config.DAEMON security.");
}
function new_upload(req, resp) {
var upload = new ImageUpload;
upload.handle_request(req, resp);
}
exports.new_upload = new_upload;
function get_thumb_specs(image, pinky, scale) {
var w = image.dims[0], h = image.dims[1];
var bound = config[pinky ? 'PINKY_DIMENSIONS' : 'THUMB_DIMENSIONS'];
var r = Math.max(w / bound[0], h / bound[1], 1);
var dims = [Math.round(w/r) * scale, Math.round(h/r) * scale];
var specs = {bound: bound, dims: dims, format: 'jpg'};
// Note: WebMs pretend to be PNGs at this step,
// but those don't need transparent backgrounds.
// (well... WebMs *can* have alpha channels...)
if (config.PNG_THUMBS && image.ext == '.png' && !image.video) {
specs.format = 'png';
specs.quality = config.PNG_THUMB_QUALITY;
}
else if (pinky) {
specs.bg = '#ffffff';
specs.quality = config.PINKY_QUALITY;
}
else {
specs.bg = '#ffffff';
specs.quality = config.THUMB_QUALITY;
}
return specs;
}
var ImageUpload = function (client_id) {
this.db = new imagerDb.Onegai;
this.client_id = client_id;
};
var IU = ImageUpload.prototype;
var validFields = ['spoiler', 'op'];
IU.status = function (msg) {
this.client_call('status', msg);
};
IU.client_call = function (t, msg) {
this.db.client_message(this.client_id, {t: t, arg: msg});
};
IU.respond = function (code, msg) {
if (!this.resp)
return;
const origin = config.MAIN_SERVER_ORIGIN;
this.resp.writeHead(code, {
'Content-Type': 'text/html; charset=UTF-8',
'Access-Control-Allow-Origin': origin,
});
this.resp.end('
Upload result\n'
+ 'This is a legitimate imager response.\n'
+ '\n');
this.resp = null;
};
IU.handle_request = function (req, resp) {
if (req.method.toLowerCase() != 'post') {
resp.writeHead(405, {Allow: 'POST'});
resp.end();
return;
}
this.resp = resp;
var query = req.query || urlParse(req.url, true).query;
this.client_id = parseInt(query.id, 10);
if (!this.client_id || this.client_id < 1) {
this.respond(400, "Bad client ID.");
return;
}
var len = parseInt(req.headers['content-length'], 10);
if (len > 0 && len > config.IMAGE_FILESIZE_MAX + (20*1024))
return this.failure(Muggle('File is too large.'));
var form = new formidable.IncomingForm({
uploadDir: config.MEDIA_DIRS.tmp,
maxFieldsSize: 50 * 1024,
hash: 'md5',
});
form.onPart = function (part) {
if (part.filename && part.name == 'image')
form.handlePart(part);
else if (!part.filename && validFields.indexOf(part.name) >= 0)
form.handlePart(part);
};
var self = this;
form.once('error', function (err) {
self.failure(Muggle('Upload request problem.', err));
});
form.once('aborted', function (err) {
self.failure(Muggle('Upload was aborted.', err));
});
this.lastProgress = 0;
form.on('progress', this.upload_progress_status.bind(this));
try {
form.parse(req, this.parse_form.bind(this));
}
catch (err) {
self.failure(err);
}
};
IU.upload_progress_status = function (received, total) {
var percent = Math.floor(100 * received / total);
var increment = (total > (512 * 1024)) ? 10 : 25;
var quantized = Math.floor(percent / increment) * increment;
if (quantized > this.lastProgress) {
this.status(percent + '% received...');
this.lastProgress = quantized;
}
};
IU.parse_form = function (err, fields, files) {
if (err)
return this.failure(Muggle('Invalid upload.', err));
if (!files.image)
return this.failure(Muggle('No image.'));
this.image = files.image;
this.pinky = !!parseInt(fields.op, 10);
var spoiler = parseInt(fields.spoiler, 10);
if (spoiler) {
var sps = config.SPOILER_IMAGES;
if (sps.normal.indexOf(spoiler) < 0
&& sps.trans.indexOf(spoiler) < 0)
return this.failure(Muggle('Bad spoiler.'));
this.image.spoiler = spoiler;
}
this.image.MD5 = index.squish_MD5(this.image.hash);
this.image.hash = null;
var self = this;
this.db.track_temporary(this.image.path, function (err) {
if (err)
winston.warn("Temp tracking error: " + err);
self.process();
});
};
IU.process = function () {
if (this.failed)
return;
var image = this.image;
var filename = image.filename || image.name;
image.ext = path.extname(filename).toLowerCase();
if (image.ext == '.jpeg')
image.ext = '.jpg';
if (image.ext == '.mov')
image.ext = '.mp4';
if (IMAGE_EXTS.indexOf(image.ext) < 0
&& (!config.VIDEO || config.VIDEO_EXTS.indexOf(image.ext) < 0))
return this.failure(Muggle('Invalid image format.'));
image.imgnm = filename.substr(0, 256);
this.status('Verifying...');
if (config.VIDEO_EXTS.indexOf(image.ext) >= 0)
video_still(image.path, image.ext, this.verify_video.bind(this));
else if (image.ext == '.jpg' && jpegtranBin && jheadBin)
jobs.schedule(new AutoRotateJob(image.path), this.verify_image.bind(this));
else
this.verify_image();
};
function AutoRotateJob(src) {
jobs.Job.call(this);
this.src = src;
}
util.inherits(AutoRotateJob, jobs.Job);
AutoRotateJob.prototype.describe_job = function () {
return "jhead+jpegtran auto rotation of " + this.src;
};
AutoRotateJob.prototype.perform_job = function () {
var self = this;
child_process.execFile(jheadBin, ['-autorot', this.src], function (err, stdout, stderr) {
// if it failed, keep calm and thumbnail on
if (err)
winston.warn('jhead: ' + (stderr || err));
self.finish_job(null);
});
};
function StillJob(src, ext) {
jobs.Job.call(this);
this.src = src;
this.ext = ext;
}
util.inherits(StillJob, jobs.Job);
StillJob.prototype.describe_job = function () {
return "FFmpeg video still of " + this.src;
};
StillJob.prototype.perform_job = function () {
var dest = index.media_path('tmp', 'still_'+etc.random_id());
var args = ['-hide_banner', '-loglevel', 'info',
'-i', this.src,
'-f', 'image2', '-vf', 'thumbnail', '-vframes', '1', '-vcodec', 'png',
'-y', dest];
var opts = {env: {AV_LOG_FORCE_NOCOLOR: '1'}};
var self = this;
child_process.execFile(ffmpegBin, args, opts, function (err, stdout, stderr) {
var lines = stderr ? stderr.split('\n') : [];
var first = lines[0];
if (err) {
var msg;
if (/no such file or directory/i.test(first))
msg = "Video went missing.";
else if (/invalid data found when/i.test(first))
msg = "Invalid video file.";
else if (/^ffmpeg version/i.test(first))
msg = "Server's ffmpeg is too old.";
else {
msg = "Unknown video reading error.";
winston.warn("Unknown ffmpeg output: "+first);
}
fs.unlink(dest, function (err) {
self.finish_job(Muggle(msg, stderr));
});
return;
}
self.test_format(first, stderr, function (format_err, has_audio, dur) {
if (err) {
fs.unlink(dest, function (_unlink_err) {
self.finish_job(Muggle(format_err));
});
return;
}
self.finish_job(null, {
still_path: dest,
has_audio: has_audio,
duration: dur,
});
});
});
};
StillJob.prototype.test_format = function (first, full, cb) {
/* Could have false positives due to chapter titles. Bah. */
var has_audio = /stream\s*#0.*audio:/i.test(full);
/* Spoofable? */
var dur = /duration: (\d\d):(\d\d):(\d\d)/i.exec(full);
if (dur) {
var m = parseInt(dur[2], 10), s = parseInt(dur[3], 10);
if (dur[1] != '00' || m > 2)
return cb('Video exceeds 3 minutes.');
dur = (m ? m + 'm' : '') + s + 's';
if (dur == '0s')
dur = '1s';
}
else {
winston.warn("Could not parse duration:\n" + full);
}
if (/stream #1/i.test(full))
return cb('Video contains more than one stream.');
if (this.ext == '.webm') {
if (!/matroska,webm/i.test(first))
return cb('Video stream is not WebM.');
cb(null, has_audio, dur);
}
else if (this.ext == '.mp4') {
if (!/mp4,/i.test(first))
return cb('Video stream is not mp4.');
cb(null, has_audio, dur);
}
else {
cb('Unsupported video format.');
}
}
function video_still(src, ext, cb) {
jobs.schedule(new StillJob(src, ext), cb);
}
IU.verify_video = function (err, info) {
if (err)
return this.failure(err);
var self = this;
this.db.track_temporary(info.still_path, function (err) {
if (err)
winston.warn("Tracking error: " + err);
if (info.has_audio && !config.AUDIO)
return self.failure(Muggle('Audio is not allowed.'));
// pretend it's a PNG for the next steps
var image = self.image;
image.video = image.ext.replace('.', '');
image.video_path = image.path;
image.path = info.still_path;
image.ext = '.png';
if (info.has_audio) {
image.audio = true;
if (config.AUDIO_SPOILER)
image.spoiler = config.AUDIO_SPOILER;
}
if (info.duration)
image.duration = info.duration;
self.verify_image();
});
};
IU.verify_image = function (err) {
if (err)
winston.error(err);
var image = this.image;
this.tagged_path = image.ext.replace('.', '') + ':' + image.path;
var checks = {
stat: fs.stat.bind(fs, image.video_path || image.path),
dims: identify.bind(null, this.tagged_path),
};
if (image.ext == '.png')
checks.apng = detect_APNG.bind(null, image.path);
var self = this;
async.parallel(checks, function (err, rs) {
if (err)
return self.failure(Muggle('Wrong image type.', err));
image.size = rs.stat.size;
image.dims = [rs.dims.width, rs.dims.height];
if (rs.apng)
image.apng = 1;
self.verified();
});
};
IU.verified = function () {
if (this.failed)
return;
var desc = this.image.video ? 'Video' : 'Image';
var w = this.image.dims[0], h = this.image.dims[1];
if (!w || !h)
return this.failure(Muggle('Bad image dimensions.'));
if (config.IMAGE_PIXELS_MAX && w * h > config.IMAGE_PIXELS_MAX)
return this.failure(Muggle('This is photoshopped, I can tell from the pixels.'));
if (w > config.IMAGE_WIDTH_MAX && h > config.IMAGE_HEIGHT_MAX)
return this.failure(Muggle(desc+' is too wide and too tall.'));
if (w > config.IMAGE_WIDTH_MAX)
return this.failure(Muggle(desc+' is too wide.'));
if (h > config.IMAGE_HEIGHT_MAX)
return this.failure(Muggle(desc+' is too tall.'));
var self = this;
perceptual_hash(this.tagged_path, this.image, function (err, hash) {
if (err)
return self.failure(err);
self.image.hash = hash;
self.db.check_duplicate(hash, function (err) {
if (err)
return self.failure(err);
self.deduped();
});
});
};
IU.fill_in_specs = function (specs, kind) {
specs.src = this.tagged_path;
specs.ext = this.image.ext;
specs.dest = this.image.path + '_' + kind;
this.image[kind + '_path'] = specs.dest;
};
IU.deduped = function (err) {
if (this.failed)
return;
var image = this.image;
var specs = get_thumb_specs(image, this.pinky, 1);
var w = image.dims[0], h = image.dims[1];
/* Determine whether we really need a thumbnail */
var sp = image.spoiler;
if (!sp && image.size < 30*1024
&& ['.jpg', '.png'].indexOf(image.ext) >= 0
&& !image.apng && !image.video
&& w <= specs.dims[0] && h <= specs.dims[1]) {
return this.got_nails();
}
this.fill_in_specs(specs, 'thumb');
// was a composited spoiler selected or forced?
if (image.audio && config.AUDIO_SPOILER)
specs.comp = specs.overlay = true;
if (sp && config.SPOILER_IMAGES.trans.indexOf(sp) >= 0)
specs.comp = true;
var self = this;
if (specs.comp) {
this.status(specs.overlay ? 'Overlaying...' : 'Spoilering...');
var comp = composite_src(sp, this.pinky);
image.comp_path = image.path + '_comp';
specs.compDims = specs.overlay ? specs.dims : specs.bound;
image.dims = [w, h].concat(specs.compDims);
specs.composite = comp;
specs.compDest = image.comp_path;
async.parallel([
self.resize_and_track.bind(self, specs, false),
self.resize_and_track.bind(self, specs, true),
], function (err) {
if (err)
return self.failure(err);
self.got_nails();
});
}
else {
image.dims = [w, h].concat(specs.dims);
if (!sp)
this.status('Thumbnailing...');
self.resize_and_track(specs, false, function (err) {
if (err)
return self.failure(err);
if (config.EXTRA_MID_THUMBNAILS)
self.middle_nail();
else
self.got_nails();
});
}
};
IU.middle_nail = function () {
if (this.failed)
return;
var specs = get_thumb_specs(this.image, this.pinky, 2);
this.fill_in_specs(specs, 'mid');
var self = this;
this.resize_and_track(specs, false, function (err) {
if (err)
self.failure(err);
self.got_nails();
});
};
IU.got_nails = function () {
if (this.failed)
return;
var image = this.image;
if (image.video_path) {
// stop pretending this is just a still image
image.path = image.video_path;
image.ext = '.' + image.video;
delete image.video_path;
}
var time = Date.now();
image.src = time + image.ext;
var base = path.basename;
var tmps = {src: base(image.path)};
if (image.thumb_path) {
image.thumb = time + '.jpg';
tmps.thumb = base(image.thumb_path);
}
if (image.mid_path) {
image.mid = time + '.jpg';
tmps.mid = base(image.mid_path);
}
if (image.comp_path) {
image.composite = time + 's' + image.spoiler + '.jpg';
tmps.comp = base(image.comp_path);
delete image.spoiler;
}
this.record_image(tmps);
};
function composite_src(spoiler, pinky) {
var file = 'spoiler' + (pinky ? 's' : '') + spoiler + '.png';
return path.join(config.SPOILER_DIR, file);
}
IU.read_image_filesize = function (callback) {
var self = this;
fs.stat(this.image.path, function (err, stat) {
if (err)
callback(Muggle('Internal filesize error.', err));
else if (stat.size > config.IMAGE_FILESIZE_MAX)
callback(Muggle('File is too large.'));
else
callback(null, stat.size);
});
};
function which(name, callback) {
child_process.exec('which ' + name, function (err, stdout, stderr) {
if (err)
callback(err);
else
callback(null, stdout.trim());
});
}
/* Look up imagemagick paths */
var identifyBin, convertBin;
which('identify', function (err, bin) { if (err) throw err; identifyBin = bin; });
which('convert', function (err, bin) { if (err) throw err; convertBin = bin; });
var ffmpegBin;
if (config.VIDEO) {
which('ffmpeg', function (err, bin) { if (err) throw err; ffmpegBin = bin; });
}
/* optional JPEG auto-rotation */
var jpegtranBin, jheadBin;
which('jpegtran', function (err, bin) { if (!err && bin) jpegtranBin = bin; });
which('jhead', function (err, bin) { if (!err && bin) jheadBin = bin; });
function identify(taggedName, callback) {
var m = taggedName.match(/^(\w{3,4}):/);
var args = ['-format', '%Wx%H', taggedName + '[0]'];
child_process.execFile(identifyBin, args, function (err,stdout,stderr){
if (err) {
var msg = "Bad image.";
if (stderr.match(/no such file/i))
msg = "Image went missing.";
else if (stderr.match(/improper image header/i)) {
var kind = m && m[1];
kind = kind ? 'a ' + kind.toUpperCase()
: 'an image';
msg = 'File is not ' + kind + '.';
}
else if (stderr.match(/no decode delegate/i))
msg = "Unsupported file type.";
return callback(Muggle(msg, stderr));
}
var line = stdout.trim();
var m = line.match(/(\d+)x(\d+)/);
if (!m)
callback(Muggle("Couldn't read image dimensions."));
else
callback(null, {width: parseInt(m[1], 10),
height: parseInt(m[2], 10)});
});
}
function ConvertJob(args, src) {
jobs.Job.call(this);
this.args = args;
this.src = src;
}
util.inherits(ConvertJob, jobs.Job);
ConvertJob.prototype.perform_job = function () {
var self = this;
child_process.execFile(convertBin, this.args,
function (err, stdout, stderr) {
self.finish_job(err ? (stderr || err) : null);
});
};
ConvertJob.prototype.describe_job = function () {
return "ImageMagick conversion of " + this.src;
};
function convert(args, src, callback) {
jobs.schedule(new ConvertJob(args, src), callback);
}
function perceptual_hash(src, image, callback) {
var tmp = index.media_path('tmp',
'hash' + etc.random_id() + '.gray');
var args = [src + '[0]'];
if (image.dims.width > 1000 || image.dims.height > 1000)
args.push('-sample', '800x800');
// do you believe in magic?
args.push('-background', 'white', '-mosaic', '+matte',
'-scale', '16x16!',
'-type', 'grayscale', '-depth', '8',
tmp);
convert(args, src, function (err) {
if (err)
return callback(Muggle('Hashing error.', err));
var bin = path.join(__dirname, 'perceptual');
child_process.execFile(bin, [tmp],
function (err, stdout, stderr) {
fs.unlink(tmp, function (err) {
if (err)
winston.warn("Deleting " +
tmp + ": " + err);
});
if (err)
return callback(Muggle('Hashing error.',
stderr || err));
var hash = stdout.trim();
if (hash.length != 64)
return callback(Muggle('Hashing problem.'));
callback(null, hash);
});
});
}
function detect_APNG(fnm, callback) {
var bin = path.join(__dirname, 'findapng');
child_process.execFile(bin, [fnm], function (err, stdout, stderr) {
if (err)
return callback(Muggle('APNG detector problem.',
stderr || err));
else if (stdout.match(/^APNG/))
return callback(null, true);
else if (stdout.match(/^PNG/))
return callback(null, false);
else
return callback(Muggle('APNG detector acting up.',
stderr || err));
});
}
function setup_image_params(o) {
// only the first time!
if (o.setup) return;
o.setup = true;
o.src += '[0]'; // just the first frame of the animation
o.dest = o.format + ':' + o.dest;
if (o.compDest)
o.compDest = o.format + ':' + o.compDest;
o.flatDims = o.dims[0] + 'x' + o.dims[1];
if (o.compDims)
o.compDims = o.compDims[0] + 'x' + o.compDims[1];
o.quality += ''; // coerce to string
}
function build_im_args(o, args) {
// avoid OOM killer
var args = ['-limit', 'memory', '32', '-limit', 'map', '64'];
var dims = o.dims;
// resample from twice the thumbnail size
// (avoid sampling from the entirety of enormous 6000x6000 images etc)
var samp = dims[0]*2 + 'x' + dims[1]*2;
if (o.ext == '.jpg')
args.push('-define', 'jpeg:size=' + samp);
setup_image_params(o);
args.push(o.src);
if (o.ext != '.jpg')
args.push('-sample', samp);
// gamma-correct yet shitty downsampling
args.push('-gamma', '0.454545', '-filter', 'box');
return args;
}
function resize_image(o, comp, callback) {
var args = build_im_args(o);
var dims = comp ? o.compDims : o.flatDims;
var dest = comp ? o.compDest : o.dest;
// in the composite case, zoom to fit. otherwise, force new size
args.push('-resize', dims + (comp ? '^' : '!'));
// add background
args.push('-gamma', '2.2');
if (o.bg)
args.push('-background', o.bg);
if (comp)
args.push(o.composite, '-layers', 'flatten', '-extent', dims);
else if (o.bg)
args.push('-layers', 'mosaic', '+matte');
// disregard metadata, acquire artifacts
args.push('-strip', '-quality', o.quality);
args.push(dest);
convert(args, o.src, function (err) {
if (err) {
winston.warn(err);
callback(Muggle("Resizing error.", err));
}
else
callback(null, dest);
});
}
IU.resize_and_track = function (o, comp, cb) {
var self = this;
resize_image(o, comp, function (err, fnm) {
if (err)
return cb(err);
// HACK: strip IM type tag
var m = /^\w{3,4}:(.+)$/.exec(fnm);
if (m)
fnm = m[1];
self.db.track_temporary(fnm, cb);
});
};
function image_files(image) {
var files = [];
if (image.path)
files.push(image.path);
if (image.thumb_path)
files.push(image.thumb_path);
if (image.mid_path)
files.push(image.mid_path);
if (image.comp_path)
files.push(image.comp_path);
return files;
}
IU.failure = function (err) {
var err_desc = 'Unknown image processing error.'
if (err instanceof Muggle) {
err_desc = err.most_precise_error_message();
err = err.deepest_reason();
}
/* Don't bother logging PEBKAC errors */
if (!(err instanceof Muggle))
winston.error(err);
this.respond(500, err_desc);
if (!this.failed) {
this.client_call('error', err_desc);
this.failed = true;
}
if (this.image) {
var files = image_files(this.image);
files.forEach(function (file) {
fs.unlink(file, function (err) {
if (err)
winston.warn("Deleting " +
file + ": " + err);
});
});
this.db.lose_temporaries(files, function (err) {
if (err)
winston.warn("Tracking failure: " + err);
});
}
this.db.disconnect();
};
IU.record_image = function (tmps) {
if (this.failed)
return;
var view = {};
var self = this;
index.image_attrs.forEach(function (key) {
if (key in self.image)
view[key] = self.image[key];
});
if (this.image.composite) {
view.realthumb = view.thumb;
view.thumb = this.image.composite;
}
view.pinky = this.pinky;
var image_id = etc.random_id().toFixed();
var alloc = {image: view, tmps: tmps};
this.db.record_image_alloc(image_id, alloc, function (err) {
if (err)
return this.failure("Image storage failure.");
self.client_call('alloc', image_id);
self.db.disconnect();
self.respond(202, 'OK');
if (index.is_standalone()) {
var where = view.src;
var size = Math.ceil(view.size / 1000) + 'kb';
winston.info('upload: ' + where + ' ' + size);
}
});
};
function run_daemon() {
var cd = config.DAEMON;
var is_unix_socket = (typeof cd.LISTEN_PORT == 'string');
if (is_unix_socket) {
try { fs.unlinkSync(cd.LISTEN_PORT); } catch (e) {}
}
var server = require('http').createServer(new_upload);
server.listen(cd.LISTEN_PORT);
if (is_unix_socket) {
fs.chmodSync(cd.LISTEN_PORT, '777'); // TEMP
}
index._make_media_dir(null, 'tmp', function (err) {});
winston.info('Imager daemon listening on '
+ (cd.LISTEN_HOST || '')
+ (is_unix_socket ? '' : ':')
+ (cd.LISTEN_PORT + '.'));
}
if (require.main == module) (function () {
if (!index.is_standalone())
throw new Error("Please enable DAEMON in imager/config.js");
var onegai = new imagerDb.Onegai;
onegai.delete_temporaries(function (err) {
onegai.disconnect();
if (err)
throw err;
process.nextTick(run_daemon);
});
})();