Complete refactor, features and optimization of code

Removed large amount of dependencies.
Added vips support and automatically resizing based on any criteria.
Faster and leaner.
Added ability to fetch listing of files in folders.
dev
Jonatan Nilsson 2022-01-05 14:47:51 +00:00
parent d2e263afbe
commit 68eef3a6b6
12 changed files with 1286 additions and 34 deletions

View File

@ -3,18 +3,29 @@ import { HttpError } from '../error.mjs'
import formidable from 'formidable'
import config from '../config.mjs'
let lastDateString = ''
let incrementor = 1
export function uploadFile(ctx, siteName) {
return new Promise((res, rej) => {
const date = new Date()
// Generate 'YYYYMMDD_HHMMSS_' prefix
const prefix = date
let prefix = date
.toISOString()
.replace(/-/g, '')
.replace('T', '_')
.replace(/:/g, '')
.replace(/\..+/, '_')
// Append xx_ if same date is hit multiple times
if (prefix === lastDateString) {
prefix += incrementor.toString().padStart('2', '0') + '_'
incrementor++
} else {
lastDateString = prefix
}
var form = new formidable.IncomingForm()
form.uploadDir = `./public/${siteName}`
form.maxFileSize = config.get('fileSize')
@ -24,6 +35,13 @@ export function uploadFile(ctx, siteName) {
if (!files || !files.file) return rej(new HttpError('File in body was missing', 422))
let file = files.file
Object.keys(fields).forEach(function(key) {
try {
fields[key] = JSON.parse(fields[key])
} catch { }
})
ctx.req.body = fields
fs.rename(files.file.path, `./public/${siteName}/${prefix}${file.name}`, function(err) {
if (err) return rej(err)
file.path = `./public/${siteName}/${prefix}${file.name}`

View File

@ -1,3 +1,7 @@
import path from 'path'
import sharp from 'sharp-lite'
import fs from 'fs/promises'
import { HttpError } from '../error.mjs'
import * as security from './security.mjs'
import * as formidable from './formidable.mjs'
@ -6,19 +10,130 @@ export default class MediaRoutes {
Object.assign(this, {
security: opts.security || security,
formidable: opts.formidable || formidable,
sharp: opts.sharp || sharp,
fs: opts.fs || fs,
})
this.siteCache = new Map()
this.collator = new Intl.Collator('is-IS', { numeric: false, sensitivity: 'accent' })
}
init() {
return fs.readdir('./public').then(folders => {
return Promise.all(folders.map(folder => {
return fs.readdir('./public/' + folder)
.then(files => {
return Promise.all(files.map(file => {
return fs.stat(`./public/${folder}/${file}`)
.then(function(stat) {
return { name: file, size: stat.size }
})
}))
})
.then(files => {
this.siteCache.set(folder, files)
})
.catch(function() {})
}))
})
}
filesCacheGet(site) {
let files = this.siteCache.get(site) || []
return files.sort((a, b) => {
return this.collator.compare(a.name, b.name)
})
}
filesCacheAdd(site, filename, size) {
let arr = this.siteCache.get(site)
if (!arr) {
this.siteCache.set(site, arr = [])
}
arr.push({ name: filename, size: size })
}
async listFiles(ctx) {
let site = await this.security.verifyToken(ctx)
ctx.body = this.filesCacheGet(site)
}
async listPublicFiles(ctx) {
this.security.throwIfNotPublic(ctx.params.site)
ctx.body = this.filesCacheGet(ctx.params.site)
}
async upload(ctx) {
let site = await this.security.verifyToken(ctx)
ctx.state.site = site
let result = await this.formidable.uploadFile(ctx, site)
let result = await this.formidable.uploadFile(ctx, ctx.state.site)
ctx.log.info(`Uploaded ${result.filename}`)
let stat = await this.fs.stat(`./public/${ctx.state.site}/${result.filename}`)
this.filesCacheAdd(ctx.state.site, result.filename, stat.size)
ctx.body = {
filename: result.filename,
path: `/${site}/${result.filename}`
path: `/${ctx.state.site}/${result.filename}`
}
}
async resize(ctx) {
await this.upload(ctx)
this.security.verifyBody(ctx)
let out = {
original: ctx.body,
}
let keys = Object.keys(ctx.req.body)
await Promise.all(keys.map(key => {
return Promise.resolve()
.then(async () => {
let item = ctx.req.body[key]
let sharp = this.sharp(`./public/${ctx.state.site}/${ctx.body.filename}`)
.rotate()
if (item.resize) {
sharp = sharp.resize(item.resize)
}
sharp = sharp[item.format](item[item.format])
let target = ctx.body.filename
if (path.extname(target).length > 0) {
target = target.slice(0, -path.extname(target).length)
}
target += `_${key}.${item.format.replace('jpeg', 'jpg')}`
if (item.out === 'base64') {
let buffer = await sharp.toBuffer()
out[key] = {
base64: `data:image/${item.format};base64,` + buffer.toString('base64'),
}
return
}
await sharp.toFile(`./public/${ctx.state.site}/${target}`)
let stat = await this.fs.stat(`./public/${ctx.state.site}/${target}`)
this.filesCacheAdd(ctx.state.site, target, stat.size)
out[key] = {
filename: target,
path: `/${ctx.state.site}/${target}`,
}
}).then(
function() {},
function(err) {
throw new HttpError(`Error processing ${key}: ${err.message}`, 422)
}
)
}))
ctx.body = out
}
}

View File

@ -8,11 +8,71 @@ export function verifyToken(ctx) {
throw new HttpError('Token is missing in query', 422)
}
let org = config.get('sites')
let sites = {}
for (let key in org) {
if (org.hasOwnProperty(key)) {
sites[key] = org[key].keys
}
}
try {
let decoded = decode(token, config.get('sites'), [])
let decoded = decode(token, sites, [])
return decoded.iss
} catch (err) {
ctx.log.error(err, 'Error decoding token: ' + token)
throw new HttpError('Token was invalid', 422)
}
}
export function throwIfNotPublic(site) {
let sites = config.get('sites')
if (!sites[site] || sites[site].public !== true) {
throw new HttpError(`Requested site ${site} did not exist`, 404)
}
}
export function verifyBody(ctx) {
let keys = Object.keys(ctx.req.body)
for (let key of keys) {
if (key === 'original') {
throw new HttpError('Body item with name original is not allowed', 422)
}
let item = ctx.req.body[key]
if (typeof(item) !== 'object'
|| !item
|| Array.isArray(item)) {
throw new HttpError(`Body item ${key} was not valid`, 422)
}
if (typeof(item.format) !== 'string'
|| !item.format
|| item.format === 'resize'
|| item.format === 'out') {
throw new HttpError(`Body item ${key} missing valid format`, 422)
}
if (typeof(item[item.format]) !== 'object'
|| !item[item.format]
|| Array.isArray(item[item.format])) {
throw new HttpError(`Body item ${key} options for format ${item.format} was not valid`, 422)
}
if (item.out != null) {
if (typeof(item.out) !== 'string'
|| (item.out !== '' && item.out !== 'file' && item.out !== 'base64')
) {
throw new HttpError(`Body item ${key} key out was invalid`, 422)
}
}
if (item.resize != null) {
if (typeof(item.resize) !== 'object'
|| Array.isArray(item.resize)) {
throw new HttpError(`Body item ${key} key resize was invalid`, 422)
}
}
}
}

View File

@ -49,7 +49,13 @@ app.get('/', test.static.bind(test))
app.get('/error', test.error.bind(test))
const media = new MediaRoutes()
media.init().then(function() {}, function(err) {
log.error(err, 'Error initing media')
})
app.get('/media', [QueryHandler()], media.listFiles.bind(media))
app.get('/media/:site', [QueryHandler()], media.listPublicFiles.bind(media))
app.post('/media', [QueryHandler()], media.upload.bind(media))
app.post('/media/resize', [QueryHandler()], media.resize.bind(media))
app.listen(config.get('server:port'), function(a,b) {
log.info(`Server listening at ${config.get('server:port')}`)

View File

@ -9,7 +9,15 @@
},
"sites": {
"development": {
"default@HS256": "asdf1234"
"keys": {
"default@HS256": "asdf1234"
}
},
"existing": {
"public": true,
"keys": {
"default@HS256": "asdf1234"
}
}
}
}

View File

@ -21,9 +21,10 @@
"homepage": "https://github.com/nfp-projects/storage-upload#readme",
"dependencies": {
"bunyan-lite": "^1.1.1",
"flaska": "^0.9.7",
"flaska": "^0.9.8",
"formidable": "^1.2.2",
"nconf-lite": "^2.0.0"
"nconf-lite": "^2.0.0",
"sharp-lite": "^1.29.5"
},
"devDependencies": {
"eltro": "^1.2.3"

View File

View File

@ -66,23 +66,37 @@ Client.prototype.get = function(url = '/') {
return this.customRequest('GET', url, null)
}
Client.prototype.upload = function(url, file, method = 'POST') {
const random = (length = 8) => {
// Declare all characters
let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// Pick characers randomly
let str = '';
for (let i = 0; i < length; i++) {
str += chars.charAt(Math.floor(Math.random() * chars.length));
}
return str;
}
Client.prototype.upload = function(url, file, method = 'POST', body = {}) {
return fs.readFile(file).then(data => {
const crlf = '\r\n'
const filename = path.basename(file)
const boundary = `---------${Math.random().toString(16)}`
const headers = [
`Content-Disposition: form-data; name="file"; filename="${filename}"` + crlf,
]
const boundary = `---------${random(32)}`
const multipartBody = Buffer.concat([
Buffer.from(
`${crlf}--${boundary}${crlf}` +
headers.join('') + crlf
`Content-Disposition: form-data; name="file"; filename="${filename}"` + crlf + crlf
),
data,
Buffer.from(
`${crlf}--${boundary}--`
),
Buffer.concat(Object.keys(body).map(function(key) {
return Buffer.from(''
+ `${crlf}--${boundary}${crlf}`
+ `Content-Disposition: form-data; name="${key}"` + crlf + crlf
+ JSON.stringify(body[key])
)
})),
Buffer.from(`${crlf}--${boundary}--`),
])
return this.customRequest(method, url, multipartBody, {

View File

@ -29,8 +29,10 @@ export function resetLog() {
export function createContext(opts) {
return defaults(opts, {
query: new Map(),
params: { },
req: { },
res: { },
state: {},
log: {
log: stub(),
warn: stub(),

View File

@ -1,3 +1,4 @@
import sharp from 'sharp-lite'
import { Eltro as t, assert} from 'eltro'
import fs from 'fs/promises'
import { fileURLToPath } from 'url'
@ -16,12 +17,12 @@ function resolve(file) {
t.describe('Media (API)', () => {
const client = new Client()
const secret = 'asdf1234'
let testFile
let testFiles = []
t.after(function() {
if (testFile) {
return fs.unlink(resolve(`../../public/${testFile}`))
}
return Promise.all(testFiles.map(function(file) {
return fs.unlink(resolve(`../../public/${file}`)).catch(function() {})
}))
})
t.timeout(10000).describe('POST /media', function temp() {
@ -86,13 +87,300 @@ t.describe('Media (API)', () => {
assert.ok(data.filename)
assert.ok(data.path)
testFile = data.path
testFiles.push(data.path)
let stats = await Promise.all([
fs.stat(resolve('test.png')),
fs.stat(resolve(`../../public/${testFile}`)),
fs.stat(resolve(`../../public/${data.path}`)),
])
assert.strictEqual(stats[0].size, stats[1].size)
let img = await sharp(resolve(`../../public/${data.path}`)).metadata()
assert.strictEqual(img.width, 600)
assert.strictEqual(img.height, 700)
assert.strictEqual(img.format, 'png')
})
})
t.timeout(10000).describe('POST /media/resize', function temp() {
t.test('should require authentication', async () => {
resetLog()
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 0)
let err = await assert.isRejected(
client.upload('/media/resize',
resolve('test.png')
)
)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /[Mm]issing/)
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 2)
assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string')
assert.match(server.log.warn.firstCall[0], /[Tt]oken/)
assert.match(server.log.warn.firstCall[0], /[Mm]issing/)
})
t.test('should verify token correctly', async () => {
const assertToken = 'asdf.asdf.asdf'
resetLog()
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 0)
assert.strictEqual(server.log.info.callCount, 0)
let err = await assert.isRejected(
client.upload('/media/resize?token=' + assertToken,
resolve('test.png')
)
)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /[Ii]nvalid/)
assert.strictEqual(server.log.error.callCount, 1)
assert.strictEqual(server.log.warn.callCount, 2)
assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string')
assert.match(server.log.warn.firstCall[0], /[Tt]oken/)
assert.match(server.log.warn.firstCall[0], /[Ii]nvalid/)
assert.ok(server.log.error.lastCall[0] instanceof Error)
assert.match(server.log.error.lastCall[1], new RegExp(assertToken))
})
t.test('should upload file and create file', async () => {
let token = encode(null, { iss: 'development' }, secret)
let data = await assert.isFulfilled(
client.upload(
`/media/resize?token=${token}`,
resolve('test.png'),
'POST',
{ }
)
)
assert.ok(data)
assert.ok(data.original)
assert.ok(data.original.filename)
assert.ok(data.original.path)
testFiles.push(data.original.path)
let stats = await Promise.all([
fs.stat(resolve('test.png')),
fs.stat(resolve(`../../public/${data.original.path}`)),
])
assert.strictEqual(stats[0].size, stats[1].size)
let img = await sharp(resolve(`../../public/${data.original.path}`)).metadata()
assert.strictEqual(img.width, 600)
assert.strictEqual(img.height, 700)
assert.strictEqual(img.format, 'png')
})
t.test('should upload file and create multiple sizes for file', async () => {
let token = encode(null, { iss: 'development' }, secret)
let data = await assert.isFulfilled(
client.upload(
`/media/resize?token=${token}`,
resolve('test.png'),
'POST',
{
test1: {
format: 'jpeg',
resize: {
width: 300,
},
jpeg: {
quality: 80,
mozjpeg: true,
}
},
test2: {
format: 'png',
resize: {
width: 150,
},
png: {
compressionLevel: 9,
}
},
}
)
)
assert.ok(data)
assert.ok(data.original)
assert.ok(data.original.filename)
assert.ok(data.original.path)
assert.ok(data.test1.filename)
assert.ok(data.test1.path)
assert.ok(data.test2.filename)
assert.ok(data.test2.path)
testFiles.push(data.original.path)
testFiles.push(data.test1.path)
testFiles.push(data.test2.path)
let stats = await Promise.all([
fs.stat(resolve('test.png')),
fs.stat(resolve(`../../public/${data.original.path}`)),
])
assert.strictEqual(stats[0].size, stats[1].size)
let img = await sharp(resolve(`../../public/${data.original.path}`)).metadata()
assert.strictEqual(img.width, 600)
assert.strictEqual(img.height, 700)
assert.strictEqual(img.format, 'png')
img = await sharp(resolve(`../../public/${data.test1.path}`)).metadata()
assert.strictEqual(img.width, 300)
assert.strictEqual(img.height, 350)
assert.strictEqual(img.format, 'jpeg')
img = await sharp(resolve(`../../public/${data.test2.path}`)).metadata()
assert.strictEqual(img.width, 150)
assert.strictEqual(img.height, 175)
assert.strictEqual(img.format, 'png')
})
t.test('should upload file and support base64 output', async () => {
let token = encode(null, { iss: 'development' }, secret)
let data = await assert.isFulfilled(
client.upload(
`/media/resize?token=${token}`,
resolve('test.png'),
'POST',
{
outtest: {
out: 'base64',
format: 'jpeg',
resize: {
width: 10,
},
jpeg: {
quality: 80,
mozjpeg: true,
}
},
}
)
)
assert.ok(data)
assert.ok(data.original)
assert.ok(data.original.filename)
assert.ok(data.original.path)
assert.ok(data.outtest.base64)
testFiles.push(data.original.path)
let stats = await Promise.all([
fs.stat(resolve('test.png')),
fs.stat(resolve(`../../public/${data.original.path}`)),
])
assert.strictEqual(stats[0].size, stats[1].size)
let img = await sharp(resolve(`../../public/${data.original.path}`)).metadata()
assert.strictEqual(img.width, 600)
assert.strictEqual(img.height, 700)
assert.strictEqual(img.format, 'png')
let bufferBase64 = Buffer.from(data.outtest.base64.slice(data.outtest.base64.indexOf(',')), 'base64')
img = await sharp(bufferBase64).metadata()
assert.strictEqual(img.width, 10)
assert.strictEqual(img.height, 12)
assert.strictEqual(img.format, 'jpeg')
})
})
t.describe('GET /media', function() {
t.test('should require authentication', async () => {
resetLog()
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 0)
let err = await assert.isRejected(client.get('/media'))
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /[Mm]issing/)
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 2)
assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string')
assert.match(server.log.warn.firstCall[0], /[Tt]oken/)
assert.match(server.log.warn.firstCall[0], /[Mm]issing/)
})
t.test('should verify token correctly', async () => {
const assertToken = 'asdf.asdf.asdf'
resetLog()
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 0)
assert.strictEqual(server.log.info.callCount, 0)
let err = await assert.isRejected(client.get('/media?token=' + assertToken))
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /[Ii]nvalid/)
assert.strictEqual(server.log.error.callCount, 1)
assert.strictEqual(server.log.warn.callCount, 2)
assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string')
assert.match(server.log.warn.firstCall[0], /[Tt]oken/)
assert.match(server.log.warn.firstCall[0], /[Ii]nvalid/)
assert.ok(server.log.error.lastCall[0] instanceof Error)
assert.match(server.log.error.lastCall[1], new RegExp(assertToken))
})
t.test('should return list of files in specified folder', async () => {
let token = encode(null, { iss: 'development' }, secret)
let data = await client.get('/media?token=' + token)
assert.ok(data.length)
let found = false
for (let file of data) {
if (file.name === '.gitkeep' && file.size === 0) {
found = true
break
}
}
assert.ok(found)
})
})
t.describe('GET /media/:site', function() {
t.test('should give 404 on invalid sites', async () => {
let err = await assert.isRejected(client.get('/media/development'))
assert.strictEqual(err.status, 404)
err = await assert.isRejected(client.get('/media/nonexisting'))
assert.strictEqual(err.status, 404)
err = await assert.isRejected(client.get('/media/blabla'))
assert.strictEqual(err.status, 404)
})
t.test('should otherwise return list of files for a public site', async () => {
let files = await client.get('/media/existing')
assert.strictEqual(files[0].name, '20220105_101610_test1.jpg')
assert.strictEqual(files[0].size, 15079)
assert.strictEqual(files[1].name, '20220105_101610_test2.png')
assert.strictEqual(files[1].size, 31705)
})
})
})

View File

@ -2,22 +2,187 @@ import { Eltro as t, assert, stub } from 'eltro'
import { createContext } from '../helper.server.mjs'
import MediaRoutes from '../../api/media/routes.mjs'
import { HttpError } from '../../api/error.mjs'
t.describe('#upload', () => {
t.describe('#filesCacheGet', function() {
t.test('should return all files in public folder', async function() {
const routes = new MediaRoutes()
await routes.init()
assert.ok(routes.filesCacheGet('development'))
assert.ok(Array.isArray(routes.filesCacheGet('development')))
assert.ok(routes.filesCacheGet('existing'))
assert.ok(Array.isArray(routes.filesCacheGet('existing')))
assert.ok(routes.filesCacheGet('nonexisting'))
assert.ok(Array.isArray(routes.filesCacheGet('nonexisting')))
assert.strictEqual(routes.filesCacheGet('development').length, 1)
assert.strictEqual(routes.filesCacheGet('existing').length, 2)
assert.strictEqual(routes.filesCacheGet('nonexisting').length, 0)
assert.strictEqual(routes.filesCacheGet('development')[0].name, '.gitkeep')
assert.strictEqual(routes.filesCacheGet('development')[0].size, 0)
assert.strictEqual(routes.filesCacheGet('existing')[0].name, '20220105_101610_test1.jpg')
assert.strictEqual(routes.filesCacheGet('existing')[0].size, 15079)
assert.strictEqual(routes.filesCacheGet('existing')[1].name, '20220105_101610_test2.png')
assert.strictEqual(routes.filesCacheGet('existing')[1].size, 31705)
})
t.test('should always sort result', async function() {
const routes = new MediaRoutes()
await routes.init()
let files = routes.filesCacheGet('existing')
assert.strictEqual(files[0].name, '20220105_101610_test1.jpg')
assert.strictEqual(files[0].size, 15079)
assert.strictEqual(files[1].name, '20220105_101610_test2.png')
assert.strictEqual(files[1].size, 31705)
routes.siteCache.get('existing').push({ name: '0000.png', size: 0 })
files = routes.filesCacheGet('existing')
assert.strictEqual(files[0].name, '0000.png')
assert.strictEqual(files[0].size, 0)
assert.strictEqual(files[1].name, '20220105_101610_test1.jpg')
assert.strictEqual(files[1].size, 15079)
assert.strictEqual(files[2].name, '20220105_101610_test2.png')
assert.strictEqual(files[2].size, 31705)
})
})
t.describe('#listFiles()', function() {
const stubVerify = stub()
const stubGetCache = stub()
const routes = new MediaRoutes({
security: { verifyToken: stubVerify },
})
routes.filesCacheGet = stubGetCache
function reset() {
stubVerify.reset()
stubGetCache.reset()
}
t.test('should call security correctly', async function() {
reset()
let ctx = createContext()
const assertError = new Error('temp')
stubVerify.rejects(assertError)
let err = await assert.isRejected(routes.listFiles(ctx))
assert.ok(stubVerify.called)
assert.strictEqual(err, assertError)
assert.strictEqual(stubVerify.firstCall[0], ctx)
})
t.test('should call filesCacheGet and return results', async function() {
reset()
let ctx = createContext()
const assertSiteName = 'benshapiro'
const assertResult = { a: 1 }
stubVerify.resolves(assertSiteName)
stubGetCache.returns(assertResult)
await routes.listFiles(ctx)
assert.ok(stubGetCache.called)
assert.strictEqual(stubGetCache.firstCall[0], assertSiteName)
assert.strictEqual(ctx.body, assertResult)
})
})
t.describe('#listPublicFiles()', function() {
const stubSitePublic = stub()
const stubGetCache = stub()
const routes = new MediaRoutes({
security: { throwIfNotPublic: stubSitePublic },
})
routes.filesCacheGet = stubGetCache
function reset() {
stubSitePublic.reset()
stubGetCache.reset()
}
t.test('should call security correctly', async function() {
reset()
let ctx = createContext()
const assertError = new Error('temp')
const assertSite = 'astalavista'
stubSitePublic.throws(assertError)
ctx.params.site = assertSite
let err = await assert.isRejected(routes.listPublicFiles(ctx))
assert.ok(stubSitePublic.called)
assert.strictEqual(err, assertError)
assert.strictEqual(stubSitePublic.firstCall[0], assertSite)
})
t.test('should call filesCacheGet and return results', async function() {
reset()
let ctx = createContext()
const assertSiteName = 'benshapiro'
const assertResult = { a: 1 }
ctx.params.site = assertSiteName
stubGetCache.returns(assertResult)
await routes.listPublicFiles(ctx)
assert.ok(stubGetCache.called)
assert.strictEqual(stubGetCache.firstCall[0], assertSiteName)
assert.strictEqual(ctx.body, assertResult)
})
})
t.describe('#filesCacheAdd', function() {
t.test('should auto-create array of site and add file', async function() {
const routes = new MediaRoutes()
const assertName = 'asdf.png'
const assertSize = 1234
await routes.init()
assert.ok(routes.filesCacheGet('nonexisting'))
assert.ok(Array.isArray(routes.filesCacheGet('nonexisting')))
assert.strictEqual(routes.filesCacheGet('nonexisting').length, 0)
routes.filesCacheAdd('nonexisting', assertName, assertSize)
assert.strictEqual(routes.filesCacheGet('nonexisting').length, 1)
assert.strictEqual(routes.filesCacheGet('nonexisting')[0].name, assertName)
assert.strictEqual(routes.filesCacheGet('nonexisting')[0].size, assertSize)
})
})
t.describe('#upload', function() {
const stubVerify = stub()
const stubUpload = stub()
const stubStat = stub()
const routes = new MediaRoutes({
security: { verifyToken: stubVerify },
formidable: { uploadFile: stubUpload },
fs: { stat: stubStat },
})
function reset() {
stubVerify.reset()
stubUpload.reset()
stubStat.reset()
}
t.test('should call security correctly', async () => {
t.test('should call security correctly', async function() {
reset()
let ctx = createContext()
@ -31,7 +196,7 @@ t.describe('#upload', () => {
assert.strictEqual(stubVerify.firstCall[0], ctx)
})
t.test('should call upload correctly', async () => {
t.test('should call upload correctly', async function() {
reset()
let ctx = createContext()
@ -48,17 +213,306 @@ t.describe('#upload', () => {
assert.strictEqual(stubUpload.firstCall[1], assertSiteName)
})
t.test('should otherwise set context status to 204 and file in result', async () => {
t.test('should otherwise set context status to 204 and file in result', async function() {
reset()
let ctx = createContext()
const assertSize = 1241412
const assertFilename = 'asdfsafd'
const assertSite = 'mario'
stubVerify.resolves(assertSite)
stubUpload.resolves({ filename: assertFilename })
stubStat.resolves({ size: assertSize })
await routes.upload(ctx)
assert.strictEqual(ctx.body.filename, assertFilename)
assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}`)
assert.ok(stubStat.called)
assert.strictEqual(stubStat.firstCall[0], `./public/${assertSite}/${assertFilename}`)
let filesFromCache = routes.filesCacheGet(assertSite)
assert.strictEqual(filesFromCache.length, 1)
assert.strictEqual(filesFromCache[0].name, assertFilename)
assert.strictEqual(filesFromCache[0].size, assertSize)
})
})
t.describe('#resize', function() {
const stubVerifyToken = stub()
const stubVerifyBody = stub()
const stubUpload = stub()
const stubSharp = stub()
const stubSharpResize = stub()
const stubSharpRotate = stub()
const stubSharpToFile = stub()
const stubSharpJpeg = stub()
const stubSharpPng = stub()
const stubSharpToBuffer = stub()
const stubStat = stub()
const routes = new MediaRoutes({
security: {
verifyToken: stubVerifyToken,
verifyBody: stubVerifyBody,
},
fs: { stat: stubStat },
formidable: { uploadFile: stubUpload },
sharp: stubSharp,
})
function reset() {
stubVerifyToken.reset()
stubVerifyBody.reset()
stubUpload.reset()
let def = {
toFile: stubSharpToFile,
jpeg: stubSharpJpeg,
png: stubSharpPng,
resize: stubSharpResize,
rotate: stubSharpRotate,
toBuffer: stubSharpToBuffer,
}
stubStat.reset()
stubStat.resolves({ size: 0 })
stubSharp.reset()
stubSharp.returns(def)
stubSharpToFile.reset()
stubSharpToFile.resolves()
stubSharpJpeg.reset()
stubSharpJpeg.returns(def)
stubSharpResize.reset()
stubSharpResize.returns(def)
stubSharpRotate.reset()
stubSharpRotate.returns(def)
stubSharpPng.reset()
stubSharpPng.returns(def)
stubSharpToBuffer.reset()
stubSharpToBuffer.resolves()
routes.siteCache.clear()
}
t.test('should call security verifyToken correctly', async function() {
reset()
let ctx = createContext({ req: { body: { }}})
const assertError = new Error('temp')
stubVerifyToken.rejects(assertError)
let err = await assert.isRejected(routes.resize(ctx))
assert.ok(stubVerifyToken.called)
assert.strictEqual(err, assertError)
assert.strictEqual(stubVerifyToken.firstCall[0], ctx)
})
t.test('should call upload correctly', async function() {
reset()
let ctx = createContext({ req: { body: { }}})
const assertSiteName = 'benshapiro'
const assertError = new Error('hello')
stubVerifyToken.resolves(assertSiteName)
stubUpload.rejects(assertError)
let err = await assert.isRejected(routes.resize(ctx))
assert.strictEqual(err, assertError)
assert.ok(stubUpload.called)
assert.strictEqual(stubUpload.firstCall[0], ctx)
assert.strictEqual(stubUpload.firstCall[1], assertSiteName)
})
t.test('should call security verifyBody correctly', async function() {
reset()
let ctx = createContext({ req: { body: { }}})
const assertError = new Error('temp')
stubUpload.resolves({ filename: 'bla' })
stubVerifyBody.throws(assertError)
let err = await assert.isRejected(routes.resize(ctx))
assert.strictEqual(err, assertError)
assert.ok(stubVerifyBody.called)
assert.strictEqual(stubVerifyBody.firstCall[0], ctx)
})
t.test('should otherwise return original in result', async function() {
reset()
let ctx = createContext({ req: { body: { }}})
const assertFilename = 'asdfsafd.png'
const assertSite = 'mario'
stubVerifyToken.resolves(assertSite)
stubUpload.resolves({ filename: assertFilename })
await routes.resize(ctx)
assert.notOk(stubSharp.called)
assert.strictEqual(ctx.body.original.filename, assertFilename)
assert.strictEqual(ctx.body.original.path, `/${assertSite}/${assertFilename}`)
})
t.test('should call sharp correctly if items are specified', async function() {
reset()
const assertKey = 'asdf'
const assertJpeg = { a: 1 }
const assertFilename = 'asdfsafd'
const assertSite = 'mario'
stubVerifyToken.resolves(assertSite)
stubUpload.resolves({ filename: assertFilename + '.png' })
let ctx = createContext({ req: { body: {
[assertKey]: {
format: 'jpeg',
}
}}})
ctx.req.body[assertKey].jpeg = assertJpeg
await routes.resize(ctx)
assert.ok(stubSharp.called)
assert.match(stubSharp.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}\.png`))
assert.ok(stubSharpRotate.called)
assert.ok(stubSharpJpeg.called)
assert.strictEqual(stubSharpJpeg.firstCall[0], assertJpeg)
assert.ok(stubSharpToFile.called)
assert.match(stubSharpToFile.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}_${assertKey}\.jpg`))
assert.notOk(stubSharpPng.called)
assert.notOk(stubSharpResize.called)
assert.strictEqual(ctx.body.original.filename, assertFilename + '.png')
assert.strictEqual(ctx.body.original.path, `/${assertSite}/${assertFilename}.png`)
assert.strictEqual(ctx.body[assertKey].filename, `${assertFilename}_${assertKey}\.jpg`)
assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.jpg`)
})
t.test('should call sharp correctly if items and resize are specified', async function() {
reset()
const assertKey = 'herpderp'
const assertPng = { a: 1 }
const assertResize = { a: 1 }
const assertFilename = 'asdfsafd'
const assertSite = 'mario'
stubVerifyToken.resolves(assertSite)
stubUpload.resolves({ filename: assertFilename + '.png' })
let called = 0
stubStat.returnWith(function() {
called += 10
return { size: called }
})
let ctx = createContext({ req: { body: {
[assertKey]: {
format: 'png',
}
}}})
ctx.req.body[assertKey].png = assertPng
ctx.req.body[assertKey].resize = assertResize
await routes.resize(ctx)
assert.ok(stubSharp.called)
assert.match(stubSharp.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}\.png`))
assert.ok(stubSharpRotate.called)
assert.ok(stubSharpPng.called)
assert.strictEqual(stubSharpPng.firstCall[0], assertPng)
assert.ok(stubSharpToFile.called)
assert.match(stubSharpToFile.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}_${assertKey}\.png`))
assert.notOk(stubSharpJpeg.called)
assert.ok(stubSharpResize.called)
assert.strictEqual(stubSharpResize.firstCall[0], assertResize)
assert.strictEqual(ctx.body.original.filename, assertFilename + '.png')
assert.strictEqual(ctx.body.original.path, `/${assertSite}/${assertFilename}.png`)
assert.strictEqual(ctx.body[assertKey].filename, `${assertFilename}_${assertKey}\.png`)
assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.png`)
let filesFromCache = routes.filesCacheGet(assertSite)
assert.strictEqual(filesFromCache.length, 2)
assert.strictEqual(filesFromCache[0].name, `${assertFilename}_${assertKey}\.png`)
assert.strictEqual(filesFromCache[0].size, 20)
assert.strictEqual(filesFromCache[1].name, assertFilename + '.png')
assert.strictEqual(filesFromCache[1].size, 10)
})
t.test('should notify which item failed if one fails', async function() {
reset()
const assertValidKey1 = 'herp'
const assertValidKey2 = 'derp'
const assertErrorKey = 'throwmyerr'
const assertErrorMessage = 'some message here'
stubVerifyToken.resolves('asdf')
stubUpload.resolves({ filename: 'file.png' })
let called = 0
stubStat.returnWith(function() {
called += 10
return { size: called }
})
let ctx = createContext({ req: { body: {
[assertValidKey1]: {
format: 'png',
png: { a: 1 },
},
[assertValidKey2]: {
format: 'png',
png: { a: 1 },
},
[assertErrorKey]: {
format: 'png',
png: { a: 1 },
},
}}})
stubSharpToFile.returnWith(function(file) {
if (file.match(new RegExp(assertErrorKey))) {
throw new Error(assertErrorMessage)
}
})
let err = await assert.isRejected(routes.resize(ctx))
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, new RegExp(assertErrorKey))
assert.match(err.message, new RegExp(assertErrorMessage))
})
t.test('should call sharp correctly and return base64 format if out is base64', async function() {
reset()
const assertKey = 'outtest'
const assertFilename = 'asdfsafd.png'
const assertSite = 'mario'
const assertBase64Data = 'asdf1234'
stubVerifyToken.resolves(assertSite)
stubUpload.resolves({ filename: assertFilename })
let ctx = createContext({ req: { body: {
[assertKey]: {
format: 'png',
png: { a: 1 },
out: 'base64',
}
}}})
stubSharpToBuffer.resolves(Buffer.from(assertBase64Data))
await routes.resize(ctx)
assert.ok(stubSharp.called)
assert.notOk(stubSharpToFile.called)
assert.ok(stubSharpToBuffer.called)
assert.ok(ctx.body[assertKey].base64)
assert.ok(ctx.body[assertKey].base64.startsWith(`data:image/png;base64,`))
let base64 = ctx.body[assertKey].base64
let bufferBase64 = Buffer.from(base64.slice(base64.indexOf(',')), 'base64')
assert.strictEqual(bufferBase64.toString(), assertBase64Data)
let filesFromCache = routes.filesCacheGet(assertSite)
assert.strictEqual(filesFromCache.length, 1)
assert.strictEqual(filesFromCache[0].name, assertFilename)
})
})

View File

@ -1,16 +1,64 @@
import { Eltro as t, assert} from 'eltro'
import { createContext } from '../helper.server.mjs'
import { verifyToken } from '../../api/media/security.mjs'
import { verifyToken, verifyBody, throwIfNotPublic } from '../../api/media/security.mjs'
import { HttpError } from '../../api/error.mjs'
import encode from '../../api/jwt/encode.mjs'
import config from '../../api/config.mjs'
t.describe('#throwIfNotPublic()', function() {
t.before(function() {
config.set('sites', {
justatest: {
},
justatest2: {
public: false,
},
justatest3: {
public: true,
},
})
})
t.test('should throw for sites that do not exist or are null', function() {
let tests = [
'justatest',
'justatest2',
'nonexisting1',
null,
]
tests.forEach(function(test) {
assert.throws(function() { throwIfNotPublic(test) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 404)
assert.match(err.message, new RegExp(test))
assert.match(err.message, /exist/i)
return true
}, `should throw with site ${test}`)
})
})
t.test('should pass for sites that allow public listing', function() {
let tests = [
'justatest3',
]
tests.forEach(function(test) {
assert.doesNotThrow(function() { throwIfNotPublic(test) }, `should not throw with site ${test}`)
})
})
})
t.describe('#verifyToken()', function() {
t.before(function() {
config.set('sites', {
justatest: {
'default@HS512': 'mysharedkey'
keys: {
'default@HS512': 'mysharedkey',
}
},
})
})
@ -23,8 +71,8 @@ t.describe('#verifyToken()', function() {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Qq]uery/)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /query/i)
assert.match(err.message, /token/i)
return true
})
})
@ -33,8 +81,8 @@ t.describe('#verifyToken()', function() {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Ii]nvalid/)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /invalid/i)
assert.match(err.message, /token/i)
return true
}
@ -49,7 +97,7 @@ t.describe('#verifyToken()', function() {
ctx.query.set('token', 'asdfasdgassdga.asdfasdg.sadfsadfas')
assert.throws(function() { verifyToken(ctx) }, assertInvalidToken)
assert.match(ctx.log.error.lastCall[0].message, /[Ii]nvalid/)
assert.match(ctx.log.error.lastCall[0].message, /invalid/i)
ctx.query.set('token', encode(
{ typ: 'JWT', alg: 'HS256' },
@ -74,7 +122,7 @@ t.describe('#verifyToken()', function() {
))
assert.throws(function() { verifyToken(ctx) }, assertInvalidToken)
assert.match(ctx.log.error.lastCall[0].message, /HS512/)
assert.match(ctx.log.error.lastCall[0].message, /[vV]erification/)
assert.match(ctx.log.error.lastCall[0].message, /Verification/i)
})
t.test('should otherwise return the issuer', function() {
@ -88,3 +136,241 @@ t.describe('#verifyToken()', function() {
assert.strictEqual(site, 'justatest')
})
})
t.describe('#verifyBody()', function() {
t.test('should succeed with empty body', function() {
let ctx = createContext({ req: { body: { } } })
verifyBody(ctx)
})
t.test('should fail with invalid body', function() {
let ctx = createContext({ req: { body: {
item: {}
} } })
let tests = [
[null, 'null'],
['', 'empty string'],
['asdf', 'string'],
[0, 'empty number'],
[123, 'number'],
[[], 'array'],
]
tests.forEach(function (check) {
ctx.req.body.item = check[0]
assert.throws(function() { verifyBody(ctx) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /body/i)
assert.match(err.message, /item/i)
assert.match(err.message, /valid/i)
return true
}, `should fail if body entry is ${check[1]}`)
})
})
t.test('should fail if an item has the name original', function() {
let ctx = createContext({ req: { body: {
original: {}
} } })
assert.throws(function() { verifyBody(ctx) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /body/i)
assert.match(err.message, /name/i)
assert.match(err.message, /original/i)
assert.match(err.message, /allowed/i)
return true
}, 'should fail if body item has the name original')
})
t.test('should require format string present in item', function() {
let ctx = createContext({ req: { body: {
item: {}
} } })
let tests = [
[undefined, 'undefined'],
[null, 'null'],
['', 'empty string'],
[{}, 'object'],
[0, 'empty number'],
[123, 'number'],
[[], 'array'],
['resize', 'not allow resize'],
['out', 'not allow out'],
]
tests.forEach(function (check) {
ctx.req.body.item = {
format: check[0],
}
assert.throws(function() { verifyBody(ctx) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /body/i)
assert.match(err.message, /format/i)
assert.match(err.message, /missing/i)
return true
}, `should fail if body item format is ${check[1]}`)
})
})
t.test('should require object of same name as format', function() {
let ctx = createContext({ req: { body: {
item: {}
} } })
let tests = [
[undefined, 'undefined'],
[null, 'null'],
['', 'emptystring'],
['asdf', 'string'],
[0, 'emptynumber'],
[123, 'number'],
[[], 'array'],
]
tests.forEach(function (check) {
ctx = createContext({ req: { body: {
item: {}
} } })
ctx.req.body.item.format = check[1]
ctx.req.body.item[check[1]] = check[0]
assert.throws(function() { verifyBody(ctx) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /body/i)
assert.match(err.message, /format/i)
assert.match(err.message, /options/i)
assert.match(err.message, /valid/i)
return true
}, `should fail if body item format options is ${check[1]}`)
})
})
t.test('should allow empty value or string in out in item', function() {
let ctx = createContext({ req: { body: {
item: {
format: 'test',
test: {},
}
} } })
let tests = [
[undefined, 'undefined'],
[null, 'null'],
['', 'empty string'],
['file', 'allow string file'],
['base64', 'allow base64'],
]
tests.forEach(function (check) {
ctx.req.body.item.out = check[0]
assert.doesNotThrow(function() {
verifyBody(ctx)
}, `should not throw with ${check[1]} in out`)
})
})
t.test('should fail if out is invalid value', function() {
let ctx = createContext({ req: { body: {
item: {
format: 'test',
test: {},
}
} } })
let tests = [
[{}, 'object'],
[0, 'empty number'],
[123, 'number'],
[[], 'array'],
['resize', 'not allow resize'],
['out', 'not allow out'],
['example', 'not allow example'],
]
tests.forEach(function (check) {
ctx.req.body.item.out = check[0]
assert.throws(function() { verifyBody(ctx) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /body/i)
assert.match(err.message, /item/i)
assert.match(err.message, /out/i)
assert.match(err.message, /valid/i)
return true
}, `should fail if body item out is ${check[1]}`)
})
})
t.test('should allow empty value or object in resize', function() {
let ctx = createContext({ req: { body: {
item: {
format: 'test',
test: {},
}
} } })
let tests = [
[undefined, 'undefined'],
[null, 'null'],
[{}, 'object'],
]
tests.forEach(function (check) {
ctx.req.body.item.resize = check[0]
assert.doesNotThrow(function() {
verifyBody(ctx)
}, `should not throw with ${check[1]} in resize`)
})
})
t.test('should fail if resize if specified is invalid', function() {
let ctx = createContext({ req: { body: {
item: {
format: 'test',
test: {},
}
} } })
let tests = [
['', 'emptystring'],
['asdf', 'string'],
[0, 'emptynumber'],
[123, 'number'],
[[], 'array'],
]
tests.forEach(function (check) {
ctx.req.body.item.resize = check[0]
assert.throws(function() { verifyBody(ctx) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /body/i)
assert.match(err.message, /item/i)
assert.match(err.message, /resize/i)
assert.match(err.message, /valid/i)
return true
}, `should fail if body item resize is ${check[1]}`)
})
})
})