diff --git a/api/defaults.js b/api/defaults.js index 678754d..c807b6c 100644 --- a/api/defaults.js +++ b/api/defaults.js @@ -1,14 +1,20 @@ -export default function defaults(options, defaults) { +function defaults(options, def) { options = options || {} - Object.keys(defaults).forEach(function(key) { + Object.keys(def).forEach(function(key) { if (typeof options[key] === 'undefined') { // No need to do clone since we mostly deal with // flat objects - options[key] = defaults[key] + options[key] = def[key] + } + else if (typeof options[key] === 'object' && + typeof def[key] === 'object') { + options[key] = defaults(options[key], def[key]) } }) return options } + +export default defaults diff --git a/api/error.js b/api/error.js new file mode 100644 index 0000000..06dcf5e --- /dev/null +++ b/api/error.js @@ -0,0 +1,12 @@ + +export async function errorMiddleware(ctx, next) { + try { + await next() + } catch (e) { + ctx.status = 422 + ctx.body = { + status: 422, + message: e.message, + } + } +} diff --git a/api/jwt.js b/api/jwt.js new file mode 100644 index 0000000..3857021 --- /dev/null +++ b/api/jwt.js @@ -0,0 +1,19 @@ +import jwt from 'jsonwebtoken' + +export function sign(value, secret) { + return jwt.sign(value, secret) +} + +export function verify(token, secret) { + return new Promise((resolve, reject) => + jwt.verify(token, secret, (err, res) => { + if (err) return reject(err) + + resolve(res) + }) + ) +} + +export function decode(token) { + return jwt.decode(token) +} diff --git a/api/media/multer.js b/api/media/multer.js new file mode 100644 index 0000000..e97c510 --- /dev/null +++ b/api/media/multer.js @@ -0,0 +1,38 @@ +const fs = require('fs') +import multer from 'multer' + +const storage = multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, '/tmp/my-uploads') + }, + filename: function (req, file, cb) { + console.log(file) + cb(null, file.fieldname + '-' + Date.now()) + } +}) + +export function uploadFile(ctx, siteName) { + return new Promise((res, rej) => { + const date = new Date() + + // Generate 'YYYYMMDD_HHMMSS_' prefix + const prefix = date + .toISOString() + .replace(/-/g, '') + .replace('T', '_') + .replace(/:/g, '') + .replace(/\..+/, '_') + + const storage = multer.diskStorage({ + destination: `./public/${siteName}`, + filename: (req, file, cb) => + cb(null, `${prefix}${file.originalname}`), + }) + + multer({ storage: storage }) + .single('file')(ctx.req, ctx.res, (err, data) => { + if (err) return rej(err) + return res(ctx.req.file) + }) + }) +} diff --git a/api/media/routes.js b/api/media/routes.js index e69de29..0091ab1 100644 --- a/api/media/routes.js +++ b/api/media/routes.js @@ -0,0 +1,14 @@ +import config from '../../config' +import { verifyToken } from './security' +import { uploadFile, rename } from './multer' + +export async function upload(ctx) { + let site = await verifyToken(ctx) + + let result = await uploadFile(ctx, site) + + ctx.body = { + filename: result.filename, + path: `/${site}/${result.filename}` + } +} diff --git a/api/media/security.js b/api/media/security.js new file mode 100644 index 0000000..1a8f4d3 --- /dev/null +++ b/api/media/security.js @@ -0,0 +1,21 @@ +import * as jwt from '../jwt' +import config from '../../config' + +export async function verifyToken(ctx) { + if (!ctx.query.token) { + throw new Error('Token is missing in query') + } + + let decoded = jwt.decode(ctx.query.token) + + if (!decoded || !decoded.site) { + throw new Error('Token is invalid') + } + + let output = await jwt.verify( + ctx.query.token, + config.get(`sites:${decoded.site}`) + ) + + return output.site +} diff --git a/api/router.js b/api/router.js index 1e7a12a..b07f5f3 100644 --- a/api/router.js +++ b/api/router.js @@ -1,9 +1,11 @@ import Router from 'koa-router' import * as test from './test/routes' +import * as media from './media/routes' const router = new Router() router.get('/', test.testStatic) +router.post('/media', media.upload) export default router diff --git a/package.json b/package.json index 65e3c06..449e454 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,11 @@ "dev": "nodemon index.js", "start": "node index.js", "test": "env NODE_ENV=test mocha --require babel-register --recursive --reporter dot", - "docker": "docker run -it --rm --name my-running-script -v \"$PWD\":/usr/src/app -w /usr/src/app node:slim", - "docker:test": "npm run docker -- npm install --no-optional && npm run test", - "docker:dev": "npm run docker -- npm install --no-optional && npm run dev", - "docker:prod": "npm run docker -- npm install --no-optional && npm run start" + "docker": "docker run -it --rm --name my-running-script -v \"$PWD\":/usr/src/app -w /usr/src/app node:alpine", + "install:test": "npm install --no-optional && npm run test", + "install:dev": "npm install --no-optional && npm run dev", + "docker:test": "npm run docker -- npm run install:test", + "docker:dev": "npm run docker -- npm run install:dev" }, "repository": { "type": "git", @@ -26,10 +27,13 @@ "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", "babel-register": "^6.26.0", "koa": "^2.3.0", + "multer": "^1.3.0", "koa-router": "^7.2.1", + "jsonwebtoken": "^8.1.0", "nconf": "^0.8.5" }, "devDependencies": { + "app-root-path": "^2.0.1", "assert-extended": "^1.0.1", "mocha": "^4.0.1", "nodemon": "^1.12.1", diff --git a/public/development/.gitkeep b/public/development/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server.js b/server.js index 8b0679b..c61ca86 100644 --- a/server.js +++ b/server.js @@ -2,9 +2,11 @@ import Koa from 'koa' import config from './config' import router from './api/router' +import { errorMiddleware } from './api/error' const app = new Koa() +app.use(errorMiddleware) app.use(router.routes()) app.use(router.allowedMethods()) diff --git a/test/defaults.test.js b/test/defaults.test.js new file mode 100644 index 0000000..b4ac13c --- /dev/null +++ b/test/defaults.test.js @@ -0,0 +1,32 @@ +import assert from 'assert-extended' +import sinon from 'sinon' + +describe('defaults', () => { + const defaults = require('../api/defaults').default + + describe('#defaults()', () => { + it('should apply defaults to flat objects', () => { + let assertOutput = { a: 1 } + let output = defaults(null, { a: 1 }) + + assert.deepEqual(output, assertOutput) + }) + + it('should allow overriding defult properties', () => { + let assertOutput = { a: 2 } + let output = defaults(assertOutput, { a: 1 }) + + assert.deepEqual(output, assertOutput) + }) + + it('should allow nesting through objects', () => { + let def = { a: { b: 2 } } + let output = defaults({ a: { c: 3} }, def) + + assert.deepEqual(output.a, { + b: 2, + c: 3, + }) + }) + }) +}) diff --git a/test/error.test.js b/test/error.test.js new file mode 100644 index 0000000..906f73d --- /dev/null +++ b/test/error.test.js @@ -0,0 +1,54 @@ +import assert from 'assert-extended' +import sinon from 'sinon' + +import { createContext } from './helper.server' + +describe('Error (Middleware)', () => { + const error = require('../api/error') + + let ctx + + beforeEach(() => { + ctx = createContext({ }) + }) + + describe('#errorMiddleware()', () => { + let stub + + beforeEach(() => { + stub = sinon.stub() + }) + + it('should call next and not do anything if success', async () => { + await error.errorMiddleware(ctx, stub) + + assert.ok(stub.called) + assert.strictEqual(ctx.body, undefined) + assert.strictEqual(ctx.status, undefined) + }) + + it('should support stub throwing', async () => { + let assertError = new Error('testetytest') + stub.throws(assertError) + + await error.errorMiddleware(ctx, stub) + + assert.ok(ctx.body) + assert.strictEqual(ctx.status, 422) + assert.strictEqual(ctx.body.status, 422) + assert.strictEqual(ctx.body.message, assertError.message) + }) + + it('should support stub resolving false', async () => { + let assertError = new Error('herpaderpderp') + stub.rejects(assertError) + + await error.errorMiddleware(ctx, stub) + + assert.ok(ctx.body) + assert.strictEqual(ctx.status, 422) + assert.strictEqual(ctx.body.status, 422) + assert.strictEqual(ctx.body.message, assertError.message) + }) + }) +}) diff --git a/test/helper.server.js b/test/helper.server.js index aa5e899..9dd3098 100644 --- a/test/helper.server.js +++ b/test/helper.server.js @@ -2,7 +2,16 @@ // import sinon from 'sinon' import server from '../server' import client from './helper.client' +import defaults from '../api/defaults' after(() => server.close()) export const createClient = client + +export function createContext(opts) { + return defaults(opts, { + query: { }, + req: { }, + res: { }, + }) +} diff --git a/test/jwt.test.js b/test/jwt.test.js new file mode 100644 index 0000000..eebc9db --- /dev/null +++ b/test/jwt.test.js @@ -0,0 +1,61 @@ +import assert from 'assert-extended' +import sinon from 'sinon' + +describe('jwt', () => { + const jsonwebtoken = require('jsonwebtoken') + const jwt = require('../api/jwt') + + describe('#sign', () => { + it('should call security correctly', () => { + let token = jwt.sign({ a: 1 }, 'asdf') + assert.ok(token) + + let decoded = jsonwebtoken.decode(token) + assert.strictEqual(decoded.a, 1) + }) + + it('should support custom secret', done => { + const assertSecret = 'sdfagsda' + let token = jwt.sign({ a: 1 }, assertSecret) + + jsonwebtoken.verify(token, assertSecret, done) + }) + }) + + describe('#decode()', () => { + it('should decode correctly', () => { + let data = { a: 1, b: 2 } + let token = jwt.sign(data, 'asdf') + let decoded = jwt.decode(token) + + assert.strictEqual(decoded.a, data.a) + assert.strictEqual(decoded.b, data.b) + }) + }) + + describe('#verify', () => { + it('should verify correctly', () => { + const assertSecret = 'asdfasdf' + const assertResult = 23532 + let token = jwt.sign({ a: assertResult }, assertSecret) + + return assert.isFulfilled(jwt.verify(token, assertSecret)) + .then(data => assert.strictEqual(data.a, assertResult)) + }) + + it('should fail if secret does not match', () => { + const assertSecret = 'asdfasdf' + let token = jwt.sign({ a: 1 }, assertSecret) + + return assert.isRejected(jwt.verify(token, assertSecret + 'a')) + .then(err => assert.match(err.message, /[Ss]ignature/)) + }) + + it('should fail token has been mishandled', () => { + let token = jwt.sign({ a: 1 }, 'asdf') + + return assert.isRejected(jwt.verify(token + 'a', 'asdf')) + .then(err => assert.match(err.message, /[Ss]ignature/)) + }) + }) +}) diff --git a/test/media/api.test.js b/test/media/api.test.js new file mode 100644 index 0000000..2521244 --- /dev/null +++ b/test/media/api.test.js @@ -0,0 +1,61 @@ +import fs from 'fs' +import assert from 'assert' +import sinon from 'sinon' +import 'assert-extended' +import appRoot from 'app-root-path' + +import createClient from '../helper.client' + +describe('Media (API)', () => { + let config = require('../../config') + let jwt = require('../../api/jwt') + let testFile + let client + + before(() => { + config.set('sites', { + development: 'hello-world' + }) + }) + + after(done => { + if (testFile) { + return fs.unlink(appRoot.resolve(`/public${testFile}`), done) + } + done() + }) + + beforeEach(() => { + client = createClient() + }) + + describe('POST /media', function temp() { + this.timeout(10000) + + it('should require authentication', async () => { + let err = await assert.isRejected( + client.sendFileAsync('/media', + appRoot.resolve('/test/media/test.png'))) + + assert.strictEqual(err.status, 422) + assert.match(err.message, /[Tt]oken/) + }) + + it('should upload file and create file', async () => { + let token = jwt.sign({ site: 'development' }, 'hello-world') + + let data = await assert.isFulfilled( + client.sendFileAsync( + `/media?token=${token}`, + appRoot.resolve('/test/media/test.png') + ) + ) + + assert.ok(data) + assert.ok(data.filename) + assert.ok(data.path) + + testFile = data.path + }) + }) +}) diff --git a/test/media/routes.test.js b/test/media/routes.test.js index e69de29..f697495 100644 --- a/test/media/routes.test.js +++ b/test/media/routes.test.js @@ -0,0 +1,73 @@ +import assert from 'assert-extended' +import sinon from 'sinon' + +import { createContext } from '../helper.server' + +describe('Media (Routes)', () => { + const multer = require('../../api/media/multer') + const routes = require('../../api/media/routes') + const security = require('../../api/media/security') + const config = require('../../config') + + let sandbox + let ctx + + beforeEach(() => { + sandbox = sinon.sandbox.create() + ctx = createContext({ + req: { + file: { }, + }, + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#upload', () => { + let stubVerifyToken + let stubUpload + + beforeEach(() => { + stubVerifyToken = sandbox.stub(security, 'verifyToken') + stubUpload = sandbox.stub(multer, 'uploadFile').resolves({}) + }) + + it('should call security correctly', async () => { + const assertError = new Error('temp') + stubVerifyToken.rejects(assertError) + + let err = await assert.isRejected(routes.upload(ctx)) + + assert.ok(stubVerifyToken.called) + assert.strictEqual(err, assertError) + assert.strictEqual(stubVerifyToken.firstCall.args[0], ctx) + }) + + it('should call upload correctly', async () => { + const assertSiteName = 'benshapiro' + const assertError = new Error('hello') + stubVerifyToken.resolves(assertSiteName) + stubUpload.rejects(assertError) + + let err = await assert.isRejected(routes.upload(ctx)) + + assert.ok(stubUpload.called) + assert.strictEqual(err, assertError) + assert.strictEqual(stubUpload.firstCall.args[0], ctx) + assert.strictEqual(stubUpload.firstCall.args[1], assertSiteName) + }) + + it('should otherwise set context status to 204 and file in result', async () => { + const assertFilename = 'asdfsafd' + const assertSite = 'mario' + stubVerifyToken.resolves(assertSite) + stubUpload.resolves({ filename: assertFilename }) + await routes.upload(ctx) + + assert.strictEqual(ctx.body.filename, assertFilename) + assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}`) + }) + }) +}) diff --git a/test/media/security.test.js b/test/media/security.test.js new file mode 100644 index 0000000..6b1af4a --- /dev/null +++ b/test/media/security.test.js @@ -0,0 +1,101 @@ +import assert from 'assert-extended' +import sinon from 'sinon' + +import { createContext } from '../helper.server' + +describe('Media (Security)', () => { + const security = require('../../api/media/security') + const jwt = require('../../api/jwt') + const config = require('../../config') + + let sandbox + let ctx + + beforeEach(() => { + sandbox = sinon.sandbox.create() + config.set('sites', { + test: 'secret', + }) + ctx = createContext({ + query: { + token: 'asdf', + }, + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + describe('#verifyToken()', () => { + let stubVerify + let stubDecode + + beforeEach(() => { + stubVerify = sandbox.stub(jwt, 'verify') + stubDecode = sandbox.stub(jwt, 'decode').returns({ site: 1 }) + }) + + it('should fail if query token is missing', async () => { + delete ctx.query.token + + let err = await assert.isRejected(security.verifyToken(ctx)) + + assert.ok(err) + assert.match(err.message, /[tT]oken/) + assert.match(err.message, /[Mm]issing/) + }) + + it('should fail if token is invalid', async () => { + const assertToken = 'asdfasdfas' + ctx.query.token = assertToken + stubDecode.returns(null) + + let err = await assert.isRejected(security.verifyToken(ctx)) + + assert.ok(err) + assert.ok(stubDecode.called) + assert.strictEqual(stubDecode.firstCall.args[0], assertToken) + assert.match(err.message, /[tT]oken/) + assert.match(err.message, /[Ii]nvalid/) + }) + + it('should fail if token does not have site', async () => { + stubDecode.returns({ s: 1 }) + + let err = await assert.isRejected(security.verifyToken(ctx)) + + assert.ok(err) + assert.ok(stubDecode.called) + assert.match(err.message, /[tT]oken/) + assert.match(err.message, /[Ii]nvalid/) + }) + + it('should fail if secret does not match one in config', async () => { + const assertError = new Error('lethal') + const assertToken = 'ewgowae' + ctx.query.token = assertToken + config.set('sites', { herp: 'derp' }) + + + stubDecode.returns({ site: 'herp' }) + stubVerify.rejects(assertError) + + let err = await assert.isRejected(security.verifyToken(ctx)) + + assert.ok(stubVerify.called) + assert.strictEqual(err, assertError) + assert.strictEqual(stubVerify.firstCall.args[0], assertToken) + assert.strictEqual(stubVerify.firstCall.args[1], 'derp') + }) + + it('should otherwise return the site name', async () => { + const assertSiteName = 'asdfasdfasdf' + stubVerify.resolves({ site: assertSiteName }) + + let site = await security.verifyToken(ctx) + + assert.strictEqual(site, assertSiteName) + }) + }) +}) diff --git a/test/media/test.png b/test/media/test.png new file mode 100644 index 0000000..33533b9 Binary files /dev/null and b/test/media/test.png differ