[Files] Per file max upload size (#151993)

## Summary

Closes https://github.com/elastic/kibana/issues/151985

- ~~This PR introduces `maxUploadSize` file kind setting, which allows
to enforce upload size per file, instead of using one value for the
whole file kind~~.
- ~~I left the `maxSizeBytes` as-is for now, because it is actually used
in two places: (1) in the Files client; (2) in HTTP routes. So, I didn't
find an easy way to reuse it~~.
- Allows to configure max upload size per file.
- This required separation of `FileKind` interface between browser and
server. And correspondingly changes to the file kind registry.


### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

### For maintainers

- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Vadim Kibana 2023-02-27 10:19:40 +01:00 committed by GitHub
parent aa5d089ef1
commit da4307e80e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 340 additions and 251 deletions

View file

@ -18,7 +18,10 @@ export class FilesExamplePlugin
core: CoreSetup<FilesExamplePluginsStart>,
{ files, developerExamples }: FilesExamplePluginsSetup
) {
files.registerFileKind(exampleFileKind);
files.registerFileKind({
id: exampleFileKind.id,
allowedMimeTypes: exampleFileKind.allowedMimeTypes,
});
developerExamples.register({
appId: PLUGIN_ID,

View file

@ -10,7 +10,7 @@ import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { base64dLogo } from '@kbn/shared-ux-file-image-mocks';
import type { FileImageMetadata, FileKind } from '@kbn/shared-ux-file-types';
import type { FileImageMetadata, FileKindBrowser } from '@kbn/shared-ux-file-types';
import type { FileJSON, BaseFilesClient as FilesClient } from '@kbn/shared-ux-file-types';
import { FilesContext } from '@kbn/shared-ux-file-context';
import { FilePicker, Props as FilePickerProps } from './file_picker';
@ -23,7 +23,7 @@ const getFileKind = (id: string) =>
id: kind,
http: {},
allowedMimeTypes: ['*'],
} as FileKind);
} as FileKindBrowser);
const defaultProps: FilePickerProps = {
kind,

View file

@ -9,7 +9,7 @@
import React from 'react';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { FileKind, BaseFilesClient as FilesClient } from '@kbn/shared-ux-file-types';
import { FileKindBrowser, BaseFilesClient as FilesClient } from '@kbn/shared-ux-file-types';
import { FilesContext } from '@kbn/shared-ux-file-context';
import { FileUpload, Props } from './file_upload';
@ -37,7 +37,7 @@ const fileKinds = {
allowedMimeTypes: ['application/zip'],
},
};
const getFileKind = (id: string) => (fileKinds as any)[id] as FileKind;
const getFileKind = (id: string) => (fileKinds as any)[id] as FileKindBrowser;
const defaultArgs: Props = {
kind,

View file

@ -9,7 +9,11 @@
import { DeeplyMockedKeys } from '@kbn/utility-types-jest';
import { of, delay, merge, tap, mergeMap } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import type { FileKind, FileJSON, BaseFilesClient as FilesClient } from '@kbn/shared-ux-file-types';
import type {
FileKindBrowser,
FileJSON,
BaseFilesClient as FilesClient,
} from '@kbn/shared-ux-file-types';
import { createMockFilesClient } from '@kbn/shared-ux-file-mocks';
import { ImageMetadataFactory } from '@kbn/shared-ux-file-util';
@ -29,7 +33,7 @@ describe('UploadState', () => {
filesClient.create.mockReturnValue(of({ file: { id: 'test' } as FileJSON }) as any);
filesClient.upload.mockReturnValue(of(undefined) as any);
uploadState = new UploadState(
{ id: 'test', http: {}, maxSizeBytes: 1000 } as FileKind,
{ id: 'test', http: {}, maxSizeBytes: 1000 } as FileKindBrowser,
filesClient,
{},
imageMetadataFactory
@ -191,7 +195,7 @@ describe('UploadState', () => {
it('option "allowRepeatedUploads" calls clear after upload is done', () => {
testScheduler.run(({ expectObservable, cold }) => {
uploadState = new UploadState(
{ id: 'test', http: {}, maxSizeBytes: 1000 } as FileKind,
{ id: 'test', http: {}, maxSizeBytes: 1000 } as FileKindBrowser,
filesClient,
{ allowRepeatedUploads: true },
imageMetadataFactory

View file

@ -8,7 +8,11 @@
import * as Rx from 'rxjs';
import { ImageMetadataFactory, getImageMetadata, isImage } from '@kbn/shared-ux-file-util';
import type { FileKind, FileJSON, BaseFilesClient as FilesClient } from '@kbn/shared-ux-file-types';
import type {
FileKindBrowser,
FileJSON,
BaseFilesClient as FilesClient,
} from '@kbn/shared-ux-file-types';
import { i18nTexts } from './i18n_texts';
import { createStateSubject, type SimpleStateSubject, parseFileName } from './util';
@ -48,7 +52,7 @@ export class UploadState {
private subscriptions: Rx.Subscription[];
constructor(
private readonly fileKind: FileKind,
private readonly fileKind: FileKindBrowser,
private readonly client: FilesClient,
private readonly opts: UploadOptions = { allowRepeatedUploads: false },
private readonly loadImageMetadata: ImageMetadataFactory = getImageMetadata
@ -240,7 +244,7 @@ export const createUploadState = ({
imageMetadataFactory,
...options
}: {
fileKind: FileKind;
fileKind: FileKindBrowser;
client: FilesClient;
imageMetadataFactory?: ImageMetadataFactory;
} & UploadOptions) => {

View file

@ -1,5 +1 @@
# @kbn/shared-ux-link-redirect-app-types
To generate the types for the file client run. See ./build_file_client.ts
`yarn ts-node ./packages/shared-ux/file/types/build_file_client.ts`

View file

@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
import type { FileJSON, FileKind } from '.';
import type { FileShareJSON, FileShareJSONWithToken } from './sharing';
import type { FileJSON, FileKindBase } from '.';
export interface Pagination {
page?: number;
@ -62,7 +63,7 @@ export interface BaseFilesClient<M = unknown> {
*/
getById: (args: { id: string; kind: string } & Abortable) => Promise<{ file: FileJSON<M> }>;
/**
* List all file objects, of a given {@link FileKind}.
* List all file objects, of a given {@link FileKindBrowser}.
*
* @param args - list files args
*/
@ -154,5 +155,5 @@ export interface BaseFilesClient<M = unknown> {
* Get a file kind
* @param id The id of the file kind
*/
getFileKind: (id: string) => FileKind;
getFileKind: (id: string) => FileKindBase;
}

View file

@ -7,6 +7,7 @@
*/
export type { BaseFilesClient, Abortable, Pagination } from './base_file_client';
export type { FileShare, FileShareJSON, FileShareJSONWithToken } from './sharing';
/* Status of a file.
*
@ -23,19 +24,6 @@ export type FileStatus = 'AWAITING_UPLOAD' | 'UPLOADING' | 'READY' | 'UPLOAD_ERR
*/
export type FileCompression = 'br' | 'gzip' | 'deflate' | 'none';
/** Definition for an endpoint that the File's service will generate */
interface HttpEndpointDefinition {
/**
* Specify the tags for this endpoint.
*
* @example
* // This will enable access control to this endpoint for users that can access "myApp" only.
* { tags: ['access:myApp'] }
*
*/
tags: string[];
}
/**
* File metadata fields are defined per the ECS specification:
*
@ -240,23 +228,11 @@ export interface FileJSON<Meta = unknown> {
user?: FileMetadata['user'];
}
/*
* A descriptor of meta values associated with a set or "kind" of files.
*
* @note In order to use the file service consumers must register a {@link FileKind}
* in the {@link FileKindsRegistry}.
*/
export interface FileKind {
export interface FileKindBase {
/**
* Unique file kind ID
*/
id: string;
/**
* Maximum size, in bytes, a file of this kind can be.
*
* @default 4MiB
*/
maxSizeBytes?: number;
/**
* The MIME type of the file content.
@ -264,52 +240,16 @@ export interface FileKind {
* @default accept all mime types
*/
allowedMimeTypes?: string[];
}
export interface FileKindBrowser extends FileKindBase {
/**
* Blob store specific settings that enable configuration of storage
* details.
*/
blobStoreSettings?: BlobStorageSettings;
/**
* Specify which HTTP routes to create for the file kind.
* Max file contents size, in bytes, enforced for this file kind in the upload
* component.
*
* You can always create your own HTTP routes for working with files but
* this interface allows you to expose basic CRUD operations, upload, download
* and sharing of files over a RESTful-like interface.
*
* @note The public {@link FileClient} uses these endpoints.
* @default 4MiB
*/
http: {
/**
* Expose file creation (and upload) over HTTP.
*/
create?: HttpEndpointDefinition;
/**
* Expose file updates over HTTP.
*/
update?: HttpEndpointDefinition;
/**
* Expose file deletion over HTTP.
*/
delete?: HttpEndpointDefinition;
/**
* Expose "get by ID" functionality over HTTP.
*/
getById?: HttpEndpointDefinition;
/**
* Expose the ability to list all files of this kind over HTTP.
*/
list?: HttpEndpointDefinition;
/**
* Expose the ability to download a file's contents over HTTP.
*/
download?: HttpEndpointDefinition;
/**
* Expose file share functionality over HTTP.
*/
share?: HttpEndpointDefinition;
};
maxSizeBytes?: number;
}
/**

View file

@ -0,0 +1,74 @@
/*
* 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.
*/
/**
* Data stored with a file share object
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type FileShare = {
/**
* ISO timestamp of when the file share was created.
*/
created: string;
/**
* Secret token used to access the associated file.
*/
token: string;
/**
* Human friendly name for this share token.
*/
name?: string;
/**
* The unix timestamp (in milliseconds) this file share will expire.
*
* TODO: in future we could add a special value like "forever", but this should
* not be the default.
*/
valid_until: number;
};
/**
* Attributes of a file that represent a serialised version of the file.
*/
export interface FileShareJSON {
/**
* Unique ID share instance
*/
id: string;
/**
* ISO timestamp the share was created
*/
created: FileShare['created'];
/**
* Unix timestamp (in milliseconds) of when this share expires
*/
validUntil: FileShare['valid_until'];
/**
* A user-friendly name for the file share
*/
name?: FileShare['name'];
/**
* The ID of the file this share is linked to
*/
fileId: string;
}
/**
* A version of the file share with a token included.
*
* @note This should only be shown when the file share is first created
*/
export type FileShareJSONWithToken = FileShareJSON & {
/**
* Secret token that can be used to access files
*/
token: string;
};

View file

@ -7,7 +7,7 @@
],
},
"include": [
"*.d.ts"
"*.ts"
],
"exclude": [
"target/**/*",

View file

@ -6,30 +6,14 @@
* Side Public License, v 1.
*/
import { FileKind } from './types';
import { FileKindBase } from '@kbn/shared-ux-file-types';
const id = 'defaultImage' as const;
const tag = 'files:defaultImage' as const;
const tags = [`access:${tag}`];
const tenMebiBytes = 1024 * 1024 * 10;
export const id = 'defaultImage' as const;
export const tag = 'files:defaultImage' as const;
export const tags = [`access:${tag}`];
export const maxSize = 1024 * 1024 * 10;
/**
* A file kind that is available to all plugins to use for uploading images
* intended to be reused across Kibana.
*/
export const defaultImageFileKind: FileKind = {
export const kind: FileKindBase = {
id,
maxSizeBytes: tenMebiBytes,
blobStoreSettings: {},
// tried using "image/*" but it did not work with the HTTP endpoint (got 415 Unsupported Media Type)
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/avif'],
http: {
create: { tags },
delete: { tags },
download: { tags },
getById: { tags },
list: { tags },
share: { tags },
update: { tags },
},
};

View file

@ -6,36 +6,39 @@
* Side Public License, v 1.
*/
import { createGetterSetter } from '@kbn/kibana-utils-plugin/common';
import assert from 'assert';
import { FileKind } from '..';
import { createGetterSetter } from '@kbn/kibana-utils-plugin/common';
import type { FileKindBase } from '@kbn/shared-ux-file-types';
import type { FileKind } from '../types';
export interface FileKindsRegistry {
export interface FileKindsRegistry<FK extends FileKindBase = FileKind> {
/**
* Register a new file kind.
*/
register(fileKind: FileKind): void;
register(fileKind: FK): void;
/**
* Gets a {@link FileKind} or throws.
*/
get(id: string): FileKind;
get(id: string): FK;
/**
* Return all registered {@link FileKind}s.
*/
getAll(): FileKind[];
getAll(): FK[];
}
/**
* @internal
*/
export class FileKindsRegistryImpl implements FileKindsRegistry {
constructor(private readonly onRegister?: (fileKind: FileKind) => void) {}
export class FileKindsRegistryImpl<FK extends FileKindBase = FileKind>
implements FileKindsRegistry<FK>
{
constructor(private readonly onRegister?: (fileKind: FK) => void) {}
private readonly fileKinds = new Map<string, FileKind>();
private readonly fileKinds = new Map<string, FK>();
register(fileKind: FileKind) {
register(fileKind: FK) {
if (this.fileKinds.get(fileKind.id)) {
throw new Error(`File kind "${fileKind.id}" already registered.`);
}
@ -50,13 +53,13 @@ export class FileKindsRegistryImpl implements FileKindsRegistry {
this.onRegister?.(fileKind);
}
get(id: string): FileKind {
get(id: string): FK {
const fileKind = this.fileKinds.get(id);
assert(fileKind, `File kind with id "${id}" not found.`);
return fileKind;
}
getAll(): FileKind[] {
getAll(): FK[] {
return Array.from(this.fileKinds.values());
}
}

View file

@ -7,11 +7,11 @@
*/
export { FILE_SO_TYPE, PLUGIN_ID, PLUGIN_NAME, ES_FIXED_SIZE_INDEX_BLOB_STORE } from './constants';
export { defaultImageFileKind } from './default_image_file_kind';
export type {
File,
FileKind,
FileKindBrowser,
FileJSON,
FileShare,
FileStatus,
@ -29,3 +29,6 @@ export type {
FileShareJSONWithToken,
UpdatableFileShareMetadata,
} from './types';
import * as DefaultFileKind from './default_image_file_kind';
export { DefaultFileKind };

View file

@ -1,15 +0,0 @@
/*
* 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 { getFileKindsRegistry } from './file_kinds_registry';
import { defaultImageFileKind } from '.';
export function registerDefaultFileKinds() {
const registry = getFileKindsRegistry();
registry.register(defaultImageFileKind);
}

View file

@ -9,11 +9,20 @@
import type { SavedObject } from '@kbn/core/server';
import type { Observable } from 'rxjs';
import type { Readable } from 'stream';
import type { FileJSON, FileStatus, FileMetadata } from '@kbn/shared-ux-file-types';
import type {
FileJSON,
FileStatus,
FileMetadata,
FileShare,
FileShareJSON,
FileKindBase,
FileShareJSONWithToken,
} from '@kbn/shared-ux-file-types';
import type { ES_FIXED_SIZE_INDEX_BLOB_STORE } from './constants';
export type {
FileKind,
FileKindBase,
FileKindBrowser,
FileJSON,
FileStatus,
FileMetadata,
@ -23,6 +32,82 @@ export type {
FileImageMetadata,
} from '@kbn/shared-ux-file-types';
/*
* A descriptor of meta values associated with a set or "kind" of files.
*
* @note In order to use the file service consumers must register a {@link FileKind}
* in the {@link FileKindsRegistry}.
*/
export interface FileKind extends FileKindBase {
/**
* Max file contents size, in bytes. Can be customized per file using the
* {@link FileJSON} object. This is enforced on the server-side as well as
* in the upload React component.
*
* @default 4MiB
*/
maxSizeBytes?: number | ((file: FileJSON) => number);
/**
* Blob store specific settings that enable configuration of storage
* details.
*/
blobStoreSettings?: BlobStorageSettings;
/**
* Specify which HTTP routes to create for the file kind.
*
* You can always create your own HTTP routes for working with files but
* this interface allows you to expose basic CRUD operations, upload, download
* and sharing of files over a RESTful-like interface.
*
* @note The public {@link FileClient} uses these endpoints.
*/
http: {
/**
* Expose file creation (and upload) over HTTP.
*/
create?: HttpEndpointDefinition;
/**
* Expose file updates over HTTP.
*/
update?: HttpEndpointDefinition;
/**
* Expose file deletion over HTTP.
*/
delete?: HttpEndpointDefinition;
/**
* Expose "get by ID" functionality over HTTP.
*/
getById?: HttpEndpointDefinition;
/**
* Expose the ability to list all files of this kind over HTTP.
*/
list?: HttpEndpointDefinition;
/**
* Expose the ability to download a file's contents over HTTP.
*/
download?: HttpEndpointDefinition;
/**
* Expose file share functionality over HTTP.
*/
share?: HttpEndpointDefinition;
};
}
/** Definition for an endpoint that the File's service will generate */
interface HttpEndpointDefinition {
/**
* Specify the tags for this endpoint.
*
* @example
* // This will enable access control to this endpoint for users that can access "myApp" only.
* { tags: ['access:myApp'] }
*
*/
tags: string[];
}
/**
* Values for paginating through results.
*/
@ -47,72 +132,7 @@ export type FileSavedObject<Meta = unknown> = SavedObject<FileMetadata<Meta>>;
*/
export type UpdatableFileMetadata<Meta = unknown> = Pick<FileJSON<Meta>, 'meta' | 'alt' | 'name'>;
/**
* Data stored with a file share object
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type FileShare = {
/**
* ISO timestamp of when the file share was created.
*/
created: string;
/**
* Secret token used to access the associated file.
*/
token: string;
/**
* Human friendly name for this share token.
*/
name?: string;
/**
* The unix timestamp (in milliseconds) this file share will expire.
*
* TODO: in future we could add a special value like "forever", but this should
* not be the default.
*/
valid_until: number;
};
/**
* Attributes of a file that represent a serialised version of the file.
*/
export interface FileShareJSON {
/**
* Unique ID share instance
*/
id: string;
/**
* ISO timestamp the share was created
*/
created: FileShare['created'];
/**
* Unix timestamp (in milliseconds) of when this share expires
*/
validUntil: FileShare['valid_until'];
/**
* A user-friendly name for the file share
*/
name?: FileShare['name'];
/**
* The ID of the file this share is linked to
*/
fileId: string;
}
/**
* A version of the file share with a token included.
*
* @note This should only be shown when the file share is first created
*/
export type FileShareJSONWithToken = FileShareJSON & {
/**
* Secret token that can be used to access files
*/
token: string;
};
export type { FileShare, FileShareJSON, FileShareJSONWithToken };
/**
* Set of attributes that can be updated in a file share.

View file

@ -7,8 +7,9 @@
*/
import type { HttpStart } from '@kbn/core/public';
import type { FileKindBrowser } from '@kbn/shared-ux-file-types';
import type { ScopedFilesClient, FilesClient } from '../types';
import { getFileKindsRegistry } from '../../common/file_kinds_registry';
import { FileKindsRegistryImpl } from '../../common/file_kinds_registry';
import {
API_BASE_PATH,
FILES_API_BASE_PATH,
@ -56,6 +57,11 @@ export const apiRoutes = {
* Arguments to create a new {@link FileClient}.
*/
export interface Args {
/**
* Registry of file kinds.
*/
registry: FileKindsRegistryImpl<FileKindBrowser>;
/**
* The http start service from core.
*/
@ -81,9 +87,11 @@ const commonBodyHeaders = {
export function createFilesClient(args: Args): FilesClient;
export function createFilesClient(scopedArgs: ScopedArgs): ScopedFilesClient;
export function createFilesClient({
registry,
http,
fileKind: scopedFileKind,
}: {
registry: FileKindsRegistryImpl<FileKindBrowser>;
http: HttpStart;
fileKind?: string;
}): FilesClient | ScopedFilesClient {
@ -172,7 +180,7 @@ export function createFilesClient({
return http.get(apiRoutes.getPublicDownloadRoute(fileName), { query: { token } });
},
getFileKind(id: string) {
return getFileKindsRegistry().get(id);
return registry.get(id);
},
};
return api;

View file

@ -7,7 +7,6 @@
*/
import { FilesPlugin } from './plugin';
export { defaultImageFileKind } from '../common/default_image_file_kind';
export type { FilesSetup, FilesStart } from './plugin';
export type {
FilesClient,

View file

@ -7,16 +7,12 @@
*/
import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import {
getFileKindsRegistry,
setFileKindsRegistry,
FileKindsRegistryImpl,
} from '../common/file_kinds_registry';
import type { FilesClient, FilesClientFactory } from './types';
import { FileKindsRegistryImpl } from '../common/file_kinds_registry';
import { createFilesClient } from './files_client';
import { FileKind } from '../common';
import { registerDefaultFileKinds } from '../common/register_default_file_kinds';
import { FileKindBrowser } from '../common';
import { ScopedFilesClient } from '.';
import * as DefaultImageFileKind from '../common/default_image_file_kind';
/**
* Public setup-phase contract
@ -24,7 +20,7 @@ import { ScopedFilesClient } from '.';
export interface FilesSetup {
/**
* A factory for creating an {@link FilesClient} instance. This requires a
* registered {@link FileKind}.
* registered {@link FileKindBrowser}.
*
* @track-adoption
*/
@ -36,7 +32,7 @@ export interface FilesSetup {
*
* @param {FileKind} fileKind - the file kind to register
*/
registerFileKind(fileKind: FileKind): void;
registerFileKind(fileKind: FileKindBrowser): void;
}
export type FilesStart = Pick<FilesSetup, 'filesClientFactory'>;
@ -45,26 +41,35 @@ export type FilesStart = Pick<FilesSetup, 'filesClientFactory'>;
* Bringing files to Kibana
*/
export class FilesPlugin implements Plugin<FilesSetup, FilesStart> {
private filesClientFactory: undefined | FilesClientFactory;
constructor() {
setFileKindsRegistry(new FileKindsRegistryImpl());
}
private registry = new FileKindsRegistryImpl<FileKindBrowser>();
private filesClientFactory?: FilesClientFactory;
setup(core: CoreSetup): FilesSetup {
this.registry.register({
...DefaultImageFileKind.kind,
maxSizeBytes: DefaultImageFileKind.maxSize,
});
this.filesClientFactory = {
asScoped<M = unknown>(fileKind: string) {
return createFilesClient({ fileKind, http: core.http }) as ScopedFilesClient<M>;
asScoped: <M = unknown>(fileKind: string) => {
return createFilesClient({
registry: this.registry,
fileKind,
http: core.http,
}) as ScopedFilesClient<M>;
},
asUnscoped<M>() {
return createFilesClient({ http: core.http }) as FilesClient<M>;
asUnscoped: <M>() => {
return createFilesClient({
registry: this.registry,
http: core.http,
}) as FilesClient<M>;
},
};
registerDefaultFileKinds();
return {
filesClientFactory: this.filesClientFactory,
registerFileKind: (fileKind: FileKind) => {
getFileKindsRegistry().register(fileKind);
registerFileKind: (fileKind: FileKindBrowser) => {
this.registry.register(fileKind);
},
};
}

View file

@ -71,7 +71,7 @@ export class File<M = unknown> implements IFile {
}
private upload(content: Readable): Observable<{ size: number }> {
return defer(() => this.fileClient.upload(this.id, content));
return defer(() => this.fileClient.upload(this.metadata, content));
}
public async uploadContent(

View file

@ -27,15 +27,15 @@ export interface CreateEsFileClientArgs {
*/
blobStorageIndex: string;
/**
* An elasticsearch client that will be used to interact with the cluster
* An elasticsearch client that will be used to interact with the cluster.
*/
elasticsearchClient: ElasticsearchClient;
/**
* The maximum file size to be write
* The maximum file size to be written.
*/
maxSizeBytes?: number;
/**
* A logger for debuggin purposes
* A logger for debugging purposes.
*/
logger: Logger;
}

View file

@ -43,6 +43,8 @@ import {
export type UploadOptions = Omit<BlobUploadOptions, 'id'>;
const fourMiB = 4 * 1024 * 1024;
export function createFileClient({
fileKindDescriptor,
auditLogger,
@ -211,17 +213,28 @@ export class FileClientImpl implements FileClient {
* @param options - Options for the upload
*/
public upload = async (
id: string,
file: FileJSON,
rs: Readable,
options?: UploadOptions
): ReturnType<BlobStorageClient['upload']> => {
const { maxSizeBytes } = this.fileKindDescriptor;
const { transforms = [], ...blobOptions } = options || {};
let maxFileSize: number = typeof maxSizeBytes === 'number' ? maxSizeBytes : fourMiB;
if (typeof maxSizeBytes === 'function') {
const sizeLimitPerFile = maxSizeBytes(file);
if (typeof sizeLimitPerFile === 'number') {
maxFileSize = sizeLimitPerFile;
}
}
transforms.push(enforceMaxByteSizeTransform(maxFileSize));
return this.blobStorageClient.upload(rs, {
...options,
transforms: [
...(options?.transforms || []),
enforceMaxByteSizeTransform(this.fileKindDescriptor.maxSizeBytes ?? Infinity),
],
id,
...blobOptions,
transforms,
id: file.id,
});
};

View file

@ -63,7 +63,9 @@ describe('FileService', () => {
});
fileKindsRegistry.register({
id: fileKindTinyFiles,
maxSizeBytes: 10,
maxSizeBytes: (file) => {
return file.mimeType === 'text/json' ? 3 : 10;
},
http: {},
});
esClient = coreStart.elasticsearch.client.asInternalUser;
@ -263,7 +265,7 @@ describe('FileService', () => {
expect(updatedFile2.data.alt).toBe(updatableFields.alt);
});
it('enforces max size settings', async () => {
it('enforces file kind max size settings', async () => {
const file = await createDisposableFile({ fileKind: fileKindTinyFiles, name: 'test' });
const tinyContent = Readable.from(['ok']);
await file.uploadContent(tinyContent);
@ -275,6 +277,26 @@ describe('FileService', () => {
);
});
it('enforces per file max size settings, using mime type', async () => {
const file = await createDisposableFile({
fileKind: fileKindTinyFiles,
name: 'test',
mime: 'text/mime',
});
const tinyContent = Readable.from(['ok ok ok']);
await file.uploadContent(tinyContent);
const file2 = await createDisposableFile({
fileKind: fileKindTinyFiles,
name: 'test',
mime: 'text/json',
});
const notSoTinyContent = Readable.from(['[123]']);
await expect(() => file2.uploadContent(notSoTinyContent)).rejects.toThrow(
new Error('Maximum of 3 bytes exceeded')
);
});
describe('ES blob integration and file kinds', () => {
it('passes blob store settings', async () => {
const file = await createDisposableFile({ fileKind: fileKindNonDefault, name: 'test' });

View file

@ -21,7 +21,6 @@ import {
getFileKindsRegistry,
FileKindsRegistryImpl,
} from '../common/file_kinds_registry';
import { registerDefaultFileKinds } from '../common/register_default_file_kinds';
import { BlobStorageService } from './blob_storage_service';
import { FileServiceFactory } from './file_service';
@ -35,6 +34,7 @@ import type {
import type { FilesRequestHandlerContext, FilesRouter } from './routes/types';
import { registerRoutes, registerFileKindRoutes } from './routes';
import { Counters, registerUsageCollector } from './usage';
import * as DefaultImageKind from '../common/default_image_file_kind';
export class FilesPlugin implements Plugin<FilesSetup, FilesStart, FilesPluginSetupDependencies> {
private static analytics?: AnalyticsServiceStart;
@ -92,8 +92,7 @@ export class FilesPlugin implements Plugin<FilesSetup, FilesStart, FilesPluginSe
getFileService: () => this.fileServiceFactory?.asInternal(),
});
// Now that everything is set up:
registerDefaultFileKinds();
this.registerDefaultImageFileKind();
return {
registerFileKind(fileKind) {
@ -125,4 +124,21 @@ export class FilesPlugin implements Plugin<FilesSetup, FilesStart, FilesPluginSe
}
public stop() {}
private registerDefaultImageFileKind() {
const registry = getFileKindsRegistry();
registry.register({
...DefaultImageKind.kind,
maxSizeBytes: DefaultImageKind.maxSize,
http: {
create: { tags: DefaultImageKind.tags },
delete: { tags: DefaultImageKind.tags },
download: { tags: DefaultImageKind.tags },
getById: { tags: DefaultImageKind.tags },
list: { tags: DefaultImageKind.tags },
share: { tags: DefaultImageKind.tags },
update: { tags: DefaultImageKind.tags },
},
});
}
}

View file

@ -12,6 +12,7 @@ import { Readable } from 'stream';
import type { FilesClient } from '../../../common/files_client';
import type { FileKind } from '../../../common/types';
import type { CreateRouteDefinition } from '../../../common/api_routes';
import { MaxByteSizeExceededError } from '../../file_client/stream_transforms/max_byte_size_transform/errors';
import { FILES_API_ROUTES } from '../api_routes';
import { fileErrors } from '../../file';
import { getById } from './helpers';
@ -57,6 +58,12 @@ export const handler: CreateHandler<Endpoint> = async ({ files, fileKind }, req,
try {
await file.uploadContent(stream as Readable, abort$);
} catch (e) {
if (e instanceof MaxByteSizeExceededError) {
return res.customError({
statusCode: 413,
});
}
if (
e instanceof fileErrors.ContentAlreadyUploadedError ||
e instanceof fileErrors.UploadInProgressError
@ -81,8 +88,6 @@ export const handler: CreateHandler<Endpoint> = async ({ files, fileKind }, req,
return res.ok({ body });
};
const fourMiB = 4 * 1024 * 1024;
export function register(fileKindRouter: FileKindRouter, fileKind: FileKind) {
if (fileKind.http.create) {
fileKindRouter[method](
@ -97,7 +102,12 @@ export function register(fileKindRouter: FileKindRouter, fileKind: FileKind) {
output: 'stream',
parse: false,
accepts: fileKind.allowedMimeTypes ?? 'application/octet-stream',
maxBytes: fileKind.maxSizeBytes ?? fourMiB,
// This is set to 10 GiB because the actual file size limit is
// enforced by the file service. This is just a limit on the
// size of the HTTP request body, but the file service will throw
// 413 errors if the file size is larger than expected.
maxBytes: 10 * 1024 * 1024 * 1024,
},
},
},

View file

@ -6,8 +6,7 @@
* Side Public License, v 1.
*/
import { FileKind } from '@kbn/shared-ux-file-types';
import { defaultImageFileKind } from '@kbn/files-plugin/common';
import { DefaultFileKind } from '@kbn/files-plugin/common';
export type {
FilesClient,
@ -27,4 +26,4 @@ export type { ApplicationStart, OverlayStart, ThemeServiceStart } from '@kbn/cor
export type { UiActionsStart, UiActionsSetup } from '@kbn/ui-actions-plugin/public';
export const imageEmbeddableFileKind: FileKind = defaultImageFileKind;
export const imageEmbeddableFileKind = DefaultFileKind.kind;