commit b48a4e92e13faad9fda55cf035cdf53e62b0b663 Author: Jonathan Cremin Date: Thu Jul 9 23:01:43 2015 +0100 Initial commit. diff --git a/.buildpacks b/.buildpacks new file mode 100644 index 0000000..fb35cb4 --- /dev/null +++ b/.buildpacks @@ -0,0 +1,2 @@ +https://github.com/mcollina/heroku-buildpack-graphicsmagick.git +https://github.com/kudos/heroku-buildpack-nodejs-jspm.git diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f4a6fa4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +.env* diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..8985849 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,14 @@ +{ + "ecmaFeatures": { + "modules": true, + "jsx": true + }, + "env": { + "node": true, + "es6": true + }, + "rules": { + "quotes": [2, "single"], + "no-underscore-dangle": [0] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..402159d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CHECKS b/CHECKS new file mode 100644 index 0000000..491e862 --- /dev/null +++ b/CHECKS @@ -0,0 +1 @@ +/ No more waiting for files to upload before sharing the links. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c78d494 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM kudoz/iojs-gm +MAINTAINER Jonathan Cremin + +WORKDIR /app + +COPY . . + +RUN npm install && npm rebuild node-sass + +RUN npm run build + +EXPOSE 4040 + +CMD npm start diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0e1cadb --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +(The MIT License) + +Copyright (c) 2015 Jonathan Cremin + +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. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..063b78f --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: npm start diff --git a/README.md b/README.md new file mode 100644 index 0000000..e178bda --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Hostr [![Circle CI](https://circleci.com/gh/kudos/hostr.svg?style=svg&circle-token=1b4dec62afcb7960446edf241a5cf9238b8c20ed)](https://circleci.com/gh/kudos/hostr) + +## Getting Started + +### Runtimes + +Acquire [iojs](https://iojs.org) somehow, using [nvm](https://github.com/creationix/nvm), [n](https://github.com/tj/n). Or if you don't have or want regular node installed globally just use homebrew `brew install iojs && brew link iojs --force`. + +### Dependencies + +You'll need `graphicsmagick` for image thumbnailing, everything else is taken care of by an `npm install`. + +### Databases + +You'll need Redis for session and pubsub and MongoDB for persistent storage, `brew install redis mongodb`. + +### Configuration + +Configuration is all sucked in from the environment. + +##### AWS + +File are always uploaded to S3, but they can optionally be written do disk and cached locally. + +`AWS_ACCESS_KEY_ID` **required** + +`AWS_SECRET_ACCESS_KEY` **required** + +`AWS_BUCKET` **required** + +##### Email + +`MANDRILL_KEY` **required** + +`EMAIL_FROM` - defaults to `nobody@example.com` + +##### Databases + +`REDIS_URL` - defaults to `redis://localhost:6379` + +`MONGO_URL` - defaults to `mongodb://localhost:27017/hostr` + +The database connections default to connecting locally if an env variable isn't found. The following indexes are required. + +```js +db.remember.ensureIndex({"created":1}, {expireAfterSeconds: 2592000}) +``` + +```js +db.file.ensureIndex({"owner" : 1, "status" : 1, "time_added" : -1}); +``` + +##### Local cache + +`LOCAL_CACHE` - defaults to `false`. + +`LOCAL_PATH` - defaults to `~/.hostr/uploads`. if `LOCAL_CACHE` is `true` will store files locally and not just on S3/GCS. + +##### SPDY + +If you want to use SPDY, add an SSL key and cert. + +`LOCALHOST_KEY` + +`LOCALHOST_CRT` + +##### App + +`BASE_URL` - defaults to `https://localhost:4040` + +`FILE_HOST` - used by API for absolute file urls, defaults to `$BASE_URL` + +`API_URL` - defaults to `/api` + +`PORT` - defaults to `4040`. + +`VIRUSTOTAL` - API key enables Virustotal integration. + +`SENTRY_DSN` - DSN enables Sentry integration. + +Additionally, Hostr uses [debug](https://github.com/visionmedia/debug) so you can use the `DEBUG` environment variable something like `DEBUG=hostr*` to get debug output. + +### Deploying to Heroku + +Because it uses iojs and graphicsmagick runtimes hostr needs an env variable for `BUILDPACK_URL` set to `https://github.com/ddollar/heroku-buildpack-multi.git`. + +You'll also need to add Heroku Redis and a MongoDB addon. + +## Usage + +### Start the app + +`npm start` or to live reload `npm run watch` + +### Run the tests + +`npm test` + +## Licence + +The code is MIT licenced, the brand is not. This applies to the logo, name and colour scheme. diff --git a/api/app.js b/api/app.js new file mode 100644 index 0000000..0927185 --- /dev/null +++ b/api/app.js @@ -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; diff --git a/api/lib/auth.js b/api/lib/auth.js new file mode 100644 index 0000000..cb15bf7 --- /dev/null +++ b/api/lib/auth.js @@ -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; +}; diff --git a/api/public/404.json b/api/public/404.json new file mode 100644 index 0000000..5cd4cf0 --- /dev/null +++ b/api/public/404.json @@ -0,0 +1,6 @@ +{ + "error": { + "message": "Invalid API endpoint", + "code": 404 + } +} diff --git a/api/public/50x.json b/api/public/50x.json new file mode 100644 index 0000000..e996335 --- /dev/null +++ b/api/public/50x.json @@ -0,0 +1,6 @@ +{ + "error": { + "message": "An error occured on the server", + "code": 665 + } +} diff --git a/api/routes/file.js b/api/routes/file.js new file mode 100644 index 0000000..10bc559 --- /dev/null +++ b/api/routes/file.js @@ -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(); + }); +} diff --git a/api/routes/user.js b/api/routes/user.js new file mode 100644 index 0000000..36ac916 --- /dev/null +++ b/api/routes/user.js @@ -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(); + }); +} diff --git a/app.js b/app.js new file mode 100644 index 0000000..9923a5c --- /dev/null +++ b/app.js @@ -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; diff --git a/circle.yml b/circle.yml new file mode 100644 index 0000000..2954b8f --- /dev/null +++ b/circle.yml @@ -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 diff --git a/config/mongo.js b/config/mongo.js new file mode 100644 index 0000000..85c44ae --- /dev/null +++ b/config/mongo.js @@ -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; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..78f9a16 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/init.js b/init.js new file mode 100644 index 0000000..c7da2cc --- /dev/null +++ b/init.js @@ -0,0 +1,2 @@ +import { init as storageInit } from './lib/storage'; +storageInit(); diff --git a/lib/format.js b/lib/format.js new file mode 100644 index 0000000..584b494 --- /dev/null +++ b/lib/format.js @@ -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; +} diff --git a/lib/hostr-file-stream.js b/lib/hostr-file-stream.js new file mode 100644 index 0000000..d17a486 --- /dev/null +++ b/lib/hostr-file-stream.js @@ -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); + }); + }); +} diff --git a/lib/hostr-id.js b/lib/hostr-id.js new file mode 100644 index 0000000..be784cb --- /dev/null +++ b/lib/hostr-id.js @@ -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); +} diff --git a/lib/koa-error.js b/lib/koa-error.js new file mode 100644 index 0000000..2625cd8 --- /dev/null +++ b/lib/koa-error.js @@ -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; + } + } + } +} diff --git a/lib/malware.js b/lib/malware.js new file mode 100644 index 0000000..84d87c6 --- /dev/null +++ b/lib/malware.js @@ -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; +}; diff --git a/lib/resize.js b/lib/resize.js new file mode 100644 index 0000000..7e44e07 --- /dev/null +++ b/lib/resize.js @@ -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(); +} diff --git a/lib/s3.js b/lib/s3.js new file mode 100644 index 0000000..b89353e --- /dev/null +++ b/lib/s3.js @@ -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}); +} diff --git a/lib/storage.js b/lib/storage.js new file mode 100644 index 0000000..932f07c --- /dev/null +++ b/lib/storage.js @@ -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')); + } + }); +} diff --git a/lib/type.js b/lib/type.js new file mode 100644 index 0000000..736d0e5 --- /dev/null +++ b/lib/type.js @@ -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'; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..34ede98 --- /dev/null +++ b/package.json @@ -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" + } + } +} diff --git a/test/.eslintrc b/test/.eslintrc new file mode 100644 index 0000000..b309be5 --- /dev/null +++ b/test/.eslintrc @@ -0,0 +1,6 @@ +{ + "env": { + "mocha": true, + "es6": true + } +} \ No newline at end of file diff --git a/test/api/auth.spec.js b/test/api/auth.spec.js new file mode 100644 index 0000000..943302c --- /dev/null +++ b/test/api/auth.spec.js @@ -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); + }); + }); +}); diff --git a/test/api/file.spec.js b/test/api/file.spec.js new file mode 100644 index 0000000..5f38802 --- /dev/null +++ b/test/api/file.spec.js @@ -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); + }); + }); +}); diff --git a/test/api/user.spec.js b/test/api/user.spec.js new file mode 100644 index 0000000..47c5b3c --- /dev/null +++ b/test/api/user.spec.js @@ -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); + }); + }); + +}); diff --git a/test/fixtures/mongo-file.js b/test/fixtures/mongo-file.js new file mode 100644 index 0000000..f71214d --- /dev/null +++ b/test/fixtures/mongo-file.js @@ -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 +}); diff --git a/test/fixtures/mongo-user.js b/test/fixtures/mongo-user.js new file mode 100644 index 0000000..95595a6 --- /dev/null +++ b/test/fixtures/mongo-user.js @@ -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" +}); diff --git a/test/fixtures/utah-arches.jpg b/test/fixtures/utah-arches.jpg new file mode 100644 index 0000000..b3f5382 Binary files /dev/null and b/test/fixtures/utah-arches.jpg differ diff --git a/test/unit/image-resize.spec.js b/test/unit/image-resize.spec.js new file mode 100644 index 0000000..ee6ddc5 --- /dev/null +++ b/test/unit/image-resize.spec.js @@ -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); + }); +}); diff --git a/test/web/file.spec.js b/test/web/file.spec.js new file mode 100644 index 0000000..7b727eb --- /dev/null +++ b/test/web/file.spec.js @@ -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); + }); + }); +}); diff --git a/test/web/user.spec.js b/test/web/user.spec.js new file mode 100644 index 0000000..31bc268 --- /dev/null +++ b/test/web/user.spec.js @@ -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); + }); + }); +}); diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..ff63caa --- /dev/null +++ b/web/app.js @@ -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; diff --git a/web/lib/auth.js b/web/lib/auth.js new file mode 100644 index 0000000..4f8e547 --- /dev/null +++ b/web/lib/auth.js @@ -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); + } +} diff --git a/web/public/.eslintrc b/web/public/.eslintrc new file mode 100644 index 0000000..e5a34ae --- /dev/null +++ b/web/public/.eslintrc @@ -0,0 +1,5 @@ +{ + "env": { + "browser": true + } +} diff --git a/web/public/404.html b/web/public/404.html new file mode 100644 index 0000000..4f892f3 --- /dev/null +++ b/web/public/404.html @@ -0,0 +1,46 @@ + + + + + + + Hostr - File not found + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+

404

+

We can't find the file you're looking for :(

+ +

The owner may have removed it or it may never have existed in the first place.

+ + Try our homepage instead :) +
+
+
+
+ + + + diff --git a/web/public/50x.html b/web/public/50x.html new file mode 100644 index 0000000..738b514 --- /dev/null +++ b/web/public/50x.html @@ -0,0 +1,44 @@ + + + + + + + Hostr - File not found + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+

Oops!

+

It looks like you've hit an unexpected error :(

+ +

Refreshing might fix the problem. If not, sit tight! We're on it!

+
+
+
+
+ + + + diff --git a/web/public/browserconfig.xml b/web/public/browserconfig.xml new file mode 100644 index 0000000..81ec113 --- /dev/null +++ b/web/public/browserconfig.xml @@ -0,0 +1,12 @@ + + + + + + + + + #00aba9 + + + diff --git a/web/public/config.js b/web/public/config.js new file mode 100644 index 0000000..7071e7c --- /dev/null +++ b/web/public/config.js @@ -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" + } + } +}); + diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000..3a24ecf Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/images/animation.png b/web/public/images/animation.png new file mode 100644 index 0000000..ffd6726 Binary files /dev/null and b/web/public/images/animation.png differ diff --git a/web/public/images/apple-logo-app.png b/web/public/images/apple-logo-app.png new file mode 100644 index 0000000..33bb965 Binary files /dev/null and b/web/public/images/apple-logo-app.png differ diff --git a/web/public/images/apple-touch-icon-114x114.png b/web/public/images/apple-touch-icon-114x114.png new file mode 100644 index 0000000..0920fb4 Binary files /dev/null and b/web/public/images/apple-touch-icon-114x114.png differ diff --git a/web/public/images/apple-touch-icon-120x120.png b/web/public/images/apple-touch-icon-120x120.png new file mode 100644 index 0000000..2534491 Binary files /dev/null and b/web/public/images/apple-touch-icon-120x120.png differ diff --git a/web/public/images/apple-touch-icon-144x144.png b/web/public/images/apple-touch-icon-144x144.png new file mode 100644 index 0000000..ce05d40 Binary files /dev/null and b/web/public/images/apple-touch-icon-144x144.png differ diff --git a/web/public/images/apple-touch-icon-152x152.png b/web/public/images/apple-touch-icon-152x152.png new file mode 100644 index 0000000..ec2749f Binary files /dev/null and b/web/public/images/apple-touch-icon-152x152.png differ diff --git a/web/public/images/apple-touch-icon-57x57.png b/web/public/images/apple-touch-icon-57x57.png new file mode 100644 index 0000000..79afc34 Binary files /dev/null and b/web/public/images/apple-touch-icon-57x57.png differ diff --git a/web/public/images/apple-touch-icon-60x60.png b/web/public/images/apple-touch-icon-60x60.png new file mode 100644 index 0000000..fca75cd Binary files /dev/null and b/web/public/images/apple-touch-icon-60x60.png differ diff --git a/web/public/images/apple-touch-icon-72x72.png b/web/public/images/apple-touch-icon-72x72.png new file mode 100644 index 0000000..21ab906 Binary files /dev/null and b/web/public/images/apple-touch-icon-72x72.png differ diff --git a/web/public/images/apple-touch-icon-76x76.png b/web/public/images/apple-touch-icon-76x76.png new file mode 100644 index 0000000..bd5e025 Binary files /dev/null and b/web/public/images/apple-touch-icon-76x76.png differ diff --git a/web/public/images/apple-touch-icon-precomposed.png b/web/public/images/apple-touch-icon-precomposed.png new file mode 100644 index 0000000..f952f98 Binary files /dev/null and b/web/public/images/apple-touch-icon-precomposed.png differ diff --git a/web/public/images/apple-touch-icon.png b/web/public/images/apple-touch-icon.png new file mode 100644 index 0000000..ec2749f Binary files /dev/null and b/web/public/images/apple-touch-icon.png differ diff --git a/web/public/images/apple.png b/web/public/images/apple.png new file mode 100644 index 0000000..7c82b40 Binary files /dev/null and b/web/public/images/apple.png differ diff --git a/web/public/images/arrow_down.png b/web/public/images/arrow_down.png new file mode 100644 index 0000000..2b63662 Binary files /dev/null and b/web/public/images/arrow_down.png differ diff --git a/web/public/images/bullet-r.png b/web/public/images/bullet-r.png new file mode 100644 index 0000000..4842a84 Binary files /dev/null and b/web/public/images/bullet-r.png differ diff --git a/web/public/images/bullet.png b/web/public/images/bullet.png new file mode 100644 index 0000000..e9e38e1 Binary files /dev/null and b/web/public/images/bullet.png differ diff --git a/web/public/images/camera.png b/web/public/images/camera.png new file mode 100644 index 0000000..865d2e8 Binary files /dev/null and b/web/public/images/camera.png differ diff --git a/web/public/images/chevron20.svg b/web/public/images/chevron20.svg new file mode 100644 index 0000000..326fed1 --- /dev/null +++ b/web/public/images/chevron20.svg @@ -0,0 +1,9 @@ + + + + + + diff --git a/web/public/images/clock-25.png b/web/public/images/clock-25.png new file mode 100644 index 0000000..1346378 Binary files /dev/null and b/web/public/images/clock-25.png differ diff --git a/web/public/images/clock-50.png b/web/public/images/clock-50.png new file mode 100644 index 0000000..37d7f03 Binary files /dev/null and b/web/public/images/clock-50.png differ diff --git a/web/public/images/cloud-transfer-upload-sm.svg b/web/public/images/cloud-transfer-upload-sm.svg new file mode 100755 index 0000000..8a25fdf --- /dev/null +++ b/web/public/images/cloud-transfer-upload-sm.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/images/cloud_top.jpg b/web/public/images/cloud_top.jpg new file mode 100644 index 0000000..32a87f3 Binary files /dev/null and b/web/public/images/cloud_top.jpg differ diff --git a/web/public/images/cloud_upload_font_awesome.svg b/web/public/images/cloud_upload_font_awesome.svg new file mode 100644 index 0000000..d9d6064 --- /dev/null +++ b/web/public/images/cloud_upload_font_awesome.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/web/public/images/cloud_upload_font_awesome_red.svg b/web/public/images/cloud_upload_font_awesome_red.svg new file mode 100644 index 0000000..1d72b7b --- /dev/null +++ b/web/public/images/cloud_upload_font_awesome_red.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/web/public/images/collection_thumb.png b/web/public/images/collection_thumb.png new file mode 100644 index 0000000..b798c27 Binary files /dev/null and b/web/public/images/collection_thumb.png differ diff --git a/web/public/images/connected.png b/web/public/images/connected.png new file mode 100644 index 0000000..018e9d7 Binary files /dev/null and b/web/public/images/connected.png differ diff --git a/web/public/images/favicon-160x160.png b/web/public/images/favicon-160x160.png new file mode 100644 index 0000000..57a1ef1 Binary files /dev/null and b/web/public/images/favicon-160x160.png differ diff --git a/web/public/images/favicon-16x16.png b/web/public/images/favicon-16x16.png new file mode 100644 index 0000000..662c92a Binary files /dev/null and b/web/public/images/favicon-16x16.png differ diff --git a/web/public/images/favicon-196x196.png b/web/public/images/favicon-196x196.png new file mode 100644 index 0000000..07e3521 Binary files /dev/null and b/web/public/images/favicon-196x196.png differ diff --git a/web/public/images/favicon-32x32.png b/web/public/images/favicon-32x32.png new file mode 100644 index 0000000..3c76d68 Binary files /dev/null and b/web/public/images/favicon-32x32.png differ diff --git a/web/public/images/favicon-96x96.png b/web/public/images/favicon-96x96.png new file mode 100644 index 0000000..32a80c8 Binary files /dev/null and b/web/public/images/favicon-96x96.png differ diff --git a/web/public/images/favicon.png b/web/public/images/favicon.png new file mode 100644 index 0000000..cbe1615 Binary files /dev/null and b/web/public/images/favicon.png differ diff --git a/web/public/images/fb.png b/web/public/images/fb.png new file mode 100644 index 0000000..68d829d Binary files /dev/null and b/web/public/images/fb.png differ diff --git a/web/public/images/file-adjusted.png b/web/public/images/file-adjusted.png new file mode 100644 index 0000000..2c0a93b Binary files /dev/null and b/web/public/images/file-adjusted.png differ diff --git a/web/public/images/file-cog.png b/web/public/images/file-cog.png new file mode 100644 index 0000000..3ca1ee5 Binary files /dev/null and b/web/public/images/file-cog.png differ diff --git a/web/public/images/file.png b/web/public/images/file.png new file mode 100644 index 0000000..3395451 Binary files /dev/null and b/web/public/images/file.png differ diff --git a/web/public/images/file_icon.png b/web/public/images/file_icon.png new file mode 100644 index 0000000..babd738 Binary files /dev/null and b/web/public/images/file_icon.png differ diff --git a/web/public/images/file_thumb.png b/web/public/images/file_thumb.png new file mode 100644 index 0000000..3f30919 Binary files /dev/null and b/web/public/images/file_thumb.png differ diff --git a/web/public/images/filetype_header.png b/web/public/images/filetype_header.png new file mode 100644 index 0000000..b2c15f6 Binary files /dev/null and b/web/public/images/filetype_header.png differ diff --git a/web/public/images/folder.png b/web/public/images/folder.png new file mode 100644 index 0000000..368fc0c Binary files /dev/null and b/web/public/images/folder.png differ diff --git a/web/public/images/gear.png b/web/public/images/gear.png new file mode 100644 index 0000000..9d0178b Binary files /dev/null and b/web/public/images/gear.png differ diff --git a/web/public/images/hostr-logo-500.png b/web/public/images/hostr-logo-500.png new file mode 100644 index 0000000..5700d26 Binary files /dev/null and b/web/public/images/hostr-logo-500.png differ diff --git a/web/public/images/icons.png b/web/public/images/icons.png new file mode 100644 index 0000000..baeb669 Binary files /dev/null and b/web/public/images/icons.png differ diff --git a/web/public/images/logo-dark-r.png b/web/public/images/logo-dark-r.png new file mode 100644 index 0000000..860861c Binary files /dev/null and b/web/public/images/logo-dark-r.png differ diff --git a/web/public/images/logo.png b/web/public/images/logo.png new file mode 100644 index 0000000..316526f Binary files /dev/null and b/web/public/images/logo.png differ diff --git a/web/public/images/logo_dark.png b/web/public/images/logo_dark.png new file mode 100644 index 0000000..a595745 Binary files /dev/null and b/web/public/images/logo_dark.png differ diff --git a/web/public/images/main-logo.png b/web/public/images/main-logo.png new file mode 100644 index 0000000..9690aa1 Binary files /dev/null and b/web/public/images/main-logo.png differ diff --git a/web/public/images/menu-retina.png b/web/public/images/menu-retina.png new file mode 100644 index 0000000..0ec2359 Binary files /dev/null and b/web/public/images/menu-retina.png differ diff --git a/web/public/images/menu.png b/web/public/images/menu.png new file mode 100644 index 0000000..a620c26 Binary files /dev/null and b/web/public/images/menu.png differ diff --git a/web/public/images/mstile-144x144.png b/web/public/images/mstile-144x144.png new file mode 100644 index 0000000..219c8eb Binary files /dev/null and b/web/public/images/mstile-144x144.png differ diff --git a/web/public/images/mstile-150x150.png b/web/public/images/mstile-150x150.png new file mode 100644 index 0000000..6a902d6 Binary files /dev/null and b/web/public/images/mstile-150x150.png differ diff --git a/web/public/images/mstile-310x150.png b/web/public/images/mstile-310x150.png new file mode 100644 index 0000000..3dbcd29 Binary files /dev/null and b/web/public/images/mstile-310x150.png differ diff --git a/web/public/images/mstile-310x310.png b/web/public/images/mstile-310x310.png new file mode 100644 index 0000000..441123d Binary files /dev/null and b/web/public/images/mstile-310x310.png differ diff --git a/web/public/images/mstile-70x70.png b/web/public/images/mstile-70x70.png new file mode 100644 index 0000000..9763fe5 Binary files /dev/null and b/web/public/images/mstile-70x70.png differ diff --git a/web/public/images/music.png b/web/public/images/music.png new file mode 100644 index 0000000..8d336a4 Binary files /dev/null and b/web/public/images/music.png differ diff --git a/web/public/images/person.png b/web/public/images/person.png new file mode 100755 index 0000000..24f7120 Binary files /dev/null and b/web/public/images/person.png differ diff --git a/web/public/images/plus.png b/web/public/images/plus.png new file mode 100644 index 0000000..54fa799 Binary files /dev/null and b/web/public/images/plus.png differ diff --git a/web/public/images/round-icons.png b/web/public/images/round-icons.png new file mode 100644 index 0000000..753487b Binary files /dev/null and b/web/public/images/round-icons.png differ diff --git a/web/public/images/search.png b/web/public/images/search.png new file mode 100644 index 0000000..d91c2a0 Binary files /dev/null and b/web/public/images/search.png differ diff --git a/web/public/images/share_anything.png b/web/public/images/share_anything.png new file mode 100644 index 0000000..3dcbf0c Binary files /dev/null and b/web/public/images/share_anything.png differ diff --git a/web/public/images/share_anywhere.png b/web/public/images/share_anywhere.png new file mode 100644 index 0000000..f869a6f Binary files /dev/null and b/web/public/images/share_anywhere.png differ diff --git a/web/public/images/stripe-128.png b/web/public/images/stripe-128.png new file mode 100644 index 0000000..1d89b3b Binary files /dev/null and b/web/public/images/stripe-128.png differ diff --git a/web/public/images/sync_wireframe.png b/web/public/images/sync_wireframe.png new file mode 100644 index 0000000..30f1460 Binary files /dev/null and b/web/public/images/sync_wireframe.png differ diff --git a/web/public/images/tagline.png b/web/public/images/tagline.png new file mode 100644 index 0000000..54c3349 Binary files /dev/null and b/web/public/images/tagline.png differ diff --git a/web/public/images/url.png b/web/public/images/url.png new file mode 100644 index 0000000..137df96 Binary files /dev/null and b/web/public/images/url.png differ diff --git a/web/public/images/user.png b/web/public/images/user.png new file mode 100644 index 0000000..ca1a39b Binary files /dev/null and b/web/public/images/user.png differ diff --git a/web/public/images/video.png b/web/public/images/video.png new file mode 100644 index 0000000..0844bb2 Binary files /dev/null and b/web/public/images/video.png differ diff --git a/web/public/images/windows-app-icon.png b/web/public/images/windows-app-icon.png new file mode 100644 index 0000000..388173a Binary files /dev/null and b/web/public/images/windows-app-icon.png differ diff --git a/web/public/images/windows-backdrop.png b/web/public/images/windows-backdrop.png new file mode 100644 index 0000000..5ef1621 Binary files /dev/null and b/web/public/images/windows-backdrop.png differ diff --git a/web/public/images/windows-logo-app.png b/web/public/images/windows-logo-app.png new file mode 100644 index 0000000..99e0536 Binary files /dev/null and b/web/public/images/windows-logo-app.png differ diff --git a/web/public/images/windows-white-icon.png b/web/public/images/windows-white-icon.png new file mode 100644 index 0000000..ac9bb39 Binary files /dev/null and b/web/public/images/windows-white-icon.png differ diff --git a/web/public/images/windows.png b/web/public/images/windows.png new file mode 100644 index 0000000..211a894 Binary files /dev/null and b/web/public/images/windows.png differ diff --git a/web/public/maintenance.html b/web/public/maintenance.html new file mode 100644 index 0000000..5a36edd --- /dev/null +++ b/web/public/maintenance.html @@ -0,0 +1,42 @@ + + + + + + + Hostr - File not found + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+

BRB

+

We're just performing some upgrades, we'll be right back!

+
+
+
+
+ + + + diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000..9213f77 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,3 @@ +# hostr.co/ + +User-agent: * diff --git a/web/public/src/app.js b/web/public/src/app.js new file mode 100644 index 0000000..a548ada --- /dev/null +++ b/web/public/src/app.js @@ -0,0 +1,129 @@ +import angular from 'angular'; +import ngRoute from 'angular/route'; +import ngResource from 'angular/resource'; +import ReconnectingWebSocket from 'angular-reconnecting-websocket'; +import ngDimensions from 'angular-strap/dist/modules/dimensions'; +import ngTooltip from 'angular-strap/dist/modules/tooltip'; + +import { FilesController, FileController, AccountController, ProController, BillingController } from './app/controllers'; +import { appHeader, appFooter, menuDropdown, searchShortcut, stripeSubscribe } from './app/directives'; +import dropzone from './app/directives/dropzone'; +import lazySrc from './app/directives/lazy-src'; +import { fileSize, direct } from './app/filters'; +import { FileService, UserService, EventService, TransactionService, SettingService } from './app/services'; + +// Declare app level module which depends on filters, and services +var app = angular.module('hostr', [ + 'ngRoute', + 'ngResource', + 'reconnectingWebSocket', + 'mgcrea.ngStrap.tooltip' +]); + +app.factory('FileService', ['$resource', '$cacheFactory', FileService.factory]); +app.factory('UserService', ['$resource', UserService.factory]); +app.factory('EventService', ['$rootScope', ReconnectingWebSocket, EventService.factory]); +app.factory('TransactionService', ['$resource', '$cacheFactory', TransactionService.factory]); +app.factory('SettingService', ['$http', SettingService.factory]); + +app.filter('fileSize', [fileSize]); +app.filter('direct', [direct]); + +app.directive('appHeader', [appHeader]); +app.directive('appFooter', [appFooter]); +app.directive('dropzone', ['FileService', '$cacheFactory', '$window', dropzone]); +app.directive('menuDropdown', [menuDropdown]); +app.directive('lazySrc', ['$window', '$document', lazySrc]); +app.directive('searchShortcut', ['$document', searchShortcut]); +app.directive('stripeSubscribe', ['$http', stripeSubscribe]); + +app.config(['$routeProvider', '$locationProvider', '$httpProvider', '$tooltipProvider', function($routeProvider, $locationProvider, $httpProvider, $tooltipProvider) { + + $tooltipProvider.defaults.template = '/jspm_packages/npm/angular-strap@2.1.2/src/tooltip/tooltip.tpl.html'; + + if (typeof window.user !== 'undefined') { + $httpProvider.defaults.headers.common.Authorization = ':' + window.user.token; + } + $locationProvider.html5Mode(true); + + $httpProvider.interceptors.push(['$q', function($q) { + return { + responseError: function(rejection) { + if (rejection.status === 401) { + window.location = '/logout'; + } + return $q.reject(rejection); + } + }; + }]); + + $routeProvider.when('/', { + templateUrl: '/build/partials/files.html', + controller: FilesController, + title: ' - Files', + resolve: { + files: ['FileService', function(Files) { + return Files.query(); + }] + } + }) + .when('/apps', { + templateUrl: '/build/partials/apps.html', + title: ' - Apps for Mac and Windows' + }) + .when('/pro', { + templateUrl: '/build/partials/pro.html', + controller: ProController, + title: ' - Pro' + }) + .when('/account', { + templateUrl: '/build/partials/account.html', + controller: AccountController, + title: ' - Account' + }) + .when('/billing', { + templateUrl: '/build/partials/billing.html', + controller: BillingController, + title: ' - Billing' + }) + .when('/terms', { + templateUrl: '/build/partials/terms.html', + title: ' - Terms of Service' + }) + .when('/privacy', { + templateUrl: '/build/partials/privacy.html', + title: ' - Privacy Policy' + }) + .when('/:id', { + templateUrl: '/build/partials/file.html', + controller: FileController, + resolve: { + file: ['$route', 'FileService', function($route, Files) { + return Files.get({id: $route.current.params.id}); + }] + } + }); +}]); + +app.run(['$location', '$rootScope', function($location, $rootScope) { + + $rootScope.$on('$routeChangeStart', function(e, curr) { + if (curr.$$route && curr.$$route.resolve) { + // Show a loading message until promises are resolved + $rootScope.loadingView = true; + } + }); + $rootScope.$on('$routeChangeSuccess', function (event, current) { + $rootScope.navError = false; + $rootScope.pageTitle = current.$$route.title; + }); + $rootScope.$on('$routeChangeError', function () { + $rootScope.loadingView = false; + $rootScope.navError = true; + }); + $rootScope.$on('$locationChangeStart', function(event, newUrl) { + if (window.ga) { + window.ga('send', 'pageview', newUrl); + } + }); +}]); diff --git a/web/public/src/app/controllers.js b/web/public/src/app/controllers.js new file mode 100644 index 0000000..547752d --- /dev/null +++ b/web/public/src/app/controllers.js @@ -0,0 +1,105 @@ +export class FilesController { + constructor($scope, UserService, files) { + $scope.$root.user = UserService.get(); + files.$promise.then(function() { + $scope.$root.loadingView = false; + }); + $scope.header = 'full'; + if (!$scope.$root.files) { + $scope.$root.files = files; + } + $scope.remove = function(file) { + $scope.$root.files.some(function(existingFile, index) { + if (file.id === existingFile.id) { + file.$remove(function() { + $scope.$root.showDropdown = false; + $scope.$root.files.splice(index, 1); + }); + return true; + } + return false; + }); + }; + } +} +FilesController.$inject = ['$scope', 'UserService', 'files']; + +export class FileController { + constructor ($scope, $rootScope, $routeParams, ReconnectingWebSocket, file) { + file.$promise.then(function() { + $scope.$root.loadingView = false; + $scope.header = 'small'; + $scope.file = file; + $scope.direct = '/file/' + file.id + '/' + file.name; + $rootScope.pageTitle = ' - ' + file.name; + if (file.status === 'uploading') { + file.percent = 0; + var ws = new ReconnectingWebSocket('wss://' + window.location.hostname + window.settings.api + '/file/' + file.id); + ws.onmessage = function (msg) { + var evt = JSON.parse(msg.data); + $rootScope.$broadcast(evt.type, evt.data); + }; + ws.onopen = function() { + ws.send(JSON.stringify({authorization: window.user.token})); + }; + $rootScope.$on('file-progress', function(evt, data) { + $scope.file.percent = data.complete; + }); + $rootScope.$on('file-added', function(evt, data) { + $scope.file = data; + }); + $rootScope.$on('file-accepted', function(evt, data) { + $scope.file = data; + }); + } + }, function() { + $rootScope.navError = true; + $scope.$root.loadingView = false; + }); + } +} +FileController.$inject = ['$scope', '$rootScope', '$routeParams', 'WebSocket', 'file']; + +export class ProController { + constructor ($scope, $http, UserService) { + $scope.$root.loadingView = false; + $scope.user = UserService.get(); + $scope.header = 'full'; + $scope.cancel = function() { + $http.post('/pro/cancel').success(function() { + window.location.reload(true); + }).error(function(data) { + console.log(new Error(data)); + }); + }; + } +} +ProController.$inject = ['$scope', '$http', 'UserService']; + +export class AccountController { + constructor ($scope, UserService, SettingService) { + $scope.$root.loadingView = false; + $scope.$root.user = UserService.get(); + $scope.submit = function(form) { + $scope.updated = false; + $scope.error = false; + SettingService.update(form).then(function() { + $scope.updated = true; + delete $scope.user.new_password; + delete $scope.user.current_password; + }, function(response) { + $scope.error = response.data.error.message; + }); + }; + } +} +AccountController.$inject = ['$scope', 'UserService', 'SettingService']; + +export class BillingController { + constructor ($scope, UserService, TransactionService) { + $scope.$root.loadingView = false; + $scope.$root.user = UserService.get(); + $scope.transactions = TransactionService.query(); + } +} +BillingController.$inject = ['$scope', 'UserService', 'TransactionService']; diff --git a/web/public/src/app/directives.js b/web/public/src/app/directives.js new file mode 100644 index 0000000..5ad1439 --- /dev/null +++ b/web/public/src/app/directives.js @@ -0,0 +1,121 @@ +import $ from 'jquery'; + +export function appHeader() { + return { + restrict: 'E', + templateUrl: '/build/partials/header.html', + replace: true, + link: function postLink(scope) { + scope.userMD5 = window.user.md5; + scope.email = window.user.email; + scope.pro = (window.user.type === 'Pro'); + } + }; +} + + +export function appFooter() { + return { + restrict: 'E', + templateUrl: '/build/partials/footer.html', + replace: true, + link: function postLink(scope) { + scope.userMD5 = window.user.md5; + scope.email = window.user.email; + scope.pro = (window.user.type === 'Pro'); + } + }; +} + + +export function menuDropdown() { + return function($scope, element) { + $scope.$root.overlayClick = function overlayClick() { + $scope.$root.showDropdown = false; + $('.dropdown').hide(); + }; + var activeDropdown = $(element).find('.dropdown'); + element.on('click', function(e) { + if (activeDropdown.not(':visible').length > 0) { + $('.dropdown').hide(); + $scope.$root.showDropdown = true; + activeDropdown.show(); + } else if (e.target === element.find('img')[0]) { + $scope.$root.showDropdown = false; + activeDropdown.hide(); + } + $scope.$apply(); + }); + }; +} + + +export function searchShortcut ($document) { + return function($scope, element) { + $document.bind('keypress', function(event) { + if(event.which === 47) { + if (['INPUT', 'TEXTAREA'].indexOf(document.activeElement.tagName) < 0) { + element[0].focus(); + event.preventDefault(); + } + } + }); + }; +} + + +export function stripeSubscribe($http) { + const handler = window.StripeCheckout.configure({ + key: window.settings.stripePublic, + image: '/images/stripe-128.png', + token: function(token) { + $http.post('/pro/create', {stripeToken: token}) + .success(function(data) { + if (data.status === 'active') { + window.user.plan = 'Pro'; + window.location.reload(true); + } + }) + .error(function() { + alert('Error upgrading your account'); + }); + } + }); + return function(scope, element) { + element.on('click', function() { + // Open Checkout with further options + handler.open({ + name: 'Hostr', + email: window.user.email, + description: 'Hostr Pro Monthly', + amount: 600, + currency: 'USD', + panelLabel: 'Subscribe {{amount}}', + billingAddress: false + }); + }); + }; +} + +// angular.module('hostr').directive('clippy', ['files', function factory() { +// return function(scope, element, attrs) { +// element = element[0]; +// var client = new ZeroClipboard(element); +// client.on('ready', function(readyEvent) { +// element.addEventListener('click', function(event) { +// event.preventDefault(); +// }); +// +// client.on( 'aftercopy', function( event ) { +// if (element.innerHTML == 'Copy Link') { +// element.innerHTML = 'Copied!'; +// setTimeout(function() { +// if (element) { +// element.innerHTML = 'Copy Link'; +// } +// }, 1500); +// } +// }); +// }); +// } +// }]); diff --git a/web/public/src/app/directives/dropzone.js b/web/public/src/app/directives/dropzone.js new file mode 100644 index 0000000..b2ce1e0 --- /dev/null +++ b/web/public/src/app/directives/dropzone.js @@ -0,0 +1,154 @@ +import Dropzone from 'dropzone'; + +function guid() { + function s4() { + return Math.floor((1 + Math.random()) * 0x10000) + .toString(16) + .substring(1); + } + return s4() + s4() + '-' + s4() + '-' + s4() + '-' + + s4() + '-' + s4() + s4() + s4(); +} + +export default function dropzone(FileService, $cacheFactory) { + var dropOverlay = document.getElementById('filedrop-overlay'); + var dropzoneEl; + var errorTimeout; + return function($scope) { + $scope.$on('$viewContentLoaded', function() { + if (!dropzoneEl) { + $scope.$root.uploadingFiles = []; + var clickable = [].slice.call(document.querySelectorAll('.choose-file')); + + dropzoneEl = new Dropzone(document.body, { + url: window.settings.apiURL + '/file', + maxFilesize: window.user.maxFileSize / 1024 / 1024, + maxThumbnailFilesize: 5, + thumbnailWidth: 150, + thumbnailHeight: 98, + parallelUploads: 1, + uploadMultiple: false, + clickable: clickable.length ? clickable : false, + autoDiscover: false, + headers: {'Authorization': ':' + window.user.token}, + previewsContainer: false + }); + dropzoneEl.on('thumbnail', function(file, thumbnail){ + file.thumbnail = thumbnail; + $scope.$apply(); + }); + dropzoneEl.on('addedfile', function(file){ + var id = guid(); + file.guid = id; + $scope.$root.uploadingFiles.push(file); + $scope.$apply(); + }); + dropzoneEl.on('sending', function(file, xhr) { + xhr.setRequestHeader('hostr-guid', file.guid); + }); + dropzoneEl.on('uploadprogress', function(file, progress) { + $scope.$root.progress = { + name: file.name, + percent: progress, + status: 'Uploading' + }; + if (progress === 100) { + $scope.$root.progress.status = 'Processing'; + } + $scope.$apply(); + }); + dropzoneEl.on('complete', function(file){ + delete $scope.$root.progress; + $scope.$apply(); + $scope.$root.uploadingFiles.some(function(uploadingFile, index) { + if (uploadingFile.guid === file.guid) { + $scope.$root.uploadingFiles.splice(index, 1); + $scope.$apply(); + return true; + } + return false; + }); + }); + dropzoneEl.on('error', function(evt, error){ + if (error.error) { + $scope.$root.uploadError = 'Error uploading file: ' + evt.name + '. ' + error.error.message; + } + else if (evt.name) { + $scope.$root.uploadError = 'Error uploading file: ' + evt.name + '. ' + error; + } else { + if (error[0] !== '<') { + $scope.$root.uploadError = 'Uknown error during upload'; + } + } + $scope.$apply(); + clearTimeout(errorTimeout); + errorTimeout = setTimeout(function() { + $scope.$root.uploadError = ''; + $scope.$apply(); + }, 5000); + }); + + var addFile = function(newFile) { + if (!$scope.$root.files.some(function (file) { + return file.id === newFile.id; + })) { + var cache = $cacheFactory.get('files-cache'); + cache.removeAll(); + var file = new FileService(newFile); + $scope.$root.files.unshift(file); + $scope.$root.user.uploads_today++; + $scope.$apply(); + } + }; + + dropzoneEl.on('success', function(file, response){ + addFile(response); + }); + $scope.$on('file-added', function(event, data){ + addFile(data); + }); + $scope.$on('file-accepted', function(event, data){ + $scope.$root.uploadingFiles.some(function(file) { + if (file.guid === data.guid) { + file.id = data.id; + file.href = data.href; + $scope.$apply(); + return true; + } + }); + }); + $scope.$on('file-deleted', function(evt, data) { + $scope.$root.files.forEach(function(file, index) { + if(data.id === file.id) { + delete $scope.$root.files[index]; + $scope.$digest(); + } + }); + }); + + document.body.addEventListener('dragenter', function(){ + dropOverlay.style.display = 'block'; + }); + + dropOverlay.addEventListener('dragleave', function(event){ + if (event.target.outerText !== 'Drop files to upload' || event.x === 0) { + dropOverlay.style.display = 'none'; + } + }); + + dropOverlay.addEventListener('drop', function(){ + dropOverlay.style.display = 'none'; + }); + } else { + var clicker = [].slice.call(document.querySelectorAll('.choose-file')); + if (clicker) { + clicker.forEach(function(el) { + el.addEventListener('click', function() { + return dropzoneEl.hiddenFileInput.click(); + }); + }); + } + } + }); + }; +} diff --git a/web/public/src/app/directives/lazy-src.js b/web/public/src/app/directives/lazy-src.js new file mode 100644 index 0000000..f0c0034 --- /dev/null +++ b/web/public/src/app/directives/lazy-src.js @@ -0,0 +1,222 @@ +import $ from 'jquery'; + +export default function lazySrc($window, $document) { + var lazyLoader = (function() { + var images = []; + var renderTimer = null; + var renderDelay = 100; + var win = $($window); + var doc = $($document); + var documentHeight = doc.height(); + var documentTimer = null; + var documentDelay = 2000; + var isWatchingWindow = false; + + // --- + // PUBLIC METHODS. + // --- + function addImage(image) { + images.push(image); + if (!renderTimer) { + startRenderTimer(); + } + + if (!isWatchingWindow) { + startWatchingWindow(); + } + } + + let removeImage = function(image) { + for (let i = 0; i < images.length; i++) { + if (images[i] === image ) { + images.splice(i, 1); + break; + } + } + if ( !images.length ) { + clearRenderTimer(); + stopWatchingWindow(); + } + }; + + // --- + // PRIVATE METHODS. + // --- + + function clearRenderTimer() { + clearTimeout( renderTimer ); + renderTimer = null; + } + + function checkImages() { + var visible = []; + var hidden = []; + var windowHeight = win.height(); + var scrollTop = win.scrollTop(); + var topFoldOffset = scrollTop; + var bottomFoldOffset = ( topFoldOffset + windowHeight ); + + for (let i = 0; i < images.length; i++) { + var image = images[ i ]; + if ( image.isVisible( topFoldOffset, bottomFoldOffset ) ) { + visible.push( image ); + } else { + hidden.push( image ); + } + } + + for (let i = 0; i < visible.length; i++) { + visible[ i ].render(); + } + + images = hidden; + + clearRenderTimer(); + + if ( !images.length ) { + stopWatchingWindow(); + } + } + + function startRenderTimer() { + renderTimer = setTimeout( checkImages, renderDelay ); + } + + function checkDocumentHeight() { + if ( renderTimer ) { + return; + } + + var currentDocumentHeight = doc.height(); + if ( currentDocumentHeight === documentHeight ) { + return; + } + + documentHeight = currentDocumentHeight; + + startRenderTimer(); + } + + function windowChanged() { + if (!renderTimer) { + startRenderTimer(); + } + } + + function startWatchingWindow() { + + isWatchingWindow = true; + + win.on( 'resize.lazySrc', windowChanged ); + win.on( 'scroll.lazySrc', windowChanged ); + + documentTimer = setInterval( checkDocumentHeight, documentDelay ); + } + + function stopWatchingWindow() { + isWatchingWindow = false; + + win.off( 'resize.lazySrc' ); + win.off( 'scroll.lazySrc' ); + + clearInterval( documentTimer ); + } + + return ({ + addImage: addImage, + removeImage: removeImage + }); + })(); + + function LazyImage( element ) { + var source = null; + var isRendered = false; + var height = null; + + element = $(element); + + // --- + // PUBLIC METHODS. + // --- + function isVisible( topFoldOffset, bottomFoldOffset ) { + if (!element.is(':visible')) { + //return( false ); + } + + bottomFoldOffset = bottomFoldOffset + 50; + + if ( height === null ) { + height = element.height(); + } + + var top = element.offset().top; + var bottom = ( top + height ); + + return ( + ( + ( top <= bottomFoldOffset ) && + ( top >= topFoldOffset ) + ) + || + ( + ( bottom <= bottomFoldOffset ) && + ( bottom >= topFoldOffset ) + ) + || + ( + ( top <= topFoldOffset ) && + ( bottom >= bottomFoldOffset ) + ) + ); + + } + + function renderSource() { + element[ 0 ].src = source; + element[ 0 ].classList.add('loaded'); + } + + function render() { + isRendered = true; + renderSource(); + } + + function setSource( newSource ) { + source = newSource; + if ( isRendered ) { + renderSource(); + } + } + + return ({ + isVisible: isVisible, + render: render, + setSource: setSource + }); + } + + function link( $scope, element, attributes ) { + var lazyImage = new LazyImage( element ); + + lazyLoader.addImage( lazyImage ); + + attributes.$observe( + 'lazySrc', + function( newSource ) { + lazyImage.setSource( newSource ); + } + ); + + $scope.$on( + '$destroy', + function() { + lazyLoader.removeImage( lazyImage ); + } + ); + } + + return ({ + link: link, + restrict: 'A' + }); +} diff --git a/web/public/src/app/filters.js b/web/public/src/app/filters.js new file mode 100644 index 0000000..f781431 --- /dev/null +++ b/web/public/src/app/filters.js @@ -0,0 +1,33 @@ +export function fileSize() { + return function(input) { + if (input >= 1073741824) { + input = Math.round((input / 1073741824) * 10) / 10 + 'GB'; + } else { + if (input >= 1048576) { + input = Math.round((input / 1048576) * 10) / 10 + 'MB'; + } else { + if (input >= 1024) { + input = Math.round((input / 1024) * 10) / 10 + 'KB'; + } else { + input = Math.round(input) + 'B'; + } + } + } + return input; + }; +} + + +export function direct() { + return function(file) { + if(file.name) { + if (file.direct && file.name.split('.').pop().toLowerCase() === 'psd') { + return file.direct['970x'].replace('/970/', '/').slice(0, -4); + } else if (file.direct && file.direct['970x']) { + return file.direct['970x'].replace('/970/', '/'); + } else { + return file.href.replace('hostr.co/', 'hostr.co/file/') + '/' + file.name; + } + } + }; +} diff --git a/web/public/src/app/services.js b/web/public/src/app/services.js new file mode 100644 index 0000000..38cee6b --- /dev/null +++ b/web/public/src/app/services.js @@ -0,0 +1,72 @@ +export class FileService { + constructor($resource, $cacheFactory) { + var cache = $cacheFactory('files-cache'); + return $resource(window.settings.apiURL + '/file/:id', {id: '@id'}, { + query: {method: 'GET', isArray: true, cache: cache, + params: {perpage: 0, all: true} + }, + delete: {method: 'DELETE', isArray: true, cache: cache} + }); + } + + static factory($resource, $cacheFactory) { + return new FileService($resource, $cacheFactory); + } +} + +export class UserService { + constructor($resource) { + return $resource(window.settings.apiURL + '/user'); + } + + static factory($resource) { + return new UserService($resource); + } +} + +export class EventService { + constructor($rootScope, ReconnectingWebSocket) { + if (window.user && WebSocket) { + let ws = new ReconnectingWebSocket('wss' + window.settings.apiURL.replace('https', '').replace('http', '') + '/user'); + ws.onmessage = function (msg) { + var evt = JSON.parse(msg.data); + $rootScope.$broadcast(evt.type, evt.data); + }; + ws.onopen = function() { + ws.send(JSON.stringify({authorization: window.user.token})); + }; + } + return true; + } + + static factory($rootScope, ReconnectingWebSocket) { + return new EventService($rootScope, ReconnectingWebSocket); + } +} + +export class TransactionService { + constructor ($resource, $cacheFactory) { + var cache = $cacheFactory('transaction-cache'); + return $resource(window.settings.apiURL + '/user/transaction/:id', {id: '@id'}, { + query: {method: 'GET', isArray: true, cache: cache} + }); + } + + static factory($resource, $cacheFactory) { + return new TransactionService($resource, $cacheFactory); + } +} + +export class SettingService { + constructor ($http) { + var service = {}; + service.update = function(data) { + return $http.post(window.settings.apiURL + '/user/settings', data); + }; + return service; + } + + static factory($http) { + return new SettingService($http); + } +} diff --git a/web/public/src/lib/smoothscroll.js b/web/public/src/lib/smoothscroll.js new file mode 100644 index 0000000..8ef14ba --- /dev/null +++ b/web/public/src/lib/smoothscroll.js @@ -0,0 +1,224 @@ +/* ============================================================= + + Smooth Scroll v4.5 + Animate scrolling to anchor links, by Chris Ferdinandi. + http://gomakethings.com + + Additional contributors: + https://github.com/cferdinandi/smooth-scroll#contributors + + Free to use under the MIT License. + http://gomakethings.com/mit/ + + * ============================================================= */ + +window.smoothScroll = (function (window, document, undefined) { + + 'use strict'; + + // Default settings + // Private {object} variable + var _defaults = { + speed: 500, + easing: 'easeInOutCubic', + offset: 0, + updateURL: false, + callbackBefore: function () {}, + callbackAfter: function () {} + }; + + // Merge default settings with user options + // Private method + // Returns an {object} + var _mergeObjects = function ( original, updates ) { + for (var key in updates) { + original[key] = updates[key]; + } + return original; + }; + + // Calculate the easing pattern + // Private method + // Returns a decimal number + var _easingPattern = function ( type, time ) { + if ( type == 'easeInQuad' ) return time * time; // accelerating from zero velocity + if ( type == 'easeOutQuad' ) return time * (2 - time); // decelerating to zero velocity + if ( type == 'easeInOutQuad' ) return time < 0.5 ? 2 * time * time : -1 + (4 - 2 * time) * time; // acceleration until halfway, then deceleration + if ( type == 'easeInCubic' ) return time * time * time; // accelerating from zero velocity + if ( type == 'easeOutCubic' ) return (--time) * time * time + 1; // decelerating to zero velocity + if ( type == 'easeInOutCubic' ) return time < 0.5 ? 4 * time * time * time : (time - 1) * (2 * time - 2) * (2 * time - 2) + 1; // acceleration until halfway, then deceleration + if ( type == 'easeInQuart' ) return time * time * time * time; // accelerating from zero velocity + if ( type == 'easeOutQuart' ) return 1 - (--time) * time * time * time; // decelerating to zero velocity + if ( type == 'easeInOutQuart' ) return time < 0.5 ? 8 * time * time * time * time : 1 - 8 * (--time) * time * time * time; // acceleration until halfway, then deceleration + if ( type == 'easeInQuint' ) return time * time * time * time * time; // accelerating from zero velocity + if ( type == 'easeOutQuint' ) return 1 + (--time) * time * time * time * time; // decelerating to zero velocity + if ( type == 'easeInOutQuint' ) return time < 0.5 ? 16 * time * time * time * time * time : 1 + 16 * (--time) * time * time * time * time; // acceleration until halfway, then deceleration + return time; // no easing, no acceleration + }; + + // Calculate how far to scroll + // Private method + // Returns an integer + var _getEndLocation = function ( anchor, headerHeight, offset ) { + var location = 0; + if (anchor.offsetParent) { + do { + location += anchor.offsetTop; + anchor = anchor.offsetParent; + } while (anchor); + } + location = location - headerHeight - offset; + if ( location >= 0 ) { + return location; + } else { + return 0; + } + }; + + // Determine the document's height + // Private method + // Returns an integer + var _getDocumentHeight = function () { + return Math.max( + document.body.scrollHeight, document.documentElement.scrollHeight, + document.body.offsetHeight, document.documentElement.offsetHeight, + document.body.clientHeight, document.documentElement.clientHeight + ); + }; + + // Convert data-options attribute into an object of key/value pairs + // Private method + // Returns an {object} + var _getDataOptions = function ( options ) { + + if ( options === null || options === undefined ) { + return {}; + } else { + var settings = {}; // Create settings object + options = options.split(';'); // Split into array of options + + // Create a key/value pair for each setting + options.forEach( function(option) { + option = option.trim(); + if ( option !== '' ) { + option = option.split(':'); + settings[option[0]] = option[1].trim(); + } + }); + + return settings; + } + + }; + + // Update the URL + // Private method + // Runs functions + var _updateURL = function ( anchor, url ) { + if ( (url === true || url === 'true') && history.pushState ) { + history.pushState( {pos:anchor.id}, '', anchor ); + } + }; + + // Start/stop the scrolling animation + // Public method + // Runs functions + var animateScroll = function ( toggle, anchor, options, event ) { + + // Options and overrides + options = _mergeObjects( _defaults, options || {} ); // Merge user options with defaults + var overrides = _getDataOptions( toggle ? toggle.getAttribute('data-options') : null ); + var speed = parseInt(overrides.speed || options.speed, 10); + var easing = overrides.easing || options.easing; + var offset = parseInt(overrides.offset || options.offset, 10); + var updateURL = overrides.updateURL || options.updateURL; + + // Selectors and variables + var fixedHeader = document.querySelector('[data-scroll-header]'); // Get the fixed header + var headerHeight = fixedHeader === null ? 0 : (fixedHeader.offsetHeight + fixedHeader.offsetTop); // Get the height of a fixed header if one exists + var startLocation = window.pageYOffset; // Current location on the page + var endLocation = _getEndLocation( document.querySelector(anchor), headerHeight, offset ); // Scroll to location + var animationInterval; // interval timer + var distance = endLocation - startLocation; // distance to travel + var documentHeight = _getDocumentHeight(); + var timeLapsed = 0; + var percentage, position; + + // Prevent default click event + if ( toggle && toggle.tagName === 'A' && event ) { + event.preventDefault(); + } + + // Update URL + _updateURL(anchor, updateURL); + + // Stop the scroll animation when it reaches its target (or the bottom/top of page) + // Private method + // Runs functions + var _stopAnimateScroll = function (position, endLocation, animationInterval) { + var currentLocation = window.pageYOffset; + if ( position == endLocation || currentLocation == endLocation || ( (window.innerHeight + currentLocation) >= documentHeight ) ) { + clearInterval(animationInterval); + options.callbackAfter( toggle, anchor ); // Run callbacks after animation complete + } + }; + + // Loop scrolling animation + // Private method + // Runs functions + var _loopAnimateScroll = function () { + timeLapsed += 16; + percentage = ( timeLapsed / speed ); + percentage = ( percentage > 1 ) ? 1 : percentage; + position = startLocation + ( distance * _easingPattern(easing, percentage) ); + window.scrollTo( 0, Math.floor(position) ); + _stopAnimateScroll(position, endLocation, animationInterval); + }; + + // Set interval timer + // Private method + // Runs functions + var _startAnimateScroll = function () { + options.callbackBefore( toggle, anchor ); // Run callbacks before animating scroll + animationInterval = setInterval(_loopAnimateScroll, 16); + }; + + // Reset position to fix weird iOS bug + // https://github.com/cferdinandi/smooth-scroll/issues/45 + if ( window.pageYOffset === 0 ) { + window.scrollTo( 0, 0 ); + } + + // Start scrolling animation + _startAnimateScroll(); + + }; + + // Initialize Smooth Scroll + // Public method + // Runs functions + var init = function ( options ) { + + // Feature test before initializing + if ( 'querySelector' in document && 'addEventListener' in window && Array.prototype.forEach ) { + + // Selectors and variables + options = _mergeObjects( _defaults, options || {} ); // Merge user options with defaults + var toggles = document.querySelectorAll('[data-scroll]'); // Get smooth scroll toggles + + // When a toggle is clicked, run the click handler + Array.prototype.forEach.call(toggles, function (toggle, index) { + toggle.addEventListener('click', animateScroll.bind( null, toggle, toggle.getAttribute('href'), options ), false); + }); + + } + + }; + + // Return public methods + return { + init: init, + animateScroll: animateScroll + }; + +})(window, document); diff --git a/web/public/src/partials/account.html b/web/public/src/partials/account.html new file mode 100644 index 0000000..46e6fe3 --- /dev/null +++ b/web/public/src/partials/account.html @@ -0,0 +1,74 @@ + + + +
+
+
+ +
+
+
+
+

Go Pro and get 500MB per file upload, no daily upload limits, no advertising and more!

+ +

+ Plan + Free — 20 MB max filesize, 15 file daily upload limit. + + + {{user.uploads_today}}/15 files uploaded today +

+
+
+

+ Plan + Pro — 500 MB max filesize, no daily upload limit. + + + 0/∞ files uploaded today +

+
+
+ +
+
{{error}}
+
Updated your details successfully
+
+ + + + + Required. Password resets will be sent to this address. +
+ +
+ + + + Leave this field blank unless you want to update your password. +
+ +
+ +
+ + + + Required. When updating your details we require your current password. +
+ + + + +
+ +
+
+
+ + diff --git a/web/public/src/partials/apps.html b/web/public/src/partials/apps.html new file mode 100644 index 0000000..95a95b8 --- /dev/null +++ b/web/public/src/partials/apps.html @@ -0,0 +1,23 @@ + + +
+
+
+

Hostr for Mac and Windows

+
    +
  • Drag and drop without opening your browser.
  • +
  • Get links instantly without waiting for uploads to finish
  • +
  • Easily capture and share screenshots.
  • +
  • Quick access to recent files.
  • +
+
+ +
+
+ + diff --git a/web/public/src/partials/billing.html b/web/public/src/partials/billing.html new file mode 100644 index 0000000..35095c9 --- /dev/null +++ b/web/public/src/partials/billing.html @@ -0,0 +1,37 @@ + + +
+
+
+ +
+
+
+ + + + + + + + + + + + + +
DateProductAmount
{{transaction.date | date : "d MMM yy"}}{{transaction.description}}${{transaction.amount}}.00
+
+ You have no billing history. You should check out Pro right now! +
+
+
+
+
+ + diff --git a/web/public/src/partials/collection.html b/web/public/src/partials/collection.html new file mode 100644 index 0000000..b36ca69 --- /dev/null +++ b/web/public/src/partials/collection.html @@ -0,0 +1,70 @@ +
+ +
+ +

Hostr

+
+ + + download (6) +
+ +
+ +
+ + + +
+ kitty + .jpg + + + +
+ + \ No newline at end of file diff --git a/web/public/src/partials/file.html b/web/public/src/partials/file.html new file mode 100644 index 0000000..254f68b --- /dev/null +++ b/web/public/src/partials/file.html @@ -0,0 +1,53 @@ +
+ + + {{ ::file.name }} + +
+ +
+
+ File has not finished uploading… + Please wait. + +

{{file.percent}}%

+ +
+
+ {{file.percent}}% +
+
+
+
+ +
+ +
+ + +
+ + {{:: file.name }} +
+
+

This file has been scanned for viruses but may still not be safe.

+

+

+ +
+
diff --git a/web/public/src/partials/files.html b/web/public/src/partials/files.html new file mode 100644 index 0000000..0024552 --- /dev/null +++ b/web/public/src/partials/files.html @@ -0,0 +1,81 @@ + +
+
+
+
+

Files

+
+ +
+
+
+
+
+
+ {{ uploadingFile.name }} + + + +
+ +
+ +
+
+ + + +
+ {{:: file.name }} + + + +
+
+ +
+
+
+

Right now you have no files!

+

Drop a file onto the page or click to begin.

+

For even easier uploading and sharing download our apps for Mac and Windows. +

+
+
+
+ +
+ diff --git a/web/public/src/partials/footer.html b/web/public/src/partials/footer.html new file mode 100644 index 0000000..ebbb2a8 --- /dev/null +++ b/web/public/src/partials/footer.html @@ -0,0 +1,20 @@ + diff --git a/web/public/src/partials/header.html b/web/public/src/partials/header.html new file mode 100644 index 0000000..357701e --- /dev/null +++ b/web/public/src/partials/header.html @@ -0,0 +1,35 @@ +
+
+
+ +
+
+ + + {{email}} + + + + {{user.uploads_today}} +
+
+ +
+
+
diff --git a/web/public/src/partials/privacy.html b/web/public/src/partials/privacy.html new file mode 100644 index 0000000..ed02c04 --- /dev/null +++ b/web/public/src/partials/privacy.html @@ -0,0 +1,52 @@ + + +
+
+
+

Privacy Policy

+

Introduction

+

Hostr (we" or "us") values its visitors' privacy. This privacy policy is effective 16th February 2013; it summarizes what information we might collect from a registered user or other visitor ("you"), and what we will and will not do with it. + Please note that this privacy policy does not govern the collection and use of information by companies that Hostr does not control, nor by individuals not employed or managed by Hostr. If you visit a Web site that we mention or link to, be sure to review its privacy policy before providing the site with information.

+

What we do with your personally identifiable information

+

It is always up to you whether to disclose personally identifiable information to us, although if you elect not to do so, we reserve the right not to register you as a user or provide you with any products or services. "Personally identifiable information" means information that can be used to identify you as an individual, such as, for example:

+
    +
  • your name, company, email address, phone number, billing address, and shipping address your Hostr user ID and password
  • +
  • credit card information
  • +
  • any account-preference information you provide us
  • +
  • your computer's domain name and IP address, indicating
  • +
  • where your computer is located on the Internet
  • +
  • session data for your login session, so that our computer can ‘talk' to yours while you are logged in
  • +
+

If you do provide personally identifiable information to us, either directly or through a reseller or other business partner, we will: + not sell or rent it to a third party without your permission — although unless you opt out (see below), we may use your contact information to provide you with information we believe you need to know or may find useful, such as (for example) news about our services and products and modifications to the Terms of Service; take commercially reasonable precautions to protect the information from loss, misuse and unauthorized access, disclosure, alteration and destruction; + not use or disclose the information except:

+
    +
  • as necessary to provide services or products you have ordered, such as (for example) by providing it to a carrier to deliver products you have ordered;
  • +
  • in other ways described in this privacy policy or to which you have otherwise consented; in the aggregate with other information in such a way so that your identity cannot reasonably be determined (for example, statistical compilations);
  • +
  • as required by law, for example, in response to a subpoena or search warrant;
  • +
  • to outside auditors who have agreed to keep the information confidential;
  • +
  • as necessary to enforce the Terms of Service;
  • +
  • as necessary to protect the rights, safety, or property of Hostr, its users, or others; this may include (for example) exchanging information with other organizations for fraud protection and/or risk reduction.
  • +
+

Other information we collect

+

We may collect other information that cannot be readily used to identify you, such as (for example) the domain name and IP address of your computer. We may use this information, individually or in the aggregate, for technical administration of our Web site(s); research and development; customer- and account administration; and to help us focus our marketing efforts more precisely.

+

Cookies

+

Hostr uses "cookies" to store personal data on your computer. We may also link information stored on your computer in cookies with personal data about specific individuals stored on our servers. If you set up your Web browser (for example, Internet Explorer or Firefox) so that cookies are not allowed, you might not be able to use some or all of the features of our Web site(s).

+

External data storage sites

+

We may store your data on servers provided by third party hosting vendors with whom we have contracted.

+

Your privacy responsibilities

+

To help protect your privacy, be sure:

+
    +
  • not to share your user ID or password with anyone else;
  • +
  • to take customary precautions to guard against "malware" (viruses, Trojan horses, bots, etc.), for example by installing and updating suitable anti-virus software.
  • +
+

Changes to this privacy policy

+

We reserve the right to change this privacy policy as we deem necessary or appropriate because of legal compliance requirements or changes in our business practices. If you have provided us with an email address, we will endeavor to notify you, by email to that address, of any material change to how we will use personally identifiable information.

+

Questions or comments?

+

If you have questions or comments about Hostr's privacy policy, send an email to support@hostr.com.

+

Thank you for choosing Hostr!

+
+
+
+ + diff --git a/web/public/src/partials/pro.html b/web/public/src/partials/pro.html new file mode 100644 index 0000000..2c995bf --- /dev/null +++ b/web/public/src/partials/pro.html @@ -0,0 +1,67 @@ + +
+
+
+

Hostr Free

+ +

Free!

+ +
    +
  • Simple, Secure file sharing
  • +
  • 15 Uploads per day
  • +
  • Share 25MB files
  • +
  • Ads on popular files
  • +
+ + + +
+
+

Hostr Pro

+ +

$6/month

+ +
    +
  • All the features of Free
  • +
  • Unlimited file sharing
  • +
  • Share 500MB files
  • +
  • No ads, for you or your files
  • +
+ + + +
+
+
+
+

Questions?

+
+
+
+
+

Is there a minimum contract?

+

You can cancel whenever you like. We will downgrade your account at the end of your billing cycle.

+
+
+

Can I pay yearly?

+

Not yet, but we do plan to offer it in the near future.

+
+
+
+
+

How secure is my credit card?

+

Every step of the transaction is protected by 256-bit SSL. We use Stripe for our credit card transactions, meaning your card details are never transmitted to us.

+
+
+

What payment methods do you accept?

+

We accept Visa, MasterCard, and American Express.

+
+
+
+
+

Got more questions?

+

Hit us up on support@hostr.co or click the support link below.

+
+
+
+ diff --git a/web/public/src/partials/terms.html b/web/public/src/partials/terms.html new file mode 100644 index 0000000..be32462 --- /dev/null +++ b/web/public/src/partials/terms.html @@ -0,0 +1,36 @@ + + +
+
+
+

Hostr Terms of Service

+

Prohibited Content

+

Website Hosting

+

Hotlinking graphics is only permitted in the context of sharing, not for hosting your website.

+

Copyright Content

+

The use of warez, media files that you are not the rightful owner of, cracks, and any other forms of copyrighted software that you are not legally allowed to use/distribute are all strictly prohibited.

+

Pornography

+

Pornography of any kind is strictly prohibited. Pornography is defined as content which displays or links to displays of genitalia.

+

Viruses and General Malware

+

Viruses, Trojans, and any other harmful files or malware are all strictly prohibited.

+

Passworded Archives

+

The use of passworded archives is strictly prohibited, Hostr needs to be able to ascertain any archive does not contain any of the above material.

+

Split Archives

+

The use of split archives to circumvent the file size limit is strictly prohibited.

+
+ +

Breaching any of the above terms may result in your account being terminated without warning.

+ +
+ +

Hotlinking

+

Hotlinking images is permitted. However, abuse of this service may result in temporary limitations on your ability to hotlink. This will not affect your ability to continue using your account. Limitations may imposed permanently for continued abuse.

+ +
+ +

Hostr reserves the right to remove content for any reason it deems appropriate.

+
+
+
+ + diff --git a/web/public/styles/app.scss b/web/public/styles/app.scss new file mode 100644 index 0000000..551896c --- /dev/null +++ b/web/public/styles/app.scss @@ -0,0 +1,1048 @@ +[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + display: none !important; +} + +html, body { + height: 100%; +} + +$icon-font-path: "../jspm_packages/npm/bootstrap-sass@3.3.5/assets/fonts/bootstrap/"; + +// MAIN VARIABLES +$body-bg: #ededf3; +$text-color: #0f2e3e; +$brand-primary: #456470; +$link-color: #96a9b3; +$link-hover-color: darken($link-color, 15%); + +// TYPOGRAPHY +$font-family-sans-serif: "Open Sans", sans-serif; +$headings-font-family: "Lato", sans-serif; +$headings-font-weight: 300; + +$input-bg: transparent; +$input-border: $brand-primary; +$input-color-placeholder: $input-border; +$input-color: $input-border; +$input-border-focus: lighten($brand-primary, 15%); +$nav-link-hover-bg: transparent; +$br-thumbs: 3px; + +$nav-pills-active-link-hover-bg: none; +$nav-pills-active-link-hover-color: $brand-primary; + +$tooltip-bg: $brand-primary !default; + +@import '../jspm_packages/npm/bootstrap-sass@3.3.5/assets/stylesheets/bootstrap'; + +$throbber-color: #96d4a1 !default; +$throbber-highlight-color: #39b54a !default; +$throbber-height: 5px !default; +$throbber-segment-width: 5px !default; +$throbber-spacing: 5px !default; + +@mixin keyframes($name) { + @-webkit-keyframes #{$name} { + @content; + } + + @-moz-keyframes #{$name} { + @content; + } + + @-o-keyframes #{$name} { + @content; + } + + @keyframes #{$name} { + @content; + } +} + +@mixin animation($value) { + -webkit-animation: $value; + -moz-animation: $value; + -ms-animation: $value; + -o-animation: $value; + animation: $value; +} + +@include keyframes(drop) { + 0% { margin-top: -10px; } + 100% { margin-top: 0px; } +} + +@include keyframes(progress) { + 0% { height: 0px; } + 100% { height: 60px; } +} + +@include keyframes(throbber) { + 0% { + background: $throbber-color; + } + 10% { + background: $throbber-highlight-color; + } + 40% { + background: $throbber-color; + } +} + +@include keyframes(spinner) { + from { + transform: rotate(90deg) rotate(90deg); + } + to { + transform: rotate(270deg) rotate(270deg); + } +} + +.loading-indicator { + @include animation(spinner 400ms linear infinite); + position: fixed; + top: 25px; + right: 25px; + width: 25px; + height: 25px; + box-sizing: border-box; + border: solid 3px transparent; + border-top-color: $throbber-highlight-color; + border-left-color: $throbber-highlight-color; + border-radius: 50%; +} + +#filedrop-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 15; + display: none; + background: #75c844; + color: #fff; + h1 { + display: inline-block; + text-align: center; + width: 100%; + position: absolute; + top: 45%; + font-size: 5em; + } +} + +#dropdown-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; +} + +.throbber:not(:required) { + @include animation(throbber 1000ms 300ms infinite ease-out); + background: $throbber-color; + display: inline-block; + position: relative; + text-indent: -9999px; + width: $throbber-segment-width; + height: $throbber-height; + margin: 0 $throbber-segment-width + $throbber-spacing; + border-radius: 50px; + &:before, &:after { + background: $throbber-color; + content: '\x200B'; + display: inline-block; + width: $throbber-segment-width; + height: $throbber-height; + position: absolute; + top: 0; + border-radius: 50px; + } + &:before { + @include animation(throbber 1000ms 150ms infinite ease-out); + left: -($throbber-segment-width + $throbber-spacing); + } + &:after { + @include animation(throbber 1000ms 450ms infinite ease-out); + right: -($throbber-segment-width + $throbber-spacing); + } +} + +.dropdown:after, +.dropdown:before { + bottom: 100%; + left: 80%; + border: solid transparent; + content: " "; + height: 0; + width: 0; + position: absolute; + pointer-events: none; +} + +.dropdown:after { + border-color: rgba(136, 183, 213, 0); + border-bottom-color: #f5f5f8; + border-width: 6px; + margin-left: -18px; +} +.dropdown:before { + border-color: rgba(194, 225, 245, 0); + border-bottom-color: #c1c1c5; + border-width: 8px; + margin-left: -20px; +} + +.tooltip { + width: 154px; + font-size: 13px; +} + +.add { + a { + cursor: pointer; + } + img { + width: 19px; + height: 19px; + margin-left: 13px; + margin-top: -5px; + } +} + +a { + &:hover, + &:focus { + color: $link-hover-color; + text-decoration: none; + } +} + +#header-messages { + position: fixed; + top: 0; + left: 0; + right: 0; + text-align: center; + z-index: 100000; + + .message-label { + @include animation(progress 300ms 0ms ease-in-out); + font-family: 'Lato', sans-serif; + font-weight: 300; + height: 60px; + line-height: 50px; + padding-bottom: 10px; + position: absolute; + margin-left: auto; + margin-right: auto; + left: 0; + right: 0; + font-size: 1.4em; + } +} + +#header-error { + color: #a94442; + height: 60px; + font-size: 0.8em; + font-weight: 700; +} + +#header-progress { + @include animation(progress 300ms 0ms ease-in-out); + height: 60px; + background: #ccc; + color: #fff; + left: 0; + right: 0; + .meter { + @include animation(progress 300ms 0ms ease-in-out); + height: 60px; + position: absolute; + left: 0; + background: #75c844; + } + +} + +.file-progress { + position: absolute; + top: 50%; + left: 50%; + margin: -150px 0 0 -120px; + width: 240px; + height: 200px; + color: lighten(desaturate($text-color, 50%), 50%); + .percent { + // font-size: 24px; + color: #39b54a; + display: block; + margin: 15px 0; + } + + .progress { + overflow: hidden; + height: 2px; + margin-bottom: 20px; + background-color: #c6d5dd; + border-radius: 0; + box-shadow: none; + + .progress-bar { + background-color: #39b54a; + } + } +} + +.header { + padding-top: 70px; + text-align: center; + .logo { + text-align: center; + margin-bottom: 20px; + } + .download { + float: right; + margin-top: 7px; + padding: 0 15px; + } + .menu { + cursor: pointer; + position: relative; + float: right; + padding-bottom: 12px; + margin-bottom: 20px; + > img.dots { + margin-top: 12px; + } + img.avatar { + background: url(../images/person.png) no-repeat; + background-size: 100%; + border-radius: 14px; + border: 0px; + width: 28px; + } + .chevron { + padding-bottom: 20px; + background: url(../images/chevron20.svg) no-repeat center bottom; + } + .dropdown { + @include animation(drop 150ms 0ms ease-in-out); + display: none; + text-align: left; + position: absolute; + background: #f5f5f8; + border: 1px solid #c1c1c5; + width: 240px; + border-radius: 5px; + left: -168px; + top: 32px; + box-shadow: 0px 0px 0px 3px rgba(230, 230, 235, 0.5); + padding: 15px; + color: #96a9b3; + z-index: 1000; + a { + color: #2f4451; + display: block; + margin-bottom: 10px; + &:hover { + color: lighten(#2f4451, 15%); + } + } + a:last-child { + margin-bottom: 0; + } + .meta { + font-size: 12px; + div { + padding-bottom: 10px; + } + } + span { + padding-right: 10px; + &.filesize:after { + content: '\2022'; + padding-left: 12px; + } + } + } + .left.dropdown { + top: 40px; + left: -18px; + @media (max-width: $screen-sm-min) { + left: calc(50% - 100px); + } + } + .left.dropdown:after, + .left.dropdown:before { + left: 18%; + } + } + .user, .stream-title { + float: left; + text-align: left; + @media (max-width: $screen-sm-min) { + float: none; + margin: 0 auto; + } + .chevron { + margin-right: 10px; + } + h3 { + display: inline-block; + vertical-align: middle; + margin: 0; + } + } + .tooltip { + width: 320px; + font-size: 12px; + margin-left: 15px; + } + .remaining { + float: left; + background: url(../images/cloud_upload_font_awesome.svg) no-repeat right; + line-height: 1.8em; + margin-left: 10px; + padding-right: 20px; + color: #c3c3d1; + font-weight: bold; + @media (max-width: $screen-sm-min) { + float: none; + } + } + .limit { + color: #a94442; + background: url(../images/cloud_upload_font_awesome_red.svg) no-repeat right; + } + input[type=search] { + float: right; + outline: none; + background: url(../images/search.png) calc(100% - 10px) center no-repeat; + background-color: transparent; + border: 1px solid #ddd; + padding: 5px; + padding-left: 12px; + padding-right: 35px; + border-radius: 15px; + @media (max-width: $screen-sm-min) { + float: none; + margin: 0 auto; + margin-top: 20px; + padding-right: 12px; + padding-left: 35px; + text-align: center; + background: url(../images/search.png) 10px center no-repeat; + } + &::-webkit-input-placeholder { + color: #7b858e; + } + } +} + +.stream { + .prev, .next { + position: absolute; + top: 50%; + margin-top: -35px; + width: 30px; + height: 70px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: none; + border: none; + &:hover { + &:after, &:before { + background-color: $brand-primary; + } + } + &:after, &:before { + content: ''; + width: 40px; + height: 2px; + background-color: #123040; + display: block; + } + &:after { + -ms-transform: rotate(45deg); + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + margin-top: 26px; + } + &:before { + -ms-transform: rotate(-45deg); + -webkit-transform: rotate(-45deg); + transform: rotate(-45deg); + } + } + .prev { + left: 0; + } + .next { + right: 0; + &:after { + -ms-transform: rotate(-45deg); + -webkit-transform: rotate(-45deg); + transform: rotate(-45deg); + } + &:before { + -ms-transform: rotate(45deg); + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + } + } + .pagination { + margin-top: 50px; + a { + padding-left: 10px; + opacity: 0.5; + &.active { + opacity: 1; + } + &:hover { + opacity: 1; + } + img { + border-radius: 3px; + } + } + } + .content { + position: relative; + } +} + +.filename { + line-height: 26px; + word-wrap: break-word; +} + +.image-preview { + padding-top: 70px; + padding-bottom: 70px; + text-align: center; + img { + margin: 0 auto; + } +} + +.file-icon { + background: url(../images/file_icon.png) top left no-repeat; + width: 70px; + height: 96px; + display: block; +} + +.filetype { + color: $link-color; +} + +.file-preview { + position: relative; + margin-top: 170px; + padding-bottom: 50px; + text-align: center; + form { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + } + .file-icon { + margin-bottom: 35px; + } + span { + margin: 0 auto; + &.filename { + display: block; + } + } + .btn-download { + margin-top: 45px; + padding-right: 50px; + padding-left: 50px; + @include button-variant(white, #39b54a, #39b54a); + } + .banner { + border: 1px solid #ccc; + border-radius: 5px; + div { + position: absolute; + bottom: 4px; + right: 4px; + color: #bcbcbc; + font-size: 0.8em; + } + } + .banner-large { + width: 300px; + margin: 0 auto; + margin-top: -150px; + margin-bottom: 50px; + position: relative; + @media (min-width: $screen-lg-min) { + position: absolute; + top: 0px; + left: 15px; + margin: 0; + } + } + .banner-wide { + position: relative; + margin: 0 auto; + margin-top: 50px; + width: 468px; + } + .file-warning, .file-alert { + background: #ddd; + max-width: 420px; + padding: 20px; + padding-bottom: 10px; + margin: 0 auto; + border-radius: 5px; + margin-top: 40px; + font-size: 0.9em; + } + + .file-alert{ + max-width: 500px; + background: #ff847c; + color: #9d2e2e; + input { + background: #ffbebe; + border-color: #9d2e2e; + color: #9d2e2e; + } + } +} + +.files { + padding-top: 70px; + header h3 { + display: inline; + } + .row:first-child { + margin-bottom: 35px; + } + .truncate { + width: 130px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: $link-color; + } + .uploading { + width: 145px; + height: 98px; + background: #eee; + margin: 0 auto 5px; + padding-left: 60px; + padding-top: 38px; + } + .collection, + .file { + .zeroclipboard-is-hover { + color: darken($link-color, 15%); + cursor: pointer; + } + width: 152px; + height: 175px; + background-color: white; + margin-top: 35px; + border: 1px solid #d6d6dc; + box-shadow: 0px 0px 0px 3px #e6e6eb; + border-radius: $br-thumbs; + padding: 5px; + float: left; + -webkit-transition: 0.2s all ease-in; + margin-right: 5px; + @media (min-width: $screen-sm-min) { + margin-right: 37px; + &:nth-of-type(4n) { + margin-right: 0; + } + } + @media (min-width: $screen-md-min) { + margin-right: 45px; + &:nth-of-type(4n) { + margin-right: 45px; + } + &:nth-of-type(5n) { + margin-right: 0; + } + } + @media (min-width: $screen-lg-min) { + margin-right: 45px; + &:nth-of-type(4n) { + margin-right: 45px; + } + &:nth-of-type(5n) { + margin-right: 45px; + } + &:nth-of-type(6n) { + margin-right: 0 !important; + } + } + &:hover { + border-color: #76d3f9; + box-shadow: 0px 0px 0px 3px #cee5f3; + cursor: pointer; + } + .menu { + img { + position: relative; + cursor: pointer; + z-index: 11; + } + float: right; + position: relative; + .dropdown { + @include animation(drop 150ms 0ms ease-in-out); + display: none; + position: absolute; + background: #f5f5f8; + border: 1px solid #c1c1c5; + border-radius: 5px; + right: -10px; + min-width: 170px; + top: 24px; + box-shadow: 0px 0px 0px 3px rgba(230, 230, 235, 0.5); + padding: 15px; + color: #96a9b3; + z-index: 1000; + font-size: 12px; + a { + color: #2f4451; + &:hover { + color: lighten(#2f4451, 15%); + } + } + li:last-child { + margin-bottom: 0; + } + .sep { + padding-bottom: 15px; + border-bottom: 1px solid #ccc; + } + } + .dropdown:after, + .dropdown:before { + bottom: 100%; + left: 98%; + } + } + .image { + width: 140px; + height: 120px; + overflow: hidden; + border-radius: $br-thumbs; + margin: 0 auto 5px; + position: relative; + img { + margin: 0 auto; + position: absolute; + left: 50%; + -webkit-transform: translate(-50%); + transform: translate(-50%); + } + } + .title { + display: block; + margin-left: 5px; + &.num-files { + font-size: 12px; + color: #96a9b3; + } + } + } + + .jumbotron.info { + background: none; + + .lead { + text-align: center; + } + + .drop-zone { + border: 2px dashed #39b54a; + color: lighten(opacify($brand-primary, 0.1), 25%); + + border-radius: 30px; + + margin: 50px auto 50px; + + padding: 100px 15px 100px 15px; + text-align: center; + + .plus { + color: #39b54a; + } + + a { + color: $brand-primary; + &:hover { + color: lighten($brand-primary, 25%); + } + } + } + } + + .file { + height: 100px; + padding: 0; + background-color: lighten($body-bg, 2%); + a { + font-size: 13px; + } + .title { + margin-left: 3px; + float: left; + } + img { + float: right; + margin-top: 3px; + } + &:hover a { + color: $link-hover-color; + } + .image { + width: 150px; + height: 98px; + overflow: hidden; + border-radius: $br-thumbs; + margin: 0 auto 5px; + position: relative; + img, .throb-cont { + position: absolute; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + } + } + } +} + +.pro { + margin-top: 70px; + li { + margin-bottom: 10px; + } + + ul { + margin-bottom: 30px; + } + + h2 { + font-size: 1.7em; + margin-bottom: 15px; + } + + h3 { + font-size: 1.3em; + } + + .questions { + margin-top: 50px; + + h2 { + text-align: center; + } + + p { + text-align: left; + } + } +} + +.admin { + + padding-top: 50px; + + th { + padding: 0 0 15px 0; + + } + + .amount { + text-align: right; + } + + td { + padding: 0 0 10px 0; + } + .nav-pills { + > li { + &.active > a { + &, + &:hover, + &:focus { + font-weight: bold; + } + } + } + } + + span { + display: block; + } + + .type { + padding: 15px 0; + font-size: 16px; + } + + .lead, .info { + font-size: 13px; + padding: 15px; + border-radius: 4px; + a { + color: #fff; + } + } + + .bg-primary { + border: 1px solid $brand-primary; + } + + .bg-info { + border: 1px solid $brand-info; + } + + .required { + color: #FF524F; + } + + .btn-danger { + float: right; + } + + form { + + .form-group { + >span { + padding: 15px 0; + } + } + + .btn { + margin: 35px auto 0; + &-signup { + @include button-variant(white, #39b54a, #39b54a); + } + } + label { + // font-family: 'Lato', sans-serif; + font-weight: 300; + } + input { + border-radius: 3px; + // font-family: 'Lato', sans-serif; + font-weight: 300; + } + .has-error .form-control { + border-color: #FF524F; + &:focus { + border-color: #FF524F; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffb4b3; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffb4b3; + } + } + .has-warning .form-control { + border-color: #F93 !important; + &:focus { + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffd894; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffd894; + } + } + } + +} + +.apps { + + padding-left: 20px; + + h1, ul { + margin-bottom: 40px; + } + + li { + font-size: 1.2em; + padding-bottom: 10px; + list-style: none; + + &:before { + color: lighten($text-color, 25%); + content:"\2713\0020"; + } + } + + .downloads { + text-align: center; + } + + .btn { + margin-top: 10px; + font-size: 20px; + } + +} + +.mac-changelog { + padding: 0 10px; + h1 { + font-size: 1.3em; + } + p { + font-size: 1.1em; + padding-left: 5px; + } + a { + color: $text-color; + text-decoration: underline; + } + ul { + padding-top: 10px; + } +} + +.footer { + font-size: 12px; + margin: 50px auto; + text-align: center; + + .nav > li { + display: inline-block; + float: none; + } +} + +.error-page { + background: none; +} + +.stats { + padding-top: 50px; + .traffic { + font-size: 1.5em; + progress { + width: 100%; + } + .traffic-stats { + text-align: right; + } + .traffic-label { + float: left; + color: lighten($brand-primary, 25%); + } + } + .download-count { + font-size: 3em; + } +} diff --git a/web/public/styles/style.scss b/web/public/styles/style.scss new file mode 100644 index 0000000..4f29a3f --- /dev/null +++ b/web/public/styles/style.scss @@ -0,0 +1,469 @@ +[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { + display: none !important; +} + +html { + height: 100%; +} + +body { + min-height: 100%; +} + +$icon-font-path: "../jspm_packages/npm/bootstrap-sass@3.3.5/assets/fonts/bootstrap/"; + +// MAIN VARIABLES +$body-bg: #0d0d1a; +$text-color: #5c686e; +$brand-primary: #456470; +$link-hover-color: lighten($brand-primary, 15%); + +// TYPOGRAPHY +$font-family-sans-serif: 'Open Sans', sans-serif; +$headings-font-family: 'Lato', sans-serif; +$headings-font-weight: 300; +$headings-color: lighten($brand-primary, 30%); + +// MODIFY GIRD +$grid-gutter-width: 30px !default; +$container-tablet: ((750px + $grid-gutter-width)) !default; +$container-desktop: $container-tablet; +$container-large-desktop: $container-tablet; + +// FORMS + +$input-border-radius: 3px; + +// $input-bg: rgba(255,255,255,0); +// $input-border: $brand-primary; +// $input-color-placeholder: $input-border; +// $input-color: $input-border; +// $input-border-focus: lighten($brand-primary, 15%); + +$nav-link-hover-bg: rgba(255,255,255,0); + + +// bower: scss +@import '../jspm_packages/npm/bootstrap-sass@3.3.5/assets/stylesheets/bootstrap'; +// endbower +html, +body { + height: 100%; +} + +a { + &:hover, + &:focus { + color: $link-hover-color; + text-decoration: none; + } +} + +.header { + padding-top: 70px; + padding-bottom: 70px; + text-align: center; + .logo { + width: 26px; + margin: 0 auto; + } +} + +.btn { + width: auto; + margin: 90px auto 15px; + font-family: 'Lato', sans-serif; + font-weight: 300; + padding-left: 45px; + padding-right: 45px; + padding-top: 5px; + font-size: 30px; +} + +.btn-mast { + @include button-variant(white, rgba(255,255,255,0), white); +} + +.masthead { + @media (min-width: $screen-lg-min) { + height: 100%; + } + text-align: center; + background: url(../images/cloud_top.jpg) 0 0 no-repeat; + .container { + position: relative; + @media (max-width: $screen-lg-min) { + // height: 900px; + padding-top: 100px; + } + @media (min-width: $screen-lg-min) { + height: 100%; + > div:first-child { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50.1%, -50.1%); + -webkit-transform: translate(-50.1%, -50.08%); + } + } + .filetypes { + margin-right: auto; + margin-left: auto; + margin-top: 75px; + margin-bottom: 65px; + width: 505px; + @media (max-width: $screen-sm-min) { + width: 100%; + } + @include clearfix; + span { + float: left; + margin-right: 65px; + } + span:last-child { + margin-right: 0; + } + } + img { + margin: 0 auto; + } + .btn-mast { + @include button-variant(white, rgba(255,255,255,0), white); + } + .more { + position: absolute; + left: 50%; + bottom: 20px; + width: 100px; + transform: translate(-50%); + -webkit-transform: translate(-50%); + img { + display: block; + margin: 0 auto; + margin-top: 5px; + } + } + .app-download { + position: absolute; + top: 75px; + left: 80%; + a { + padding-right: 10px; + opacity: 0.25; + } + a:hover { + opacity: 0.5; + } + img { + vertical-align: baseline; + } + } + @media (max-width: $screen-lg-min) { + .more, .app-download { + display: none; + } + } + } +} + +.user-form { + display: block; + .lead { + text-align: center; + color: #8997A0; + } + .holder { + @media (max-width: $screen-sm-min) { + width: 100%; + } + width: 50%; + margin: 0 auto; + text-align: left; + background: #ededf3; + padding: 35px; + border-radius: 3px; + } + .alert { + padding: 8px 15px; + } + .alert-signin { + @media (max-width: $screen-sm-min) { + width: 100%; + } + margin: 10px auto; + width: 50%; + text-align: center; + } + form { + .btn { + margin: 35px auto 0; + &-signup { + @include button-variant(white, #39b54a, #39b54a); + } + } + label { + font-family: 'Lato', sans-serif; + font-weight: 300; + } + input { + border-radius: 3px; + font-family: 'Lato', sans-serif; + font-weight: 300; + } + .has-error .form-control { + border-color: #FF524F; + &:focus { + border-color: #FF524F; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffb4b3; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffb4b3; + } + } + .has-warning .form-control { + border-color: #F93 !important; + &:focus { + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffd894; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #ffd894; + } + } + .forgot, .terms { + text-decoration: underline; + } + } + .trail { + font-family: 'Lato', sans-serif; + font-weight: 300; + font-size: 1.15em; + text-align: center; + color: #8997A0; + padding-top: 15px; + } +} + +.headline { + .row { + margin-top: 115px; + > div { + > img { + margin-bottom: 100px; + } + .lead { + margin-top: 45px; + margin-bottom: 130px; + } + } + } + .lead { + @media (min-width: $screen-sm-min) { + font-size: floor($font-size-base * 1.45); + } + line-height: 1.7; + } + h1 { + position: relative; + .tag { + display: inline-block; + width: 36px; + height: 18px; + background-color: #cf0d39; + color: #fff; + font-size: 12px; + text-align: center; + line-height: 16px; + margin-left: 25px; + border-radius: 3px; + font-weight: 400; + vertical-align: middle; + } + } +} + +.features { + background-color: #0d0d1f; + @media (max-width: $screen-sm-min) { + text-align: center; + img { + margin: 0 auto; + } + } + .row { + margin-top: 120px; + &:first-child { + margin-top: 90px; + } + &:last-child { + margin-bottom: 90px; + } + } + h2, + h3 { + color: #309184; + } + h2 { + text-align: center; + } + .f1 .copy { + margin-top: 75px; + } + .f2 .copy { + margin-top: 40px; + } + p { + margin-top: 25px; + line-height: 1.7; + } +} + +.faq { + .row { + margin-top: 60px; + &:first-child { + margin-top: 100px; + margin-bottom: 100px; + @media (max-width: $screen-sm-min) { + margin-top: 60px; + margin-bottom: 60px; + } + } + &.cta { + margin-top: 100px; + margin-bottom: 50px; + } + } + h2 { + text-align: center; + } + h4 { + color: #309184; + } + p { + font-size: 13px; + margin-top: 20px; + line-height: 1.7; + } + .col-sm-4 { + padding-left: 30px; + padding-right: 30px; + @media (max-width: $screen-sm-min) { + padding-right: 8px; + padding-left: 8px; + } + } + .btn { + @media (max-width: $screen-sm-min) { + font-size: 24px; + } + } + text-align: center; + .btn-mast { + @include button-variant(white, #cf0d39, #cf0d39); + } +} + +.pro { + + h2 { + margin-bottom: 0; + } + + h3 { + color: #309184; + margin-top: 5px; + font-size: 1.2em; + } + + li { + padding-bottom: 5px; + &:before { + color: lighten($text-color, 25%); + content:"\2713\0020"; + } + } + + .pricing { + margin-bottom: 20px; + } + + .btn { + margin-top: 10px; + font-size: 20px; + } + +} + +.questions { + margin-top: 50px; + h3 { + color: #309184; + font-size: 1.3em; + } +} + +.apps { + + h1, ul { + margin-bottom: 40px; + } + + li { + font-size: 1.2em; + padding-bottom: 10px; + list-style: none; + + &:before { + color: lighten($text-color, 25%); + content:"\2713\0020"; + } + } + + .downloads { + text-align: center; + } + + .btn { + margin-top: 10px; + font-size: 20px; + } +} + +.footer { + font-size: 12px; + margin: 0 auto; + margin-top: 50px; + margin-bottom: 35px; + text-align: center; + + .nav > li { + display: inline-block; + float: none; + } + + p { + margin-top: 20px; + font-size: 90%; + } +} + + +.icon { + width: 30px; + height: 30px; + display: block; + &.file { + background: url(../images/file.png) 0 0 no-repeat; + } + &.folder { + background: url(../images/folder.png) 0 0 no-repeat; + } + &.music { + background: url(../images/music.png) 0 0 no-repeat; + } + &.url { + background: url(../images/url.png) 0 0 no-repeat; + } + &.video { + background: url(../images/video.png) 0 0 no-repeat; + } + &.camera { + background: url(../images/camera.png) 0 0 no-repeat; + } +} diff --git a/web/public/updaters/mac-changelog.html b/web/public/updaters/mac-changelog.html new file mode 100644 index 0000000..2ce33fa --- /dev/null +++ b/web/public/updaters/mac-changelog.html @@ -0,0 +1,33 @@ + + + + + + + Hostr - File not found + + + + + + +
+

Read this first

+

Note: If you're coming from 0.7.* you'll need to update manually.

+

Click here to download the latest version.

+

We're sorry for the inconvenience, but we think you'll agree the upgrade is worth it.

+

What's new in 0.8.0?

+
    +
  • Instant sharing: get links to the files you're uploading instantly
  • +
  • Tons of bugfixes (including automatic updates!)
  • +
+
+ + + + diff --git a/web/public/updaters/mac.xml b/web/public/updaters/mac.xml new file mode 100644 index 0000000..d3815e1 --- /dev/null +++ b/web/public/updaters/mac.xml @@ -0,0 +1,89 @@ + + + + Hostr's Changelog + https://hostr.co/updaters/mac.xml + Hostr updates. + en + + Version 0.8.0 + + https://hostr.co/updaters/mac-changelog.html + + Mon, 27 Jul 2014 20:55:00 +0000 + + + + Version 0.7.0 + + https://hostr.co/updaters/mac-changelog.html + + Mon, 7 Jul 2014 13:20:11 +0000 + + + + Version 0.6.0 + + https://hostr.co/updaters/mac/changelog + + Wed, 3 May 2013 23:20:11 +0000 + + + + Version 0.5.3 + + https://hostr.co/updaters/mac/changelog + + Wed, 24 Mar 2013 19:30:11 +0000 + + + + Version 0.5.2 + + https://hostr.co/updaters/mac/changelog + + Wed, 24 Mar 2013 19:30:11 +0000 + + + + Version 0.5.0 + + https://hostr.co/updaters/mac/changelog + + Wed, 24 Mar 2013 19:30:11 +0000 + + + + Version 0.4.0 + + https://hostr.co/updaters/mac/changelog + + Wed, 24 Mar 2013 19:30:11 +0000 + + + + Version 0.3.0 + + https://hostr.co/updaters/mac/changelog + + Wed, 24 Mar 2013 19:30:11 +0000 + + + + Version 0.2.0 + + https://hostr.co/updaters/mac/changelog + + Wed, 24 Mar 2013 19:30:11 +0000 + + + + Version 0.1.0 + + https://hostr.co/updaters/mac/changelog + + Wed, 23 Mar 2013 12:20:11 +0000 + + + + diff --git a/web/routes/file.js b/web/routes/file.js new file mode 100644 index 0000000..8e4c455 --- /dev/null +++ b/web/routes/file.js @@ -0,0 +1,59 @@ +import fs from 'fs'; +import path from 'path'; +import mime from 'mime-types'; +import hostrFileStream from '../../lib/hostr-file-stream'; +import { formatFile } from '../../lib/format'; + +import debugname from 'debug'; +const debug = debugname('hostr-web:file'); + +const storePath = process.env.STORE_PATH || path.join(process.env.HOME, '.hostr', 'uploads'); + +const userAgentCheck = function(userAgent) { + if (!userAgent){ + return false; + } + return userAgent.match(/^(wget|curl|vagrant)/i); +}; + +export function* get(id, name, size) { + debug('%s, %s, %s', id, name, size); + const file = yield this.db.Files.findOne({_id: id, 'file_name': name, 'status': 'active'}); + this.assert(file, 404); + let localPath = path.join(storePath, file._id[0], file._id + '_' + file.file_name); + let remotePath = path.join(file._id[0], file._id + '_' + file.file_name); + if (size > 0) { + localPath = path.join(storePath, file._id[0], size, file._id + '_' + file.file_name); + remotePath = path.join(size, file._id + '_' + file.file_name); + } + + let type = 'application/octet-stream'; + if (file.width > 0) { + type = mime.lookup(file.file_name); + } + + this.set('Content-type', type); + this.set('Expires', new Date(2020, 1).toISOString()); + this.set('Cache-control', 'max-age=2592000'); + + this.body = yield hostrFileStream(localPath, remotePath); +} + +export function* resized(size, id, name) { + yield get.call(this, id, name, size); +} + +export function* landing(id, next) { + if (id === 'config.js') { + return yield next; + } + const file = yield this.db.Files.findOne({_id: id}); + this.assert(file, 404); + if(userAgentCheck(this.headers['user-agent'])) { + return direct(file._id, file.file_name); + } + + const formattedFile = formatFile(file); + debug(formattedFile); + yield this.render('file', {file: formattedFile}); +} diff --git a/web/routes/index.js b/web/routes/index.js new file mode 100644 index 0000000..fc49641 --- /dev/null +++ b/web/routes/index.js @@ -0,0 +1,55 @@ +import uuid from 'node-uuid'; +import auth from '../lib/auth'; + +export function* main() { + if (this.session.user) { + if (this.query['app-token']) { + return this.redirect('/'); + } + const token = uuid.v4(); + yield this.redis.set(token, this.session.user.id, 'EX', 604800); + this.session.user.token = token; + yield this.render('index', {user: this.session.user}); + } else { + if (this.query['app-token']) { + const user = yield auth.fromToken(this, this.query['app-token']); + yield auth.setupSession(this, user); + this.redirect('/'); + } else if (this.cookies.r) { + const user = yield auth.fromCookie(this, this.cookies.r); + yield auth.setupSession(this, user); + this.redirect('/'); + } else { + yield this.render('marketing'); + } + } +} + +export function* staticPage(next) { + if (this.session.user) { + const token = uuid.v4(); + yield this.redis.set(token, this.session.user.id, 'EX', 604800); + this.session.user.token = token; + yield this.render('index', {user: this.session.user}); + } else { + switch(this.originalUrl) { + case '/terms': + yield this.render('terms'); + break; + case '/privacy': + yield this.render('privacy'); + break; + case '/pricing': + yield this.render('pricing'); + break; + case '/apps': + yield this.render('apps'); + break; + case '/stats': + yield this.render('index', {user: {}}); + break; + default: + yield next; + } + } +} diff --git a/web/routes/pro.js b/web/routes/pro.js new file mode 100644 index 0000000..fd20710 --- /dev/null +++ b/web/routes/pro.js @@ -0,0 +1,81 @@ +import path from 'path'; +import views from 'co-views'; +const render = views(path.join(__dirname, '/../views'), { default: 'ejs'}); +import Stripe from 'stripe'; +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); +import { Mandrill } from 'mandrill-api/mandrill'; +const mandrill = new Mandrill(process.env.MANDRILL_KEY); + +const fromEmail = process.env.EMAIL_FROM || 'nobody@example.com'; +const fromName = process.env.EMAIL_NAME || 'Nobody'; + +export function* create() { + const Users = this.db.Users; + const Transactions = this.db.Transactions; + const stripeToken = this.request.body.stripeToken; + + const createCustomer = { + card: stripeToken.id, + plan: 'usd_monthly', + email: this.session.email + }; + + const customer = yield stripe.customers.create(createCustomer); + + this.assert(customer.subscription.status === 'active', 400, '{"status": "error"}'); + + delete customer.subscriptions; + + yield Users.updateOne({_id: this.session.user.id}, {'$set': {'stripe_customer': customer, type: 'Pro'}}); + + const transaction = { + 'user_id': this.session.user.id, + amount: customer.subscription.plan.amount, + desc: customer.subscription.plan.name, + date: new Date(customer.subscription.plan.created * 1000) + }; + + yield Transactions.insertOne(transaction); + + this.session.user.plan = 'Pro'; + this.body = {status: 'active'}; + + let html = yield render('email/inlined/pro'); + let text = `Hey, thanks for upgrading to Hostr Pro! + + You've signed up for Hostr Pro Monthly at $6/Month. + + — Jonathan Cremin, Hostr Founder + `; + + mandrill.messages.send({message: { + html: html, + text: text, + subject: 'Hostr Pro', + 'from_email': fromEmail, + 'from_name': fromName, + to: [{ + email: this.session.user.email, + type: 'to' + }], + 'tags': [ + 'pro-upgrade' + ] + }}); +} + +export function* cancel() { + const Users = this.db.Users; + const user = yield Users.findOne({_id: this.session.user.id}); + + const confirmation = yield stripe.customers.cancelSubscription( + user.stripe_customer.id, + user.stripe_customer.subscription.id, + { 'at_period_end': true } + ); + + yield Users.updateOne({_id: this.session.user.id}, {'$set': {'stripe_customer.subscription': confirmation, type: 'Free'}}); + + this.session.user.plan = 'Pro'; + this.body = {status: 'inactive'}; +} diff --git a/web/routes/user.js b/web/routes/user.js new file mode 100644 index 0000000..5a5dda9 --- /dev/null +++ b/web/routes/user.js @@ -0,0 +1,85 @@ +import { authenticate, setupSession, signup as signupUser, activateUser, sendResetToken, validateResetToken, updatePassword } from '../lib/auth'; + +export function* signin() { + if (!this.request.body.email) { + return yield this.render('signin'); + } + + const user = yield authenticate(this, this.request.body.email, this.request.body.password); + if(!user) { + return yield this.render('signin', {error: 'Invalid login details'}); + } else if (user.activationCode) { + return yield this.render('signin', {error: 'Your account hasn\'t been activated yet. Check your for an activation email.'}); + } else { + yield setupSession(this, user); + this.redirect('/'); + } +} + + +export function* signup() { + if (!this.request.body.email) { + return yield this.render('signup'); + } + + if (this.request.body.email !== this.request.body.confirm_email) { + return yield this.render('signup', {error: 'Emails do not match.'}); + } else if (this.request.body.email && !this.request.body.terms) { + return yield this.render('signup', {error: 'You must agree to the terms of service.'}); + } else if (this.request.body.password && this.request.body.password.length < 7) { + return yield this.render('signup', {error: 'Password must be at least 7 characters long.'}); + } + const ip = this.headers['x-real-ip'] || this.ip; + const email = this.request.body.email; + const password = this.request.body.password; + try { + yield signupUser(this, email, password, ip); + } catch (e) { + return yield this.render('signup', {error: e.message}); + } + return yield this.render('signup', {message: 'Thanks for signing up, we\'ve sent you an email to activate your account.'}); +} + + +export function* forgot(token) { + const Reset = this.db.Reset; + const Users = this.db.Users; + if (this.request.body.email) { + var email = this.request.body.email; + yield sendResetToken(this, email); + return yield this.render('forgot', {message: 'We\'ve sent an email with a link to reset your password. Be sure to check your spam folder if you it doesn\'t appear within a few minutes', token: null}); + } else if (token && this.request.body.password) { + if (this.request.body.password.length < 7) { + return this.render('forgot', {error: 'Password needs to be at least 7 characters long.', token: token}); + } + const tokenUser = yield validateResetToken(this, token); + var userId = tokenUser._id; + yield updatePassword(this, userId, this.request.body.password); + yield Reset.remove({_id: userId}); + const user = yield Users.findOne({_id: userId}); + yield setupSession(this, user); + this.redirect('/'); + } else if (token.length) { + const tokenUser = yield validateResetToken(this, token); + if (!tokenUser) { + return yield this.render('forgot', {error: 'Invalid password reset token. It might be expired, or has already been used.', token: null}); + } else { + return yield this.render('forgot', {token: token}); + } + } else { + return yield this.render('forgot', {token: null}); + } +} + + +export function* logout() { + this.cookies.set('r', {expires: new Date(1), path: '/'}); + this.session = null; + this.redirect('/'); +} + + +export function* activate(code) { + yield activateUser(this, code); + this.redirect('/'); +} diff --git a/web/views/apps.ejs b/web/views/apps.ejs new file mode 100644 index 0000000..58d3e68 --- /dev/null +++ b/web/views/apps.ejs @@ -0,0 +1,55 @@ + + + + + Download Hostr for Mac and Windows | Hostr • Instant Sharing + + + + + + <%- CDN(['/styles/style.css']) %> + + + +
+ +
+ +
+
+
+

Hostr for Mac and Windows

+
    +
  • Drag and drop without opening your browser.
  • +
  • Get links instantly without waiting for uploads to finish
  • +
  • Easily capture and share screenshots.
  • +
  • Quick access to recent files.
  • +
+
+ +
+
+ + <% include footer.ejs %> + + + diff --git a/web/views/email/activate.html b/web/views/email/activate.html new file mode 100644 index 0000000..e66e8f8 --- /dev/null +++ b/web/views/email/activate.html @@ -0,0 +1,66 @@ + + + + + +Hostr Activation + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ Thanks for signing up to Hostr! +
+ Please confirm your email address by clicking the link below. +
+ We may need to send you critical information about our service and it is important that we have an accurate email address. +
+ Confirm email address +
+ — Jonathan Cremin, Hostr Founder +
+
+
+
+ + + diff --git a/web/views/email/forgot.html b/web/views/email/forgot.html new file mode 100644 index 0000000..014279d --- /dev/null +++ b/web/views/email/forgot.html @@ -0,0 +1,61 @@ + + + + + +Hostr Activation + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + +
+ It seems you've forgotten your password :( +
+ To set a new password click the link below. +
+ Set New Password +
+ — Jonathan Cremin, Hostr Founder +
+
+
+
+ + + diff --git a/web/views/email/inlined/activate.ejs b/web/views/email/inlined/activate.ejs new file mode 100644 index 0000000..4d22ad7 --- /dev/null +++ b/web/views/email/inlined/activate.ejs @@ -0,0 +1,111 @@ + + + + + +Hostr Activation + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + + + + +
+ Thanks for signing up to Hostr! +
+ Please confirm your email address by clicking the link below. +
+ We may need to send you critical information about our service and it is important that we have an accurate email address. +
+ Confirm email address +
+ — Jonathan Cremin, Hostr Founder +
+
+
+
+ + + diff --git a/web/views/email/inlined/forgot.ejs b/web/views/email/inlined/forgot.ejs new file mode 100644 index 0000000..e74b40a --- /dev/null +++ b/web/views/email/inlined/forgot.ejs @@ -0,0 +1,106 @@ + + + + + +Hostr Activation + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + + + + +
+ It seems you've forgotten your password :( +
+ To set a new password click the link below. +
+ Set New Password +
+ — Jonathan Cremin, Hostr Founder +
+
+
+
+ + + diff --git a/web/views/email/inlined/pro.ejs b/web/views/email/inlined/pro.ejs new file mode 100644 index 0000000..108e147 --- /dev/null +++ b/web/views/email/inlined/pro.ejs @@ -0,0 +1,101 @@ + + + + + +Hostr Pro + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + +
+ Thanks for upgrading to Hostr Pro! +
+ You've signed up for Hostr Pro Monthly at $6/month +
+ — Jonathan Cremin, Hostr Founder +
+
+
+
+ + + diff --git a/web/views/email/pro.html b/web/views/email/pro.html new file mode 100644 index 0000000..1f1cb57 --- /dev/null +++ b/web/views/email/pro.html @@ -0,0 +1,56 @@ + + + + + +Hostr Pro + + + + + + + + + + + + + + +
+
+ + + + +
+ + + + + + + + + + +
+ Thanks for upgrading to Hostr Pro! +
+ You've signed up for Hostr Pro Monthly at $6/month +
+ — Jonathan Cremin, Hostr Founder +
+
+
+
+ + + diff --git a/web/views/email/style.css b/web/views/email/style.css new file mode 100644 index 0000000..3bd01e3 --- /dev/null +++ b/web/views/email/style.css @@ -0,0 +1,271 @@ +/* ------------------------------------- + GLOBAL + A very basic CSS reset +------------------------------------- */ +* { + margin: 0; + padding: 0; + font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif; + box-sizing: border-box; + font-size: 14px; +} + +img { + max-width: 100%; +} + +body { + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: none; + width: 100% !important; + height: 100%; + line-height: 1.6; +} + +/* Let's make sure all tables have defaults */ +table td { + vertical-align: top; +} + +/* ------------------------------------- + BODY & CONTAINER +------------------------------------- */ +body { + background-color: #f6f6f6; +} + +.body-wrap { + background-color: #f6f6f6; + width: 100%; +} + +.container { + display: block !important; + max-width: 600px !important; + margin: 0 auto !important; + /* makes it centered */ + clear: both !important; +} + +.content { + max-width: 600px; + margin: 0 auto; + display: block; + padding: 20px; +} + +/* ------------------------------------- + HEADER, FOOTER, MAIN +------------------------------------- */ +.main { + background: #fff; + border: 1px solid #e9e9e9; + border-radius: 3px; +} + +.content-wrap { + padding: 20px; +} + +.content-block { + padding: 0 0 20px; +} + +.header { + width: 100%; + margin-bottom: 20px; +} + +.logo { + margin-top: 40px; + margin-bottom: 20px; +} + +.footer { + width: 100%; + clear: both; + color: #999; + padding: 20px; +} +.footer a { + color: #999; +} +.footer p, .footer a, .footer unsubscribe, .footer td { + font-size: 12px; +} + +/* ------------------------------------- + TYPOGRAPHY +------------------------------------- */ +h1, h2, h3 { + font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; + color: #000; + margin: 40px 0 0; + line-height: 1.2; + font-weight: 400; +} + +h1 { + font-size: 32px; + font-weight: 500; +} + +h2 { + font-size: 24px; +} + +h3 { + font-size: 18px; +} + +h4 { + font-size: 14px; + font-weight: 600; +} + +p, ul, ol { + margin-bottom: 10px; + font-weight: normal; +} +p li, ul li, ol li { + margin-left: 5px; + list-style-position: inside; +} + +/* ------------------------------------- + LINKS & BUTTONS +------------------------------------- */ +a { + color: #456470; + text-decoration: underline; +} + +.btn-primary { + text-decoration: none; + color: #FFF; + background-color: #456470; + border: solid #456470; + /* seems to be a bug stopping this from being applied above */ + border-color: #456470; + border-width: 10px 20px; + line-height: 2; + font-weight: bold; + text-align: center; + cursor: pointer; + display: inline-block; + border-radius: 5px; + text-transform: capitalize; +} + +/* ------------------------------------- + OTHER STYLES THAT MIGHT BE USEFUL +------------------------------------- */ +.last { + margin-bottom: 0; +} + +.first { + margin-top: 0; +} + +.aligncenter { + text-align: center; +} + +.alignright { + text-align: right; +} + +.alignleft { + text-align: left; +} + +.clear { + clear: both; +} + +/* ------------------------------------- + ALERTS + Change the class depending on warning email, good email or bad email +------------------------------------- */ +.alert { + font-size: 16px; + color: #fff; + font-weight: 500; + padding: 20px; + text-align: center; + border-radius: 3px 3px 0 0; +} +.alert a { + color: #fff; + text-decoration: none; + font-weight: 500; + font-size: 16px; +} +.alert.alert-warning { + background: #ff9f00; +} +.alert.alert-bad { + background: #d0021b; +} +.alert.alert-good { + background: #68b90f; +} + +/* ------------------------------------- + INVOICE + Styles for the billing table +------------------------------------- */ +.invoice { + margin: 40px auto; + text-align: left; + width: 80%; +} +.invoice td { + padding: 5px 0; +} +.invoice .invoice-items { + width: 100%; +} +.invoice .invoice-items td { + border-top: #eee 1px solid; +} +.invoice .invoice-items .total td { + border-top: 2px solid #333; + border-bottom: 2px solid #333; + font-weight: 700; +} + +/* ------------------------------------- + RESPONSIVE AND MOBILE FRIENDLY STYLES +------------------------------------- */ +@media only screen and (max-width: 640px) { + h1, h2, h3, h4 { + font-weight: 600 !important; + margin: 20px 0 5px !important; + } + + h1 { + font-size: 22px !important; + } + + h2 { + font-size: 18px !important; + } + + h3 { + font-size: 16px !important; + } + + .container { + width: 100% !important; + } + + .content, .content-wrapper { + padding: 10px !important; + } + + .invoice { + width: 100% !important; + } +} diff --git a/web/views/file.ejs b/web/views/file.ejs new file mode 100644 index 0000000..658e165 --- /dev/null +++ b/web/views/file.ejs @@ -0,0 +1,155 @@ + + + + + + + <%= file.name %> - Hostr, download and share anything. + + + + + <% if (file.width) { %> + + + <% } else { %> + + <% } %> + + + + + <% if(file.width > 280 && file.height > 150) { %> + + + + + + + <% } %> + + + +
+ + + <% if (file.width) { %><%=file.name %><% } %> + +
+ <% if (file.width) { %> +
+ +
+ <% } %> + + <% if (!file.width) { %> +
+ + <%=file.name %> + +
+ <% if (file.malware) { %> +
+

This file has been identified as likely being malware, proceed with caution.

+

+

+

+ <% } else { %> +
+

This file has been scanned for viruses but may still not be safe.

+

+

+ <% } %> + +
+
+ <% } %> + + + + + diff --git a/web/views/footer.ejs b/web/views/footer.ejs new file mode 100644 index 0000000..404e5e0 --- /dev/null +++ b/web/views/footer.ejs @@ -0,0 +1,17 @@ + diff --git a/web/views/forgot.ejs b/web/views/forgot.ejs new file mode 100644 index 0000000..afa5846 --- /dev/null +++ b/web/views/forgot.ejs @@ -0,0 +1,61 @@ + + + + + Password Reset | Hostr • Instant Sharing + + + + + + + + + +
+ +
+ +
+

Reset your Hostr password.

+ <% if(typeof message !== 'undefined') { %> + + <% } %> +
+
+ + <% if(typeof error !== 'undefined') { %> +
+ <%= error %> +
+ <% } %> +
+ <% if (token) { %> + + + <% } else { %> + + + <% } %> +
+ +
+
+

Actually, I remember it. Let me sign in.

+
+ + + <% include footer.ejs %> + + + diff --git a/web/views/index.ejs b/web/views/index.ejs new file mode 100644 index 0000000..2a830ed --- /dev/null +++ b/web/views/index.ejs @@ -0,0 +1,105 @@ + + + + + + + Hostr + + + + + + + + + + + + + + + + + + + + + > + +
+
+
{{progress.status}} {{progress.name}} • {{progress.percent| number:0}}%
+
+
+
{{uploadError}}
+
+
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+
+
+

404

+

We can't find the file you're looking for :(

+

The owner may have removed it or it may never have existed in the first place.

+ Try our homepage instead :) +
+
+
+
+
+ + +

Drop files to upload

+ + + <% if (typeof user.email !== 'undefined') { %> + + <% } %> + + + + + + + diff --git a/web/views/mac-update-changelog.ejs b/web/views/mac-update-changelog.ejs new file mode 100644 index 0000000..69a4702 --- /dev/null +++ b/web/views/mac-update-changelog.ejs @@ -0,0 +1,24 @@ + + + + Hostr Changelog + + + <%- CDN(['/styles/app.css']) %> + + +
+
+
+

Hostr 0.7.0

+

Changes

+
    +
  • Updated branding
  • +
  • Minor bugfixes
  • +
+

Stay tuned for a bigger update coming soon!

+
+
+
+ + diff --git a/web/views/mac-update.ejs b/web/views/mac-update.ejs new file mode 100644 index 0000000..508ee10 --- /dev/null +++ b/web/views/mac-update.ejs @@ -0,0 +1,25 @@ + + + + Hostr's Changelog + https://hostr.co/updaters/mac.xml + Hostr updates. + en + + Version 0.7.0 + + https://hostr.co/updaters/mac/changelog + + Mon, 7 Jul 2014 13:20:11 +0000 + + + + Version 0.6.0 + + https://hostr.co/updaters/mac/changelog + + Wed, 3 May 2013 23:20:11 +0000 + + + + diff --git a/web/views/marketing.ejs b/web/views/marketing.ejs new file mode 100644 index 0000000..fed680d --- /dev/null +++ b/web/views/marketing.ejs @@ -0,0 +1,186 @@ + + + + + Hostr • Instant Sharing + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ logo + +
+ +
+ + + + +
+
+ Learn More + +
+
+ +
+ +
+
+
+ +

Instant sharing. +
new
+

+

+ No more waiting for files to upload before sharing the links. Start uploading and immediately get the link to share with your friends or anyone. Super useful for those large files you need to share! +

+
+
+
+ + +
+
+
+
+

You can share

+
+
+ +
+
+ +
+ +
+
+

... anything and everything ...

+

From .avi to .zip, all file types are supported not just images. Share screenshots, movie clips, audio... anything you can think of!

+
+
+
+ +
+
+ +
+ +
+
+

... from anywhere.

+

Download our Mac and Windows apps to make sharing even simpler, with iOS and Android apps coming soon!

+
+
+ +
+
+
+ +
+
+
+

Still have questions?

+
+
+
+
+

Do have to I pay for hostr?

+

Nope, Hostr is free to use! You can upgrade your account to Pro for more features, but Hostr will always be available for free.

+
+
+

How much can I upload?

+

You can upload 15 files per day for free and unlimited files per day for Pro users.

+
+
+

How long are my files stored for?

+

Files are stored as long as your account is active. In addition, all files are redundantly backed up to Amazon Web Services.

+
+
+
+
+

What is the largest file size I can upload?

+

You can upload 20MB files with a free account and 500MB files with Pro.

+
+
+

Are there any bandwidth limits?

+

There are no bandwidth limits, however we reserve the right to suspend hotlinking of images in cases of abuse.

+
+
+

Will Hostr be around in 6 months?

+

Many sharing services have come and go, but we've been operating for 8 years. Hostr is a labour of love for us.

+
+
+
+
+

Convinced?

+ Create an account +
+
+
+ + + + + + + diff --git a/web/views/pricing.ejs b/web/views/pricing.ejs new file mode 100644 index 0000000..7d37191 --- /dev/null +++ b/web/views/pricing.ejs @@ -0,0 +1,104 @@ + + + + + Hostr Pro Pricing | Hostr • Instant Sharing + + + + + + <%- CDN(['/styles/style.css']) %> + + + +
+ +
+ +
+
+
+

Pricing

+

Hostr is available to everyone for free, no trial period, just free. If you want to get even more from Hostr, you can upgrade to Hostr Pro.

+
+
+ +
+
+

Hostr Free

+ +

Free!

+ +
    +
  • Simple, Secure file sharing
  • +
  • 15 Uploads per day
  • +
  • Share 25MB files
  • +
  • Ads only on popular files
  • +
+ + Sign Up +
+
+

Hostr Pro

+ +

$6/month

+ +
    +
  • All the features of Free
  • +
  • Unlimited file sharing
  • +
  • Share 500MB files
  • +
  • No ads, for you or your files
  • +
+ + Go Pro +
+
+
+ +
+
+
+

Questions?

+
+
+
+
+

Is there a minimum contract?

+

You can cancel whenever you like. We will downgrade your account at the end of your billing cycle.

+
+
+

Can I pay yearly?

+

Not yet, but we do plan to offer it in the near future.

+
+
+
+
+

How secure is my credit card?

+

Every step of the transaction is protected by 256-bit SSL. We use Stripe for our credit card transactions, meaning your card details are never transmitted to us.

+
+
+

What payment methods do you accept?

+

We accept Visa, MasterCard, and American Express.

+
+
+
+
+

Got more questions?

+

Hit us up on support@hostr.co and we'll be happy to answer them.

+
+
+
+ + <% include footer.ejs %> + + + diff --git a/web/views/privacy.ejs b/web/views/privacy.ejs new file mode 100644 index 0000000..5131b77 --- /dev/null +++ b/web/views/privacy.ejs @@ -0,0 +1,81 @@ + + + + + + + Hostr + + + + + + +
+ + +
+ +
+
+
+

Privacy Policy

+

Introduction

+

Hostr (we" or "us") values its visitors' privacy. This privacy policy is effective 16th February 2013; it summarizes what information we might collect from a registered user or other visitor ("you"), and what we will and will not do with it. + Please note that this privacy policy does not govern the collection and use of information by companies that Hostr does not control, nor by individuals not employed or managed by Hostr. If you visit a Web site that we mention or link to, be sure to review its privacy policy before providing the site with information.

+

What we do with your personally identifiable information

+

It is always up to you whether to disclose personally identifiable information to us, although if you elect not to do so, we reserve the right not to register you as a user or provide you with any products or services. "Personally identifiable information" means information that can be used to identify you as an individual, such as, for example:

+
    +
  • your name, company, email address, phone number, billing address, and shipping address your Hostr user ID and password
  • +
  • credit card information
  • +
  • any account-preference information you provide us
  • +
  • your computer's domain name and IP address, indicating
  • +
  • where your computer is located on the Internet
  • +
  • session data for your login session, so that our computer can ‘talk' to yours while you are logged in
  • +
+

If you do provide personally identifiable information to us, either directly or through a reseller or other business partner, we will: + not sell or rent it to a third party without your permission — although unless you opt out (see below), we may use your contact information to provide you with information we believe you need to know or may find useful, such as (for example) news about our services and products and modifications to the Terms of Service; take commercially reasonable precautions to protect the information from loss, misuse and unauthorized access, disclosure, alteration and destruction; + not use or disclose the information except:

+
    +
  • as necessary to provide services or products you have ordered, such as (for example) by providing it to a carrier to deliver products you have ordered;
  • +
  • in other ways described in this privacy policy or to which you have otherwise consented; in the aggregate with other information in such a way so that your identity cannot reasonably be determined (for example, statistical compilations);
  • +
  • as required by law, for example, in response to a subpoena or search warrant;
  • +
  • to outside auditors who have agreed to keep the information confidential;
  • +
  • as necessary to enforce the Terms of Service;
  • +
  • as necessary to protect the rights, safety, or property of Hostr, its users, or others; this may include (for example) exchanging information with other organizations for fraud protection and/or risk reduction.
  • +
+

Other information we collect

+

We may collect other information that cannot be readily used to identify you, such as (for example) the domain name and IP address of your computer. We may use this information, individually or in the aggregate, for technical administration of our Web site(s); research and development; customer- and account administration; and to help us focus our marketing efforts more precisely.

+

Cookies

+

Hostr uses "cookies" to store personal data on your computer. We may also link information stored on your computer in cookies with personal data about specific individuals stored on our servers. If you set up your Web browser (for example, Internet Explorer or Firefox) so that cookies are not allowed, you might not be able to use some or all of the features of our Web site(s).

+

External data storage sites

+

We may store your data on servers provided by third party hosting vendors with whom we have contracted.

+

Your privacy responsibilities

+

To help protect your privacy, be sure:

+
    +
  • not to share your user ID or password with anyone else;
  • +
  • to take customary precautions to guard against "malware" (viruses, Trojan horses, bots, etc.), for example by installing and updating suitable anti-virus software.
  • +
+

Changes to this privacy policy

+

We reserve the right to change this privacy policy as we deem necessary or appropriate because of legal compliance requirements or changes in our business practices. If you have provided us with an email address, we will endeavor to notify you, by email to that address, of any material change to how we will use personally identifiable information.

+

Questions or comments?

+

If you have questions or comments about Hostr's privacy policy, send an email to support@hostr.com.

+

Thank you for choosing Hostr!

+
+
+
+ + <% include footer.ejs %> + + + diff --git a/web/views/signin.ejs b/web/views/signin.ejs new file mode 100644 index 0000000..31c9f85 --- /dev/null +++ b/web/views/signin.ejs @@ -0,0 +1,66 @@ + + + + + Sign in | Hostr • Instant Sharing + + + + + + + + + +
+ +
+ +
+

Sign in to Hostr.

+ <% if(typeof message !== 'undefined') { %> + + <% } %> +
+
+ + <% if(typeof error !== 'undefined') { %> +
+ <%= error %> +
+ <% } %> +
+ + +
+
+ + +
+
+ +
+ + +
+
+

Don't have an account? Sign up now

+
+ + + <% include footer.ejs %> + + + diff --git a/web/views/signup.ejs b/web/views/signup.ejs new file mode 100644 index 0000000..826b678 --- /dev/null +++ b/web/views/signup.ejs @@ -0,0 +1,64 @@ + + + + + Hostr • Instant Sharing + + + + + + + + + +
+ +
+ +
+

Sign up to Hostr.

+
+
+ <% if(typeof error !== 'undefined') { %> +
+ <%= error %> +
+ <% } %> +
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+

Already have an account? Sign in

+
+ + + <% include footer.ejs %> + + + diff --git a/web/views/terms.ejs b/web/views/terms.ejs new file mode 100644 index 0000000..94347eb --- /dev/null +++ b/web/views/terms.ejs @@ -0,0 +1,65 @@ + + + + + + + Hostr + + + + + + +
+ + +
+ +
+
+
+

Hostr Terms of Service

+

Prohibited Content

+

Website Hosting

+

Hotlinking graphics is only permitted in the context of sharing, not for hosting your website.

+

Copyright Content

+

The use of warez, media files that you are not the rightful owner of, cracks, and any other forms of copyrighted software that you are not legally allowed to use/distribute are all strictly prohibited.

+

Pornography

+

Pornography of any kind is strictly prohibited. Pornography is defined as content which displays or links to displays of genitalia.

+

Viruses and General Malware

+

Viruses, Trojans, and any other harmful files or malware are all strictly prohibited.

+

Passworded Archives

+

The use of passworded archives is strictly prohibited, Hostr needs to be able to ascertain any archive does not contain any of the above material.

+

Split Archives

+

The use of split archives to circumvent the file size limit is strictly prohibited.

+
+ +

Breaching any of the above terms may result in your account being terminated without warning.

+ +
+ +

Hotlinking

+

Hotlinking images is permitted. However, abuse of this service may result in temporary limitations on your ability to hotlink. This will not affect your ability to continue using your account. Limitations may imposed permanently for continued abuse.

+ +
+ +

Hostr reserves the right to remove content for any reason it deems appropriate.

+
+
+
+ + <% include footer.ejs %> + + +