| // Copyright 2023 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| |
| import {contentAsDataURL, type DeferredContent} from './ContentProvider.js'; |
| import {Text} from './Text.js'; |
| |
| const objectUrlRegistry = new FinalizationRegistry<string>(url => { |
| URL.revokeObjectURL(url); |
| }); |
| |
| /** |
| * The maximum size in bytes that we are willing to convert to a Blob. |
| * 10MB is chosen as a safe upper limit to prevent freezing the DevTools UI |
| * when synchronously decoding large Base64 strings. |
| */ |
| const MAX_BLOB_SIZE_BYTES = 10 * 1024 * 1024; |
| |
| /** |
| * This class is a small wrapper around either raw binary or text data. |
| * As the binary data can actually contain textual data, we also store the |
| * MIME type and if applicable, the charset. |
| * |
| * This information should be generally kept together, as interpreting text |
| * from raw bytes requires an encoding. |
| * |
| * Note that we only rarely have to decode text ourselves in the frontend, |
| * this is mostly handled by the backend. There are cases though (e.g. SVG, |
| * or streaming response content) where we receive text data in |
| * binary (base64-encoded) form. |
| * |
| * The class only implements decoding. We currently don't have a use-case |
| * to re-encode text into base64 bytes using a specified charset. |
| */ |
| export class ContentData { |
| readonly mimeType: string; |
| readonly charset: string; |
| #contentAsBase64?: string; |
| #contentAsText?: string; |
| #contentAsTextObj?: Text; |
| #imagePreviewUrl?: string; |
| |
| constructor(data: string, isBase64: boolean, mimeType: string, charset?: string) { |
| this.charset = charset || 'utf-8'; |
| if (isBase64) { |
| this.#contentAsBase64 = data; |
| } else { |
| this.#contentAsText = data; |
| } |
| |
| this.mimeType = mimeType; |
| if (!this.mimeType) { |
| // Tests or broken requests might pass an empty/undefined mime type. Fallback to |
| // "default" mime types. |
| this.mimeType = isBase64 ? 'application/octet-stream' : 'text/plain'; |
| } |
| } |
| |
| static async fromCompressedBase64(data: string, mimeType: string, charset?: string, |
| contentEncoding?: string): Promise<ContentData> { |
| const normalizedEncoding = contentEncoding?.toLowerCase(); |
| if (!normalizedEncoding) { |
| return new ContentData(data, true, mimeType, charset); |
| } |
| |
| let contentAsBase64 = data; |
| try { |
| const bytes = Common.Base64.decode(data); |
| let decompressed: ArrayBuffer|null = null; |
| if (normalizedEncoding.includes('gzip') && Common.Gzip.isGzip(bytes.buffer)) { |
| decompressed = await Common.Gzip.decompressToBuffer(bytes.buffer); |
| } else if (normalizedEncoding.includes('deflate')) { |
| decompressed = await Common.Gzip.decompressDeflateToBuffer(bytes.buffer); |
| } |
| |
| if (decompressed) { |
| contentAsBase64 = await Common.Base64.encode(decompressed); |
| } |
| } catch (e) { |
| console.warn('Failed to decompress base64 content data:', e); |
| } |
| |
| return new ContentData(contentAsBase64, true, mimeType, charset); |
| } |
| |
| /** |
| * Returns the data as base64. |
| * |
| * @throws if this `ContentData` was constructed from text content. |
| */ |
| get base64(): string { |
| if (this.#contentAsBase64 === undefined) { |
| throw new Error('Encoding text content as base64 is not supported'); |
| } |
| return this.#contentAsBase64; |
| } |
| |
| /** |
| * Returns the content as text. If this `ContentData` was constructed with base64 |
| * encoded bytes, it will use the provided charset to attempt to decode the bytes. |
| * |
| * @throws if `mimeType` is not a text type. |
| */ |
| get text(): string { |
| if (this.#contentAsText !== undefined) { |
| return this.#contentAsText; |
| } |
| |
| if (!this.isTextContent) { |
| throw new Error('Cannot interpret binary data as text'); |
| } |
| |
| const bytes = Common.Base64.decode(this.#contentAsBase64 as string); |
| this.#contentAsText = new TextDecoder(this.charset).decode(bytes); |
| return this.#contentAsText; |
| } |
| |
| /** @returns true, if this `ContentData` was constructed from text content or the mime type indicates text that can be decoded */ |
| get isTextContent(): boolean { |
| return this.#createdFromText || Platform.MimeType.isTextType(this.mimeType); |
| } |
| |
| get isEmpty(): boolean { |
| // Don't trigger unnecessary decoding. Only check if both of the strings are empty. |
| return !Boolean(this.#contentAsBase64) && !Boolean(this.#contentAsText); |
| } |
| |
| get createdFromBase64(): boolean { |
| return this.#contentAsBase64 !== undefined; |
| } |
| |
| get #createdFromText(): boolean { |
| return this.#contentAsBase64 === undefined; |
| } |
| |
| /** |
| * Returns the text content as a `Text` object. The returned object is always the same to |
| * minimize the number of times we have to calculate the line endings array. |
| * |
| * @throws if `mimeType` is not a text type. |
| */ |
| get textObj(): Text { |
| if (this.#contentAsTextObj === undefined) { |
| this.#contentAsTextObj = new Text(this.text); |
| } |
| return this.#contentAsTextObj; |
| } |
| |
| /** |
| * @returns True, iff the contents (base64 or text) are equal. |
| * Does not compare mime type and charset, but will decode base64 data if both |
| * mime types indicate that it's text content. |
| */ |
| contentEqualTo(other: ContentData): boolean { |
| if (this.#contentAsBase64 !== undefined && other.#contentAsBase64 !== undefined) { |
| return this.#contentAsBase64 === other.#contentAsBase64; |
| } |
| if (this.#contentAsText !== undefined && other.#contentAsText !== undefined) { |
| return this.#contentAsText === other.#contentAsText; |
| } |
| if (this.isTextContent && other.isTextContent) { |
| return this.text === other.text; |
| } |
| return false; |
| } |
| |
| asDataUrl(): string|null { |
| // To keep with existing behavior we prefer to return the content |
| // encoded if that is how this ContentData was constructed with. |
| if (this.#contentAsBase64 !== undefined) { |
| const charset = this.isTextContent ? this.charset : null; |
| return contentAsDataURL(this.#contentAsBase64, this.mimeType ?? '', true, charset); |
| } |
| return contentAsDataURL(this.text, this.mimeType ?? '', false, 'utf-8'); |
| } |
| |
| /** |
| * Returns the content as a Blob. |
| * |
| * We prefer Base64 as the source for the Blob because it represents the raw binary |
| * bytes. Converting binary data (like an image) to a UTF-16 string (contentAsText) |
| * is destructive and will corrupt the image data. |
| * |
| * @returns The Blob representation, or `null` if the content exceeds the 10MB safety limit. |
| */ |
| asBlob(): Blob|null { |
| // We prefer Base64 as the source for the Blob because it represents the raw binary |
| // bytes. Converting binary data (like an image) to a UTF-16 string (contentAsText) |
| // is destructive and will corrupt the image data. |
| if (this.#contentAsBase64 !== undefined) { |
| // Base64 encoding uses 4 characters to represent 3 bytes of data. |
| // Therefore, the byte size is approximately 75% of the string length. |
| if (this.#contentAsBase64.length * 0.75 > MAX_BLOB_SIZE_BYTES) { |
| return null; |
| } |
| const bytes = Common.Base64.decode(this.#contentAsBase64); |
| return new Blob([bytes], {type: this.mimeType}); |
| } |
| const text = this.#contentAsText ?? ''; |
| if (text.length > MAX_BLOB_SIZE_BYTES) { |
| return null; |
| } |
| return new Blob([text], {type: this.mimeType}); |
| } |
| |
| /** |
| * Gets the image as either a data: or blob: URL. |
| * |
| * We prefer data: for simplicity, but these are limited in size to 1MB. If |
| * the resource is >1MB, we fall back to a blob: URL. |
| * |
| * @returns An object URL, or `null` if the content exceeds the 10MB safety limit. |
| */ |
| asImagePreviewUrl(): string|null { |
| if (this.#imagePreviewUrl) { |
| return this.#imagePreviewUrl; |
| } |
| const url = this.asDataUrl(); |
| if (url !== null) { |
| this.#imagePreviewUrl = url; |
| return this.#imagePreviewUrl; |
| } |
| |
| const blob = this.asBlob(); |
| if (blob === null) { |
| return null; |
| } |
| this.#imagePreviewUrl = URL.createObjectURL(blob); |
| objectUrlRegistry.register(this, this.#imagePreviewUrl); |
| return this.#imagePreviewUrl; |
| } |
| |
| /** |
| * @deprecated Used during migration from `DeferredContent` to `ContentData`. |
| */ |
| asDeferedContent(): DeferredContent { |
| // To prevent encoding mistakes, we'll return text content already decoded. |
| if (this.isTextContent) { |
| return {content: this.text, isEncoded: false}; |
| } |
| if (this.#contentAsText !== undefined) { |
| // Unknown text mime type, this should not really happen. |
| return {content: this.#contentAsText, isEncoded: false}; |
| } |
| if (this.#contentAsBase64 !== undefined) { |
| return {content: this.#contentAsBase64, isEncoded: true}; |
| } |
| throw new Error('Unreachable'); |
| } |
| |
| static isError(contentDataOrError: ContentDataOrError): contentDataOrError is {error: string} { |
| return 'error' in contentDataOrError; |
| } |
| |
| /** @returns `value` if the passed `ContentDataOrError` is an error, or the text content otherwise */ |
| static textOr<T>(contentDataOrError: ContentDataOrError, value: T): string|T { |
| if (ContentData.isError(contentDataOrError)) { |
| return value; |
| } |
| return contentDataOrError.text; |
| } |
| |
| /** @returns an empty 'text/plain' content data if the passed `ContentDataOrError` is an error, or the content data itself otherwise */ |
| static contentDataOrEmpty(contentDataOrError: ContentDataOrError): ContentData { |
| if (ContentData.isError(contentDataOrError)) { |
| return EMPTY_TEXT_CONTENT_DATA; |
| } |
| return contentDataOrError; |
| } |
| |
| /** |
| * @deprecated Used during migration from `DeferredContent` to `ContentData`. |
| */ |
| static asDeferredContent(contentDataOrError: ContentDataOrError): DeferredContent { |
| if (ContentData.isError(contentDataOrError)) { |
| return {error: contentDataOrError.error, content: null, isEncoded: false}; |
| } |
| return contentDataOrError.asDeferedContent(); |
| } |
| } |
| |
| export const EMPTY_TEXT_CONTENT_DATA = new ContentData('', /* isBase64 */ false, 'text/plain'); |
| |
| export type ContentDataOrError = ContentData|{error: string}; |