implemented storage uploader

This commit is contained in:
Jonatan Nilsson 2017-12-10 09:45:38 +00:00
parent aa15a46b61
commit 4dee8dfbb2
18 changed files with 516 additions and 7 deletions

View file

@ -1,14 +1,20 @@
export default function defaults(options, defaults) { function defaults(options, def) {
options = options || {} options = options || {}
Object.keys(defaults).forEach(function(key) { Object.keys(def).forEach(function(key) {
if (typeof options[key] === 'undefined') { if (typeof options[key] === 'undefined') {
// No need to do clone since we mostly deal with // No need to do clone since we mostly deal with
// flat objects // 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 return options
} }
export default defaults

12
api/error.js Normal file
View file

@ -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,
}
}
}

19
api/jwt.js Normal file
View file

@ -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)
}

38
api/media/multer.js Normal file
View file

@ -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)
})
})
}

View file

@ -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}`
}
}

21
api/media/security.js Normal file
View file

@ -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
}

View file

@ -1,9 +1,11 @@
import Router from 'koa-router' import Router from 'koa-router'
import * as test from './test/routes' import * as test from './test/routes'
import * as media from './media/routes'
const router = new Router() const router = new Router()
router.get('/', test.testStatic) router.get('/', test.testStatic)
router.post('/media', media.upload)
export default router export default router

View file

@ -7,10 +7,11 @@
"dev": "nodemon index.js", "dev": "nodemon index.js",
"start": "node index.js", "start": "node index.js",
"test": "env NODE_ENV=test mocha --require babel-register --recursive --reporter dot", "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": "docker run -it --rm --name my-running-script -v \"$PWD\":/usr/src/app -w /usr/src/app node:alpine",
"docker:test": "npm run docker -- npm install --no-optional && npm run test", "install:test": "npm install --no-optional && npm run test",
"docker:dev": "npm run docker -- npm install --no-optional && npm run dev", "install:dev": "npm install --no-optional && npm run dev",
"docker:prod": "npm run docker -- npm install --no-optional && npm run start" "docker:test": "npm run docker -- npm run install:test",
"docker:dev": "npm run docker -- npm run install:dev"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -26,10 +27,13 @@
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0",
"babel-register": "^6.26.0", "babel-register": "^6.26.0",
"koa": "^2.3.0", "koa": "^2.3.0",
"multer": "^1.3.0",
"koa-router": "^7.2.1", "koa-router": "^7.2.1",
"jsonwebtoken": "^8.1.0",
"nconf": "^0.8.5" "nconf": "^0.8.5"
}, },
"devDependencies": { "devDependencies": {
"app-root-path": "^2.0.1",
"assert-extended": "^1.0.1", "assert-extended": "^1.0.1",
"mocha": "^4.0.1", "mocha": "^4.0.1",
"nodemon": "^1.12.1", "nodemon": "^1.12.1",

View file

View file

@ -2,9 +2,11 @@ import Koa from 'koa'
import config from './config' import config from './config'
import router from './api/router' import router from './api/router'
import { errorMiddleware } from './api/error'
const app = new Koa() const app = new Koa()
app.use(errorMiddleware)
app.use(router.routes()) app.use(router.routes())
app.use(router.allowedMethods()) app.use(router.allowedMethods())

32
test/defaults.test.js Normal file
View file

@ -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,
})
})
})
})

54
test/error.test.js Normal file
View file

@ -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)
})
})
})

View file

@ -2,7 +2,16 @@
// import sinon from 'sinon' // import sinon from 'sinon'
import server from '../server' import server from '../server'
import client from './helper.client' import client from './helper.client'
import defaults from '../api/defaults'
after(() => server.close()) after(() => server.close())
export const createClient = client export const createClient = client
export function createContext(opts) {
return defaults(opts, {
query: { },
req: { },
res: { },
})
}

61
test/jwt.test.js Normal file
View file

@ -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/))
})
})
})

61
test/media/api.test.js Normal file
View file

@ -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
})
})
})

View file

@ -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}`)
})
})
})

101
test/media/security.test.js Normal file
View file

@ -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)
})
})
})

BIN
test/media/test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB