2016-06-06 13:39:42 +01:00
|
|
|
import { join } from 'path';
|
2018-06-02 15:50:39 +00:00
|
|
|
import Busboy from 'busboy';
|
2016-06-06 13:39:42 +01:00
|
|
|
import crypto from 'crypto';
|
|
|
|
import fs from 'mz/fs';
|
|
|
|
import sizeOf from 'image-size';
|
2018-06-02 18:07:00 +00:00
|
|
|
import debugname from 'debug';
|
2016-06-06 13:39:42 +01:00
|
|
|
|
2016-06-19 10:14:47 -07:00
|
|
|
import models from '../models';
|
|
|
|
import createHostrId from './hostr-id';
|
2016-06-06 13:39:42 +01:00
|
|
|
import { formatFile } from './format';
|
|
|
|
import resize from './resize';
|
|
|
|
import malware from './malware';
|
2018-06-02 18:07:00 +00:00
|
|
|
import sniff from './sniff';
|
2018-06-02 15:50:39 +00:00
|
|
|
import { upload as s3upload } from './s3';
|
2016-06-06 13:39:42 +01:00
|
|
|
|
|
|
|
const debug = debugname('hostr-api:uploader');
|
|
|
|
|
|
|
|
const storePath = process.env.UPLOAD_STORAGE_PATH;
|
|
|
|
const baseURL = process.env.WEB_BASE_URL;
|
|
|
|
const supported = ['jpeg', 'jpg', 'png', 'gif'];
|
|
|
|
|
|
|
|
|
|
|
|
export default class Uploader {
|
|
|
|
constructor(context) {
|
|
|
|
this.context = context;
|
|
|
|
this.expectedSize = context.request.headers['content-length'];
|
|
|
|
this.tempGuid = context.request.headers['hostr-guid'];
|
2016-08-07 20:41:21 +01:00
|
|
|
this.remoteIp = context.request.headers['x-forwarded-for'] || context.req.connection.remoteAddress;
|
2020-06-14 22:29:04 +01:00
|
|
|
this.remoteIp = this.remoteIp.split(',')[0];
|
2016-06-06 13:39:42 +01:00
|
|
|
this.md5sum = crypto.createHash('md5');
|
|
|
|
|
|
|
|
this.lastPercent = 0;
|
|
|
|
this.percentComplete = 0;
|
|
|
|
this.lastTick = 0;
|
|
|
|
this.receivedSize = 0;
|
|
|
|
}
|
|
|
|
|
2018-06-02 15:50:39 +00:00
|
|
|
async checkLimit() {
|
|
|
|
const count = await models.file.count({
|
2016-08-07 14:38:05 +01:00
|
|
|
where: {
|
|
|
|
userId: this.context.user.id,
|
|
|
|
createdAt: {
|
|
|
|
$gt: Date.now() - 86400000,
|
|
|
|
},
|
2016-06-19 10:14:47 -07:00
|
|
|
},
|
2016-06-06 13:39:42 +01:00
|
|
|
});
|
|
|
|
const userLimit = this.context.user.daily_upload_allowance;
|
|
|
|
const underLimit = (count < userLimit || userLimit === 'unlimited');
|
|
|
|
if (!underLimit) {
|
|
|
|
this.context.statsd.incr('file.overlimit', 1);
|
|
|
|
}
|
|
|
|
this.context.assert(underLimit, 400, `{
|
|
|
|
"error": {
|
|
|
|
"message": "Daily upload limits (${this.context.user.daily_upload_allowance}) exceeded.",
|
|
|
|
"code": 602
|
|
|
|
}
|
|
|
|
}`);
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2018-06-02 15:50:39 +00:00
|
|
|
async accept() {
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
this.upload = new Busboy({
|
|
|
|
autoFields: true,
|
|
|
|
headers: this.context.request.headers,
|
|
|
|
limits: { files: 1 },
|
|
|
|
highWaterMark: 10000000,
|
|
|
|
});
|
|
|
|
|
2018-06-02 18:07:00 +00:00
|
|
|
this.upload.on('file', async (fieldname, file, filename) => {
|
2018-06-02 15:50:39 +00:00
|
|
|
this.upload.filename = filename;
|
|
|
|
|
|
|
|
this.file = await models.file.create({
|
|
|
|
id: await createHostrId(),
|
2018-06-02 18:07:00 +00:00
|
|
|
name: this.upload.filename.replace(/[^a-zA-Z0-9\.\-_\s]/g, '').replace(/\s+/g, ''), // eslint-disable-line no-useless-escape
|
2018-06-02 15:50:39 +00:00
|
|
|
originalName: this.upload.filename,
|
|
|
|
userId: this.context.user.id,
|
|
|
|
status: 'uploading',
|
|
|
|
type: sniff(this.upload.filename),
|
|
|
|
ip: this.remoteIp,
|
|
|
|
accessedAt: null,
|
|
|
|
width: null,
|
|
|
|
height: null,
|
|
|
|
});
|
|
|
|
await this.file.save();
|
|
|
|
|
|
|
|
this.path = join(this.file.id[0], `${this.file.id}_${this.file.name}`);
|
|
|
|
this.localStream = fs.createWriteStream(join(storePath, this.path));
|
|
|
|
this.localStream.on('finish', () => {
|
|
|
|
resolve();
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
file.on('data', (data) => {
|
|
|
|
this.receivedSize += data.length;
|
|
|
|
if (this.receivedSize > this.context.user.max_filesize) {
|
|
|
|
fs.unlink(join(storePath, this.path));
|
|
|
|
this.context.throw(413, `{"error": {"message": "The file you uploaded is too large.",
|
|
|
|
"code": 601}}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
this.localStream.write(data);
|
|
|
|
|
2018-06-02 18:07:00 +00:00
|
|
|
this.percentComplete = Math.floor((this.receivedSize * 100) / this.expectedSize);
|
2018-06-02 15:50:39 +00:00
|
|
|
if (this.percentComplete > this.lastPercent && this.lastTick < Date.now() - 1000) {
|
|
|
|
const progressEvent = `{"type": "file-progress", "data":
|
|
|
|
{"id": "${this.file.id}", "complete": ${this.percentComplete}}}`;
|
|
|
|
this.context.redis.publish(`/file/${this.file.id}`, progressEvent);
|
|
|
|
this.context.redis.publish(`/user/${this.context.user.id}`, progressEvent);
|
|
|
|
this.lastTick = Date.now();
|
|
|
|
}
|
|
|
|
this.lastPercent = this.percentComplete;
|
|
|
|
|
|
|
|
this.md5sum.update(data);
|
|
|
|
});
|
|
|
|
|
|
|
|
debug('accepted');
|
|
|
|
const accepted = `{"type": "file-accepted", "data":
|
|
|
|
{"id": "${this.file.id}", "guid": "${this.tempGuid}", "href": "${baseURL}/${this.file.id}"}}`;
|
|
|
|
this.context.redis.publish(`/user/${this.context.user.id}`, accepted);
|
|
|
|
this.context.statsd.incr('file.upload.accepted', 1);
|
|
|
|
|
|
|
|
file.on('end', () => {
|
|
|
|
this.file.size = this.receivedSize;
|
|
|
|
this.file.md5 = this.md5sum.digest('hex');
|
|
|
|
this.localStream.end();
|
|
|
|
this.processingEvent();
|
|
|
|
});
|
|
|
|
|
|
|
|
this.localStream.on('end', () => {
|
|
|
|
s3upload(fs.createReadStream(join(storePath, this.path)), this.path);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
this.context.req.pipe(this.upload);
|
|
|
|
});
|
2016-06-06 13:39:42 +01:00
|
|
|
}
|
|
|
|
|
2018-06-02 15:50:39 +00:00
|
|
|
processingEvent() {
|
|
|
|
debug('processing');
|
|
|
|
const processing = `{"type": "file-progress", "data":
|
|
|
|
{"id": "${this.file.id}", "complete": 100}}`;
|
|
|
|
this.context.redis.publish(`/file/${this.file.id}`, processing);
|
|
|
|
this.context.redis.publish(`/user/${this.context.user.id}`, processing);
|
|
|
|
this.context.statsd.incr('file.upload.complete', 1);
|
2016-06-06 13:39:42 +01:00
|
|
|
}
|
|
|
|
|
2018-06-02 15:50:39 +00:00
|
|
|
async processImage(upload) {
|
2016-06-06 13:39:42 +01:00
|
|
|
return new Promise((resolve) => {
|
|
|
|
let size;
|
|
|
|
try {
|
|
|
|
if (supported.indexOf(this.path.split('.').pop().toLowerCase()) < 0) {
|
|
|
|
resolve();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
size = sizeOf(join(storePath, this.path));
|
|
|
|
} catch (err) {
|
|
|
|
debug(err);
|
|
|
|
resolve();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!size.width || supported.indexOf(size.type) < 0) {
|
|
|
|
resolve();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2016-06-19 10:14:47 -07:00
|
|
|
this.file.width = size.width;
|
|
|
|
this.file.height = size.height;
|
2016-06-06 13:39:42 +01:00
|
|
|
|
|
|
|
Promise.all([
|
|
|
|
this.resizeImage(upload, size.type, size, { width: 150, height: 150 }),
|
|
|
|
this.resizeImage(upload, size.type, size, { width: 970 }),
|
|
|
|
]).then(() => {
|
|
|
|
resolve(size);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-06-02 15:50:39 +00:00
|
|
|
resizeImage(upload, type, currentSize, dim) {
|
|
|
|
return resize(join(storePath, this.path), type, currentSize, dim).then((image) => {
|
|
|
|
const path = join(this.file.id[0], String(dim.width), `${this.file.id}_${this.file.name}`);
|
|
|
|
debug('Writing file');
|
|
|
|
debug(join(storePath, path));
|
|
|
|
return fs.writeFile(join(storePath, path), image).then(() => {
|
|
|
|
s3upload(fs.createReadStream(join(storePath, path)), path);
|
|
|
|
}).catch(debug);
|
|
|
|
}).catch(debug);
|
|
|
|
}
|
|
|
|
|
|
|
|
async finalise() {
|
|
|
|
debug('finalise');
|
|
|
|
this.file.size = this.receivedSize;
|
|
|
|
this.file.status = 'active';
|
|
|
|
this.file.processed = 'true';
|
|
|
|
await this.file.save();
|
|
|
|
this.completeEvent();
|
|
|
|
}
|
|
|
|
|
|
|
|
completeEvent() {
|
|
|
|
debug('complete');
|
|
|
|
const complete = `{"type": "file-added", "data": ${JSON.stringify(formatFile(this.file))}}`;
|
|
|
|
this.context.redis.publish(`/file/${this.file.id}`, complete);
|
|
|
|
this.context.redis.publish(`/user/${this.context.user.id}`, complete);
|
|
|
|
}
|
|
|
|
|
2016-06-06 13:39:42 +01:00
|
|
|
malwareScan() {
|
|
|
|
if (process.env.VIRUSTOTAL_KEY) {
|
|
|
|
// Check in the background
|
2018-06-02 15:50:39 +00:00
|
|
|
process.nextTick(async () => {
|
2016-06-06 13:39:42 +01:00
|
|
|
debug('Malware Scan');
|
2018-06-02 18:07:00 +00:00
|
|
|
const result = await malware(this.file);
|
2016-06-06 13:39:42 +01:00
|
|
|
if (result) {
|
2016-06-19 10:14:47 -07:00
|
|
|
this.file.malwarePositives = result.positives;
|
|
|
|
this.file.save();
|
2018-06-02 15:50:39 +00:00
|
|
|
const fileMalware = await models.malware.create({
|
2016-06-19 10:14:47 -07:00
|
|
|
fileId: this.file.id,
|
|
|
|
positives: result.positives,
|
|
|
|
virustotal: result,
|
|
|
|
});
|
|
|
|
fileMalware.save();
|
|
|
|
if (result.positive > 5) {
|
2016-06-06 13:39:42 +01:00
|
|
|
this.context.statsd.incr('file.malware', 1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
debug('Skipping Malware Scan, VIRUSTOTAL env variable not found.');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|