[8.7] [core][http] add response file method (#151130) (#152069)

# Backport

This will backport the following commits from `main` to `8.7`:
- [[core][http] add response `file` method
(#151130)](https://github.com/elastic/kibana/pull/151130)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Ahmad
Bamieh","email":"ahmad.bamyeh@elastic.co"},"sourceCommit":{"committedDate":"2023-02-24T09:06:37Z","message":"[core][http]
add response `file` method (#151130)\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"dc927d98baa30c0e63a464ff02a2dca46ce251bb","branchLabelMapping":{"^v8.8.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","v8.7.0","v8.6.2","v8.8.0"],"number":151130,"url":"https://github.com/elastic/kibana/pull/151130","mergeCommit":{"message":"[core][http]
add response `file` method (#151130)\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"dc927d98baa30c0e63a464ff02a2dca46ce251bb"}},"sourceBranch":"main","suggestedTargetBranches":["8.7","8.6"],"targetPullRequestStates":[{"branch":"8.7","label":"v8.7.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"8.6","label":"v8.6.2","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.8.0","labelRegex":"^v8.8.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/151130","number":151130,"mergeCommit":{"message":"[core][http]
add response `file` method (#151130)\n\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"dc927d98baa30c0e63a464ff02a2dca46ce251bb"}}]}]
BACKPORT-->

Co-authored-by: Ahmad Bamieh <ahmad.bamyeh@elastic.co>
This commit is contained in:
Kibana Machine 2023-02-24 05:18:30 -05:00 committed by GitHub
parent 4b211f17ad
commit 551ed12704
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 183 additions and 2 deletions

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fileResponseFactory } from './response';
describe('fileResponseFactory', () => {
describe('res.file', () => {
it('returns a kibana response with attachment', () => {
const body = Buffer.from('Attachment content');
const result = fileResponseFactory.file({
body,
filename: 'myfile.test',
fileContentSize: 30,
});
expect(result.payload).toBe(body);
expect(result.status).toBe(200);
expect(result.options.headers).toMatchInlineSnapshot(`
Object {
"content-disposition": "attachment; filename=myfile.test",
"content-length": "30",
"content-type": "application/octet-stream",
"x-content-type-options": "nosniff",
}
`);
});
it('converts string body content to buffer in response', () => {
const body = 'I am a string';
const result = fileResponseFactory.file({ body, filename: 'myfile.test' });
expect(result.payload?.toString()).toBe(body);
});
it('doesnt pass utf-16 characters in filename into the content-disposition header', () => {
const isMultiByte = (str: string) => [...str].some((c) => (c.codePointAt(0) || 0) > 255);
const multuByteCharacters = '日本語ダッシュボード.pdf';
const result = fileResponseFactory.file({
body: 'content',
filename: multuByteCharacters,
});
const { headers } = result.options;
if (!headers) {
throw new Error('Missing headers');
}
const contentDispositionHeader = headers['content-disposition'];
if (typeof contentDispositionHeader !== 'string') {
throw new Error(`Expecting a string content-disposition header`);
}
expect(typeof contentDispositionHeader).toBe('string');
expect(isMultiByte(multuByteCharacters)).toBe(true);
expect(contentDispositionHeader).toMatchInlineSnapshot(
`"attachment; filename=%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%83%80%E3%83%83%E3%82%B7%E3%83%A5%E3%83%9C%E3%83%BC%E3%83%89.pdf"`
);
expect(isMultiByte(contentDispositionHeader)).toBe(false);
expect(decodeURIComponent(contentDispositionHeader)).toBe(
`attachment; filename=${multuByteCharacters}`
);
});
it('accepts additional headers but doesnt override file headers', () => {
const extraHeaders = { 'content-language': 'en', 'x-test-header': 'ok' };
const overrideHeaders = {
'content-disposition': 'i will not be in the response',
'content-length': 'i will not be in the response',
};
const filename = 'myfile.test';
const fileContent = 'content';
const result = fileResponseFactory.file({
body: fileContent,
filename,
headers: { ...extraHeaders, ...overrideHeaders },
});
expect(result.options.headers).toEqual(
expect.objectContaining({
...extraHeaders,
'content-disposition': `attachment; filename=${filename}`,
'content-length': `${fileContent.length}`,
})
);
});
describe('content-type', () => {
it('default mime type octet-stream', () => {
const result = fileResponseFactory.file({ body: 'content', filename: 'myfile.unknown' });
expect(result.options.headers).toHaveProperty('content-type', 'application/octet-stream');
});
it('gets mime type from filename', () => {
const result = fileResponseFactory.file({ body: 'content', filename: 'myfile.mp4' });
expect(result.options.headers).toHaveProperty('content-type', 'video/mp4');
});
it('gets accepts contentType override', () => {
const result = fileResponseFactory.file({
body: 'content',
filename: 'myfile.mp4',
fileContentType: 'custom',
});
expect(result.options.headers).toHaveProperty('content-type', 'custom');
});
});
});
});

View file

@ -5,7 +5,6 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Stream } from 'stream';
import type {
IKibanaResponse,
@ -14,6 +13,7 @@ import type {
HttpResponseOptions,
RedirectResponseOptions,
CustomHttpResponseOptions,
FileHttpResponseOptions,
ErrorHttpResponseOptions,
KibanaErrorResponseFactory,
KibanaRedirectionResponseFactory,
@ -21,6 +21,7 @@ import type {
KibanaResponseFactory,
LifecycleResponseFactory,
} from '@kbn/core-http-server';
import mime from 'mime';
export function isKibanaResponse(response: Record<string, any>): response is IKibanaResponse {
return typeof response.status === 'number' && typeof response.options === 'object';
@ -76,10 +77,51 @@ const errorResponseFactory: KibanaErrorResponseFactory = {
},
};
export const fileResponseFactory = {
file: <T extends HttpResponsePayload | ResponseError>(options: FileHttpResponseOptions<T>) => {
const {
body,
bypassErrorFormat,
fileContentSize,
headers,
filename,
fileContentType,
bypassFileNameEncoding,
} = options;
const reponseFilename = bypassFileNameEncoding ? filename : encodeURIComponent(filename);
const responseBody = typeof body === 'string' ? Buffer.from(body) : body;
if (!responseBody) {
throw new Error(`options.body is expected to be set.`);
}
const responseContentType =
fileContentType ?? mime.getType(filename) ?? 'application/octet-stream';
const responseContentLength =
typeof fileContentSize === 'number'
? fileContentSize
: Buffer.isBuffer(responseBody)
? responseBody.length
: '';
return new KibanaResponse(200, responseBody, {
bypassErrorFormat,
headers: {
...headers,
'content-type': `${responseContentType}`,
'content-length': `${responseContentLength}`,
'content-disposition': `attachment; filename=${reponseFilename}`,
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
'x-content-type-options': 'nosniff',
},
});
},
};
export const kibanaResponseFactory: KibanaResponseFactory = {
...successResponseFactory,
...redirectionResponseFactory,
...errorResponseFactory,
...fileResponseFactory,
custom: <T extends HttpResponsePayload | ResponseError>(
options: CustomHttpResponseOptions<T>
) => {

View file

@ -127,6 +127,7 @@ const createResponseFactoryMock = (): jest.Mocked<KibanaResponseFactory> => ({
notFound: jest.fn(),
conflict: jest.fn(),
customError: jest.fn(),
file: jest.fn(),
});
export const mockRouter = {

View file

@ -91,7 +91,6 @@ export function adoptToHapiOnPreResponseFormat(fn: OnPreResponseHandler, log: Lo
}
} else if (preResponseResult.isRender(result)) {
const overriddenResponse = responseToolkit.response(result.body).code(statusCode);
const originalHeaders = isBoom(response) ? response.output.headers : response.headers;
setHeaders(overriddenResponse, originalHeaders as { [key: string]: string });
if (result.headers) {

View file

@ -69,6 +69,7 @@ export type {
RequestHandlerContextBase,
ResponseError,
CustomHttpResponseOptions,
FileHttpResponseOptions,
HttpResponseOptions,
HttpResponsePayload,
IKibanaResponse,

View file

@ -40,6 +40,7 @@ export type {
RedirectResponseOptions,
ResponseErrorAttributes,
ErrorHttpResponseOptions,
FileHttpResponseOptions,
} from './response';
export type {
RouteConfigOptions,

View file

@ -69,6 +69,27 @@ export interface CustomHttpResponseOptions<T extends HttpResponsePayload | Respo
statusCode: number;
}
/**
* HTTP response parameters for a response with adjustable status code.
* @public
*/
export interface FileHttpResponseOptions<T extends HttpResponsePayload | ResponseError> {
/** Attachment content to send to the client */
body: T;
/** Attachment name, encoded and added to the headers to send to the client */
filename: string;
/** Explicitly set the attachment content type. Tries to detect the type based on extension and defaults to application/octet-stream */
fileContentType?: string | null;
/** Attachment content size in bytes, Tries to detect the content size from body */
fileContentSize?: number;
/** HTTP Headers with additional information about response */
headers?: ResponseHeaders;
/** Bypass the default error formatting */
bypassErrorFormat?: boolean;
/** Bypass filename encoding, only set to true if the filename is already encoded */
bypassFileNameEncoding?: boolean;
}
/**
* HTTP response parameters for redirection response
* @public

View file

@ -13,6 +13,7 @@ import type {
HttpResponsePayload,
IKibanaResponse,
RedirectResponseOptions,
FileHttpResponseOptions,
ResponseError,
ErrorHttpResponseOptions,
} from './response';
@ -196,6 +197,13 @@ export interface KibanaErrorResponseFactory {
export type KibanaResponseFactory = KibanaSuccessResponseFactory &
KibanaRedirectionResponseFactory &
KibanaErrorResponseFactory & {
/**
* Creates a response with defined status code and payload.
* @param options - {@link FileHttpResponseOptions} configures HTTP response parameters.
*/
file<T extends HttpResponsePayload | ResponseError>(
options: FileHttpResponseOptions<T>
): IKibanaResponse;
/**
* Creates a response with defined status code and payload.
* @param options - {@link CustomHttpResponseOptions} configures HTTP response parameters.