Postgres.

This commit is contained in:
Jonathan Cremin 2016-06-19 10:14:47 -07:00
parent 695644c260
commit 806f42e3f8
25 changed files with 501 additions and 294 deletions

View file

@ -1,63 +1,88 @@
import passwords from 'passwords'; import passwords from 'passwords';
import auth from 'basic-auth'; import auth from 'basic-auth';
import models from '../../models';
import debugname from 'debug'; import debugname from 'debug';
const debug = debugname('hostr-api:auth'); const debug = debugname('hostr-api:auth');
const badLoginMsg = '{"error": {"message": "Incorrect login details.", "code": 607}}'; const badLoginMsg = '{"error": {"message": "Incorrect login details.", "code": 607}}';
export default function* (next) { export default function* (next) {
const Users = this.db.Users;
const Files = this.db.Files;
const Logins = this.db.Logins;
let user = false; let user = false;
const remoteIp = this.req.headers['x-real-ip'] || this.req.connection.remoteAddress;
const login = yield models.login.create({
ip: remoteIp,
successful: false,
});
if (this.req.headers.authorization && this.req.headers.authorization[0] === ':') { if (this.req.headers.authorization && this.req.headers.authorization[0] === ':') {
debug('Logging in with token'); debug('Logging in with token');
const userToken = yield this.redis.get(this.req.headers.authorization.substr(1)); const userToken = yield this.redis.get(this.req.headers.authorization.substr(1));
this.assert(userToken, 401, '{"error": {"message": "Invalid token.", "code": 606}}'); this.assert(userToken, 401, '{"error": {"message": "Invalid token.", "code": 606}}');
debug('Token found'); debug('Token found');
user = yield Users.findOne({ _id: this.db.objectId(userToken) }); user = yield models.user.findById(userToken);
if (!user) {
login.save();
return;
}
} else { } else {
const authUser = auth(this); const authUser = auth(this);
this.assert(authUser, 401, badLoginMsg); this.assert(authUser, 401, badLoginMsg);
const remoteIp = this.req.headers['x-real-ip'] || this.req.connection.remoteAddress; const count = yield models.login.count({
const count = yield Logins.count({ where: {
ip: remoteIp, ip: remoteIp,
successful: false, successful: false,
at: { $gt: Math.ceil(Date.now() / 1000) - 600 }, createdAt: {
$gt: new Date(Math.ceil(Date.now()) - 600000),
},
},
}); });
this.assert(count < 25, 401, this.assert(count < 25, 401,
'{"error": {"message": "Too many incorrect logins.", "code": 608}}'); '{"error": {"message": "Too many incorrect logins.", "code": 608}}');
yield Logins.insertOne({ ip: remoteIp, at: Math.ceil(Date.now() / 1000), successful: null }); user = yield models.user.findOne({
user = yield Users.findOne({ where: {
email: authUser.name, email: authUser.name,
banned: { $exists: false }, activated: true,
status: { $ne: 'deleted' }, },
}); });
this.assert(user, 401, badLoginMsg);
const authenticated = yield passwords.match(authUser.pass, user.salted_password); if (!user || !(yield passwords.match(authUser.pass, user.password))) {
this.assert(authenticated, 401, badLoginMsg); login.save();
this.throw(401, badLoginMsg);
return;
}
} }
debug('Checking user'); debug('Checking user');
this.assert(user, 401, badLoginMsg); this.assert(user, 401, badLoginMsg);
debug('Checking user is activated'); debug('Checking user is activated');
this.assert(!user.activationCode, 401, debug(user.activated);
this.assert(user.activated === true, 401,
'{"error": {"message": "Account has not been activated.", "code": 603}}'); '{"error": {"message": "Account has not been activated.", "code": 603}}');
const uploadedTotal = yield Files.count({ owner: user._id, status: { $ne: 'deleted' } }); login.successful = true;
const uploadedToday = yield Files.count({ yield login.save();
owner: user._id,
time_added: { $gt: Math.ceil(Date.now() / 1000) - 86400 }, const uploadedTotal = yield models.file.count({
where: {
userId: user.id,
},
});
const uploadedToday = yield models.file.count({
where: {
userId: user.id,
createdAt: {
$gt: Math.ceil(Date.now() / 1000) - 86400,
},
},
}); });
const normalisedUser = { const normalisedUser = {
id: user._id, id: user.id,
email: user.email, email: user.email,
daily_upload_allowance: user.type === 'Pro' ? 'unlimited' : 15, daily_upload_allowance: user.plan === 'Pro' ? 'unlimited' : 15,
file_count: uploadedTotal, file_count: uploadedTotal,
max_filesize: user.type === 'Pro' ? 524288000 : 20971520, max_filesize: user.plan === 'Pro' ? 524288000 : 20971520,
plan: user.type || 'Free', plan: user.plan,
uploads_today: uploadedToday, uploads_today: uploadedToday,
}; };
this.response.set('Daily-Uploads-Remaining', this.response.set('Daily-Uploads-Remaining',

View file

@ -1,5 +1,6 @@
import redis from 'redis'; import redis from 'redis';
import models from '../../models';
import { formatFile } from '../../lib/format'; import { formatFile } from '../../lib/format';
import Uploader from '../../lib/uploader'; import Uploader from '../../lib/uploader';
@ -20,7 +21,6 @@ export function* post(next) {
uploader.receive(); uploader.receive();
yield uploader.save();
yield uploader.promise; yield uploader.promise;
uploader.processingEvent(); uploader.processingEvent();
@ -31,7 +31,7 @@ export function* post(next) {
yield uploader.finalise(); yield uploader.finalise();
this.status = 201; this.status = 201;
this.body = uploader.toJSON(); this.body = formatFile(uploader.file);
uploader.completeEvent(); uploader.completeEvent();
uploader.malwareScan(); uploader.malwareScan();
@ -39,48 +39,44 @@ export function* post(next) {
export function* list() { export function* list() {
const Files = this.db.Files;
let status = 'active';
if (this.request.query.trashed) {
status = 'trashed';
} else if (this.request.query.all) {
status = { $in: ['active', 'trashed'] };
}
let limit = 20; let limit = 20;
if (this.request.query.perpage === '0') { if (this.request.query.perpage === '0') {
limit = false; limit = 1000;
} else if (this.request.query.perpage > 0) { } else if (this.request.query.perpage > 0) {
limit = parseInt(this.request.query.perpage / 1, 10); limit = parseInt(this.request.query.perpage / 1, 10);
} }
let skip = 0; let offset = 0;
if (this.request.query.page) { if (this.request.query.page) {
skip = parseInt(this.request.query.page - 1, 10) * limit; offset = parseInt(this.request.query.page - 1, 10) * limit;
} }
const queryOptions = { const files = yield models.file.findAll({
limit, skip, sort: [['time_added', 'desc']], where: {
hint: { userId: this.user.id,
owner: 1, status: 1, time_added: -1, status: 'active',
}, },
}; order: '"createdAt" DESC',
offset,
limit,
});
const userFiles = yield Files.find({
owner: this.user.id, status }, queryOptions).toArray();
this.statsd.incr('file.list', 1); this.statsd.incr('file.list', 1);
this.body = userFiles.map(formatFile); this.body = files.map(formatFile);
} }
export function* get() { export function* get() {
const Files = this.db.Files; const file = yield models.file.findOne({
const Users = this.db.Users; where: {
const file = yield Files.findOne({ _id: this.params.id, id: this.params.id,
status: { $in: ['active', 'uploading'] } }); status: {
$in: ['active', 'uploading'],
},
},
});
this.assert(file, 404, '{"error": {"message": "File not found", "code": 604}}'); this.assert(file, 404, '{"error": {"message": "File not found", "code": 604}}');
const user = yield Users.findOne({ _id: file.owner }); const user = yield file.getUser();
this.assert(user && !user.banned, 404, '{"error": {"message": "File not found", "code": 604}}'); this.assert(user && !user.banned, 404, '{"error": {"message": "File not found", "code": 604}}');
this.statsd.incr('file.get', 1); this.statsd.incr('file.get', 1);
this.body = formatFile(file); this.body = formatFile(file);
@ -89,17 +85,28 @@ export function* get() {
export function* put() { export function* put() {
if (this.request.body.trashed) { if (this.request.body.trashed) {
const Files = this.db.Files; const file = yield models.file.findOne({
const status = this.request.body.trashed ? 'trashed' : 'active'; where: {
yield Files.updateOne({ _id: this.params.id, owner: this.user.id }, id: this.params.id,
{ $set: { status } }, { w: 1 }); userId: this.user.id,
},
});
file.status = this.request.body.trashed ? 'trashed' : 'active';
yield file.save();
} }
} }
export function* del() { export function* del() {
yield this.db.Files.updateOne({ _id: this.params.id, owner: this.db.objectId(this.user.id) }, const file = yield models.file.findOne({
{ $set: { status: 'deleted' } }, { w: 1 }); where: {
id: this.params.id,
userId: this.user.id,
},
});
this.assert(file, 401, '{"error": {"message": "File not found", "code": 604}}');
file.status = 'deleted';
yield file.save();
const event = { type: 'file-deleted', data: { id: this.params.id } }; const event = { type: 'file-deleted', data: { id: this.params.id } };
yield this.redis.publish(`/file/${this.params.id}`, JSON.stringify(event)); yield this.redis.publish(`/file/${this.params.id}`, JSON.stringify(event));
yield this.redis.publish(`/user/${this.user.id}`, JSON.stringify(event)); yield this.redis.publish(`/user/${this.user.id}`, JSON.stringify(event));

View file

@ -2,6 +2,7 @@ import uuid from 'node-uuid';
import redis from 'redis'; import redis from 'redis';
import co from 'co'; import co from 'co';
import passwords from 'passwords'; import passwords from 'passwords';
import models from '../../models';
import debugname from 'debug'; import debugname from 'debug';
const debug = debugname('hostr-api:user'); const debug = debugname('hostr-api:user');
@ -19,17 +20,15 @@ export function* token() {
} }
export function* transaction() { export function* transaction() {
const Transactions = this.db.Transactions; const transactions = yield models.transaction.findAll({ userId: this.user.id });
const transactions = yield Transactions.find({ user_id: this.user.id }).toArray();
this.body = transactions.map((transaction) => { // eslint-disable-line no-shadow this.body = transactions.map((item) => {
const type = transaction.paypal ? 'paypal' : 'direct';
return { return {
id: transaction._id, id: item.id,
amount: transaction.paypal ? transaction.amount : transaction.amount / 100, amount: item.amount / 100,
date: transaction.date, date: item.date,
description: transaction.desc, description: item.description,
type, type: 'direct',
}; };
}); });
} }
@ -39,23 +38,18 @@ export function* settings() {
'{"error": {"message": "Current Password required to update account.", "code": 612}}'); '{"error": {"message": "Current Password required to update account.", "code": 612}}');
this.assert(this.request.body.current_password, 400, this.assert(this.request.body.current_password, 400,
'{"error": {"message": "Current Password required to update account.", "code": 612}}'); '{"error": {"message": "Current Password required to update account.", "code": 612}}');
const Users = this.db.Users; const user = yield models.user.findById(this.user.id);
const user = yield Users.findOne({ _id: this.user.id }); this.assert(yield passwords.match(this.request.body.current_password, user.password), 400,
this.assert(yield passwords.match(this.request.body.current_password, user.salted_password), 400,
'{"error": {"message": "Incorrect password", "code": 606}}'); '{"error": {"message": "Incorrect password", "code": 606}}');
const data = {};
if (this.request.body.email && this.request.body.email !== user.email) { if (this.request.body.email && this.request.body.email !== user.email) {
data.email = this.request.body.email; user.email = this.request.body.email;
if (!user.activated_email) {
data.activated_email = user.email; // eslint-disable-line camelcase
}
} }
if (this.request.body.new_password) { if (this.request.body.new_password) {
this.assert(this.request.body.new_password.length >= 7, 400, this.assert(this.request.body.new_password.length >= 7, 400,
'{"error": {"message": "Password must be 7 or more characters long.", "code": 606}}'); '{"error": {"message": "Password must be 7 or more characters long.", "code": 606}}');
data.salted_password = yield passwords.hash(this.request.body.new_password); user.password = yield passwords.hash(this.request.body.new_password);
} }
Users.updateOne({ _id: user._id }, { $set: data }); yield user.save();
this.body = {}; this.body = {};
} }

2
app.js
View file

@ -13,6 +13,8 @@ import * as redis from './lib/redis';
import api, { ws } from './api/app'; import api, { ws } from './api/app';
import web from './web/app'; import web from './web/app';
import models from './models';
import debugname from 'debug'; import debugname from 'debug';
const debug = debugname('hostr'); const debug = debugname('hostr');

View file

@ -22,15 +22,15 @@ export function formatSize(size) {
export function formatFile(file) { export function formatFile(file) {
const formattedFile = { const formattedFile = {
added: moment.unix(file.time_added).format(), added: moment.unix(file.createdAt).format(),
readableAdded: formatDate(file.time_added), readableAdded: formatDate(file.createdAt),
downloads: file.downloads !== undefined ? file.downloads : 0, downloads: file.downloads !== undefined ? file.downloads : 0,
href: `${baseURL}/${file._id}`, href: `${baseURL}/${file.id}`,
id: file._id, id: file.id,
name: file.file_name, name: file.name,
size: file.file_size, size: file.size,
readableSize: formatSize(file.file_size), readableSize: formatSize(file.size),
type: sniff(file.file_name), type: sniff(file.name),
trashed: (file.status === 'trashed'), trashed: (file.status === 'trashed'),
status: file.status, status: file.status,
}; };
@ -38,10 +38,10 @@ export function formatFile(file) {
if (file.width) { if (file.width) {
formattedFile.height = file.height; formattedFile.height = file.height;
formattedFile.width = file.width; formattedFile.width = file.width;
const ext = (file.file_name.split('.').pop().toLowerCase() === 'psd' ? '.png' : ''); const ext = (file.name.split('.').pop().toLowerCase() === 'psd' ? '.png' : '');
formattedFile.direct = { formattedFile.direct = {
'150x': `${baseURL}/file/150/${file._id}/${file.file_name}${ext}`, '150x': `${baseURL}/file/150/${file.id}/${file.name}${ext}`,
'970x': `${baseURL}/file/970/${file._id}/${file.file_name}${ext}`, '970x': `${baseURL}/file/970/${file.id}/${file.name}${ext}`,
}; };
} }
return formattedFile; return formattedFile;

View file

@ -1,3 +1,5 @@
import models from '../models';
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function randomID() { function randomID() {
@ -12,7 +14,7 @@ function* checkId(Files, fileId, attempts) {
if (attempts > 10) { if (attempts > 10) {
return false; return false;
} }
const file = yield Files.findOne({ _id: fileId }); const file = yield models.file.findById(fileId);
if (file === null) { if (file === null) {
return fileId; return fileId;
} }

View file

@ -71,7 +71,7 @@ export default function* (file) {
} }
const result = yield virustotal.getFileReport(file.md5); const result = yield virustotal.getFileReport(file.md5);
return { return {
positive: result.positives >= 5, positives: result.positives,
result, result,
}; };
} }

View file

@ -55,17 +55,17 @@ function scale(path, type, size) {
}); });
} }
export default function resize(path, type, currentSize, newSize) { export default function resize(path, type, currentSize, dim) {
debug('Resizing'); debug('Resizing');
const ratio = 970 / currentSize.width; const ratio = 970 / currentSize.width;
debug(newSize.width, ratio); debug(dim.width, ratio);
if (newSize.width <= 150) { if (dim.width <= 150) {
debug('Cover'); debug('Cover');
return cover(path, type, newSize); return cover(path, type, dim);
} else if (newSize.width >= 970 && ratio < 1) { } else if (dim.width >= 970 && ratio < 1) {
debug('Scale'); debug('Scale');
newSize.height = currentSize.height * ratio; // eslint-disable-line no-param-reassign dim.height = currentSize.height * ratio; // eslint-disable-line no-param-reassign
return scale(path, type, newSize); return scale(path, type, dim);
} }
debug('Copy'); debug('Copy');
return fs.readFile(path); return fs.readFile(path);

View file

@ -4,8 +4,9 @@ import crypto from 'crypto';
import fs from 'mz/fs'; import fs from 'mz/fs';
import sizeOf from 'image-size'; import sizeOf from 'image-size';
import models from '../models';
import createHostrId from './hostr-id';
import { formatFile } from './format'; import { formatFile } from './format';
import hostrId from './hostr-id';
import resize from './resize'; import resize from './resize';
import malware from './malware'; import malware from './malware';
import { sniff } from './type'; import { sniff } from './type';
@ -22,7 +23,6 @@ const supported = ['jpeg', 'jpg', 'png', 'gif'];
export default class Uploader { export default class Uploader {
constructor(context) { constructor(context) {
this.context = context; this.context = context;
this.Files = context.db.Files;
this.expectedSize = context.request.headers['content-length']; this.expectedSize = context.request.headers['content-length'];
this.tempGuid = context.request.headers['hostr-guid']; this.tempGuid = context.request.headers['hostr-guid'];
this.remoteIp = context.request.headers['x-real-ip'] || context.req.connection.remoteAddress; this.remoteIp = context.request.headers['x-real-ip'] || context.req.connection.remoteAddress;
@ -55,13 +55,23 @@ export default class Uploader {
}); });
this.tempGuid = this.tempGuid; this.tempGuid = this.tempGuid;
this.originalName = this.upload.filename; this.file = yield models.file.create({
this.filename = this.upload.filename.replace(/[^a-zA-Z0-9\.\-_\s]/g, '').replace(/\s+/g, ''); id: yield createHostrId(),
this.id = yield hostrId(this.Files); name: this.upload.filename.replace(/[^a-zA-Z0-9\.\-_\s]/g, '').replace(/\s+/g, ''),
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,
});
yield this.file.save();
} }
receive() { receive() {
this.path = join(this.id[0], `${this.id}_${this.filename}`); this.path = join(this.file.id[0], `${this.file.id}_${this.file.name}`);
this.localStream = fs.createWriteStream(join(storePath, this.path)); this.localStream = fs.createWriteStream(join(storePath, this.path));
this.upload.pause(); this.upload.pause();
@ -79,8 +89,8 @@ export default class Uploader {
this.percentComplete = Math.floor(this.receivedSize * 100 / this.expectedSize); this.percentComplete = Math.floor(this.receivedSize * 100 / this.expectedSize);
if (this.percentComplete > this.lastPercent && this.lastTick < Date.now() - 1000) { if (this.percentComplete > this.lastPercent && this.lastTick < Date.now() - 1000) {
const progressEvent = `{"type": "file-progress", "data": const progressEvent = `{"type": "file-progress", "data":
{"id": "${this.upload.id}", "complete": ${this.percentComplete}}}`; {"id": "${this.file.id}", "complete": ${this.percentComplete}}}`;
this.context.redis.publish(`/file/${this.upload.id}`, progressEvent); this.context.redis.publish(`/file/${this.file.id}`, progressEvent);
this.context.redis.publish(`/user/${this.context.user.id}`, progressEvent); this.context.redis.publish(`/user/${this.context.user.id}`, progressEvent);
this.lastTick = Date.now(); this.lastTick = Date.now();
} }
@ -90,6 +100,8 @@ export default class Uploader {
}); });
this.upload.on('end', () => { this.upload.on('end', () => {
this.file.size = this.receivedSize;
this.file.md5 = this.md5sum.digest('hex');
this.localStream.end(); this.localStream.end();
}); });
@ -101,62 +113,34 @@ export default class Uploader {
} }
acceptedEvent() { acceptedEvent() {
const acceptedEvent = `{"type": "file-accepted", "data": const accepted = `{"type": "file-accepted", "data":
{"id": "${this.id}", "guid": "${this.tempGuid}", "href": "${baseURL}/${this.id}"}}`; {"id": "${this.file.id}", "guid": "${this.tempGuid}", "href": "${baseURL}/${this.file.id}"}}`;
this.context.redis.publish(`/user/${this.context.user.id}`, acceptedEvent); this.context.redis.publish(`/user/${this.context.user.id}`, accepted);
this.context.statsd.incr('file.upload.accepted', 1); this.context.statsd.incr('file.upload.accepted', 1);
} }
processingEvent() { processingEvent() {
const processingEvent = `{"type": "file-progress", "data": const processing = `{"type": "file-progress", "data":
{"id": "${this.id}", "complete": 100}}`; {"id": "${this.file.id}", "complete": 100}}`;
this.context.redis.publish(`/file/${this.id}`, processingEvent); this.context.redis.publish(`/file/${this.file.id}`, processing);
this.context.redis.publish(`/user/${this.context.user.id}`, processingEvent); this.context.redis.publish(`/user/${this.context.user.id}`, processing);
this.context.statsd.incr('file.upload.complete', 1); this.context.statsd.incr('file.upload.complete', 1);
} }
completeEvent() { completeEvent() {
const completeEvent = `{"type": "file-added", "data": ${JSON.stringify(this.toDBFormat())}}`; const complete = `{"type": "file-added", "data": ${JSON.stringify(formatFile(this.file))}}`;
this.context.redis.publish(`/file/${this.id}`, completeEvent); this.context.redis.publish(`/file/${this.file.id}`, complete);
this.context.redis.publish(`/user/${this.context.user.id}`, completeEvent); this.context.redis.publish(`/user/${this.context.user.id}`, complete);
}
toDBFormat() {
const formatted = {
owner: this.context.user.id,
ip: this.remoteIp,
system_name: this.id,
file_name: this.filename,
original_name: this.originalName,
file_size: this.receivedSize,
time_added: Math.ceil(Date.now() / 1000),
status: 'active',
last_accessed: null,
s3: false,
type: sniff(this.filename),
};
if (this.width) {
formatted.width = this.width;
formatted.height = this.height;
}
return formatted;
}
save() {
return this.Files.insertOne({ _id: this.id, ...this.toDBFormat() });
}
toJSON() {
return formatFile({ _id: this.id, ...this.toDBFormat() });
} }
*checkLimit() { *checkLimit() {
const count = yield this.Files.count({ const count = yield models.file.count({
owner: this.context.user.id, userId: this.context.user.id,
time_added: { $gt: Math.ceil(Date.now() / 1000) - 86400 }, createdAt: {
$gt: Math.ceil(Date.now() / 1000) - 86400,
},
}); });
debug(count);
const userLimit = this.context.user.daily_upload_allowance; const userLimit = this.context.user.daily_upload_allowance;
const underLimit = (count < userLimit || userLimit === 'unlimited'); const underLimit = (count < userLimit || userLimit === 'unlimited');
if (!underLimit) { if (!underLimit) {
@ -172,22 +156,14 @@ export default class Uploader {
} }
*finalise() { *finalise() {
const dbFile = this.toDBFormat(); this.file.size = this.receivedSize;
dbFile.file_size = this.receivedSize; this.file.status = 'active';
dbFile.status = 'active'; yield this.file.save();
dbFile.md5 = this.md5sum.digest('hex');
if (this.width) {
dbFile.width = this.width;
dbFile.height = this.height;
} }
yield this.Files.updateOne({ _id: this.id }, { $set: dbFile }); 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}`);
resizeImage(upload, type, currentSize, newSize) {
return resize(join(storePath, this.path), type, currentSize, newSize).then((image) => {
const path = join(this.id[0], String(newSize.width), `${this.id}_${this.filename}`);
debug('Writing file'); debug('Writing file');
debug(join(storePath, path)); debug(join(storePath, path));
return fs.writeFile(join(storePath, path), image).then(() => { return fs.writeFile(join(storePath, path), image).then(() => {
@ -217,8 +193,8 @@ export default class Uploader {
return; return;
} }
this.width = size.width; this.file.width = size.width;
this.height = size.height; this.file.height = size.height;
Promise.all([ Promise.all([
this.resizeImage(upload, size.type, size, { width: 150, height: 150 }), this.resizeImage(upload, size.type, size, { width: 150, height: 150 }),
@ -236,9 +212,15 @@ export default class Uploader {
debug('Malware Scan'); debug('Malware Scan');
const result = yield malware(this); const result = yield malware(this);
if (result) { if (result) {
yield this.Files.updateOne({ _id: this.id }, this.file.malwarePositives = result.positives;
{ $set: { malware: result.positive, virustotal: result } }); this.file.save();
if (result.positive) { const fileMalware = yield models.malware.create({
fileId: this.file.id,
positives: result.positives,
virustotal: result,
});
fileMalware.save();
if (result.positive > 5) {
this.context.statsd.incr('file.malware', 1); this.context.statsd.incr('file.malware', 1);
} }
} }

15
models/activation.js Normal file
View file

@ -0,0 +1,15 @@
export default function (sequelize, DataTypes) {
const Activation = sequelize.define('activation', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
activated: false,
email: DataTypes.STRING,
}, {
classMethods: {
associate: (models) => {
Activation.belongsTo(models.user);
},
},
});
return Activation;
}

40
models/file.js Normal file
View file

@ -0,0 +1,40 @@
export default function (sequelize, DataTypes) {
const File = sequelize.define('file', {
id: { type: DataTypes.STRING(12), primaryKey: true }, // eslint-disable-line new-cap
name: DataTypes.TEXT,
originalName: DataTypes.TEXT,
size: DataTypes.BIGINT,
downloads: DataTypes.BIGINT,
accessedAt: DataTypes.DATE,
status: DataTypes.ENUM('active', 'uploading', 'deleted'), // eslint-disable-line new-cap
type: DataTypes.ENUM( // eslint-disable-line new-cap
'image',
'audio',
'video',
'archive',
'other'
),
width: DataTypes.INTEGER,
height: DataTypes.INTEGER,
ip: 'inet',
legacyId: DataTypes.STRING(12), // eslint-disable-line new-cap
md5: DataTypes.STRING(32), // eslint-disable-line new-cap
malwarePositives: DataTypes.INTEGER,
}, {
classMethods: {
accessed: (id) => sequelize.query(`
UPDATE files
SET "downloads" = downloads + 1, "accessedAt" = NOW()
WHERE "id" = :id`,
{
replacements: { id },
type: sequelize.QueryTypes.UPDATE,
}),
associate: (models) => {
File.belongsTo(models.user);
},
},
});
return File;
}

34
models/index.js Normal file
View file

@ -0,0 +1,34 @@
import fs from 'fs';
import path from 'path';
import Sequelize from 'sequelize';
import debugname from 'debug';
const debug = debugname('hostr:models');
const config = {
dialect: 'postgres',
protocol: 'postgres',
logging: debug,
};
const sequelize = new Sequelize(process.env.DATABASE_URL, config);
const db = {};
fs
.readdirSync(__dirname)
.filter((file) => (file.indexOf('.') !== 0) && (file !== 'index.js'))
.forEach((file) => {
const model = sequelize.import(path.join(__dirname, file));
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if ('associate' in db[modelName]) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
export default db;

15
models/login.js Normal file
View file

@ -0,0 +1,15 @@
export default function (sequelize, DataTypes) {
const Login = sequelize.define('login', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
successful: { type: DataTypes.BOOLEAN },
ip: { type: 'inet' },
}, {
classMethods: {
associate: (models) => {
Login.belongsTo(models.user);
},
},
});
return Login;
}

15
models/malware.js Normal file
View file

@ -0,0 +1,15 @@
export default function (sequelize, DataTypes) {
const Malware = sequelize.define('malware', {
fileId: { type: DataTypes.STRING(12), primaryKey: true }, // eslint-disable-line new-cap
positives: DataTypes.INTEGER,
virustotal: DataTypes.JSON,
}, {
classMethods: {
associate: (models) => {
Malware.belongsTo(models.file);
},
},
});
return Malware;
}

13
models/remember.js Normal file
View file

@ -0,0 +1,13 @@
export default function (sequelize, DataTypes) {
const Remember = sequelize.define('remember', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
}, {
classMethods: {
associate: (models) => {
Remember.belongsTo(models.user);
},
},
});
return Remember;
}

13
models/reset.js Normal file
View file

@ -0,0 +1,13 @@
export default function (sequelize, DataTypes) {
const Reset = sequelize.define('reset', {
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
}, {
classMethods: {
associate: (models) => {
Reset.belongsTo(models.user);
},
},
});
return Reset;
}

17
models/transaction.js Normal file
View file

@ -0,0 +1,17 @@
export default function (sequelize, DataTypes) {
const Transaction = sequelize.define('transaction', {
uuid: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4 },
amount: DataTypes.DECIMAL,
description: DataTypes.STRING,
type: DataTypes.ENUM('direct', 'paypal'), // eslint-disable-line new-cap
ip: 'inet',
}, {
classMethods: {
associate: (models) => {
Transaction.belongsTo(models.user);
},
},
});
return Transaction;
}

21
models/user.js Normal file
View file

@ -0,0 +1,21 @@
export default function (sequelize, DataTypes) {
const User = sequelize.define('user', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
email: DataTypes.STRING,
password: DataTypes.STRING,
plan: DataTypes.ENUM('Free', 'Pro'), // eslint-disable-line new-cap
ip: 'inet',
activated: DataTypes.BOOLEAN,
banned: DataTypes.BOOLEAN,
deleted: DataTypes.BOOLEAN,
}, {
classMethods: {
associate: (models) => {
User.hasMany(models.file);
User.hasOne(models.activation);
},
},
});
return User;
}

View file

@ -19,7 +19,7 @@
"lint": "eslint .", "lint": "eslint .",
"start": "node -r babel-register app.js", "start": "node -r babel-register app.js",
"test": "npm run test-seed && mocha -r babel-register test/**/*.spec.js", "test": "npm run test-seed && mocha -r babel-register test/**/*.spec.js",
"test-seed": "node test/fixtures/mongo-user.js && node test/fixtures/mongo-file.js", "test-seed": "babel-node test/fixtures/user.js",
"watch": "parallelshell \"npm run watch-js\" \"npm run watch-sass\" \"npm run watch-server\"", "watch": "parallelshell \"npm run watch-js\" \"npm run watch-sass\" \"npm run watch-server\"",
"watch-js": "babel -Dw -m system -d web/public/build web/public/src", "watch-js": "babel -Dw -m system -d web/public/build web/public/src",
"watch-server": "nodemon -r babel-register app.js", "watch-server": "nodemon -r babel-register app.js",
@ -73,6 +73,8 @@
"raven": "^0.11.0", "raven": "^0.11.0",
"redis": "^2.6.1", "redis": "^2.6.1",
"sendgrid": "^2.0.0", "sendgrid": "^2.0.0",
"sequelize": "^3.23.3",
"sequelize-classes": "^0.1.12",
"ssh2": "^0.5.0", "ssh2": "^0.5.0",
"statsy": "~0.2.0", "statsy": "~0.2.0",
"stripe": "^4.7.0", "stripe": "^4.7.0",

View file

@ -1,11 +0,0 @@
const MongoClient = require('mongodb').MongoClient;
MongoClient.connect(process.env.MONGO_URL, function connect(err, db) {
const collection = db.collection('files');
collection.createIndex({
'owner': 1,
'status': 1,
'time_added': -1,
});
db.close();
});

View file

@ -1,15 +0,0 @@
const MongoClient = require('mongodb').MongoClient;
MongoClient.connect(process.env.MONGO_URL, function connect(err, db) {
const collection = db.collection('users');
collection.remove({
'email': 'test@hostr.co',
});
collection.save({
'email': 'test@hostr.co',
'salted_password': '$pbkdf2-256-1$2$kBhIDRqFwnF/1ms6ZHfME2o2$a48e8c350d26397fcc88bf0a7a2817b1cdcd1ffffe0521a5',
'joined': Math.ceil(Date.now() / 1000),
'signup_ip': '127.0.0.1',
});
db.close();
});

27
test/fixtures/user.js vendored Normal file
View file

@ -0,0 +1,27 @@
import models from '../../models';
function createUser() {
models.user.create({
'email': 'test@hostr.co',
'password': '$pbkdf2-256-1$2$kBhIDRqFwnF/1ms6ZHfME2o2$a48e8c350d26397fcc88bf0a7a2817b1cdcd1ffffe0521a5',
'ip': '127.0.0.1',
'plan': 'Free',
'activated': true,
}).then((user) => {
user.save().then(() => {
models.sequelize.close();
});
});
}
models.user.findOne({
where: {
email: 'test@hostr.co',
},
}).then((user) => {
if (user) {
user.destroy().then(createUser);
} else {
createUser();
}
});

View file

@ -1,8 +1,9 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { join } from 'path';
import passwords from 'passwords'; import passwords from 'passwords';
import uuid from 'node-uuid'; import uuid from 'node-uuid';
import views from 'co-views'; import views from 'co-views';
import { join } from 'path'; import models from '../../models';
const render = views(join(__dirname, '..', 'views'), { default: 'ejs' }); const render = views(join(__dirname, '..', 'views'), { default: 'ejs' });
import debugname from 'debug'; import debugname from 'debug';
const debug = debugname('hostr-web:auth'); const debug = debugname('hostr-web:auth');
@ -13,45 +14,46 @@ const from = process.env.EMAIL_FROM;
const fromname = process.env.EMAIL_NAME; const fromname = process.env.EMAIL_NAME;
export function* authenticate(email, password) { export function* authenticate(email, password) {
const Users = this.db.Users;
const Logins = this.db.Logins;
const remoteIp = this.headers['x-real-ip'] || this.ip; const remoteIp = this.headers['x-real-ip'] || this.ip;
if (!password || password.length < 6) { if (!password || password.length < 6) {
debug('No password, or password too short'); debug('No password, or password too short');
return new Error('Invalid login details'); return new Error('Invalid login details');
} }
const count = yield Logins.count({ const count = yield models.login.count({
where: {
ip: remoteIp, ip: remoteIp,
successful: false, successful: false,
at: { $gt: Math.ceil(Date.now() / 1000) - 600 }, createdAt: {
$gt: Math.ceil(Date.now() / 1000) - 600,
},
},
}); });
if (count > 25) { if (count > 25) {
debug('Throttling brute force'); debug('Throttling brute force');
return new Error('Invalid login details'); return new Error('Invalid login details');
} }
const login = { ip: remoteIp, at: Math.ceil(Date.now() / 1000), successful: null }; const user = yield models.user.findOne({
yield Logins.save(login);
const user = yield Users.findOne({
email: email.toLowerCase(), email: email.toLowerCase(),
banned: { $exists: false }, status: { $ne: 'deleted' }, activated: 'true',
}); });
const login = yield models.login.create({
ip: remoteIp,
successful: false,
});
if (user) { if (user) {
const verified = yield passwords.verify(password, user.salted_password); if (yield passwords.verify(password, user.password)) {
if (verified) {
debug('Password verified'); debug('Password verified');
login.successful = true; login.successful = true;
yield Logins.updateOne({ _id: login._id }, login); yield login.save();
return user; return user;
} }
debug('Password invalid'); debug('Password invalid');
login.successful = false; login.userId = user.id;
yield Logins.updateOne({ _id: login._id }, login);
} else {
debug('Email invalid');
login.successful = false;
yield Logins.updateOne({ _id: login._id }, login);
} }
yield login.save();
return new Error('Invalid login details'); return new Error('Invalid login details');
} }
@ -59,18 +61,16 @@ export function* authenticate(email, password) {
export function* setupSession(user) { export function* setupSession(user) {
debug('Setting up session'); debug('Setting up session');
const token = uuid.v4(); const token = uuid.v4();
yield this.redis.set(token, user._id, 'EX', 604800); yield this.redis.set(token, user.id, 'EX', 604800);
const sessionUser = { const sessionUser = {
id: user._id, id: user.id,
email: user.email, email: user.email,
dailyUploadAllowance: 15, dailyUploadAllowance: 15,
maxFileSize: 20971520, maxFileSize: 20971520,
joined: user.joined, joined: user.createdAt,
plan: user.type || 'Free', plan: user.plan,
uploadsToday: yield this.db.Files.count({ uploadsToday: yield models.file.count({ userId: user.id }),
owner: user._id, time_added: { $gt: Math.ceil(Date.now() / 1000) - 86400 },
}),
md5: crypto.createHash('md5').update(user.email).digest('hex'), md5: crypto.createHash('md5').update(user.email).digest('hex'),
token, token,
}; };
@ -82,39 +82,45 @@ export function* setupSession(user) {
this.session.user = sessionUser; this.session.user = sessionUser;
if (this.request.body.remember && this.request.body.remember === 'on') { if (this.request.body.remember && this.request.body.remember === 'on') {
const Remember = this.db.Remember; const remember = yield models.remember.create({
const rememberToken = uuid(); id: uuid(),
Remember.save({ _id: rememberToken, user_id: user.id, created: new Date().getTime() }); userId: user.id,
this.cookies.set('r', rememberToken, { maxAge: 1209600000, httpOnly: true }); });
this.cookies.set('r', remember.id, { maxAge: 1209600000, httpOnly: true });
} }
debug('Session set up'); debug('Session set up');
} }
export function* signup(email, password, ip) { export function* signup(email, password, ip) {
const Users = this.db.Users; const existingUser = yield models.user.findOne({ where: { email, activated: true } });
const existingUser = yield Users.findOne({ email, status: { $ne: 'deleted' } });
if (existingUser) { if (existingUser) {
debug('Email already in use.'); debug('Email already in use.');
throw new Error('Email already in use.'); throw new Error('Email already in use.');
} }
const cryptedPassword = yield passwords.crypt(password); const cryptedPassword = yield passwords.crypt(password);
const user = { const user = yield models.user.create({
email, email,
salted_password: cryptedPassword, password: cryptedPassword,
joined: Math.round(new Date().getTime() / 1000), created: Math.round(new Date().getTime() / 1000),
signup_ip: ip, ip,
activationCode: uuid(), activation: {
}; id: uuid(),
Users.insertOne(user); email,
},
}, {
include: [models.activation],
});
yield user.save();
const html = yield render('email/inlined/activate', { const html = yield render('email/inlined/activate', {
activationUrl: `${process.env.WEB_BASE_URL}/activate/${user.activationCode}`, activationUrl: `${process.env.WEB_BASE_URL}/activate/${user.activation.id}`,
}); });
const text = `Thanks for signing up to Hostr! const text = `Thanks for signing up to Hostr!
Please confirm your email address by clicking the link below. Please confirm your email address by clicking the link below.
${process.env.WEB_BASE_URL}/activate/${user.activationCode} ${process.env.WEB_BASE_URL}/activate/${user.activation.id}
Jonathan Cremin, Hostr Founder Jonathan Cremin, Hostr Founder
`; `;
@ -132,21 +138,17 @@ ${process.env.WEB_BASE_URL}/activate/${user.activationCode}
export function* sendResetToken(email) { export function* sendResetToken(email) {
const Users = this.db.Users; const user = yield models.user.findOne({ email });
const Reset = this.db.Reset;
const user = yield Users.findOne({ email });
if (user) { if (user) {
const token = uuid.v4(); const reset = yield models.reset.create({
Reset.save({ id: uuid.v4(),
_id: user._id, userId: user.id,
created: Math.round(new Date().getTime() / 1000),
token,
}); });
const html = yield render('email/inlined/forgot', { const html = yield render('email/inlined/forgot', {
forgotUrl: `${process.env.WEB_BASE_URL}/forgot/${token}`, forgotUrl: `${process.env.WEB_BASE_URL}/forgot/${reset.id}`,
}); });
const text = `It seems you've forgotten your password :( const text = `It seems you've forgotten your password :(
Visit ${process.env.WEB_BASE_URL}/forgot/${token} to set a new one. Visit ${process.env.WEB_BASE_URL}/forgot/${reset.id} to set a new one.
`; `;
const mail = new sendgrid.Email({ const mail = new sendgrid.Email({
to: user.email, to: user.email,
@ -165,38 +167,43 @@ Visit ${process.env.WEB_BASE_URL}/forgot/${token} to set a new one.
export function* fromToken(token) { export function* fromToken(token) {
const Users = this.db.Users; const userId = yield this.redis.get(token);
const reply = yield this.redis.get(token); return yield models.user.findbyId(userId);
return yield Users.findOne({ _id: reply });
} }
export function* fromCookie(cookie) { export function* fromCookie(rememberId) {
const Remember = this.db.Remember; const userId = yield models.remember.findById(rememberId);
const Users = this.db.Users; return yield models.user.findbyId(userId);
const remember = yield Remember.findOne({ _id: cookie });
return yield Users.findOne({ _id: remember.user_id });
} }
export function* validateResetToken() { export function* validateResetToken(resetId) {
const Reset = this.db.Reset; return yield models.reset.findbyId(resetId);
return yield Reset.findOne({ token: this.params.token });
} }
export function* updatePassword(userId, password) { export function* updatePassword(userId, password) {
const Users = this.db.Users;
const cryptedPassword = yield passwords.crypt(password); const cryptedPassword = yield passwords.crypt(password);
yield Users.updateOne({ _id: userId }, { $set: { salted_password: cryptedPassword } }); const user = yield models.user.findById(userId);
user.password = cryptedPassword;
yield user.save();
} }
export function* activateUser(code) { export function* activateUser(code) {
const Users = this.db.Users; debug(code);
const user = yield Users.findOne({ activationCode: code }); const activation = yield models.activation.findOne({
if (user) { where: {
Users.updateOne({ _id: user._id }, { $unset: { activationCode: '' } }); id: code,
},
});
if (activation.updatedAt.getTime() === activation.createdAt.getTime()) {
activation.activated = true;
yield activation.save();
const user = yield activation.getUser();
user.activated = true;
yield user.save();
yield setupSession.call(this, user); yield setupSession.call(this, user);
return true; return true;
} }

View file

@ -1,5 +1,6 @@
import { join } from 'path'; import { join } from 'path';
import mime from 'mime-types'; import mime from 'mime-types';
import models from '../../models';
import hostrFileStream from '../../lib/hostr-file-stream'; import hostrFileStream from '../../lib/hostr-file-stream';
import { formatFile } from '../../lib/format'; import { formatFile } from '../../lib/format';
@ -32,36 +33,38 @@ export function* get() {
return; return;
} }
const file = yield this.db.Files.findOne({ const file = yield models.file.findOne({
_id: this.params.id, where: {
file_name: this.params.name, id: this.params.id,
name: this.params.name,
status: 'active', status: 'active',
},
}); });
this.assert(file, 404); this.assert(file, 404);
if (!hotlinkCheck(file, this.headers['user-agent'], this.headers.referer)) { if (!hotlinkCheck(file, this.headers['user-agent'], this.headers.referer)) {
this.redirect(`/${file._id}`); this.redirect(`/${file.id}`);
return; return;
} }
if (!file.width && this.request.query.warning !== 'on') { if (!file.width && this.request.query.warning !== 'on') {
this.redirect(`/${file._id}`); this.redirect(`/${file.id}`);
return; return;
} }
if (file.malware) { if (file.malware) {
const alert = this.request.query.alert; const alert = this.request.query.alert;
if (!alert || !alert.match(/i want to download malware/i)) { if (!alert || !alert.match(/i want to download malware/i)) {
this.redirect(`/${file._id}`); this.redirect(`/${file.id}`);
return; return;
} }
} }
let localPath = join(storePath, file._id[0], `${file._id}_${file.file_name}`); let localPath = join(storePath, file.id[0], `${file.id}_${file.name}`);
let remotePath = join(file._id[0], `${file._id}_${file.file_name}`); let remotePath = join(file.id[0], `${file.id}_${file.name}`);
if (this.params.size > 0) { if (this.params.size > 0) {
localPath = join(storePath, file._id[0], this.params.size, `${file._id}_${file.file_name}`); localPath = join(storePath, file.id[0], this.params.size, `${file.id}_${file.name}`);
remotePath = join(file._id[0], this.params.size, `${file._id}_${file.file_name}`); remotePath = join(file.id[0], this.params.size, `${file.id}_${file.name}`);
} }
if (file.malware) { if (file.malware) {
@ -73,13 +76,13 @@ export function* get() {
if (this.params.size) { if (this.params.size) {
this.statsd.incr('file.view', 1); this.statsd.incr('file.view', 1);
} }
type = mime.lookup(file.file_name); type = mime.lookup(file.name);
} else { } else {
this.statsd.incr('file.download', 1); this.statsd.incr('file.download', 1);
} }
if (userAgentCheck(this.headers['user-agent'])) { if (userAgentCheck(this.headers['user-agent'])) {
this.set('Content-Disposition', `attachment; filename=${file.file_name}`); this.set('Content-Disposition', `attachment; filename=${file.name}`);
} }
this.set('Content-type', type); this.set('Content-type', type);
@ -87,10 +90,7 @@ export function* get() {
this.set('Cache-control', 'max-age=2592000'); this.set('Cache-control', 'max-age=2592000');
if (!this.params.size || (this.params.size && this.params.size > 150)) { if (!this.params.size || (this.params.size && this.params.size > 150)) {
this.db.Files.updateOne( models.file.accessed(file.id);
{ _id: file._id },
{ $set: { last_accessed: Math.ceil(Date.now() / 1000) }, $inc: { downloads: 1 } },
{ w: 0 });
} }
this.body = yield hostrFileStream(localPath, remotePath); this.body = yield hostrFileStream(localPath, remotePath);
@ -101,10 +101,15 @@ export function* resized() {
} }
export function* landing() { export function* landing() {
const file = yield this.db.Files.findOne({ _id: this.params.id, status: 'active' }); const file = yield models.file.findOne({
where: {
id: this.params.id,
status: 'active',
},
});
this.assert(file, 404); this.assert(file, 404);
if (userAgentCheck(this.headers['user-agent'])) { if (userAgentCheck(this.headers['user-agent'])) {
this.params.name = file.file_name; this.params.name = file.name;
yield get.call(this); yield get.call(this);
return; return;
} }

View file

@ -2,6 +2,7 @@ import {
authenticate, setupSession, signup as signupUser, activateUser, sendResetToken, authenticate, setupSession, signup as signupUser, activateUser, sendResetToken,
validateResetToken, updatePassword, validateResetToken, updatePassword,
} from '../lib/auth'; } from '../lib/auth';
import models from '../../models';
import debugname from 'debug'; import debugname from 'debug';
const debug = debugname('hostr-web:user'); const debug = debugname('hostr-web:user');
@ -69,8 +70,6 @@ export function* signup() {
export function* forgot() { export function* forgot() {
const Reset = this.db.Reset;
const Users = this.db.Users;
const token = this.params.token; const token = this.params.token;
if (this.request.body.password) { if (this.request.body.password) {
@ -83,16 +82,14 @@ export function* forgot() {
return; return;
} }
this.assertCSRF(this.request.body); this.assertCSRF(this.request.body);
const tokenUser = yield validateResetToken.call(this, token); const user = yield validateResetToken(token);
const userId = tokenUser._id; yield updatePassword(user.id, this.request.body.password);
yield updatePassword.call(this, userId, this.request.body.password); yield models.reset.deleteById(token);
yield Reset.deleteOne({ _id: userId }); yield setupSession(this, user);
const user = yield Users.findOne({ _id: userId });
yield setupSession.call(this, user);
this.statsd.incr('auth.reset.success', 1); this.statsd.incr('auth.reset.success', 1);
this.redirect('/'); this.redirect('/');
} else if (token) { } else if (token) {
const tokenUser = yield validateResetToken.call(this, token); const tokenUser = yield validateResetToken(token);
if (!tokenUser) { if (!tokenUser) {
this.statsd.incr('auth.reset.fail', 1); this.statsd.incr('auth.reset.fail', 1);
yield this.render('forgot', { yield this.render('forgot', {