Flaska: HEAD request support across the board.
All checks were successful
continuous-integration/appveyor/branch AppVeyor build succeeded
All checks were successful
continuous-integration/appveyor/branch AppVeyor build succeeded
Flaska: Set status to 204 if status is 200 and body is null Flaska: Fix implementation of how content-type is set FileResponse: Don't open file if running HEAD request
This commit is contained in:
parent
d4bac4940e
commit
b97e34c1eb
5 changed files with 637 additions and 216 deletions
53
flaska.mjs
53
flaska.mjs
|
@ -376,10 +376,13 @@ export class FileResponse {
|
||||||
ctx.type = found[found.length - 1]
|
ctx.type = found[found.length - 1]
|
||||||
}
|
}
|
||||||
|
|
||||||
let stream = useFs.createReadStream(this.filepath, readOptions)
|
|
||||||
ctx.headers['Last-Modified'] = lastModified
|
ctx.headers['Last-Modified'] = lastModified
|
||||||
ctx.headers['Content-Length'] = size
|
ctx.headers['Content-Length'] = size
|
||||||
return stream
|
if (ctx.method !== 'HEAD') {
|
||||||
|
let stream = useFs.createReadStream(this.filepath, readOptions)
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -912,7 +915,8 @@ ctx.state.nonce = nonce;
|
||||||
|
|
||||||
requestEnd(orgErr, ctx) {
|
requestEnd(orgErr, ctx) {
|
||||||
let err = orgErr
|
let err = orgErr
|
||||||
if (ctx.body && ctx.body.handleRequest) {
|
let handleUsed = Boolean(ctx.body && ctx.body.handleRequest)
|
||||||
|
if (handleUsed) {
|
||||||
try {
|
try {
|
||||||
ctx.body = ctx.body.handleRequest(ctx)
|
ctx.body = ctx.body.handleRequest(ctx)
|
||||||
} catch (newErr) {
|
} catch (newErr) {
|
||||||
|
@ -931,13 +935,18 @@ ctx.state.nonce = nonce;
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ctx.body === null && !handleUsed && ctx.status === 200) {
|
||||||
|
ctx.status = 204
|
||||||
|
}
|
||||||
|
|
||||||
if (statuses.empty[ctx.status]) {
|
if (statuses.empty[ctx.status]) {
|
||||||
ctx.res.writeHead(ctx.status, ctx.headers)
|
ctx.res.writeHead(ctx.status, ctx.headers)
|
||||||
return ctx.res.end()
|
return ctx.res.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = ctx.body
|
let body = ctx.body
|
||||||
let length = 0
|
|
||||||
|
// Special handling for files
|
||||||
if (body && typeof(body.pipe) === 'function') {
|
if (body && typeof(body.pipe) === 'function') {
|
||||||
// Be smart when handling file handles, auto detect mime-type
|
// Be smart when handling file handles, auto detect mime-type
|
||||||
// based off of the extension of the file.
|
// based off of the extension of the file.
|
||||||
|
@ -951,22 +960,42 @@ ctx.state.nonce = nonce;
|
||||||
ctx.headers['Content-Type'] = ctx.type || 'application/octet-stream'
|
ctx.headers['Content-Type'] = ctx.type || 'application/octet-stream'
|
||||||
|
|
||||||
ctx.res.writeHead(ctx.status, ctx.headers)
|
ctx.res.writeHead(ctx.status, ctx.headers)
|
||||||
return this.stream.pipeline(body, ctx.res, function() { })
|
|
||||||
|
if (ctx.method !== 'HEAD') {
|
||||||
|
return this.stream.pipeline(body, ctx.res, function() { })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
body.destroy()
|
||||||
|
} catch { }
|
||||||
|
return ctx.res.end()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (typeof(body) === 'object') {
|
|
||||||
|
let length = 0
|
||||||
|
|
||||||
|
if (typeof(body) === 'object' && body) {
|
||||||
body = JSON.stringify(body)
|
body = JSON.stringify(body)
|
||||||
length = Buffer.byteLength(body)
|
length = Buffer.byteLength(body)
|
||||||
ctx.headers['Content-Type'] = 'application/json; charset=utf-8'
|
ctx.type = 'application/json; charset=utf-8'
|
||||||
} else {
|
} else if (body) {
|
||||||
body = body.toString()
|
body = body.toString()
|
||||||
length = Buffer.byteLength(body)
|
length = Buffer.byteLength(body)
|
||||||
ctx.headers['Content-Type'] = ctx.type || 'text/plain; charset=utf-8'
|
ctx.type = ctx.type || 'text/plain; charset=utf-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.type) {
|
||||||
|
ctx.headers['Content-Type'] = ctx.type
|
||||||
|
}
|
||||||
|
if (!ctx.headers['Content-Length']) {
|
||||||
|
ctx.headers['Content-Length'] = length
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.headers['Content-Length'] = length
|
|
||||||
|
|
||||||
ctx.res.writeHead(ctx.status, ctx.headers)
|
ctx.res.writeHead(ctx.status, ctx.headers)
|
||||||
ctx.res.end(body)
|
if (body && ctx.method !== 'HEAD') {
|
||||||
|
ctx.res.end(body)
|
||||||
|
} else {
|
||||||
|
ctx.res.end()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestEnded(ctx) {
|
requestEnded(ctx) {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "flaska",
|
"name": "flaska",
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"description": "Flaska is a micro web-framework for node. It is designed to be fast, simple and lightweight, and is distributed as a single file module with no dependencies.",
|
"description": "Flaska is a micro web-framework for node. It is designed to be fast, simple and lightweight, and is distributed as a single file module with no dependencies.",
|
||||||
"main": "flaska.mjs",
|
"main": "flaska.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
@ -13,84 +13,86 @@ t.test('should add path and stat', function() {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.describe('CreateReader()', function() {
|
t.describe('CreateReader()', function() {
|
||||||
const assertPath = '/some/path/here.png'
|
t.describe('return fileReader', function() {
|
||||||
const assertBody = { a: 1 }
|
const assertPath = '/some/path/here.png'
|
||||||
const assertSize = 12498
|
const assertBody = { a: 1 }
|
||||||
const assertmTime = new Date('2021-04-02T11:22:33.777')
|
const assertSize = 12498
|
||||||
const roundedModified = new Date('2021-04-02T11:22:33')
|
const assertmTime = new Date('2021-04-02T11:22:33.777')
|
||||||
const assertIno = 3569723027
|
const roundedModified = new Date('2021-04-02T11:22:33')
|
||||||
const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"`
|
const assertIno = 3569723027
|
||||||
const stat = {
|
const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"`
|
||||||
size: assertSize,
|
const stat = {
|
||||||
mtime: assertmTime,
|
size: assertSize,
|
||||||
ino: assertIno,
|
mtime: assertmTime,
|
||||||
}
|
ino: assertIno,
|
||||||
|
}
|
||||||
|
|
||||||
|
let tests = [
|
||||||
|
[
|
||||||
|
'if no range',
|
||||||
|
function() {},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range start is higher than end',
|
||||||
|
function(headers) { headers['range'] = 'bytes=2000-1000' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if pre-condition if-match passes',
|
||||||
|
function(headers) { headers['if-match'] = assertEtag },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if pre-condition if-unmodified-since passes',
|
||||||
|
function(headers) { headers['if-unmodified-since'] = roundedModified.toUTCString(); },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if both pre-condition pass',
|
||||||
|
function(headers) { headers['if-unmodified-since'] = new Date(roundedModified.getTime() + 1000).toUTCString(); headers['if-match'] = assertEtag },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range is specified but if-range etag does not match',
|
||||||
|
function(headers) {
|
||||||
|
headers['if-range'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() + 1}"`
|
||||||
|
headers['range'] = 'bytes=1000-2000'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range is specified but if-range modified by is older',
|
||||||
|
function(headers) {
|
||||||
|
headers['if-range'] = `${new Date(roundedModified.getTime() - 1000).toUTCString()}`
|
||||||
|
headers['range'] = 'bytes=1000-2000'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range is specified but if-range is garbage',
|
||||||
|
function(headers) {
|
||||||
|
headers['if-range'] = `asdf`
|
||||||
|
headers['range'] = 'bytes=1000-2000'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
let tests = [
|
tests.forEach(function(test){
|
||||||
[
|
t.test(test[0], function() {
|
||||||
'if no range',
|
|
||||||
function() {},
|
const stubCreateReadStream = stub().returns(assertBody)
|
||||||
],
|
let ctx = createCtx()
|
||||||
[
|
test[1](ctx.req.headers)
|
||||||
'if range start is higher than end',
|
ctx.body = new FileResponse(assertPath, stat)
|
||||||
function(headers) { headers['range'] = 'bytes=2000-1000' },
|
let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream })
|
||||||
],
|
|
||||||
[
|
assert.strictEqual(checkBody, assertBody)
|
||||||
'if pre-condition if-match passes',
|
assert.strictEqual(stubCreateReadStream.firstCall[0], assertPath)
|
||||||
function(headers) { headers['if-match'] = assertEtag },
|
assert.deepStrictEqual(stubCreateReadStream.firstCall[1], {})
|
||||||
],
|
assert.strictEqual(ctx.headers['Etag'], assertEtag)
|
||||||
[
|
assert.strictEqual(ctx.headers['Last-Modified'], assertmTime.toUTCString())
|
||||||
'if pre-condition if-unmodified-since passes',
|
assert.strictEqual(ctx.headers['Content-Length'], assertSize)
|
||||||
function(headers) { headers['if-unmodified-since'] = roundedModified.toUTCString(); },
|
assert.strictEqual(ctx.type, 'image/png')
|
||||||
],
|
assert.notOk(ctx.headers['Content-Range'])
|
||||||
[
|
assert.strictEqual(ctx.status, 200)
|
||||||
'if both pre-condition pass',
|
})
|
||||||
function(headers) { headers['if-unmodified-since'] = new Date(roundedModified.getTime() + 1000).toUTCString(); headers['if-match'] = assertEtag },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'if range is specified but if-range etag does not match',
|
|
||||||
function(headers) {
|
|
||||||
headers['if-range'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() + 1}"`
|
|
||||||
headers['range'] = 'bytes=1000-2000'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'if range is specified but if-range modified by is older',
|
|
||||||
function(headers) {
|
|
||||||
headers['if-range'] = `${new Date(roundedModified.getTime() - 1000).toUTCString()}`
|
|
||||||
headers['range'] = 'bytes=1000-2000'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'if range is specified but if-range is garbage',
|
|
||||||
function(headers) {
|
|
||||||
headers['if-range'] = `asdf`
|
|
||||||
headers['range'] = 'bytes=1000-2000'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
]
|
|
||||||
|
|
||||||
tests.forEach(function(test){
|
|
||||||
t.test('return fileReader ' + test[0], function() {
|
|
||||||
|
|
||||||
const stubCreateReadStream = stub().returns(assertBody)
|
|
||||||
let ctx = createCtx()
|
|
||||||
test[1](ctx.req.headers)
|
|
||||||
ctx.body = new FileResponse(assertPath, stat)
|
|
||||||
let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream })
|
|
||||||
|
|
||||||
assert.strictEqual(checkBody, assertBody)
|
|
||||||
assert.strictEqual(stubCreateReadStream.firstCall[0], assertPath)
|
|
||||||
assert.deepStrictEqual(stubCreateReadStream.firstCall[1], {})
|
|
||||||
assert.strictEqual(ctx.headers['Etag'], assertEtag)
|
|
||||||
assert.strictEqual(ctx.headers['Last-Modified'], assertmTime.toUTCString())
|
|
||||||
assert.strictEqual(ctx.headers['Content-Length'], assertSize)
|
|
||||||
assert.strictEqual(ctx.type, 'image/png')
|
|
||||||
assert.notOk(ctx.headers['Content-Range'])
|
|
||||||
assert.strictEqual(ctx.status, 200)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.test('return fileReader with proper length with range', function() {
|
t.test('return fileReader with proper length with range', function() {
|
||||||
const assertPath = '/some/path/here.jpg'
|
const assertPath = '/some/path/here.jpg'
|
||||||
const assertBody = { a: 1 }
|
const assertBody = { a: 1 }
|
||||||
|
@ -120,6 +122,101 @@ t.describe('CreateReader()', function() {
|
||||||
assert.strictEqual(ctx.status, 206)
|
assert.strictEqual(ctx.status, 206)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.test('should not call createReadStream if HEAD request', function() {
|
||||||
|
const assertPath = '/some/path/here.png'
|
||||||
|
const assertNotBody = { a: 1 }
|
||||||
|
const assertSize = 12498
|
||||||
|
const assertmTime = new Date('2021-04-02T11:22:33.777')
|
||||||
|
const roundedModified = new Date('2021-04-02T11:22:33')
|
||||||
|
const assertIno = 3569723027
|
||||||
|
const assertEtag = `"${assertIno}-${assertSize}-${assertmTime.getTime()}"`
|
||||||
|
const stat = {
|
||||||
|
size: assertSize,
|
||||||
|
mtime: assertmTime,
|
||||||
|
ino: assertIno,
|
||||||
|
}
|
||||||
|
|
||||||
|
let tests = [
|
||||||
|
[
|
||||||
|
'if no range',
|
||||||
|
function() {},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range start is higher than end',
|
||||||
|
function(headers) { headers['range'] = 'bytes=2000-1000' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if pre-condition if-match passes',
|
||||||
|
function(headers) { headers['if-match'] = assertEtag },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if pre-condition if-unmodified-since passes',
|
||||||
|
function(headers) { headers['if-unmodified-since'] = roundedModified.toUTCString(); },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if both pre-condition pass',
|
||||||
|
function(headers) { headers['if-unmodified-since'] = new Date(roundedModified.getTime() + 1000).toUTCString(); headers['if-match'] = assertEtag },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range is specified but if-range etag does not match',
|
||||||
|
function(headers) {
|
||||||
|
headers['if-range'] = `"${assertIno}-${assertSize}-${assertmTime.getTime() + 1}"`
|
||||||
|
headers['range'] = 'bytes=1000-2000'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range is specified but if-range modified by is older',
|
||||||
|
function(headers) {
|
||||||
|
headers['if-range'] = `${new Date(roundedModified.getTime() - 1000).toUTCString()}`
|
||||||
|
headers['range'] = 'bytes=1000-2000'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'if range is specified but if-range is garbage',
|
||||||
|
function(headers) {
|
||||||
|
headers['if-range'] = `asdf`
|
||||||
|
headers['range'] = 'bytes=1000-2000'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
tests.forEach(function(test){
|
||||||
|
const stubCreateReadStream = stub().returns(assertNotBody)
|
||||||
|
let ctx = createCtx({
|
||||||
|
method: 'HEAD',
|
||||||
|
})
|
||||||
|
test[1](ctx.req.headers)
|
||||||
|
ctx.body = new FileResponse(assertPath, stat)
|
||||||
|
let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream })
|
||||||
|
|
||||||
|
assert.strictEqual(checkBody, null)
|
||||||
|
assert.notOk(stubCreateReadStream.called)
|
||||||
|
assert.strictEqual(ctx.headers['Etag'], assertEtag)
|
||||||
|
assert.strictEqual(ctx.headers['Last-Modified'], assertmTime.toUTCString())
|
||||||
|
assert.strictEqual(ctx.headers['Content-Length'], assertSize)
|
||||||
|
assert.strictEqual(ctx.type, 'image/png')
|
||||||
|
assert.notOk(ctx.headers['Content-Range'])
|
||||||
|
assert.strictEqual(ctx.status, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
const testStart = [0, 1000, 1999]
|
||||||
|
testStart.forEach(function(start) {
|
||||||
|
const stubCreateReadStream = stub()
|
||||||
|
let ctx = createCtx({
|
||||||
|
method: 'HEAD',
|
||||||
|
})
|
||||||
|
ctx.req.headers['range'] = 'bytes=' + start + '-'
|
||||||
|
ctx.body = new FileResponse('file.png', stat)
|
||||||
|
let checkBody = ctx.body.handleRequest(ctx, { createReadStream: stubCreateReadStream })
|
||||||
|
|
||||||
|
assert.strictEqual(checkBody, null)
|
||||||
|
assert.notOk(stubCreateReadStream.called)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Length'], assertSize - start)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Range'], `${start}-${assertSize - 1}/${assertSize}`)
|
||||||
|
assert.strictEqual(ctx.status, 206)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
t.test('return fileReader with proper length with range and if-range passes', function() {
|
t.test('return fileReader with proper length with range and if-range passes', function() {
|
||||||
const assertPath = '/some/path/here.jpg'
|
const assertPath = '/some/path/here.jpg'
|
||||||
const assertBody = { a: 1 }
|
const assertBody = { a: 1 }
|
||||||
|
|
|
@ -61,110 +61,320 @@ t.describe('#requestEnd()', function() {
|
||||||
flaska.requestEnd(assertErrorNotSeen, ctx)
|
flaska.requestEnd(assertErrorNotSeen, ctx)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.test('call res and end correctly when dealing with objects', function(cb) {
|
let testMethods = ['GET', 'HEAD']
|
||||||
const assertStatus = 202
|
|
||||||
// Calculated manually just in case
|
|
||||||
const assertBodyLength = 7
|
|
||||||
const assertBody = { a: 1 }
|
|
||||||
let onFinish = function(body) {
|
|
||||||
try {
|
|
||||||
assert.strictEqual(ctx.status, assertStatus)
|
|
||||||
assert.strictEqual(ctx.body, assertBody)
|
|
||||||
assert.strictEqual(ctx.headers['Content-Type'], 'application/json; charset=utf-8')
|
|
||||||
assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength)
|
|
||||||
assert.ok(body)
|
|
||||||
assert.strictEqual(body, '{"a":1}')
|
|
||||||
assert.ok(ctx.res.writeHead.called)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
|
||||||
cb()
|
|
||||||
} catch (err) { cb(err) }
|
|
||||||
}
|
|
||||||
const ctx = createCtx({
|
|
||||||
status: assertStatus,
|
|
||||||
}, onFinish)
|
|
||||||
ctx.body = assertBody
|
|
||||||
|
|
||||||
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
|
||||||
flaska.requestEnd(null, ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.test('call res and end correctly when dealing with strings', function(cb) {
|
testMethods.forEach(function(method) {
|
||||||
const assertStatus = 206
|
t.describe(method, function() {
|
||||||
// Calculated manually just in case
|
t.test('call res and end correctly when dealing with objects', function(cb) {
|
||||||
const assertBodyLength = 4
|
const assertStatus = 202
|
||||||
const assertBody = 'eða'
|
// Calculated manually just in case
|
||||||
let onFinish = function(body) {
|
const assertBodyLength = 7
|
||||||
try {
|
const assertBody = { a: 1 }
|
||||||
assert.strictEqual(ctx.status, assertStatus)
|
let onFinish = function(body) {
|
||||||
assert.strictEqual(ctx.body, assertBody)
|
try {
|
||||||
assert.strictEqual(ctx.headers['Content-Type'], 'text/plain; charset=utf-8')
|
assert.strictEqual(ctx.status, assertStatus)
|
||||||
assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength)
|
assert.strictEqual(ctx.body, assertBody)
|
||||||
assert.ok(body)
|
assert.strictEqual(ctx.headers['Content-Type'], 'application/json; charset=utf-8')
|
||||||
assert.strictEqual(body, assertBody)
|
assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength)
|
||||||
assert.ok(ctx.res.writeHead.called)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
|
||||||
cb()
|
|
||||||
} catch (err) { cb(err) }
|
|
||||||
}
|
|
||||||
const ctx = createCtx({
|
|
||||||
status: assertStatus,
|
|
||||||
}, onFinish)
|
|
||||||
ctx.body = assertBody
|
|
||||||
|
|
||||||
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
if (method === 'GET') {
|
||||||
flaska.requestEnd(null, ctx)
|
assert.ok(body)
|
||||||
})
|
assert.strictEqual(body, '{"a":1}')
|
||||||
|
} else {
|
||||||
t.test('call res and end correctly when dealing with numbers', function(cb) {
|
assert.notOk(body)
|
||||||
const assertStatus = 211
|
}
|
||||||
// Calculated manually just in case
|
|
||||||
const assertBodyLength = 7
|
|
||||||
const assertBody = 4214124
|
|
||||||
let onFinish = function(body) {
|
|
||||||
try {
|
|
||||||
assert.strictEqual(ctx.status, assertStatus)
|
|
||||||
assert.strictEqual(ctx.body, assertBody)
|
|
||||||
assert.strictEqual(ctx.headers['Content-Type'], 'text/plain; charset=utf-8')
|
|
||||||
assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength)
|
|
||||||
assert.ok(body)
|
|
||||||
assert.strictEqual(body, assertBody.toString())
|
|
||||||
assert.ok(ctx.res.writeHead.called)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
|
||||||
cb()
|
|
||||||
} catch (err) { cb(err) }
|
|
||||||
}
|
|
||||||
const ctx = createCtx({
|
|
||||||
status: assertStatus,
|
|
||||||
}, onFinish)
|
|
||||||
ctx.body = assertBody
|
|
||||||
|
|
||||||
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
assert.ok(ctx.res.writeHead.called)
|
||||||
flaska.requestEnd(null, ctx)
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
})
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
method: method,
|
||||||
|
status: assertStatus,
|
||||||
|
}, onFinish)
|
||||||
|
ctx.body = assertBody
|
||||||
|
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
})
|
||||||
|
|
||||||
t.test('call handleRequest if body contains it', function() {
|
t.test('call res and end correctly when dealing with null and no status override', function(cb) {
|
||||||
let body = new FileResponse()
|
// Calculated manually just in case
|
||||||
let assertBody = { a : 1 }
|
const assertBodyLength = 0
|
||||||
const ctx = createCtx({
|
const assertBody = null
|
||||||
status: 204,
|
let onFinish = function(body) {
|
||||||
|
try {
|
||||||
|
assert.strictEqual(ctx.status, 204)
|
||||||
|
assert.strictEqual(ctx.body, assertBody)
|
||||||
|
assert.notOk(ctx.headers['Content-Type'])
|
||||||
|
assert.notOk(ctx.headers['Content-Length'])
|
||||||
|
|
||||||
|
assert.strictEqual(body, undefined)
|
||||||
|
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
method: method,
|
||||||
|
status: 200,
|
||||||
|
}, onFinish)
|
||||||
|
ctx.body = assertBody
|
||||||
|
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('call res and end correctly when dealing with null and status override', function(cb) {
|
||||||
|
const assertStatus = 202
|
||||||
|
// Calculated manually just in case
|
||||||
|
const assertBody = null
|
||||||
|
let onFinish = function(body) {
|
||||||
|
try {
|
||||||
|
assert.strictEqual(ctx.status, assertStatus)
|
||||||
|
assert.strictEqual(ctx.body, assertBody)
|
||||||
|
assert.notOk(ctx.headers['Content-Type'])
|
||||||
|
assert.strictEqual(ctx.headers['Content-Length'], 0)
|
||||||
|
|
||||||
|
assert.strictEqual(body, undefined)
|
||||||
|
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
method: method,
|
||||||
|
status: assertStatus,
|
||||||
|
}, onFinish)
|
||||||
|
ctx.body = assertBody
|
||||||
|
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('call res and end correctly when dealing with strings', function(cb) {
|
||||||
|
const assertStatus = 206
|
||||||
|
// Calculated manually just in case
|
||||||
|
const assertBodyLength = 4
|
||||||
|
const assertBody = 'eða'
|
||||||
|
let onFinish = function(body) {
|
||||||
|
try {
|
||||||
|
assert.strictEqual(ctx.status, assertStatus)
|
||||||
|
assert.strictEqual(ctx.body, assertBody)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Type'], 'text/plain; charset=utf-8')
|
||||||
|
assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength)
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
assert.ok(body)
|
||||||
|
assert.strictEqual(body, assertBody)
|
||||||
|
} else {
|
||||||
|
assert.notOk(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
method: method,
|
||||||
|
status: assertStatus,
|
||||||
|
}, onFinish)
|
||||||
|
ctx.body = assertBody
|
||||||
|
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('call res and end correctly when dealing with numbers', function(cb) {
|
||||||
|
const assertStatus = 211
|
||||||
|
// Calculated manually just in case
|
||||||
|
const assertBodyLength = 7
|
||||||
|
const assertBody = 4214124
|
||||||
|
let onFinish = function(body) {
|
||||||
|
try {
|
||||||
|
assert.strictEqual(ctx.status, assertStatus)
|
||||||
|
assert.strictEqual(ctx.body, assertBody)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Type'], 'text/plain; charset=utf-8')
|
||||||
|
assert.strictEqual(ctx.headers['Content-Length'], assertBodyLength)
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
assert.ok(body)
|
||||||
|
assert.strictEqual(body, assertBody.toString())
|
||||||
|
} else {
|
||||||
|
assert.notOk(body)
|
||||||
|
}
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
method: method,
|
||||||
|
status: assertStatus,
|
||||||
|
}, onFinish)
|
||||||
|
ctx.body = assertBody
|
||||||
|
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (method === 'GET') {
|
||||||
|
t.test('call handleRequest if body contains it', function(cb) {
|
||||||
|
const assertContentType = 'test/test'
|
||||||
|
const assertContentLength = 1241241
|
||||||
|
const assertHandle = stub().returnWith(function(checkCtx, secondary) {
|
||||||
|
assert.notOk(secondary)
|
||||||
|
assert.strictEqual(checkCtx, ctx)
|
||||||
|
assert.notOk(ctx.res.writeHead.called)
|
||||||
|
ctx.type = assertContentType
|
||||||
|
ctx.headers['Content-Length'] = assertContentLength
|
||||||
|
return assertBody
|
||||||
|
})
|
||||||
|
|
||||||
|
let body = new FileResponse()
|
||||||
|
let assertBody = { pipe: function() {} }
|
||||||
|
let onFinish = function(source, target, callback) {
|
||||||
|
try {
|
||||||
|
assert.ok(assertHandle.called)
|
||||||
|
assert.ok(ctx.res.writeHead)
|
||||||
|
assert.strictEqual(ctx.body, assertBody)
|
||||||
|
assert.strictEqual(source, assertBody)
|
||||||
|
assert.strictEqual(target, ctx.res)
|
||||||
|
assert.strictEqual(typeof(callback), 'function')
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Type'], assertContentType)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Length'], assertContentLength)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
})
|
||||||
|
ctx.body = body
|
||||||
|
fakeStream.pipeline = onFinish
|
||||||
|
body.handleRequest = assertHandle
|
||||||
|
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
|
||||||
|
try {
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
} catch (err) {
|
||||||
|
cb(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('call pipeline correctly when dealing with pipe', function(cb) {
|
||||||
|
const assertStatus = 211
|
||||||
|
const assertType = 'herp/derp'
|
||||||
|
const assertBody = { pipe: function() {} }
|
||||||
|
|
||||||
|
let onFinish = function(source, target, callback) {
|
||||||
|
try {
|
||||||
|
assert.strictEqual(ctx.status, assertStatus)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Type'], assertType)
|
||||||
|
assert.strictEqual(source, assertBody)
|
||||||
|
assert.strictEqual(target, ctx.res)
|
||||||
|
assert.strictEqual(typeof(callback), 'function')
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
status: assertStatus,
|
||||||
|
})
|
||||||
|
fakeStream.pipeline = onFinish
|
||||||
|
|
||||||
|
ctx.body = assertBody
|
||||||
|
ctx.type = assertType
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
t.test('call handleRequest if body contains it', function(cb) {
|
||||||
|
const assertContentType = 'test/test'
|
||||||
|
const assertContentLength = 1241241
|
||||||
|
const assertHandle = stub().returnWith(function(checkCtx, secondary) {
|
||||||
|
assert.notOk(secondary)
|
||||||
|
assert.strictEqual(checkCtx, ctx)
|
||||||
|
assert.notOk(ctx.res.writeHead.called)
|
||||||
|
ctx.type = assertContentType
|
||||||
|
ctx.headers['Content-Length'] = assertContentLength
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
let body = new FileResponse()
|
||||||
|
let onFinish = function(body) {
|
||||||
|
try {
|
||||||
|
assert.ok(assertHandle.called)
|
||||||
|
assert.ok(ctx.res.writeHead)
|
||||||
|
assert.strictEqual(ctx.body, null)
|
||||||
|
assert.notOk(body)
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Type'], assertContentType)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Length'], assertContentLength)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
method: method,
|
||||||
|
}, onFinish)
|
||||||
|
ctx.body = body
|
||||||
|
body.handleRequest = assertHandle
|
||||||
|
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
|
||||||
|
try {
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
} catch (err) {
|
||||||
|
cb(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.test('call pipeline correctly when dealing with pipe', function(cb) {
|
||||||
|
const assertStatus = 211
|
||||||
|
const assertType = 'herp/derp'
|
||||||
|
const assertBody = { pipe: function() {}, destroy: stub() }
|
||||||
|
|
||||||
|
let onFinish = function(body) {
|
||||||
|
try {
|
||||||
|
assert.strictEqual(ctx.status, assertStatus)
|
||||||
|
assert.strictEqual(ctx.headers['Content-Type'], assertType)
|
||||||
|
assert.notOk(ctx.headers['Content-Length'])
|
||||||
|
assert.notOk(body)
|
||||||
|
|
||||||
|
assert.ok(assertBody.destroy.called)
|
||||||
|
assert.ok(ctx.res.writeHead.called)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
||||||
|
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
||||||
|
cb()
|
||||||
|
} catch (err) { cb(err) }
|
||||||
|
}
|
||||||
|
const ctx = createCtx({
|
||||||
|
method: method,
|
||||||
|
status: assertStatus,
|
||||||
|
}, onFinish)
|
||||||
|
|
||||||
|
ctx.body = assertBody
|
||||||
|
ctx.type = assertType
|
||||||
|
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
||||||
|
flaska.requestEnd(null, ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
ctx.body = body
|
|
||||||
const assertHandle = stub().returnWith(function(checkCtx, secondary) {
|
|
||||||
assert.notOk(secondary)
|
|
||||||
assert.strictEqual(checkCtx, ctx)
|
|
||||||
assert.notOk(ctx.res.writeHead.called)
|
|
||||||
return assertBody
|
|
||||||
})
|
|
||||||
body.handleRequest = assertHandle
|
|
||||||
|
|
||||||
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
|
||||||
flaska.requestEnd(null, ctx)
|
|
||||||
assert.ok(assertHandle.called)
|
|
||||||
assert.ok(ctx.res.writeHead)
|
|
||||||
assert.strictEqual(ctx.body, assertBody)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.test('call _onerror with error if handleRequest throws error', function() {
|
t.test('call _onerror with error if handleRequest throws error', function() {
|
||||||
|
@ -216,35 +426,6 @@ t.describe('#requestEnd()', function() {
|
||||||
flaska.requestEnd(null, ctx)
|
flaska.requestEnd(null, ctx)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.test('call pipeline correctly when dealing with pipe', function(cb) {
|
|
||||||
const assertStatus = 211
|
|
||||||
const assertType = 'herp/derp'
|
|
||||||
const assertBody = { pipe: function() {} }
|
|
||||||
|
|
||||||
let onFinish = function(source, target, callback) {
|
|
||||||
try {
|
|
||||||
assert.strictEqual(ctx.status, assertStatus)
|
|
||||||
assert.strictEqual(ctx.headers['Content-Type'], assertType)
|
|
||||||
assert.strictEqual(source, assertBody)
|
|
||||||
assert.strictEqual(target, ctx.res)
|
|
||||||
assert.strictEqual(typeof(callback), 'function')
|
|
||||||
assert.ok(ctx.res.writeHead.called)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[0], ctx.status)
|
|
||||||
assert.strictEqual(ctx.res.writeHead.firstCall[1], ctx.headers)
|
|
||||||
cb()
|
|
||||||
} catch (err) { cb(err) }
|
|
||||||
}
|
|
||||||
const ctx = createCtx({
|
|
||||||
status: assertStatus,
|
|
||||||
})
|
|
||||||
fakeStream.pipeline = onFinish
|
|
||||||
|
|
||||||
ctx.body = assertBody
|
|
||||||
ctx.type = assertType
|
|
||||||
let flaska = new Flaska({}, fakerHttp, fakeStream)
|
|
||||||
flaska.requestEnd(null, ctx)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.test('call pipe should have default type', function(cb) {
|
t.test('call pipe should have default type', function(cb) {
|
||||||
let onFinish = function(source, target) {
|
let onFinish = function(source, target) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -285,6 +285,120 @@ t.describe('/file/upload', function() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.describe('HEAD', function() {
|
||||||
|
const agent = new http.Agent({
|
||||||
|
keepAlive: true,
|
||||||
|
maxSockets: 1,
|
||||||
|
keepAliveMsecs: 3000,
|
||||||
|
})
|
||||||
|
|
||||||
|
t.describe('/file', function() {
|
||||||
|
t.test('server return HEAD for pipes', async function() {
|
||||||
|
log.error.reset()
|
||||||
|
|
||||||
|
let res = await client.customRequest('HEAD', '/file', null, { getRaw: true, agent: agent })
|
||||||
|
|
||||||
|
while (!file.closed) {
|
||||||
|
await setTimeout(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(file.closed)
|
||||||
|
|
||||||
|
let statSource = await fs.stat('./test/test.png')
|
||||||
|
|
||||||
|
assert.strictEqual(res.data, '')
|
||||||
|
assert.strictEqual(res.headers['content-type'], 'image/png')
|
||||||
|
assert.notOk(res.headers['content-length'])
|
||||||
|
})
|
||||||
|
t.test('server should autoclose body file handles on errors', async function() {
|
||||||
|
reset()
|
||||||
|
|
||||||
|
file = null
|
||||||
|
|
||||||
|
let req = await client.customRequest('HEAD', '/file/leak', null, { returnRequest: true })
|
||||||
|
|
||||||
|
req.end()
|
||||||
|
|
||||||
|
while (!file) {
|
||||||
|
await setTimeout(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(file)
|
||||||
|
assert.notOk(file.closed)
|
||||||
|
|
||||||
|
req.destroy()
|
||||||
|
|
||||||
|
while (!file.closed) {
|
||||||
|
await setTimeout(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(log.error.callCount, 0)
|
||||||
|
assert.strictEqual(log.info.callCount, 1)
|
||||||
|
assert.strictEqual(log.info.firstCall[0].message, 'aborted')
|
||||||
|
|
||||||
|
assert.ok(file.closed)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.describe('/filehandle', function() {
|
||||||
|
t.test('server should send correctly', async function() {
|
||||||
|
log.error.reset()
|
||||||
|
|
||||||
|
let res = await client.customRequest('HEAD', '/filehandle', null, { getRaw: true, agent: agent })
|
||||||
|
assert.strictEqual(res.status, 200)
|
||||||
|
assert.strictEqual(res.headers['content-length'], '11')
|
||||||
|
assert.strictEqual(res.headers['content-type'], 'text/plain')
|
||||||
|
assert.match(res.headers['etag'], /\"\d+-11-\d+"/)
|
||||||
|
assert.ok(res.headers['last-modified'])
|
||||||
|
let d = new Date(Date.parse(res.headers['last-modified']))
|
||||||
|
let etag = res.headers['etag']
|
||||||
|
assert.ok(d.getTime())
|
||||||
|
assert.strictEqual(res.data, '')
|
||||||
|
|
||||||
|
res = await client.customRequest('HEAD', '/filehandle', null, { getRaw: true, agent: agent,
|
||||||
|
headers: {
|
||||||
|
'If-Modified-Since': d.toUTCString()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.strictEqual(res.status, 304)
|
||||||
|
assert.strictEqual(res.data, '')
|
||||||
|
assert.strictEqual(res.headers['etag'], etag)
|
||||||
|
|
||||||
|
res = await client.customRequest('HEAD', '/filehandle', null, { getRaw: true, agent: agent,
|
||||||
|
headers: {
|
||||||
|
'If-None-Match': etag
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.strictEqual(res.status, 304)
|
||||||
|
assert.strictEqual(res.data, '')
|
||||||
|
assert.strictEqual(res.headers['etag'], etag)
|
||||||
|
|
||||||
|
res = await client.customRequest('HEAD', '/filehandle', null, { getRaw: true, agent: agent,
|
||||||
|
headers: {
|
||||||
|
'Range': 'bytes=2-5'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.strictEqual(res.status, 206)
|
||||||
|
assert.strictEqual(res.data, '')
|
||||||
|
assert.strictEqual(res.headers['content-length'], '4')
|
||||||
|
|
||||||
|
|
||||||
|
res = await client.customRequest('HEAD', '/filehandle', null, { getRaw: true, agent: agent,
|
||||||
|
headers: {
|
||||||
|
'Range': 'bytes=0-0'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.strictEqual(res.status, 206)
|
||||||
|
assert.strictEqual(res.data, '')
|
||||||
|
assert.strictEqual(res.headers['content-length'], '1')
|
||||||
|
})
|
||||||
|
|
||||||
|
t.after(function() {
|
||||||
|
agent.destroy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
t.after(function() {
|
t.after(function() {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
fs.rm('./test_tmp.png', { force: true }),
|
fs.rm('./test_tmp.png', { force: true }),
|
||||||
|
|
Loading…
Reference in a new issue