Initial commit.

This commit is contained in:
Jonathan Cremin 2015-07-09 23:01:43 +01:00
commit b48a4e92e1
169 changed files with 7538 additions and 0 deletions

2
.buildpacks Normal file
View file

@ -0,0 +1,2 @@
https://github.com/mcollina/heroku-buildpack-graphicsmagick.git
https://github.com/kudos/heroku-buildpack-nodejs-jspm.git

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
.git
.env*

14
.eslintrc Normal file
View file

@ -0,0 +1,14 @@
{
"ecmaFeatures": {
"modules": true,
"jsx": true
},
"env": {
"node": true,
"es6": true
},
"rules": {
"quotes": [2, "single"],
"no-underscore-dangle": [0]
}
}

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.env*
.DS_Store
.sass-cache/
node_modules
jspm_packages
/coverage/
npm-debug.log
web/public/build
web/public/styles/*.css
*.gz

1
CHECKS Normal file
View file

@ -0,0 +1 @@
/ No more waiting for files to upload before sharing the links.

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM kudoz/iojs-gm
MAINTAINER Jonathan Cremin <jonathan@crem.in>
WORKDIR /app
COPY . .
RUN npm install && npm rebuild node-sass
RUN npm run build
EXPOSE 4040
CMD npm start

25
LICENSE Normal file
View file

@ -0,0 +1,25 @@
(The MIT License)
Copyright (c) 2015 Jonathan Cremin <jonathan@crem.in>
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
'Software'), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This license applies only to the code and not to the logo, branding or
design.

1
Procfile Normal file
View file

@ -0,0 +1 @@
web: npm start

101
README.md Normal file
View file

@ -0,0 +1,101 @@
# Hostr [![Circle CI](https://circleci.com/gh/kudos/hostr.svg?style=svg&circle-token=1b4dec62afcb7960446edf241a5cf9238b8c20ed)](https://circleci.com/gh/kudos/hostr)
## Getting Started
### Runtimes
Acquire [iojs](https://iojs.org) somehow, using [nvm](https://github.com/creationix/nvm), [n](https://github.com/tj/n). Or if you don't have or want regular node installed globally just use homebrew `brew install iojs && brew link iojs --force`.
### Dependencies
You'll need `graphicsmagick` for image thumbnailing, everything else is taken care of by an `npm install`.
### Databases
You'll need Redis for session and pubsub and MongoDB for persistent storage, `brew install redis mongodb`.
### Configuration
Configuration is all sucked in from the environment.
##### AWS
File are always uploaded to S3, but they can optionally be written do disk and cached locally.
`AWS_ACCESS_KEY_ID` **required**
`AWS_SECRET_ACCESS_KEY` **required**
`AWS_BUCKET` **required**
##### Email
`MANDRILL_KEY` **required**
`EMAIL_FROM` - defaults to `nobody@example.com`
##### Databases
`REDIS_URL` - defaults to `redis://localhost:6379`
`MONGO_URL` - defaults to `mongodb://localhost:27017/hostr`
The database connections default to connecting locally if an env variable isn't found. The following indexes are required.
```js
db.remember.ensureIndex({"created":1}, {expireAfterSeconds: 2592000})
```
```js
db.file.ensureIndex({"owner" : 1, "status" : 1, "time_added" : -1});
```
##### Local cache
`LOCAL_CACHE` - defaults to `false`.
`LOCAL_PATH` - defaults to `~/.hostr/uploads`. if `LOCAL_CACHE` is `true` will store files locally and not just on S3/GCS.
##### SPDY
If you want to use SPDY, add an SSL key and cert.
`LOCALHOST_KEY`
`LOCALHOST_CRT`
##### App
`BASE_URL` - defaults to `https://localhost:4040`
`FILE_HOST` - used by API for absolute file urls, defaults to `$BASE_URL`
`API_URL` - defaults to `/api`
`PORT` - defaults to `4040`.
`VIRUSTOTAL` - API key enables Virustotal integration.
`SENTRY_DSN` - DSN enables Sentry integration.
Additionally, Hostr uses [debug](https://github.com/visionmedia/debug) so you can use the `DEBUG` environment variable something like `DEBUG=hostr*` to get debug output.
### Deploying to Heroku
Because it uses iojs and graphicsmagick runtimes hostr needs an env variable for `BUILDPACK_URL` set to `https://github.com/ddollar/heroku-buildpack-multi.git`.
You'll also need to add Heroku Redis and a MongoDB addon.
## Usage
### Start the app
`npm start` or to live reload `npm run watch`
### Run the tests
`npm test`
## Licence
The code is MIT licenced, the brand is not. This applies to the logo, name and colour scheme.

154
api/app.js Normal file
View file

@ -0,0 +1,154 @@
import spdy from 'spdy';
import koa from 'koa';
import route from 'koa-route';
import websockify from 'koa-websocket';
import logger from 'koa-logger';
import compress from 'koa-compress';
import bodyparser from 'koa-bodyparser';
import cors from 'kcors';
import co from 'co';
import redis from 'redis-url';
import coRedis from 'co-redis';
import raven from 'raven';
import auth from './lib/auth';
import mongoConnect from '../config/mongo';
import * as user from './routes/user';
import * as file from './routes/file';
import debugname from 'debug';
const debug = debugname('hostr-api');
if (process.env.SENTRY_DSN) {
const ravenClient = new raven.Client(process.env.SENTRY_DSN);
ravenClient.patchGlobal();
}
const app = websockify(koa());
const redisUrl = process.env.REDIS_URL || process.env.REDISTOGO_URL || 'redis://localhost:6379';
app.use(logger());
app.use(cors({
origin: '*',
credentials: true
}));
app.use(function* (next){
this.set('Server', 'Nintendo 64');
if(this.req.headers['x-forwarded-proto'] === 'http'){
return this.redirect('https://' + this.req.headers.host + this.req.url);
}
yield next;
});
const redisConn = redis.connect(redisUrl);
let coRedisConn = {};
co(function*() {
coRedisConn = coRedis(redisConn);
coRedisConn.on('error', function (err) {
debug('Redis error ' + err);
});
}).catch(function(err) {
console.error(err);
});
let mongoConnecting = false;
const mongoDeferred = {};
mongoDeferred.promise = new Promise(function(resolve, reject) {
mongoDeferred.resolve = resolve;
mongoDeferred.reject = reject;
});
function* getMongo() {
if (!mongoConnecting) {
mongoConnecting = true;
const db = yield mongoConnect();
mongoDeferred.resolve(db);
return db;
} else {
return mongoDeferred.promise;
}
}
function* setupConnections(next){
this.db = yield getMongo();
this.redis = coRedisConn;
yield next;
}
app.ws.use(setupConnections);
app.use(setupConnections);
app.use(route.get('/', function* (){
this.status = 200;
this.body = '';
}));
app.use(function* (next){
try {
yield next;
if (this.response.status === 404 && !this.response.body) {
this.throw(404);
}
} catch (err) {
if (err.status === 401) {
this.set('WWW-Authenticate', 'Basic');
this.status = 401;
this.body = err.message;
} else if(err.status === 404) {
this.status = 404;
this.body = {
error: {
message: 'File not found',
code: 604
}
};
} else {
if (!err.status) {
debug(err);
throw err;
} else {
this.status = err.status;
this.body = err.message;
}
}
}
this.type = 'application/json';
});
app.use(compress());
app.use(bodyparser());
app.ws.use(route.all('/file/:id', file.events));
app.ws.use(route.all('/user', user.events));
app.use(route.get('/file/:id', file.get));
// Run auth middleware before all other endpoints
app.use(auth);
app.use(route.get('/user', user.get));
app.use(route.get('/user/token', user.token));
app.use(route.get('/user/transaction', user.transaction));
app.use(route.post('/user/settings', user.settings));
app.use(route.get('/file', file.list));
app.use(route.post('/file', file.post));
app.use(route.put('/file/:id', file.put));
app.use(route.delete('/file/:id', file.del));
if (!module.parent) {
if (process.env.LOCALHOST_KEY) {
spdy.createServer({
key: process.env.LOCALHOST_KEY,
cert: process.env.LOCALHOST_CRT
}, app.callback()).listen(4042, function() {
debug('Koa SPDY server listening on port ' + (process.env.PORT || 4042));
});
} else {
app.listen(process.env.PORT || 4042, function() {
debug('Koa HTTP server listening on port ' + (process.env.PORT || 4042));
});
}
}
export default app;

57
api/lib/auth.js Normal file
View file

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

6
api/public/404.json Normal file
View file

@ -0,0 +1,6 @@
{
"error": {
"message": "Invalid API endpoint",
"code": 404
}
}

6
api/public/50x.json Normal file
View file

@ -0,0 +1,6 @@
{
"error": {
"message": "An error occured on the server",
"code": 665
}
}

259
api/routes/file.js Normal file
View file

@ -0,0 +1,259 @@
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import gm from 'gm';
import redis from 'redis-url';
import parse from 'co-busboy';
import { upload as s3Upload } from '../../lib/s3';
import { sniff } from '../../lib/type';
import hostrId from '../../lib/hostr-id';
import malware from '../../lib/malware';
import { formatFile } from '../../lib/format';
import debugname from 'debug';
const debug = debugname('hostr-api:file');
const redisUrl = process.env.REDIS_URL || process.env.REDISTOGO_URL || 'redis://localhost:6379';
const fileHost = process.env.FILE_HOST || 'https://localhost:4040';
const storePath = process.env.STORE_PATH || path.join(process.env.HOME, '.hostr', 'uploads');
export function* post(next) {
if (!this.request.is('multipart/*')) {
return yield next;
}
const Files = this.db.Files;
const expectedSize = this.request.headers['content-length'];
const tempGuid = this.request.headers['hostr-guid'];
const remoteIp = this.request.headers['x-real-ip'] || this.req.connection.remoteAddress;
const md5sum = crypto.createHash('md5');
let lastPercent = 0;
let percentComplete = 0;
let lastTick = 0;
let receivedSize = 0;
// Receive upload
debug('Parsing upload');
const upload = yield parse(this, {autoFields: true, headers: this.request.headers, limits: { files: 1}, highWaterMark: 1000000});
// Check daily upload limit
const count = yield Files.count({owner: this.user.id, 'time_added': {'$gt': Math.ceil(Date.now() / 1000) - 86400}});
const userLimit = this.user.daily_upload_allowance;
const underLimit = (count < userLimit || userLimit === 'unlimited');
this.assert(underLimit, 400, `{
"error": {
"message": "Daily upload limits (${this.user.daily_upload_allowance}) exceeded.",
"code": 602
}
}`);
// Clean filename for storage, keep original for display
upload.originalName = upload.filename;
upload.filename = upload.filename.replace(/[^a-zA-Z0-9\.\-\_\s]/g, '').replace(/\s+/g, '');
const fileId = yield hostrId(Files);
const uploadPromise = new Promise((resolve, reject) => {
upload.on('error', () => {
reject();
});
upload.on('end', () => {
resolve();
});
});
const key = path.join(fileId[0], fileId + '_' + upload.filename);
const localStream = fs.createWriteStream(path.join(storePath, key));
upload.pipe(localStream);
upload.pipe(s3Upload(key));
const thumbsPromises = [
new Promise((resolve, reject) => {
const small = gm(upload).resize(150, 150, '>').stream();
small.pipe(fs.createWriteStream(path.join(storePath, fileId[0], '150', fileId + '_' + upload.filename)));
small.pipe(s3Upload(path.join('150', fileId + '_' + upload.filename))).on('finish', resolve);
}),
new Promise((resolve, reject) => {
const medium = gm(upload).resize(970, '>').stream();
medium.pipe(fs.createWriteStream(path.join(storePath, fileId[0], '970', fileId + '_' + upload.filename)));
medium.pipe(s3Upload(path.join('970', fileId + '_' + upload.filename))).on('finish', resolve);
})
];
let dimensionsPromise = new Promise((resolve, reject) => {
gm(upload).size((err, size) => {
if (err) {
reject(err);
} else {
resolve(size);
}
});
});
upload.on('data', (data) => {
receivedSize += data.length;
if (receivedSize > this.user.max_filesize) {
fs.unlink(path.join(storePath, key));
this.throw(413, '{"error": {"message": "The file you tried to upload is too large.", "code": 601}}');
}
percentComplete = Math.floor(receivedSize * 100 / expectedSize);
if (percentComplete > lastPercent && lastTick < Date.now() - 1000) {
const progressEvent = `{type: 'file-progress', data: {id: ${fileId}, complete: ${percentComplete}}}`;
this.redis.publish('/file/' + fileId, progressEvent);
this.redis.publish('/user/' + this.user.id, progressEvent);
lastTick = Date.now();
}
lastPercent = percentComplete;
md5sum.update(data);
});
// Fire an event to let the frontend map the GUID it sent to the real ID. Allows immediate linking to the file
let acceptedEvent = `{type: 'file-accepted', data: {id: ${fileId}, guid: ${tempGuid}, href: ${fileHost}/${fileId}}}`;
this.redis.publish('/user/' + this.user.id, acceptedEvent);
// Fire final upload progress event so users know it's now processing
const completeEvent = `{type: 'file-progress', data: {id: ${fileId}, complete: 100}}`;
this.redis.publish('/file/' + fileId, completeEvent);
this.redis.publish('/user/' + this.user.id, completeEvent);
const dbFile = {
_id: fileId,
owner: this.user.id,
ip: remoteIp,
'system_name': fileId,
'file_name': upload.filename,
'original_name': upload.originalName,
'file_size': receivedSize,
'time_added': Math.ceil(Date.now() / 1000),
status: 'active',
'last_accessed': null,
s3: false,
type: sniff(upload.filename)
};
yield Files.insertOne(dbFile);
yield uploadPromise;
try {
const dimensions = yield dimensionsPromise;
dbFile.width = dimensions.width;
dbFile.height = dimensions.height;
} catch (e) {
debug('Not an image');
}
yield thumbsPromises;
dbFile.file_size = receivedSize; // eslint-disable-line camelcase
dbFile.status = 'active';
dbFile.md5 = md5sum.digest('hex');
const formattedFile = formatFile(dbFile);
delete dbFile._id;
yield Files.updateOne({_id: fileId}, {$set: dbFile});
// Fire upload complete event
const addedEvent = `{type: 'file-added', data: ${formattedFile}}`;
this.redis.publish('/file/' + fileId, addedEvent);
this.redis.publish('/user/' + this.user.id, addedEvent);
this.status = 201;
this.body = formattedFile;
if (process.env.VIRUSTOTAL) {
// Check in the background
process.nextTick(function*() {
debug('Malware Scan');
const { positive, result } = yield malware(dbFile);
yield Files.updateOne({_id: fileId}, {'$set': {malware: positive, virustotal: result}});
});
} else {
debug('Skipping Malware Scan, VIRUSTOTAL env variable not found.');
}
}
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;
if (this.request.query.perpage === '0') {
limit = false;
} else if(this.request.query.perpage > 0) {
limit = parseInt(this.request.query.perpage / 1);
}
let skip = 0;
if (this.request.query.page) {
skip = parseInt(this.request.query.page - 1) * limit;
}
const queryOptions = {
limit: limit, skip: skip, sort: [['time_added', 'desc']],
hint: {
owner: 1, status: 1, 'time_added': -1
}
};
const userFiles = yield Files.find({owner: this.user.id, status: status}, queryOptions).toArray();
this.body = userFiles.map(formatFile);
}
export function* get(id) {
const Files = this.db.Files;
const Users = this.db.Users;
const file = yield Files.findOne({_id: id, status: {'$in': ['active', 'uploading']}});
this.assert(file, 404, '{"error": {"message": "File not found", "code": 604}}');
const user = yield Users.findOne({_id: file.owner});
this.assert(user && !user.banned, 404, '{"error": {"message": "File not found", "code": 604}}');
this.body = formatFile(file);
}
export function* put(id) {
if (this.request.body.trashed) {
const Files = this.db.Files;
const status = this.request.body.trashed ? 'trashed' : 'active';
yield Files.updateOne({'_id': id, owner: this.user.id}, {$set: {status: status}}, {w: 1});
}
}
export function* del(id) {
const Files = this.db.Files;
yield Files.updateOne({'_id': id, owner: this.user.id}, {$set: {status: 'deleted'}}, {w: 1});
const event = {type: 'file-deleted', data: {'id': id}};
yield this.redis.publish('/user/' + this.user.id, JSON.stringify(event));
yield this.redis.publish('/file/' + id, JSON.stringify(event));
this.body = '';
}
export function* events() {
const pubsub = redis.connect(redisUrl);
pubsub.on('ready', function() {
pubsub.subscribe(this.path);
}.bind(this));
pubsub.on('message', function(channel, message) {
this.websocket.send(message);
}.bind(this));
this.on('close', function() {
pubsub.quit();
});
}

85
api/routes/user.js Normal file
View file

@ -0,0 +1,85 @@
import uuid from 'node-uuid';
import redis from 'redis-url';
import co from 'co';
import passwords from 'passwords';
import debugname from 'debug';
const debug = debugname('hostr-api:file');
const redisUrl = process.env.REDIS_URL || process.env.REDISTOGO_URL || 'redis://localhost:6379';
export function* get (){
this.body = this.user;
}
export function* token(){
const token = uuid.v4(); // eslint-disable-line no-shadow
yield this.redis.set(token, this.user.id, 'EX', 86400);
this.body = {token: token};
}
export function* transaction(){
const Transactions = this.db.Transactions;
const transactions = yield Transactions.find({'user_id': this.user.id}).toArray();
this.body = transactions.map(function(transaction) { // eslint-disable-line no-shadow
const type = transaction.paypal ? 'paypal' : 'direct';
return {
id: transaction._id,
amount: transaction.paypal ? transaction.amount : transaction.amount / 100,
date: transaction.date,
description: transaction.desc,
type: type
};
});
}
export function* settings() {
this.assert(this.request.body, 400, '{"error": {"message": "Current Password required to update account.", "code": 612}}');
this.assert(this.request.body.current_password, 400, '{"error": {"message": "Current Password required to update account.", "code": 612}}');
const Users = this.db.Users;
const user = yield Users.findOne({'_id': this.user.id});
this.assert(yield passwords.match(this.request.body.current_password, user.salted_password), 400, '{"error": {"message": "Incorrect password", "code": 606}}');
const data = {};
if (this.request.body.email && this.request.body.email !== user.email) {
data.email = this.request.body.email;
if (!user.activated_email) {
data.activated_email = user.email; // eslint-disable-line camelcase
}
}
if (this.request.body.new_password) {
this.assert(this.request.body.new_password.length >= 7, 400, '{"error": {"message": "Password must be 7 or more characters long.", "code": 606}}');
data.salted_password = yield passwords.hash(this.request.body.new_password); // eslint-disable-line camelcase
}
Users.updateOne({_id: user._id}, {'$set': data});
this.body = {};
}
export function* events() {
const pubsub = redis.connect(redisUrl);
pubsub.on('message', function(channel, message) {
this.websocket.send(message);
}.bind(this));
pubsub.on('ready', function () {
this.websocket.on('message', co.wrap(function* (message) {
let json;
try{
json = JSON.parse(message);
} catch(e) {
debug('Invalid JSON for socket auth');
this.websocket.send('Invalid authentication message. Bad JSON?');
}
const reply = yield this.redis.get(json.authorization);
if (reply) {
pubsub.subscribe('/user/' + reply);
debug('Subscribed to: /user/%s', reply);
} else {
this.websocket.send('Invalid authentication token.');
}
}));
}.bind(this));
this.on('close', function() {
debug('Socket closed');
pubsub.quit();
});
}

35
app.js Normal file
View file

@ -0,0 +1,35 @@
import koa from 'koa';
import mount from 'koa-mount';
import spdy from 'spdy';
import api from './api/app';
import web from './web/app';
import { init as storageInit } from './lib/storage';
import debugname from 'debug';
const debug = debugname('hostr');
storageInit();
const app = koa();
app.keys = [process.env.KEYS || 'INSECURE'];
app.use(mount('/api', api));
app.use(mount('/', web));
if (!module.parent) {
if (process.env.LOCALHOST_KEY) {
spdy.createServer({
key: process.env.LOCALHOST_KEY,
cert: process.env.LOCALHOST_CRT
}, app.callback()).listen(4040, function() {
debug('Koa SPDY server listening on port ' + (process.env.PORT || 4040));
});
} else {
app.listen(process.env.PORT || 4040, function() {
debug('Koa HTTP server listening on port ' + (process.env.PORT || 4040));
});
}
}
module.exports = app;

24
circle.yml Normal file
View file

@ -0,0 +1,24 @@
machine:
services:
- docker
pre:
- curl https://raw.githubusercontent.com/creationix/nvm/v0.25.4/install.sh | bash
- npm install -g npm@3
node:
version: iojs-2.5.0
test:
pre:
- mongo hostr test/fixtures/mongo-user.js test/fixtures/mongo-file.js
override:
- npm run cover
post:
- docker build -t $CIRCLE_PROJECT_REPONAME:$CIRCLE_BUILD_NUM . && docker save $CIRCLE_PROJECT_REPONAME:$CIRCLE_BUILD_NUM | gzip > $CIRCLE_ARTIFACTS/$CIRCLE_PROJECT_REPONAME-ci-build-$CIRCLE_BUILD_NUM.tar.gz
dependencies:
cache_directories:
- node_modules
- web/public/jspm_packages
post:
- ./node_modules/.bin/jspm config registries.github.auth $JSPM_GITHUB_AUTH_TOKEN
- ./node_modules/.bin/jspm install

21
config/mongo.js Normal file
View file

@ -0,0 +1,21 @@
import mongodb from 'mongodb-promisified';
const MongoClient = mongodb().MongoClient;
import debugname from 'debug';
const debug = debugname('hostr-api:db');
const uristring = process.env.MONGO_URL || process.env.MONGOLAB_URI || 'mongodb://localhost:27017/hostr';
export default function*() {
debug('Connecting to Mongodb');
const client = yield MongoClient.connect(uristring);
debug('Successfully connected to Mongodb');
client.Users = client.collection('users');
client.Files = client.collection('files');
client.Transactions = client.collection('transactions');
client.Logins = client.collection('logins');
client.Remember = client.collection('remember');
client.Reset = client.collection('reset');
client.Remember.ensureIndex({'created': 1}, {expireAfterSeconds: 2592000});
client.Files.ensureIndex({'owner': 1, 'status': 1, 'time_added': -1});
return client;
}

16
docker-compose.yml Normal file
View file

@ -0,0 +1,16 @@
web:
image: kudoz/hostr
env_file: .env-docker
links:
- redis
- mongo
ports:
- 443:4040
redis:
image: redis
ports:
- 6379:6379
mongo:
image: mongo
ports:
- 27017:27017

2
init.js Normal file
View file

@ -0,0 +1,2 @@
import { init as storageInit } from './lib/storage';
storageInit();

52
lib/format.js Normal file
View file

@ -0,0 +1,52 @@
import moment from 'moment';
import { sniff } from './type';
const fileHost = process.env.FILE_HOST || 'http://localhost:4040';
export function formatDate(timestamp) {
return moment.unix(timestamp).format('D MMM YY [at] h:mm A');
}
export function formatSize(size) {
if (size >= 1073741824) {
size = Math.round((size / 1073741824) * 10) / 10 + 'GB';
} else {
if (size >= 1048576) {
size = Math.round((size / 1048576) * 10) / 10 + 'MB';
} else {
if (size >= 1024) {
size = Math.round((size / 1024) * 10) / 10 + 'KB';
} else {
size = Math.round(size) + 'B';
}
}
}
return size;
};
export function formatFile(file) {
const formattedFile = {
added: moment.unix(file.time_added).format(),
readableAdded: formatDate(file.time_added),
downloads: file.downloads !== undefined ? file.downloads : 0,
href: fileHost + '/' + file._id, // eslint-disable-line no-underscore-dangle
id: file._id, // eslint-disable-line no-underscore-dangle
name: file.file_name,
size: file.file_size,
readableSize: formatSize(file.file_size),
type: sniff(file.file_name),
trashed: (file.status === 'trashed'),
status: file.status
};
if (file.width) {
formattedFile.height = file.height;
formattedFile.width = file.width;
const ext = (file.file_name.split('.').pop().toLowerCase() === 'psd' ? '.png' : '');
formattedFile.direct = {
'150x': fileHost + '/file/150/' + file._id + '/' + file.file_name + ext, // eslint-disable-line no-underscore-dangle
'970x': fileHost + '/file/970/' + file._id + '/' + file.file_name + ext // eslint-disable-line no-underscore-dangle
};
}
return formattedFile;
}

36
lib/hostr-file-stream.js Normal file
View file

@ -0,0 +1,36 @@
import fs from 'fs';
import path from 'path';
import createError from 'http-errors';
import { get as getFile } from './s3';
import debugname from 'debug';
const debug = debugname('hostr:file-stream');
export default function* hostrFileStream(localPath, remotePath) {
const localRead = fs.createReadStream(localPath);
return new Promise((resolve, reject) => {
localRead.once('error', () => {
debug('local error');
const remoteRead = getFile(remotePath);
remoteRead.once('readable', () => {
debug('remote readable');
const localWrite = fs.createWriteStream(localPath);
localWrite.once('finish', () => {
debug('local write end');
resolve(fs.createReadStream(localPath));
});
remoteRead.pipe(localWrite);
});
remoteRead.once('error', () => {
debug('remote error');
reject(createError(404));
});
});
localRead.once('readable', () => {
debug('local readable');
resolve(localRead);
});
});
}

26
lib/hostr-id.js Normal file
View file

@ -0,0 +1,26 @@
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
function randomID() {
let rand = '';
for (let i = 0; i < 12; i++) {
rand += chars.charAt(Math.floor((Math.random() * chars.length)));
}
return rand;
}
function* checkId(Files, fileId, attempts) {
if (attempts > 10) {
return false;
}
const file = yield Files.findOne({'_id': fileId});
if(file === null) {
return fileId;
} else {
return checkId(randomID(), attempts++);
}
}
export default function* (Files) {
let attempts = 0;
return yield checkId(Files, randomID(), attempts);
}

75
lib/koa-error.js Normal file
View file

@ -0,0 +1,75 @@
/**
* Module dependencies.
*/
var swig = require('swig');
var http = require('http');
/**
* Expose `error`.
*/
module.exports = error;
/**
* Error middleware.
*
* - `template` defaults to ./error.html
*
* @param {Object} opts
* @api public
*/
function error(opts) {
opts = opts || {};
// template
var path = opts.template || __dirname + '/error.html';
var render = swig.compileFile(path);
// env
var env = process.env.NODE_ENV || 'development';
return function *error(next){
try {
yield next;
if (404 == this.response.status && !this.response.body) this.throw(404);
} catch (err) {
this.status = err.status || 500;
// application
this.app.emit('error', err, this);
// accepted types
switch (this.accepts('html', 'text', 'json')) {
case 'text':
this.type = 'text/plain';
if ('development' == env) this.body = err.message
else if (err.expose) this.body = err.message
else throw err;
break;
case 'json':
this.type = 'application/json';
if ('development' == env) this.body = { error: err.message }
else if (err.expose) this.body = { error: err.message }
else this.body = { error: http.STATUS_CODES[this.status] }
break;
case 'html':
this.type = 'text/html';
this.body = render({
env: env,
ctx: this,
request: this.request,
response: this.response,
error: err.message,
stack: err.stack,
status: this.status,
code: err.code
});
break;
}
}
}
}

38
lib/malware.js Normal file
View file

@ -0,0 +1,38 @@
import virustotal from 'virustotal.js';
virustotal.setKey(process.env.VIRUSTOTAL);
const extensions = ['EXE', 'PIF', 'APPLICATION', 'GADGET', 'MSI', 'MSP', 'COM', 'SCR', 'HTA', 'CPL', 'MSC',
'JAR', 'BAT', 'CMD', 'VB', 'VBS', 'VBE', 'JS', 'JSE', 'WS', 'WSF', 'WSC', 'WSH', 'PS1', 'PS1XML', 'PS2',
'PS2XML', 'PSC1', 'PSC2', 'MSH', 'MSH1', 'MSH2', 'MSHXML', 'MSH1XML', 'MSH2XML', 'SCF', 'LNK', 'INF', 'REG',
'PDF', 'DOC', 'XLS', 'PPT', 'DOCM', 'DOTM', 'XLSM', 'XLTM', 'XLAM', 'PPTM', 'POTM', 'PPAM', 'PPSM', 'SLDM',
'RAR', 'TAR', 'ZIP', 'GZ'
];
function getExtension(filename) {
const i = filename.lastIndexOf('.');
return (i < 0) ? '' : filename.substr(i + 1);
};
export default function (file) {
const deferred = {};
deferred.promise = new Promise(function(resolve, reject) {
deferred.resolve = resolve;
deferred.reject = reject;
});
if (extensions.indexOf(getExtension(file.file_name.toUpperCase())) >= 0) {
virustotal.getFileReport(file.md5, function (err, res) {
if (err) {
return deferred.reject(err);
}
if (res.scans) {
deferred.resolve({positive: res.positives >= 5, result: res});
} else {
deferred.resolve();
}
});
} else {
deferred.resolve();
}
return deferred.promise;
};

10
lib/resize.js Normal file
View file

@ -0,0 +1,10 @@
import debugname from 'debug';
const debug = debugname('hostr-api:resize');
import gm from 'gm';
export default function(input, size) {
debug('Resizing');
const image = gm(input);
return image.resize(size.width, size.height, '>').stream();
}

19
lib/s3.js Normal file
View file

@ -0,0 +1,19 @@
import aws from 'aws-sdk';
import s3UploadStream from 's3-upload-stream';
import debugname from 'debug';
const debug = debugname('hostr:s3');
const bucket = process.env.AWS_BUCKET || 'hostrdotcodev';
const s3 = new aws.S3();
const s3Stream = s3UploadStream(s3);
export function get(key) {
debug('fetching file: %s', 'hostr_files/' + key);
return s3.getObject({Bucket: bucket, Key: 'hostr_files/' + key}).createReadStream();
}
export function upload(key, body) {
debug('Uploading file: %s', 'hostr_files/' + key);
return s3Stream.upload({Bucket: bucket, Key: 'hostr_files/' + key});
}

24
lib/storage.js Normal file
View file

@ -0,0 +1,24 @@
import fs from 'fs';
import path from 'path';
function range(start,stop) {
var result=[];
for (var idx=start.charCodeAt(0),end=stop.charCodeAt(0); idx <=end; ++idx){
result.push(String.fromCharCode(idx));
}
return result;
};
const storePath = process.env.FILE_PATH || path.join(process.env.HOME, '.hostr', 'uploads');
const directories = range('A', 'Z').concat(range('a', 'z'), range('0', '9'));
export function init() {
directories.forEach((directory) => {
if (!fs.existsSync(path.join(storePath, directory))) {
fs.mkdirSync(path.join(storePath, directory));
fs.mkdirSync(path.join(storePath, directory, '150'));
fs.mkdirSync(path.join(storePath, directory, '970'));
}
});
}

34
lib/type.js Normal file
View file

@ -0,0 +1,34 @@
const extensions = {
'jpg': 'image',
'jpeg': 'image',
'png': 'image',
'gif': 'image',
'bmp': 'image',
'tiff': 'image',
'psd': 'image',
'mp3': 'audio',
'm4a': 'audio',
'ogg': 'audio',
'flac': 'audio',
'aac': 'audio',
'mpg': 'video',
'mkv': 'video',
'avi': 'video',
'divx': 'video',
'mpeg': 'video',
'flv': 'video',
'mp4': 'video',
'mov': 'video',
'zip': 'archive',
'gz': 'archive',
'tgz': 'archive',
'bz2': 'archive',
'rar': 'archive'
};
export function sniff(filename) {
if (extensions[filename.split('.').pop().toLowerCase()]) {
return extensions[filename.split('.').pop().toLowerCase()];
}
return 'other';
}

98
package.json Normal file
View file

@ -0,0 +1,98 @@
{
"name": "hostr",
"description": "Hostr - simple sharing",
"repository": "https://github.com/kudos/hostr-web",
"version": "0.0.0",
"private": true,
"engines": {
"iojs": "^2.5.0",
"npm": "^3.2.0"
},
"scripts": {
"build": "npm run build-js && npm run build-sass",
"build-js": "babel -D -m system -d web/public/build web/public/src",
"build-sass": "node-sass -r -o web/public/styles/ web/public/styles/",
"cover": "istanbul cover _mocha -- --require babel/register test/**/*.spec.js",
"init": "node --require babel/register init.js",
"jspm": "jspm install",
"start": "npm run build && node -r 'babel/register' app.js",
"test": "mongo hostr test/fixtures/mongo-user.js test/fixtures/mongo-file.js && mocha -r babel/register test/api test/web",
"watch": "nodemon -x \"node -r 'babel/register'\" -i web/public/ app.js",
"watch-js": "babel -D -w -m system -d web/public/build web/public/src",
"watch-sass": "node-sass -w -r -o web/public/styles/ web/public/styles/"
},
"dependencies": {
"aws-sdk": "~2.1.42",
"babel": "~5.8.20",
"basic-auth": "~1.0.3",
"co": "~4.6.0",
"co-busboy": "~1.3.0",
"co-redis": "~1.2.1",
"co-views": "~1.0.0",
"debug": "~2.2.0",
"ejs": "~2.3.2",
"gm": "~1.18.1",
"http-errors": "^1.3.1",
"jspm": "~0.16.0-beta.3",
"kcors": "~1.0.1",
"koa": "~0.21.0",
"koa-bodyparser": "~2.0.0",
"koa-compress": "~1.0.8",
"koa-favicon": "~1.2.0",
"koa-file-server": "~2.3.1",
"koa-generic-session": "~1.9.0",
"koa-logger": "~1.3.0",
"koa-mount": "~1.3.0",
"koa-redis": "~1.0.0",
"koa-route": "~2.4.2",
"koa-views": "~3.1.0",
"koa-websocket": "~1.0.0",
"mandrill-api": "~1.0.45",
"mime-types": "~2.1.4",
"mkdirp": "~0.5.1",
"moment": "~2.10.6",
"mongodb-promisified": "~1.0.3",
"node-sass": "~3.2.0",
"node-uuid": "~1.4.3",
"passwords": "~1.3.0",
"pretty-error": "^1.1.2",
"raven": "~0.8.1",
"redis": "0.12.1",
"redis-url": "~1.2.1",
"s3-upload-stream": "^1.0.7",
"spdy": "~1.32.4",
"stripe": "~3.6.0",
"supertest": "~1.0.1",
"swig": "^1.4.2",
"virustotal.js": "~0.3.1"
},
"devDependencies": {
"eslint": "~1.0.0",
"istanbul": "^0.3.17",
"mocha": "~2.2.5",
"nodemon": "~1.4.0",
"tmp": "0.0.26"
},
"jspm": {
"directories": {
"baseURL": "web/public"
},
"dependencies": {
"angular": "npm:angular@~1.4.3",
"angular-reconnecting-websocket": "github:adieu/angular-reconnecting-websocket@~0.1.1",
"angular-strap": "npm:angular-strap@~2.3.1",
"angular/resource": "npm:angular-resource@~1.4.3",
"angular/route": "npm:angular-route@~1.4.3",
"bootstrap-sass": "npm:bootstrap-sass@~3.3.5",
"cferdinandi/smooth-scroll": "github:cferdinandi/smooth-scroll@~5.3.7",
"dropzone": "npm:dropzone@~4.0.1",
"jquery": "npm:jquery@~2.1.4",
"zeroclipboard": "npm:zeroclipboard@~2.2.0"
},
"devDependencies": {
"babel": "npm:babel-core@^5.8.5",
"babel-runtime": "npm:babel-runtime@^5.8.5",
"core-js": "npm:core-js@~1.0.0"
}
}
}

6
test/.eslintrc Normal file
View file

@ -0,0 +1,6 @@
{
"env": {
"mocha": true,
"es6": true
}
}

33
test/api/auth.spec.js Normal file
View file

@ -0,0 +1,33 @@
import { agent } from 'supertest';
import app from '../../api/app';
const request = agent(app.listen());
describe('hostr-api auth', function(){
describe('with no credentials', function(){
it('should `throw` 401', function(done){
request
.get('/user')
.expect(401, done);
});
});
describe('with invalid credentials', function(){
it('should `throw` 401', function(done){
request
.get('/user')
.auth('user', 'invalid password')
.expect(401, done);
});
});
describe('with valid credentials', function(){
it('should call the next middleware', function(done){
request
.get('/')
.auth('test@hostr.co', 'test-password')
.expect(200, done);
});
});
});

68
test/api/file.spec.js Normal file
View file

@ -0,0 +1,68 @@
import assert from 'assert';
import { agent } from 'supertest';
import app from '../../api/app';
const request = agent(app.listen());
describe('hostr-api file', function() {
let id;
describe('when GET /file', function() {
it('should receive a list of file objects', function(done) {
request
.get('/file')
.auth('test@hostr.co', 'test-password')
.expect(200)
.expect(function(response) {
assert(response.body instanceof Array);
})
.end(done);
});
});
describe('when POSTing a file to /file', function() {
it('should receive a new file object', function(done) {
this.timeout(30000);
request
.post('/file')
.attach('file', 'test/fixtures/utah-arches.jpg')
.auth('test@hostr.co', 'test-password')
.expect(201)
.expect(function(response) {
assert(response.body.name === 'utah-arches.jpg');
id = response.body.id;
})
.end(done);
});
});
describe('when GET /file/:id', function() {
it('should receive the file object', function(done) {
request
.get('/file/' + id)
.expect(200)
.expect(function(response) {
assert(response.body.name === 'utah-arches.jpg');
})
.end(done);
});
});
describe('when DELETE /file/:id', function() {
it('should delete the file object', function(done) {
request
.delete('/file/' + id)
.auth('test@hostr.co', 'test-password')
.expect(200, done);
});
});
describe('when GET deleted /file/:id', function() {
it('should not receive the file object', function(done) {
request
.get('/file/' + id)
.expect(404, done);
});
});
});

62
test/api/user.spec.js Normal file
View file

@ -0,0 +1,62 @@
import assert from 'assert';
import { agent } from 'supertest';
import app from '../../api/app';
const request = agent(app.listen());
describe('hostr-api user', function() {
describe('when GET /user', function() {
it('should receive a user object', function(done) {
request
.get('/user')
.auth('test@hostr.co', 'test-password')
.expect(function(response) {
assert(response.body.id === '54fd04a37675bcd06213eac8');
})
.expect(200)
.end(done);
});
});
describe('when GET /user/token', function() {
it('should receive a user token object', function(done) {
request
.get('/user/token')
.auth('test@hostr.co', 'test-password')
.expect(function(response) {
assert(response.body.token);
})
.expect(200)
.end(done);
});
});
describe('when GET /user/transaction', function() {
it('should receive a user transactions object', function(done) {
request
.get('/user/transaction')
.auth('test@hostr.co', 'test-password')
.expect(200)
.expect(function(response) {
assert(response.body instanceof Array);
})
.end(done);
});
});
describe('when GET /user/settings', function() {
it('should update user password', function(done) {
request
.post('/user/settings')
.send({'current_password': 'test-password', 'new_password': 'test-password' })
.auth('test@hostr.co', 'test-password')
.expect(200)
.expect(function(response) {
assert(response.body instanceof Object);
})
.end(done);
});
});
});

21
test/fixtures/mongo-file.js vendored Normal file
View file

@ -0,0 +1,21 @@
db.files.createIndex({
"owner": 1,
"status": 1,
"time_added": -1
});
db.files.save({"_id": "94U1ruo7anyQ",
"owner": ObjectId("54fd04a37675bcd06213eac8"),
"system_name": "94U1ruo7anyQ",
"file_name": "utah-arches.jpg",
"original_name": "utah-arches.jpg",
"file_size": 194544,
"time_added": 1436223854,
"status": "active",
"last_accessed": null,
"s3": true,
"type": "image",
"ip": "::1",
"md5": "1f4185751b4db05494cbc0aad68d7d77",
"width": 1024,
"height": 683
});

7
test/fixtures/mongo-user.js vendored Normal file
View file

@ -0,0 +1,7 @@
db.users.save({
"_id": ObjectId("54fd04a37675bcd06213eac8"),
"email": "test@hostr.co",
"salted_password": "$pbkdf2-256-1$2$kBhIDRqFwnF/1ms6ZHfME2o2$a48e8c350d26397fcc88bf0a7a2817b1cdcd1ffffe0521a5",
"joined": 1425867940,
"signup_ip": "127.0.0.1"
});

BIN
test/fixtures/utah-arches.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

View file

@ -0,0 +1,16 @@
import fs from 'fs';
import path from 'path';
import assert from 'assert';
import tmp from 'tmp';
import resize from '../../lib/resize';
const file = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'utah-arches.jpg'));
describe('Image resizing', function() {
it('should resize an image', function*() {
const imageBuffer = yield resize(file, {height: 100, width: 100});
const tmpFile = tmp.tmpNameSync();
fs.writeFileSync(tmpFile + '.jpg', imageBuffer);
assert(tmpFile);
});
});

102
test/web/file.spec.js Normal file
View file

@ -0,0 +1,102 @@
import web from '../../web/app';
import api from '../../api/app';
import assert from 'assert';
import { agent } from 'supertest';
const request = agent(web.listen());
const apiRequest = agent(api.listen());
let file = {};
describe('setup hostr-web file', function() {
describe('when POSTing a file to /file', function() {
it('should receive a new file object', function(done) {
this.timeout(30000);
apiRequest
.post('/file')
.attach('file', 'test/fixtures/utah-arches.jpg')
.auth('test@hostr.co', 'test-password')
.expect(201)
.expect(function(response) {
assert(response.body.name === 'utah-arches.jpg');
file = response.body;
})
.end(done);
});
});
});
describe('hostr-web file', function() {
describe('when GET /file/:id/:name', function() {
it('should receive an image', function(done) {
request
.get('/file/' + file.id + '/' + file.name)
.expect(200)
.expect('Content-type', 'image/jpeg')
.expect(function(response) {
assert(response.body.length === 194544);
})
.end(done);
});
});
describe('when GET /file/150/:id/:name', function() {
it('should receive a 150px wide thumbnail of the image', function(done) {
request
.get('/file/150/' + file.id + '/' + file.name)
.expect(200)
.expect('Content-type', 'image/jpeg')
.expect(function(response) {
assert(response.body.length === 3658);
})
.end(done);
});
});
describe('when GET /file/970/:id/:name', function() {
it('should receive a 970px wide thumbnail of the image', function(done) {
request
.get('/file/970/' + file.id + '/' + file.name)
.expect(200)
.expect('Content-type', 'image/jpeg')
.expect(function(response) {
assert(response.body.length === 79091);
})
.end(done);
});
});
describe('when GET /:id', function() {
it('should receive some HTML', function(done) {
request
.get('/' + file.id)
.expect(200)
.expect('Content-type', /text\/html/) // Could include charset
.expect(function(response) {
assert(response.text.indexOf('src="/file/970/' + file.id + '/' + file.name + '"') > -1);
})
.end(done);
});
});
describe('when GET /file/:badid/:name', function() {
it('should receive 404 and some HTML', function(done) {
request
.get('/notarealid')
.expect(404)
.expect('Content-type', /text\/html/) // Could include charset
.end(done);
});
});
describe('when GET /:bad-id', function() {
it('should receive 404 and some HTML', function(done) {
request
.get('/file/notarealid/orname')
.expect(404)
.expect('Content-type', /text\/html/) // Could include charset
.end(done);
});
});
});

26
test/web/user.spec.js Normal file
View file

@ -0,0 +1,26 @@
import app from '../../web/app';
import { agent } from 'supertest';
const request = agent(app.listen());
describe('hostr-web user', function() {
describe('when POST /signin with invalid credentials', function() {
it('should not redirect to /', function(done) {
request
.post('/signin')
.send({'email': 'test@hostr.co', 'password': 'test-passworddeded'})
.expect(200, done);
});
});
describe('when POST /signin with valid credentials', function() {
it('should redirect to /', function(done) {
request
.post('/signin')
.send({'email': 'test@hostr.co', 'password': 'test-password'})
.expect(302)
.expect('Location', '/')
.end(done);
});
});
});

170
web/app.js Normal file
View file

@ -0,0 +1,170 @@
import path from 'path';
import spdy from 'spdy';
import koa from 'koa';
import route from 'koa-route';
import views from 'koa-views';
import logger from 'koa-logger';
import favicon from 'koa-favicon';
import redisStore from 'koa-redis';
import compress from 'koa-compress';
import bodyparser from 'koa-bodyparser';
import session from 'koa-generic-session';
import staticHandler from 'koa-file-server';
import co from 'co';
import redis from 'redis-url';
import coRedis from 'co-redis';
import raven from 'raven';
// waiting for PR to be merged, can remove swig dependency when done
import errors from '../lib/koa-error';
import mongoConnect from '../config/mongo';
import * as index from './routes/index';
import * as file from './routes/file';
import * as pro from './routes/pro';
import * as user from './routes/user';
import mongodb from 'mongodb-promisified';
const objectId = mongodb().ObjectId;
import debugname from 'debug';
const debug = debugname('hostr-web');
if (process.env.SENTRY_DSN) {
const ravenClient = new raven.Client(process.env.SENTRY_DSN);
ravenClient.patchGlobal();
}
const redisUrl = process.env.REDIS_URL || process.env.REDISTOGO_URL || 'redis://localhost:6379';
const app = koa();
app.use(errors({template: path.join(__dirname, 'public', '404.html')}));
app.use(function*(next){
this.set('Server', 'Nintendo 64');
if(this.req.headers['x-forwarded-proto'] === 'http'){
return this.redirect('https://' + this.request.headers.host + this.request.url);
}
yield next;
});
app.use(function*(next){
this.state = {
apiURL: process.env.API_URL,
baseURL: process.env.BASE_URL,
stripePublic: process.env.STRIPE_PUBLIC_KEY
};
yield next;
});
const redisConn = redis.connect(redisUrl);
let coRedisConn = {};
co(function*() {
coRedisConn = coRedis(redisConn);
coRedisConn.on('error', function (err) {
debug('Redis error ' + err);
});
}).catch(function(err) {
debug(err);
});
let mongoConnecting = false;
const mongoDeferred = {};
mongoDeferred.promise = new Promise(function(resolve, reject) {
mongoDeferred.resolve = resolve;
mongoDeferred.reject = reject;
});
function* getMongo() {
if (!mongoConnecting) {
mongoConnecting = true;
const db = yield mongoConnect();
mongoDeferred.resolve(db);
return db;
} else {
return mongoDeferred.promise;
}
}
app.use(compress());
app.use(bodyparser());
app.use(favicon(path.join(__dirname, 'public/images/favicon.png')));
app.use(staticHandler({root: path.join(__dirname, 'public'), maxage: 31536000000}));
app.use(logger());
app.use(views('views', {
default: 'ejs'
}));
app.use(function* setupConnections(next){
this.db = yield getMongo();
this.redis = coRedisConn;
yield next;
});
app.keys = [process.env.KEYS || 'INSECURE'];
app.use(session({
store: redisStore({client: redisConn})
}));
app.use(function* objectIdSession(next) {
if (this.session.user) {
this.session.user.id = objectId(this.session.user.id);
}
yield next;
});
app.use(route.get('/', index.main));
app.use(route.get('/account', index.main));
app.use(route.get('/billing', index.main));
app.use(route.get('/pro', index.main));
app.use(route.get('/signin', user.signin));
app.use(route.post('/signin', user.signin));
app.use(route.get('/signup', user.signup));
app.use(route.post('/signup', user.signup));
app.use(route.get('/logout', user.logout));
app.use(route.post('/logout', user.logout));
app.use(route.get('/forgot', user.forgot));
app.use(route.get('/forgot/:token', user.forgot));
app.use(route.post('/forgot/:token', user.forgot));
app.use(route.post('/forgot', user.forgot));
app.use(route.get('/activate/:code', user.activate));
app.use(route.get('/terms', index.staticPage));
app.use(route.get('/privacy', index.staticPage));
app.use(route.get('/pricing', index.staticPage));
app.use(route.get('/apps', index.staticPage));
app.use(route.get('/stats', index.staticPage));
app.use(route.post('/pro/create', pro.create));
app.use(route.post('/pro/cancel', pro.cancel));
app.use(route.get('/:id', file.landing));
app.use(route.get('/download/:id/:name', function(id) {
this.redirect('/' + id);
}));
app.use(route.get('/file/:id/:name', file.get));
app.use(route.get('/files/:id/:name', file.get));
app.use(route.get('/file/:size/:id/:name', file.resized));
app.use(route.get('/updaters/mac', function() {
this.redirect('/updaters/mac.xml');
}));
app.use(route.get('/updaters/mac/changelog', function() {
this.render('mac-update-changelog');
}));
if (!module.parent) {
if (process.env.LOCALHOST_KEY) {
spdy.createServer({
key: process.env.LOCALHOST_KEY,
cert: process.env.LOCALHOST_CRT
}, app.callback()).listen(4041, function() {
debug('Koa SPDY server listening on port ' + (process.env.PORT || 4041));
});
} else {
app.listen(process.env.PORT || 4041, function() {
debug('Koa HTTP server listening on port ' + (process.env.PORT || 4041));
});
}
}
export default app;

193
web/lib/auth.js Normal file
View file

@ -0,0 +1,193 @@
import crypto from 'crypto';
import passwords from 'passwords';
import uuid from 'node-uuid';
import views from 'co-views';
const render = views('views', { default: 'ejs'});
import debugname from 'debug';
const debug = debugname('hostr-web:auth');
import { Mandrill } from 'mandrill-api/mandrill';
const mandrill = new Mandrill(process.env.MANDRILL_KEY);
export function* authenticate(ctx, email, password) {
const Users = ctx.db.Users;
const Logins = ctx.db.Logins;
const remoteIp = ctx.headers['x-real-ip'] || ctx.ip;
if (!password || password.length < 6){
debug('No password, or password too short');
return new Error('Invalid login details');
}
const count = yield Logins.count({ip: remoteIp, successful: false, at: { '$gt': Math.ceil(Date.now() / 1000) - 600}});
if (count > 25) {
debug('Throttling brute force');
return new Error('Invalid login details');
}
const login = {ip: remoteIp, at: Math.ceil(Date.now() / 1000), successful: null};
yield Logins.save(login);
const user = yield Users.findOne({email: email.toLowerCase(), banned: {'$exists': false}, status: {'$ne': 'deleted'}});
if (user) {
const verified = yield passwords.verify(password, user.salted_password);
if (verified) {
debug('Password verified');
login.successful = true;
yield Logins.updateOne({_id: login._id}, login);
return user;
} else {
debug('Password invalid');
login.successful = false;
yield Logins.updateOne({_id: login._id}, login);
}
} else {
debug('Email invalid');
login.successful = false;
yield Logins.updateOne({_id: login._id}, login);
}
}
export function* setupSession(ctx, user) {
debug('Setting up session');
const token = uuid.v4();
yield ctx.redis.set(token, user._id, 'EX', 604800);
const sessionUser = {
'id': user._id,
'email': user.email,
'dailyUploadAllowance': 15,
'maxFileSize': 20971520,
'joined': user.joined,
'plan': user.type || 'Free',
'uploadsToday': 0,
'token': token,
'md5': crypto.createHash('md5').update(user.email).digest('hex')
};
if (sessionUser.plan === 'Pro') {
sessionUser.maxFileSize = 524288000;
sessionUser.dailyUploadAllowance = 'unlimited';
}
ctx.session.user = sessionUser;
if (ctx.request.body.remember && ctx.request.body.remember === 'on') {
const Remember = ctx.db.Remember;
var rememberToken = uuid();
Remember.save({_id: rememberToken, 'user_id': user.id, created: new Date().getTime()});
ctx.cookies.set('r', rememberToken, { maxAge: 1209600000, httpOnly: true});
}
debug('Session set up');
}
export function* signup(ctx, email, password, ip) {
const Users = ctx.db.Users;
const existingUser = yield Users.findOne({email: email, status: {'$ne': 'deleted'}});
if (existingUser) {
debug('Email already in use.');
return 'Email already in use.';
}
const cryptedPassword = yield passwords.crypt(password);
var user = {
email: email,
'salted_password': cryptedPassword,
joined: Math.round(new Date().getTime() / 1000),
'signup_ip': ip,
activationCode: uuid()
};
Users.insertOne(user);
const html = yield render('email/inlined/activate', {activationUrl: process.env.BASE_URL + '/activate/' + user.activationCode});
const text = `Thanks for signing up to Hostr!
Please confirm your email address by clicking the link below.
${process.env.BASE_URL + '/activate/' + user.activationCode}
Jonathan Cremin, Hostr Founder
`;
mandrill.messages.send({message: {
html: html,
text: text,
subject: 'Welcome to Hostr',
'from_email': 'jonathan@hostr.co',
'from_name': 'Jonathan from Hostr',
to: [{
email: user.email,
type: 'to'
}],
'tags': [
'user-activation'
]
}});
}
export function* sendResetToken(ctx, email) {
const Users = ctx.db.Users;
const Reset = ctx.db.Reset;
const user = yield Users.findOne({email: email});
if (user) {
var token = uuid.v4();
Reset.save({
'_id': user._id,
'token': token,
'created': Math.round(new Date().getTime() / 1000)
});
const html = yield this.render('email/inlined/forgot', {forgotUrl: this.locals.baseUrl + '/forgot/' + token});
const text = `It seems you've forgotten your password :(
Visit ${ctx.locals.baseUrl + '/forgot/' + token} to set a new one.
`;
mandrill.messages.send({message: {
html: html,
text: text,
subject: 'Hostr Password Reset',
'from_email': 'jonathan@hostr.co',
'from_name': 'Jonathan from Hostr',
to: [{
email: user.email,
type: 'to'
}],
'tags': [
'password-reset'
]
}});
} else {
return 'There was an error looking up your email address.';
}
}
export function* fromToken(ctx, token) {
const Users = ctx.db.Users;
const reply = yield ctx.redis.get(token);
return yield Users.findOne({_id: reply});
}
export function* fromCookie(ctx, cookie) {
const Remember = ctx.db.Remember;
const Users = ctx.db.Users;
const remember = yield Remember.findOne({_id: cookie});
return yield Users.findOne({_id: remember.user_id});
}
export function* validateResetToken(ctx) {
const Reset = ctx.db.Reset;
return yield Reset.findOne({token: ctx.params.id});
}
export function* updatePassword(ctx, userId, password) {
const Users = ctx.db.Users;
const cryptedPassword = yield passwords.crypt(password);
yield Users.update({_id: userId}, {'$set': {'salted_password': cryptedPassword}});
}
export function* activateUser(ctx, code) {
const Users = ctx.db.Users;
const user = yield Users.findOne({activationCode: code});
if (user) {
Users.updateOne({_id: user._id}, {'$unset': {activationCode: ''}});
yield setupSession(ctx, user);
}
}

5
web/public/.eslintrc Normal file
View file

@ -0,0 +1,5 @@
{
"env": {
"browser": true
}
}

46
web/public/404.html Normal file
View file

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title ng-bind="Hostr">Hostr - File not found</title>
<link rel="icon" type="image/png" href="/images/favicon.png">
<link href='//fonts.googleapis.com/css?family=Lato:300,400' rel='stylesheet' type='text/css'>
<link href='//fonts.googleapis.com/css?family=Open+Sans:400,300,600' rel='stylesheet' type='text/css'>
<link href="/styles/app.css" rel="stylesheet" />
</head>
<body>
<section class="container header clearfix">
<div class="row">
<div class="col-md-8 col-md-offset-2" style='text-align: center;'>
<div class="logo">
<a href="/"><img src="/images/logo-dark-r.png" height="22" width="26" alt=""></a>
</div>
</div>
</div>
</section>
<section class="jumbotron error-page">
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<h1>404</h1>
<h2>We can't find the file you're looking for :(</h2>
<p class="lead">The owner may have removed it or it may never have existed in the first place.</p>
<a href="/">Try our homepage instead :)</a>
</div>
</div>
</div>
</section>
<script>
var _gaq=[['_setAccount','UA-66209-2'],['_setDomainName', 'hostr.co'],['_trackPageview']];
(function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src='//www.google-analytics.com/ga.js';
s.parentNode.insertBefore(g,s)}(document,'script'));
</script>
</body>
</html>

44
web/public/50x.html Normal file
View file

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title ng-bind="Hostr">Hostr - File not found</title>
<link rel="icon" type="image/png" href="/images/favicon.png">
<link href='//fonts.googleapis.com/css?family=Lato:300,400' rel='stylesheet' type='text/css'>
<link href='//fonts.googleapis.com/css?family=Open+Sans:400,300,600' rel='stylesheet' type='text/css'>
<link href="/styles/app.css" rel="stylesheet" />
</head>
<body>
<section class="container header clearfix">
<div class="row">
<div class="col-md-8 col-md-offset-2" style='text-align: center;'>
<div class="logo">
<a href="/"><img src="/images/logo-dark-r.png" height="22" width="26" alt=""></a>
</div>
</div>
</div>
</section>
<section class="jumbotron error-page">
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<h1>Oops!</h1>
<h2>It looks like you've hit an unexpected error :(</h2>
<p class="lead">Refreshing might fix the problem. If not, sit tight! We're on it!</p>
</div>
</div>
</div>
</section>
<script>
var _gaq=[['_setAccount','UA-66209-2'],['_setDomainName', 'hostr.co'],['_trackPageview']];
(function(d,t){var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src='//www.google-analytics.com/ga.js';
s.parentNode.insertBefore(g,s)}(document,'script'));
</script>
</body>
</html>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/mstile-70x70.png"/>
<square150x150logo src="/mstile-150x150.png"/>
<square310x310logo src="/mstile-310x310.png"/>
<wide310x150logo src="/mstile-310x150.png"/>
<TileColor>#00aba9</TileColor>
</tile>
</msapplication>
</browserconfig>

78
web/public/config.js Normal file
View file

@ -0,0 +1,78 @@
System.config({
"defaultJSExtensions": true,
"transpiler": "babel",
"babelOptions": {
"optional": [
"runtime"
]
},
"paths": {
"github:*": "jspm_packages/github/*",
"npm:*": "jspm_packages/npm/*"
}
});
System.config({
"map": {
"angular": "npm:angular@1.4.3",
"angular-reconnecting-websocket": "github:adieu/angular-reconnecting-websocket@0.1.1",
"angular-strap": "npm:angular-strap@2.1.2",
"angular/resource": "npm:angular-resource@1.4.3",
"angular/route": "npm:angular-route@1.4.3",
"babel": "npm:babel-core@5.8.5",
"babel-runtime": "npm:babel-runtime@5.8.5",
"bootstrap-sass": "npm:bootstrap-sass@3.3.5",
"cferdinandi/smooth-scroll": "github:cferdinandi/smooth-scroll@5.3.7",
"core-js": "npm:core-js@0.9.18",
"dropzone": "npm:dropzone@4.0.1",
"jquery": "npm:jquery@2.1.4",
"zeroclipboard": "npm:zeroclipboard@2.2.0",
"github:jspm/nodelibs-path@0.1.0": {
"path-browserify": "npm:path-browserify@0.0.0"
},
"github:jspm/nodelibs-process@0.1.1": {
"process": "npm:process@0.10.1"
},
"github:jspm/nodelibs-util@0.1.0": {
"util": "npm:util@0.10.3"
},
"npm:angular-strap@2.1.2": {
"fs": "github:jspm/nodelibs-fs@0.1.2",
"path": "github:jspm/nodelibs-path@0.1.0",
"process": "github:jspm/nodelibs-process@0.1.1",
"systemjs-json": "github:systemjs/plugin-json@0.1.0",
"util": "github:jspm/nodelibs-util@0.1.0"
},
"npm:angular@1.4.3": {
"process": "github:jspm/nodelibs-process@0.1.1"
},
"npm:babel-runtime@5.8.5": {
"process": "github:jspm/nodelibs-process@0.1.1"
},
"npm:core-js@0.9.18": {
"fs": "github:jspm/nodelibs-fs@0.1.2",
"process": "github:jspm/nodelibs-process@0.1.1",
"systemjs-json": "github:systemjs/plugin-json@0.1.0"
},
"npm:dropzone@4.0.1": {
"process": "github:jspm/nodelibs-process@0.1.1"
},
"npm:inherits@2.0.1": {
"util": "github:jspm/nodelibs-util@0.1.0"
},
"npm:jquery@2.1.4": {
"process": "github:jspm/nodelibs-process@0.1.1"
},
"npm:path-browserify@0.0.0": {
"process": "github:jspm/nodelibs-process@0.1.1"
},
"npm:util@0.10.3": {
"inherits": "npm:inherits@2.0.1",
"process": "github:jspm/nodelibs-process@0.1.1"
},
"npm:zeroclipboard@2.2.0": {
"process": "github:jspm/nodelibs-process@0.1.1"
}
}
});

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 849 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
web/public/images/apple.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 10 10" enable-background="new 0 0 10 10" xml:space="preserve" height="10" width="10">
<path fill="#c3c3d1" d="M5,5.1L1.9,1.9C1.7,1.8,1.5,1.7,1.3,1.7S0.9,1.8,0.7,1.9L0.2,2.4C0.1,2.6,0,2.8,0,3c0,0.2,0.1,0.4,0.2,0.6l4.2,4.2
C4.6,7.9,4.8,8,5,8s0.4-0.1,0.6-0.2l4.2-4.2C9.9,3.4,10,3.2,10,3c0-0.2-0.1-0.4-0.2-0.6L9.3,1.9C9.1,1.8,8.9,1.7,8.7,1.7
c-0.2,0-0.4,0.1-0.6,0.2L5,5.1z"/>
</svg>

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" data-icon="cloud-transfer-upload" width="16" height="16" data-container-transform="scale(1 1 ) translate(0 )" viewBox="0 0 16 16">
<path fill="#c3c3d1" d="M8 0c-2.5 0-4.506 1.794-4.906 4.094-1.8.4-3.094 2.006-3.094 3.906 0 2.2 1.8 4 4 4l4-4 4 4h1c1.7 0 3-1.3 3-3s-1.3-3-3-3v-1c0-2.8-2.2-5-5-5zm0 10l-3 3h2v2a1 1 0 1 0 2 0v-2h2l-3-3z" />
</svg>

After

Width:  |  Height:  |  Size: 400 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1"
id="svg3001" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:dc="http://purl.org/dc/elements/1.1/" inkscape:version="0.48.3.1 r9886" sodipodi:docname="cloud_upload_font_awesome.svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16"
enable-background="new 0 0 16 16" xml:space="preserve" height="16" width="16">
<sodipodi:namedview inkscape:cx="960" inkscape:cy="896" gridtolerance="10" borderopacity="1" id="namedview3007" bordercolor="#666666" objecttolerance="10" inkscape:zoom="0.13169643" showgrid="false" guidetolerance="10" pagecolor="#ffffff" inkscape:current-layer="svg3001" inkscape:window-maximized="0" inkscape:window-y="25" inkscape:window-x="0" inkscape:window-height="16" inkscape:window-width="16" inkscape:pageopacity="0" inkscape:pageshadow="2">
</sodipodi:namedview>
<g id="g3003" transform="matrix(1,0,0,-1,22.779661,1428.2373)">
<path fill="#c3c3d1" id="path3005" inkscape:connector-curvature="0" d="M-12.1,1420c0,0.1,0,0.1-0.1,0.2l-2.9,2.9c-0.1,0-0.1,0.1-0.2,0.1
s-0.1,0-0.2-0.1l-2.9-2.9c-0.1-0.1-0.1-0.1-0.1-0.2s0-0.1,0.1-0.2c0-0.1,0.1-0.1,0.2-0.1h1.9v-2.9c0-0.1,0-0.1,0.1-0.2
s0.1-0.1,0.2-0.1h1.6c0.1,0,0.1,0,0.2,0.1c0.1,0.1,0.1,0.1,0.1,0.2v2.9h1.9c0.1,0,0.1,0,0.2,0.1
C-12.1,1419.9-12.1,1419.9-12.1,1420z M-6.8,1417.6c0-0.9-0.3-1.6-0.9-2.3s-1.4-0.9-2.3-0.9h-9c-1,0-1.9,0.4-2.6,1.1
s-1.1,1.6-1.1,2.6c0,0.7,0.2,1.4,0.6,2s0.9,1.1,1.6,1.4c0,0.2,0,0.3,0,0.4c0,1.2,0.4,2.2,1.2,3s1.8,1.2,3,1.2
c0.9,0,1.7-0.2,2.4-0.7s1.2-1.1,1.6-1.9c0.4,0.3,0.9,0.5,1.4,0.5c0.6,0,1.1-0.2,1.5-0.6s0.6-0.9,0.6-1.5c0-0.4-0.1-0.8-0.3-1.1
c0.7-0.2,1.3-0.5,1.8-1.1C-7,1419-6.8,1418.3-6.8,1417.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1"
id="svg3001" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:svg="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:dc="http://purl.org/dc/elements/1.1/" inkscape:version="0.48.3.1 r9886" sodipodi:docname="cloud_upload_font_awesome.svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 16 16"
enable-background="new 0 0 16 16" xml:space="preserve" height="16" width="16">
<sodipodi:namedview inkscape:cx="960" inkscape:cy="896" gridtolerance="10" borderopacity="1" id="namedview3007" bordercolor="#666666" objecttolerance="10" inkscape:zoom="0.13169643" showgrid="false" guidetolerance="10" pagecolor="#ffffff" inkscape:current-layer="svg3001" inkscape:window-maximized="0" inkscape:window-y="25" inkscape:window-x="0" inkscape:window-height="16" inkscape:window-width="16" inkscape:pageopacity="0" inkscape:pageshadow="2">
</sodipodi:namedview>
<g id="g3003" transform="matrix(1,0,0,-1,22.779661,1428.2373)">
<path fill="#a94442" id="path3005" inkscape:connector-curvature="0" d="M-12.1,1420c0,0.1,0,0.1-0.1,0.2l-2.9,2.9c-0.1,0-0.1,0.1-0.2,0.1
s-0.1,0-0.2-0.1l-2.9-2.9c-0.1-0.1-0.1-0.1-0.1-0.2s0-0.1,0.1-0.2c0-0.1,0.1-0.1,0.2-0.1h1.9v-2.9c0-0.1,0-0.1,0.1-0.2
s0.1-0.1,0.2-0.1h1.6c0.1,0,0.1,0,0.2,0.1c0.1,0.1,0.1,0.1,0.1,0.2v2.9h1.9c0.1,0,0.1,0,0.2,0.1
C-12.1,1419.9-12.1,1419.9-12.1,1420z M-6.8,1417.6c0-0.9-0.3-1.6-0.9-2.3s-1.4-0.9-2.3-0.9h-9c-1,0-1.9,0.4-2.6,1.1
s-1.1,1.6-1.1,2.6c0,0.7,0.2,1.4,0.6,2s0.9,1.1,1.6,1.4c0,0.2,0,0.3,0,0.4c0,1.2,0.4,2.2,1.2,3s1.8,1.2,3,1.2
c0.9,0,1.7-0.2,2.4-0.7s1.2-1.1,1.6-1.9c0.4,0.3,0.9,0.5,1.4,0.5c0.6,0,1.1-0.2,1.5-0.6s0.6-0.9,0.6-1.5c0-0.4-0.1-0.8-0.3-1.1
c0.7-0.2,1.3-0.5,1.8-1.1C-7,1419-6.8,1418.3-6.8,1417.6z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 B

BIN
web/public/images/fb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
web/public/images/file.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

BIN
web/public/images/gear.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
web/public/images/icons.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

BIN
web/public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

BIN
web/public/images/menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Some files were not shown because too many files have changed in this diff Show more