mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
# 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:
parent
4b211f17ad
commit
551ed12704
8 changed files with 183 additions and 2 deletions
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
) => {
|
||||
|
|
|
@ -127,6 +127,7 @@ const createResponseFactoryMock = (): jest.Mocked<KibanaResponseFactory> => ({
|
|||
notFound: jest.fn(),
|
||||
conflict: jest.fn(),
|
||||
customError: jest.fn(),
|
||||
file: jest.fn(),
|
||||
});
|
||||
|
||||
export const mockRouter = {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -69,6 +69,7 @@ export type {
|
|||
RequestHandlerContextBase,
|
||||
ResponseError,
|
||||
CustomHttpResponseOptions,
|
||||
FileHttpResponseOptions,
|
||||
HttpResponseOptions,
|
||||
HttpResponsePayload,
|
||||
IKibanaResponse,
|
||||
|
|
|
@ -40,6 +40,7 @@ export type {
|
|||
RedirectResponseOptions,
|
||||
ResponseErrorAttributes,
|
||||
ErrorHttpResponseOptions,
|
||||
FileHttpResponseOptions,
|
||||
} from './response';
|
||||
export type {
|
||||
RouteConfigOptions,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue