Initial commit.
2
.buildpacks
Normal 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
|
@ -0,0 +1,2 @@
|
||||||
|
.git
|
||||||
|
.env*
|
14
.eslintrc
Normal 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
|
@ -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
|
@ -0,0 +1 @@
|
||||||
|
/ No more waiting for files to upload before sharing the links.
|
14
Dockerfile
Normal 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
|
@ -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
|
@ -0,0 +1 @@
|
||||||
|
web: npm start
|
101
README.md
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
# Hostr [](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
|
@ -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
|
@ -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
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "Invalid API endpoint",
|
||||||
|
"code": 404
|
||||||
|
}
|
||||||
|
}
|
6
api/public/50x.json
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"message": "An error occured on the server",
|
||||||
|
"code": 665
|
||||||
|
}
|
||||||
|
}
|
259
api/routes/file.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,2 @@
|
||||||
|
import { init as storageInit } from './lib/storage';
|
||||||
|
storageInit();
|
52
lib/format.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"mocha": true,
|
||||||
|
"es6": true
|
||||||
|
}
|
||||||
|
}
|
33
test/api/auth.spec.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
After Width: | Height: | Size: 190 KiB |
16
test/unit/image-resize.spec.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
}
|
||||||
|
}
|
46
web/public/404.html
Normal 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
|
@ -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>
|
12
web/public/browserconfig.xml
Normal 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
|
@ -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
After Width: | Height: | Size: 15 KiB |
BIN
web/public/images/animation.png
Normal file
After Width: | Height: | Size: 9 KiB |
BIN
web/public/images/apple-logo-app.png
Normal file
After Width: | Height: | Size: 982 B |
BIN
web/public/images/apple-touch-icon-114x114.png
Normal file
After Width: | Height: | Size: 860 B |
BIN
web/public/images/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 849 B |
BIN
web/public/images/apple-touch-icon-144x144.png
Normal file
After Width: | Height: | Size: 1,022 B |
BIN
web/public/images/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
web/public/images/apple-touch-icon-57x57.png
Normal file
After Width: | Height: | Size: 539 B |
BIN
web/public/images/apple-touch-icon-60x60.png
Normal file
After Width: | Height: | Size: 537 B |
BIN
web/public/images/apple-touch-icon-72x72.png
Normal file
After Width: | Height: | Size: 632 B |
BIN
web/public/images/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 598 B |
BIN
web/public/images/apple-touch-icon-precomposed.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
web/public/images/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 1 KiB |
BIN
web/public/images/apple.png
Normal file
After Width: | Height: | Size: 221 B |
BIN
web/public/images/arrow_down.png
Normal file
After Width: | Height: | Size: 173 B |
BIN
web/public/images/bullet-r.png
Normal file
After Width: | Height: | Size: 217 B |
BIN
web/public/images/bullet.png
Normal file
After Width: | Height: | Size: 155 B |
BIN
web/public/images/camera.png
Normal file
After Width: | Height: | Size: 379 B |
9
web/public/images/chevron20.svg
Normal 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 |
BIN
web/public/images/clock-25.png
Normal file
After Width: | Height: | Size: 470 B |
BIN
web/public/images/clock-50.png
Normal file
After Width: | Height: | Size: 855 B |
3
web/public/images/cloud-transfer-upload-sm.svg
Executable 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 |
BIN
web/public/images/cloud_top.jpg
Normal file
After Width: | Height: | Size: 56 KiB |
19
web/public/images/cloud_upload_font_awesome.svg
Normal 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 |
19
web/public/images/cloud_upload_font_awesome_red.svg
Normal 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 |
BIN
web/public/images/collection_thumb.png
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
web/public/images/connected.png
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
web/public/images/favicon-160x160.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
web/public/images/favicon-16x16.png
Normal file
After Width: | Height: | Size: 277 B |
BIN
web/public/images/favicon-196x196.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
web/public/images/favicon-32x32.png
Normal file
After Width: | Height: | Size: 441 B |
BIN
web/public/images/favicon-96x96.png
Normal file
After Width: | Height: | Size: 919 B |
BIN
web/public/images/favicon.png
Normal file
After Width: | Height: | Size: 542 B |
BIN
web/public/images/fb.png
Normal file
After Width: | Height: | Size: 31 KiB |
BIN
web/public/images/file-adjusted.png
Normal file
After Width: | Height: | Size: 272 B |
BIN
web/public/images/file-cog.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
web/public/images/file.png
Normal file
After Width: | Height: | Size: 182 B |
BIN
web/public/images/file_icon.png
Normal file
After Width: | Height: | Size: 236 B |
BIN
web/public/images/file_thumb.png
Normal file
After Width: | Height: | Size: 8.4 KiB |
BIN
web/public/images/filetype_header.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
web/public/images/folder.png
Normal file
After Width: | Height: | Size: 168 B |
BIN
web/public/images/gear.png
Normal file
After Width: | Height: | Size: 234 B |
BIN
web/public/images/hostr-logo-500.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
web/public/images/icons.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/images/logo-dark-r.png
Normal file
After Width: | Height: | Size: 884 B |
BIN
web/public/images/logo.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
web/public/images/logo_dark.png
Normal file
After Width: | Height: | Size: 335 B |
BIN
web/public/images/main-logo.png
Normal file
After Width: | Height: | Size: 816 B |
BIN
web/public/images/menu-retina.png
Normal file
After Width: | Height: | Size: 183 B |
BIN
web/public/images/menu.png
Normal file
After Width: | Height: | Size: 123 B |
BIN
web/public/images/mstile-144x144.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
web/public/images/mstile-150x150.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
web/public/images/mstile-310x150.png
Normal file
After Width: | Height: | Size: 1 KiB |