implemented storage uploader
This commit is contained in:
parent
aa15a46b61
commit
4dee8dfbb2
18 changed files with 516 additions and 7 deletions
|
@ -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
|
||||
|
|
12
api/error.js
Normal file
12
api/error.js
Normal 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
19
api/jwt.js
Normal 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
38
api/media/multer.js
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
|
@ -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
21
api/media/security.js
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
12
package.json
12
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",
|
||||
|
|
0
public/development/.gitkeep
Normal file
0
public/development/.gitkeep
Normal file
|
@ -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())
|
||||
|
||||
|
|
32
test/defaults.test.js
Normal file
32
test/defaults.test.js
Normal 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
54
test/error.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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: { },
|
||||
})
|
||||
}
|
||||
|
|
61
test/jwt.test.js
Normal file
61
test/jwt.test.js
Normal 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
61
test/media/api.test.js
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
101
test/media/security.test.js
Normal 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
BIN
test/media/test.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 278 KiB |
Loading…
Reference in a new issue