diff --git a/src/cloudflare/internal/images-api.ts b/src/cloudflare/internal/images-api.ts index efe33b99174..42b36db2517 100644 --- a/src/cloudflare/internal/images-api.ts +++ b/src/cloudflare/internal/images-api.ts @@ -251,6 +251,9 @@ interface ServiceEntrypointStub { options?: ImageUploadOptions ): Promise; list(options?: ImageListOptions): Promise; + createDirectUpload( + options?: ImageDirectUploadOptions + ): Promise; } class HostedImagesBindingImpl implements HostedImagesBinding { @@ -274,6 +277,12 @@ class HostedImagesBindingImpl implements HostedImagesBinding { async list(options?: ImageListOptions): Promise { return this.#fetcher.list(options); } + + async createDirectUpload( + options?: ImageDirectUploadOptions + ): Promise { + return this.#fetcher.createDirectUpload(options); + } } class ImagesBindingImpl implements ImagesBinding { diff --git a/src/cloudflare/internal/images.d.ts b/src/cloudflare/internal/images.d.ts index 280061e03e9..0c1930cad29 100644 --- a/src/cloudflare/internal/images.d.ts +++ b/src/cloudflare/internal/images.d.ts @@ -140,11 +140,50 @@ interface ImageUpdateOptions { creator?: string; } +type ImageMetadataFilterOperators = { + eq?: string | number | boolean; + in?: string[] | number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; +}; + +type ImageMetadataFilterValue = + | string + | number + | boolean + | ImageMetadataFilterOperators; + +interface ImageListFilter { + metadata?: Record; +} + interface ImageListOptions { limit?: number; cursor?: string; sortOrder?: 'asc' | 'desc'; creator?: string; + filter?: ImageListFilter; +} + +interface ImageSignedUrlOptions { + variant: string; + expiresIn?: number; + keyName?: string; +} + +interface ImageDirectUploadOptions { + id?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + expiresIn?: number; +} + +interface ImageDirectUploadResult { + id: string; + uploadURL: string; } interface ImageList { @@ -166,6 +205,14 @@ interface ImageHandle { */ bytes(): Promise | null>; + /** + * Generate a signed delivery URL for this hosted image. + * @param options Signing configuration + * @returns A signed image delivery URL + * @throws {@link ImagesError} if signing fails + */ + signedUrl(options: ImageSignedUrlOptions): Promise; + /** * Update hosted image metadata * @param options Properties to update @@ -208,6 +255,17 @@ interface HostedImagesBinding { * @throws {@link ImagesError} if list fails */ list(options?: ImageListOptions): Promise; + + /** + * Create a Direct Creator Upload link, letting an end user upload an + * image straight to Cloudflare without exposing an API token + * @param options Upload link configuration + * @returns The new image ID and the upload URL to hand to the end user + * @throws {@link ImagesError} if creation fails + */ + createDirectUpload( + options?: ImageDirectUploadOptions + ): Promise; } interface ImagesBinding { diff --git a/src/cloudflare/internal/test/images/images-api-test.js b/src/cloudflare/internal/test/images/images-api-test.js index 00e9de64de2..9216269b9a7 100644 --- a/src/cloudflare/internal/test/images/images-api-test.js +++ b/src/cloudflare/internal/test/images/images-api-test.js @@ -511,6 +511,23 @@ export const test_images_getImage_not_found = { }, }; +export const test_images_signed_url = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const url = await env.images.hosted + .image('test-image-id') + .signedUrl({ variant: 'private' }); + + assert.equal( + url, + 'https://imagedelivery.example/test-image-id/private?sig=mock-signature' + ); + }, +}; + // UPLOAD export const test_images_upload_with_options = { /** @@ -645,6 +662,27 @@ export const test_images_list_with_options = { }, }; +export const test_images_list_metadata_filter_forwards_correctly = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const result = await env.images.hosted.list({ + filter: { + metadata: { + status: 'active', + priority: { gte: 1, lte: 3 }, + 'config.region': 'eu-west', + }, + }, + }); + + assert.equal(result.images.length, 1); + assert.equal(result.images[0].id, 'image-1'); + }, +}; + // UPLOAD with base64 encoding export const test_images_upload_base64_stream = { /** @@ -687,3 +725,36 @@ export const test_images_upload_base64_arraybuffer = { assert.notEqual(metadata.id, null); }, }; + +// CREATE DIRECT UPLOAD +export const test_images_create_direct_upload_default = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const result = await env.images.hosted.createDirectUpload(); + + assert.notEqual(result.id, null); + assert.equal(result.uploadURL.includes(result.id), true); + }, +}; + +export const test_images_create_direct_upload_with_options = { + /** + * @param {unknown} _ + * @param {Env} env + */ + async test(_, env) { + const result = await env.images.hosted.createDirectUpload({ + id: 'custom-upload-id', + requireSignedURLs: true, + metadata: { userId: 'abc123' }, + creator: 'direct-upload-creator', + expiresIn: 600, + }); + + assert.equal(result.id, 'custom-upload-id'); + assert.equal(result.uploadURL.includes('custom-upload-id'), true); + }, +}; diff --git a/src/cloudflare/internal/test/images/images-upstream-mock.js b/src/cloudflare/internal/test/images/images-upstream-mock.js index 3b7c65597c4..2c4b3a0db04 100644 --- a/src/cloudflare/internal/test/images/images-upstream-mock.js +++ b/src/cloudflare/internal/test/images/images-upstream-mock.js @@ -20,6 +20,57 @@ async function imageAsString(blob) { return blob.text(); } +function resolveMetaPath(obj, path) { + return path + .split('.') + .reduce( + (acc, key) => (acc && typeof acc === 'object' ? acc[key] : undefined), + obj + ); +} + +function matchesCondition(actual, condition) { + if ( + condition === null || + typeof condition !== 'object' || + Array.isArray(condition) + ) { + return actual === condition; + } + + return Object.entries(condition).every(([op, expected]) => { + switch (op) { + case 'eq': + return actual === expected; + case 'in': + return ( + Array.isArray(expected) && + expected.some((candidate) => candidate === actual) + ); + case 'gt': + return typeof actual === 'number' && actual > expected; + case 'gte': + return typeof actual === 'number' && actual >= expected; + case 'lt': + return typeof actual === 'number' && actual < expected; + case 'lte': + return typeof actual === 'number' && actual <= expected; + default: + return false; + } + }); +} + +function matchesMetadataFilters(image, filters) { + if (!filters) { + return true; + } + + return Object.entries(filters).every(([field, condition]) => + matchesCondition(resolveMetaPath(image.meta ?? {}, field), condition) + ); +} + class ImageHandleMock extends RpcTarget { /** @type {string} */ #imageId; @@ -56,6 +107,10 @@ class ImageHandleMock extends RpcTarget { return new Blob([mockData]).stream(); } + async signedUrl(options) { + return `https://imagedelivery.example/${this.#imageId}/${options.variant}?sig=mock-signature`; + } + /** * @param {ImageUpdateOptions} body * @returns {Promise} @@ -133,7 +188,7 @@ export class ServiceEntrypoint extends WorkerEntrypoint { uploaded: '2024-01-01T00:00:00Z', requireSignedURLs: false, variants: ['public'], - meta: {}, + meta: { status: 'active', priority: 1, config: { region: 'eu-west' } }, creator: 'test-creator', }, { @@ -142,13 +197,21 @@ export class ServiceEntrypoint extends WorkerEntrypoint { uploaded: '2024-01-02T00:00:00Z', requireSignedURLs: false, variants: ['public'], - meta: {}, + meta: { + status: 'archived', + priority: 5, + config: { region: 'us-east' }, + }, creator: 'test-creator', }, ]; + const filtered = images.filter((image) => + matchesMetadataFilters(image, options?.filter?.metadata) + ); + const limit = options?.limit || 50; - const slicedImages = images.slice(0, limit); + const slicedImages = filtered.slice(0, limit); return { images: slicedImages, @@ -156,6 +219,18 @@ export class ServiceEntrypoint extends WorkerEntrypoint { }; } + /** + * @param {ImageDirectUploadOptions} [options] + * @returns {Promise} + */ + async createDirectUpload(options) { + const id = options?.id || 'generated-upload-id'; + return { + id, + uploadURL: `https://upload.imagedelivery.example/${id}`, + }; + } + /** * Handle HTTP requests for info and transform operations. * In production these go to a separate transformation service, diff --git a/types/defines/images.d.ts b/types/defines/images.d.ts index caf381f6afb..bdeb00b8142 100644 --- a/types/defines/images.d.ts +++ b/types/defines/images.d.ts @@ -137,11 +137,50 @@ interface ImageUpdateOptions { creator?: string; } +type ImageMetadataFilterOperators = { + eq?: string | number | boolean; + in?: string[] | number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; +}; + +type ImageMetadataFilterValue = + | string + | number + | boolean + | ImageMetadataFilterOperators; + +interface ImageListFilter { + metadata?: Record; +} + interface ImageListOptions { limit?: number; cursor?: string; sortOrder?: 'asc' | 'desc'; creator?: string; + filter?: ImageListFilter; +} + +interface ImageSignedUrlOptions { + variant: string; + expiresIn?: number; + keyName?: string; +} + +interface ImageDirectUploadOptions { + id?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + expiresIn?: number; +} + +interface ImageDirectUploadResult { + id: string; + uploadURL: string; } interface ImageList { @@ -163,6 +202,14 @@ interface ImageHandle { */ bytes(): Promise | null>; + /** + * Generate a signed delivery URL for this hosted image. + * @param options Signing configuration + * @returns A signed image delivery URL + * @throws {@link ImagesError} if signing fails + */ + signedUrl(options: ImageSignedUrlOptions): Promise; + /** * Update hosted image metadata * @param options Properties to update @@ -205,6 +252,17 @@ interface HostedImagesBinding { * @throws {@link ImagesError} if list fails */ list(options?: ImageListOptions): Promise; + + /** + * Create a Direct Creator Upload link, letting an end user upload an + * image straight to Cloudflare without exposing an API token + * @param options Upload link configuration + * @returns The new image ID and the upload URL to hand to the end user + * @throws {@link ImagesError} if creation fails + */ + createDirectUpload( + options?: ImageDirectUploadOptions + ): Promise; } interface ImagesBinding { diff --git a/types/generated-snapshot/experimental/index.d.ts b/types/generated-snapshot/experimental/index.d.ts index 05c9f61bea5..95f83867411 100755 --- a/types/generated-snapshot/experimental/index.d.ts +++ b/types/generated-snapshot/experimental/index.d.ts @@ -14724,11 +14724,44 @@ interface ImageUpdateOptions { metadata?: Record; creator?: string; } +type ImageMetadataFilterOperators = { + eq?: string | number | boolean; + in?: string[] | number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; +}; +type ImageMetadataFilterValue = + | string + | number + | boolean + | ImageMetadataFilterOperators; +interface ImageListFilter { + metadata?: Record; +} interface ImageListOptions { limit?: number; cursor?: string; sortOrder?: "asc" | "desc"; creator?: string; + filter?: ImageListFilter; +} +interface ImageSignedUrlOptions { + variant: string; + expiresIn?: number; + keyName?: string; +} +interface ImageDirectUploadOptions { + id?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + expiresIn?: number; +} +interface ImageDirectUploadResult { + id: string; + uploadURL: string; } interface ImageList { images: ImageMetadata[]; @@ -14746,6 +14779,13 @@ interface ImageHandle { * @returns ReadableStream of image bytes, or null if not found */ bytes(): Promise | null>; + /** + * Generate a signed delivery URL for this hosted image. + * @param options Signing configuration + * @returns A signed image delivery URL + * @throws {@link ImagesError} if signing fails + */ + signedUrl(options: ImageSignedUrlOptions): Promise; /** * Update hosted image metadata * @param options Properties to update @@ -14784,6 +14824,16 @@ interface HostedImagesBinding { * @throws {@link ImagesError} if list fails */ list(options?: ImageListOptions): Promise; + /** + * Create a Direct Creator Upload link, letting an end user upload an + * image straight to Cloudflare without exposing an API token + * @param options Upload link configuration + * @returns The new image ID and the upload URL to hand to the end user + * @throws {@link ImagesError} if creation fails + */ + createDirectUpload( + options?: ImageDirectUploadOptions, + ): Promise; } interface ImagesBinding { /** diff --git a/types/generated-snapshot/experimental/index.ts b/types/generated-snapshot/experimental/index.ts index 92867d33f75..50d338f863d 100755 --- a/types/generated-snapshot/experimental/index.ts +++ b/types/generated-snapshot/experimental/index.ts @@ -14751,11 +14751,44 @@ export interface ImageUpdateOptions { metadata?: Record; creator?: string; } +export type ImageMetadataFilterOperators = { + eq?: string | number | boolean; + in?: string[] | number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; +}; +export type ImageMetadataFilterValue = + | string + | number + | boolean + | ImageMetadataFilterOperators; +export interface ImageListFilter { + metadata?: Record; +} export interface ImageListOptions { limit?: number; cursor?: string; sortOrder?: "asc" | "desc"; creator?: string; + filter?: ImageListFilter; +} +export interface ImageSignedUrlOptions { + variant: string; + expiresIn?: number; + keyName?: string; +} +export interface ImageDirectUploadOptions { + id?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + expiresIn?: number; +} +export interface ImageDirectUploadResult { + id: string; + uploadURL: string; } export interface ImageList { images: ImageMetadata[]; @@ -14773,6 +14806,13 @@ export interface ImageHandle { * @returns ReadableStream of image bytes, or null if not found */ bytes(): Promise | null>; + /** + * Generate a signed delivery URL for this hosted image. + * @param options Signing configuration + * @returns A signed image delivery URL + * @throws {@link ImagesError} if signing fails + */ + signedUrl(options: ImageSignedUrlOptions): Promise; /** * Update hosted image metadata * @param options Properties to update @@ -14811,6 +14851,16 @@ export interface HostedImagesBinding { * @throws {@link ImagesError} if list fails */ list(options?: ImageListOptions): Promise; + /** + * Create a Direct Creator Upload link, letting an end user upload an + * image straight to Cloudflare without exposing an API token + * @param options Upload link configuration + * @returns The new image ID and the upload URL to hand to the end user + * @throws {@link ImagesError} if creation fails + */ + createDirectUpload( + options?: ImageDirectUploadOptions, + ): Promise; } export interface ImagesBinding { /** diff --git a/types/generated-snapshot/index.d.ts b/types/generated-snapshot/index.d.ts index 12246afaf8d..cedfde89a69 100755 --- a/types/generated-snapshot/index.d.ts +++ b/types/generated-snapshot/index.d.ts @@ -14076,11 +14076,44 @@ interface ImageUpdateOptions { metadata?: Record; creator?: string; } +type ImageMetadataFilterOperators = { + eq?: string | number | boolean; + in?: string[] | number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; +}; +type ImageMetadataFilterValue = + | string + | number + | boolean + | ImageMetadataFilterOperators; +interface ImageListFilter { + metadata?: Record; +} interface ImageListOptions { limit?: number; cursor?: string; sortOrder?: "asc" | "desc"; creator?: string; + filter?: ImageListFilter; +} +interface ImageSignedUrlOptions { + variant: string; + expiresIn?: number; + keyName?: string; +} +interface ImageDirectUploadOptions { + id?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + expiresIn?: number; +} +interface ImageDirectUploadResult { + id: string; + uploadURL: string; } interface ImageList { images: ImageMetadata[]; @@ -14098,6 +14131,13 @@ interface ImageHandle { * @returns ReadableStream of image bytes, or null if not found */ bytes(): Promise | null>; + /** + * Generate a signed delivery URL for this hosted image. + * @param options Signing configuration + * @returns A signed image delivery URL + * @throws {@link ImagesError} if signing fails + */ + signedUrl(options: ImageSignedUrlOptions): Promise; /** * Update hosted image metadata * @param options Properties to update @@ -14136,6 +14176,16 @@ interface HostedImagesBinding { * @throws {@link ImagesError} if list fails */ list(options?: ImageListOptions): Promise; + /** + * Create a Direct Creator Upload link, letting an end user upload an + * image straight to Cloudflare without exposing an API token + * @param options Upload link configuration + * @returns The new image ID and the upload URL to hand to the end user + * @throws {@link ImagesError} if creation fails + */ + createDirectUpload( + options?: ImageDirectUploadOptions, + ): Promise; } interface ImagesBinding { /** diff --git a/types/generated-snapshot/index.ts b/types/generated-snapshot/index.ts index 39357652538..7543f09e8ef 100755 --- a/types/generated-snapshot/index.ts +++ b/types/generated-snapshot/index.ts @@ -14103,11 +14103,44 @@ export interface ImageUpdateOptions { metadata?: Record; creator?: string; } +export type ImageMetadataFilterOperators = { + eq?: string | number | boolean; + in?: string[] | number[]; + gt?: number; + gte?: number; + lt?: number; + lte?: number; +}; +export type ImageMetadataFilterValue = + | string + | number + | boolean + | ImageMetadataFilterOperators; +export interface ImageListFilter { + metadata?: Record; +} export interface ImageListOptions { limit?: number; cursor?: string; sortOrder?: "asc" | "desc"; creator?: string; + filter?: ImageListFilter; +} +export interface ImageSignedUrlOptions { + variant: string; + expiresIn?: number; + keyName?: string; +} +export interface ImageDirectUploadOptions { + id?: string; + requireSignedURLs?: boolean; + metadata?: Record; + creator?: string; + expiresIn?: number; +} +export interface ImageDirectUploadResult { + id: string; + uploadURL: string; } export interface ImageList { images: ImageMetadata[]; @@ -14125,6 +14158,13 @@ export interface ImageHandle { * @returns ReadableStream of image bytes, or null if not found */ bytes(): Promise | null>; + /** + * Generate a signed delivery URL for this hosted image. + * @param options Signing configuration + * @returns A signed image delivery URL + * @throws {@link ImagesError} if signing fails + */ + signedUrl(options: ImageSignedUrlOptions): Promise; /** * Update hosted image metadata * @param options Properties to update @@ -14163,6 +14203,16 @@ export interface HostedImagesBinding { * @throws {@link ImagesError} if list fails */ list(options?: ImageListOptions): Promise; + /** + * Create a Direct Creator Upload link, letting an end user upload an + * image straight to Cloudflare without exposing an API token + * @param options Upload link configuration + * @returns The new image ID and the upload URL to hand to the end user + * @throws {@link ImagesError} if creation fails + */ + createDirectUpload( + options?: ImageDirectUploadOptions, + ): Promise; } export interface ImagesBinding { /**