From ef84733a5f4665f24235c62a5420b1482567a875 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 13:14:56 +0300 Subject: [PATCH 1/2] feat(media): stream downloads, add --url and --from-event - media download streams through a temp file (SDK downloadStream/writeStreamToFile), follows storage redirects without the API key, enforces --max-bytes while streaming - --url prints the short-lived signed link with a stderr warning - --from-event decrypts WhatsApp CDN media from a stored webhook (no credential) - map PolymorfaMediaIntegrityError to a non-retryable validation error - pin @polymorfa/sdk to polymorfa/sdks@5a7245d (unmerged codex/media-download) --- README.md | 32 ++++- src/commands/media/download.ts | 93 +++++++++++-- src/errors/present.ts | 6 + src/services/media.ts | 183 ++++++++++++++++++++++-- test/commands/media.test.ts | 246 ++++++++++++++++++++++++++++++++- 5 files changed, 524 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 078d5d42..a0c7c39e 100644 --- a/README.md +++ b/README.md @@ -340,16 +340,36 @@ polymorfa media view media_123 polymorfa media download media_123 \ --output ./attachment.bin +polymorfa media download media_123 --url + +polymorfa media download --from-event ./message.json \ + --output ./photo.jpg + polymorfa media persist media_123 \ --idempotency-key persist_media_123 ``` -`media download` retrieves metadata before fetching bytes and rejects media -larger than 100 MiB by default. Override the memory guard with `--max-bytes` -only when the process has enough memory. An explicit output path is required, -and existing files are preserved unless `--force` is passed. The SDK buffers -the complete response before the CLI writes it; this is not a streaming or -range-download command. +`media download` retrieves metadata, then streams the file into a temporary +sibling file and renames it into place when the download completes. It +rejects media larger than 100 MiB by default, both from the declared size and +while bytes arrive. Raise the limit with `--max-bytes`. An explicit +`--output` path (`-o`) is required. Existing files are kept unless you pass +`--force`, and an interrupted or rejected download leaves no partial file. +When the API redirects to object storage, the CLI requests the storage URL +without your API key. + +`--url` prints the short-lived signed storage link to stdout instead of +downloading it, and writes a warning to stderr. Anyone who has the link can +fetch the file until it expires, so do not log, store or share it. If the API +streams the media itself, no link exists and the command exits with code 2. + +`--from-event ` reads a stored message webhook, either the full event +or its `payload`. It downloads the encrypted attachment from the WhatsApp CDN +and verifies and decrypts it locally, with no Polymorfa credential. It works +only for messages that carry the `media` descriptor, which projects without +media persistence receive. The event file contains the decryption key: keep +it private. If verification fails, the command exits with code 2 and writes +no file. The CDN link in the event expires, so older events stop working. `media persist` asks the server to copy the media into configured object storage. It does not write a local file. The source API has no Messaging media diff --git a/src/commands/media/download.ts b/src/commands/media/download.ts index 308e0c68..c10bba2e 100644 --- a/src/commands/media/download.ts +++ b/src/commands/media/download.ts @@ -1,40 +1,109 @@ import {Args, Flags} from '@oclif/core' +import {PolymorfaValidationError} from '@polymorfa/sdk' import {BaseCommand} from '../../base-command.js' -import {DEFAULT_MAX_MEDIA_BYTES, downloadMedia} from '../../services/media.js' +import { + DEFAULT_MAX_MEDIA_BYTES, + downloadMedia, + downloadMediaFromEvent, + signedMediaUrl, +} from '../../services/media.js' + +export const SIGNED_URL_WARNING = + 'Warning: this signed link expires within minutes and grants access to the media without further authentication. Do not log, store, or share it.\n' export default class MediaDownload extends BaseCommand { static override args = { - media: Args.string({description: 'Messaging media ID.', required: true}), + media: Args.string({ + description: 'Messaging media ID. Omit it when using --from-event.', + required: false, + }), } - static override description = 'Download Messaging media to a local file.' + static override description = + 'Download Messaging media to a local file, print its signed link, or decrypt it from a stored webhook event.' + + static override examples = [ + '<%= config.bin %> media download media_123 --output ./attachment.bin', + '<%= config.bin %> media download media_123 --url', + '<%= config.bin %> media download --from-event ./message.json -o ./photo.jpg', + ] static override flags = { force: Flags.boolean({description: 'Overwrite an existing output file.'}), + 'from-event': Flags.string({ + description: + 'Decrypt media from the WhatsApp CDN using a stored message webhook JSON file. The file contains a decryption key.', + exclusive: ['url'], + }), 'max-bytes': Flags.integer({ default: DEFAULT_MAX_MEDIA_BYTES, - description: 'Maximum bytes to buffer in memory.', + description: 'Maximum bytes to download.', min: 1, }), output: Flags.string({ char: 'o', description: 'Destination file path.', - required: true, + exclusive: ['url'], + }), + url: Flags.boolean({ + description: + 'Print the short-lived signed download link to stdout instead of downloading.', + exclusive: ['output', 'from-event', 'force'], }), } async run(): Promise { const {args, flags} = await this.parse(MediaDownload) - const result = await downloadMedia( - await this.messagingClient(flags, {serverApiKeyOnly: 'Media'}), - args.media, - { + const fromEvent = flags['from-event'] + + if (fromEvent !== undefined) { + if (args.media !== undefined) { + throw new PolymorfaValidationError( + 'Pass either a media ID or --from-event, not both.', + ) + } + const output = requireOutput(flags.output) + const result = await downloadMediaFromEvent(fromEvent, { force: flags.force, maxBytes: flags['max-bytes'], - output: flags.output, - }, - ) + output, + }) + this.writeValue(result, result.data.path, flags) + return + } + + if (args.media === undefined) { + throw new PolymorfaValidationError( + 'A media ID is required unless --from-event is passed.', + ) + } + + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Media', + }) + + if (flags.url) { + const result = await signedMediaUrl(client, args.media) + process.stderr.write(SIGNED_URL_WARNING) + this.writeValue(result, result.data.url, flags) + return + } + + const result = await downloadMedia(client, args.media, { + force: flags.force, + maxBytes: flags['max-bytes'], + output: requireOutput(flags.output), + }) this.writeValue(result, result.data.path, flags) } } + +function requireOutput(output: string | undefined): string { + if (output === undefined || output.length === 0) { + throw new PolymorfaValidationError( + 'Pass --output , or --url to print a signed link.', + ) + } + return output +} diff --git a/src/errors/present.ts b/src/errors/present.ts index 80ef6573..2c22b6dd 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -6,6 +6,7 @@ import { PolymorfaConflictError, PolymorfaConnectionError, PolymorfaError, + PolymorfaMediaIntegrityError, PolymorfaNotFoundError, PolymorfaPaymentRequiredError, PolymorfaRateLimitError, @@ -121,6 +122,11 @@ function classifyError( return {category: 'validation', exitCode: 2} } + // Media that fails its MAC, hash, or size checks is not retryable. + if (error instanceof PolymorfaMediaIntegrityError) { + return {category: 'validation', exitCode: 2} + } + if (error instanceof PolymorfaAuthenticationError) { return {category: 'authentication', exitCode: 3} } diff --git a/src/services/media.ts b/src/services/media.ts index 2de5a632..61b86065 100644 --- a/src/services/media.ts +++ b/src/services/media.ts @@ -1,21 +1,29 @@ -import {stat, writeFile} from 'node:fs/promises' +import {readFile, stat} from 'node:fs/promises' import {resolve} from 'node:path' import { PolymorfaConfigurationError, PolymorfaConflictError, + PolymorfaValidationError, type MessagingClient, type MessagingMediaInfo, type RequestOptions, type ResponseMetadata, type SuccessResponse, + type WhatsAppMediaKind, + type WhatsAppMediaMessage, } from '@polymorfa/sdk' +import { + downloadWhatsAppMediaToFile, + writeStreamToFile, +} from '@polymorfa/sdk/node' export const DEFAULT_MAX_MEDIA_BYTES = 100 * 1024 * 1024 +const MAX_EVENT_FILE_BYTES = 10 * 1024 * 1024 export interface MediaResult { readonly data: T - readonly metadata: { + readonly metadata?: { readonly apiVersion?: string readonly attempts: number readonly requestId?: string @@ -30,6 +38,21 @@ export interface DownloadedMedia { readonly path: string } +export interface DownloadedWhatsAppMedia { + readonly bytes: number + readonly fileName?: string + readonly mediaKind: WhatsAppMediaKind + readonly mimeType: string + readonly path: string +} + +export interface SignedMediaUrl { + readonly expiresAt?: string + readonly mediaId: string + readonly requestId?: string + readonly url: string +} + export async function viewMedia( client: MessagingClient, mediaId: string, @@ -38,6 +61,11 @@ export async function viewMedia( return result(response.data.data, response.metadata) } +/** + * Streams Messaging media into a temporary sibling file and moves it into + * place after the whole body arrives. Storage redirects are followed without + * sending the credential to the storage host. + */ export async function downloadMedia( client: MessagingClient, mediaId: string, @@ -53,29 +81,93 @@ export async function downloadMedia( const info = await client.media.retrieve(mediaId) enforceLimit(info.data.data.fileLength, options.maxBytes, mediaId) - const downloaded = await client.media.download(mediaId) - enforceLimit(downloaded.data.byteLength, options.maxBytes, mediaId) - - try { - await writeFile(output, new Uint8Array(downloaded.data), { - flag: options.force ? 'w' : 'wx', - }) - } catch (error) { - if (nodeErrorCode(error) === 'EEXIST') throw outputConflict(output) - throw error + const download = await client.media.downloadStream(mediaId) + if (download.contentLength !== undefined) { + try { + enforceLimit(download.contentLength, options.maxBytes, mediaId) + } catch (error) { + await download.body.cancel().catch(() => {}) + throw error + } } + const written = await writeStreamToFile(download.body, output, { + maxBytes: options.maxBytes, + overwrite: options.force, + }).catch((error: unknown) => { + throw mapWriteError(error, output, mediaId, options.maxBytes) + }) + return result( { - bytes: downloaded.data.byteLength, + bytes: written.bytes, mediaId, mimeType: info.data.data.mimeType, path: output, }, - downloaded.metadata, + download.metadata, ) } +/** Returns the short-lived signed storage URL without downloading the media. */ +export async function signedMediaUrl( + client: MessagingClient, + mediaId: string, +): Promise> { + const target = await client.media.downloadUrl(mediaId) + if (target.streamed) { + throw new PolymorfaConfigurationError( + `The API streams media ${mediaId} directly and issued no signed link. Use --output to download it.`, + 'url', + ) + } + + return { + data: { + mediaId, + url: target.url, + ...(target.requestId === undefined ? {} : {requestId: target.requestId}), + ...(target.expiresAt === undefined + ? {} + : {expiresAt: target.expiresAt.toISOString()}), + }, + } +} + +/** + * Decrypts media straight from the WhatsApp CDN using a stored message + * webhook. No Polymorfa credential is used or sent. + */ +export async function downloadMediaFromEvent( + eventFile: string, + options: { + readonly force: boolean + readonly maxBytes: number + readonly output: string + }, +): Promise> { + const output = resolve(options.output) + if (!options.force) await rejectExistingOutput(output) + const message = await readMediaEvent(eventFile) + + const written = await downloadWhatsAppMediaToFile(message, output, { + maxBytes: options.maxBytes, + overwrite: options.force, + }).catch((error: unknown) => { + throw mapWriteError(error, output, 'from the event', options.maxBytes) + }) + + return { + data: { + bytes: written.bytes, + mediaKind: written.mediaKind, + mimeType: written.mimetype, + path: output, + ...(written.fileName === undefined ? {} : {fileName: written.fileName}), + }, + } +} + export async function persistMedia( client: MessagingClient, mediaId: string, @@ -85,6 +177,44 @@ export async function persistMedia( return result(response.data, response.metadata) } +/** + * Accepts a full webhook event (`{event, payload}`) or a bare message + * payload. The file contains a media decryption key, so its contents are + * never echoed in errors. + */ +export async function readMediaEvent( + path: string, +): Promise { + const file = resolve(path) + const stats = await stat(file) + if (!stats.isFile() || stats.size > MAX_EVENT_FILE_BYTES) { + throw new PolymorfaValidationError( + `${file} must be a regular JSON file no larger than 10 MiB.`, + ) + } + + let parsed: unknown + try { + parsed = JSON.parse(await readFile(file, 'utf8')) as unknown + } catch { + throw new PolymorfaValidationError(`${file} is not valid JSON.`) + } + + const payload = + isRecord(parsed) && isRecord(parsed.payload) ? parsed.payload : parsed + if ( + !isRecord(payload) || + typeof payload.type !== 'string' || + typeof payload.media !== 'string' + ) { + throw new PolymorfaValidationError( + `${file} has no message payload with an encrypted media descriptor. Persisted media (mediaUrl) must be downloaded by media ID.`, + ) + } + + return {media: payload.media, type: payload.type} +} + async function rejectExistingOutput(output: string): Promise { try { await stat(output) @@ -99,17 +229,40 @@ async function rejectExistingOutput(output: string): Promise { function enforceLimit(size: number, maxBytes: number, mediaId: string): void { if (size <= maxBytes) return throw new PolymorfaConfigurationError( - `Media ${mediaId} is ${size} bytes and exceeds the ${maxBytes} byte download limit. Increase --max-bytes only when enough memory is available.`, + `Media ${mediaId} is ${size} bytes and exceeds the ${maxBytes} byte download limit. Increase --max-bytes to allow larger files.`, 'max-bytes', ) } +function mapWriteError( + error: unknown, + output: string, + mediaId: string, + maxBytes: number, +): unknown { + if (error instanceof PolymorfaConflictError) return outputConflict(output) + if ((error as {code?: unknown} | undefined)?.code === 'media_too_large') { + const subject = + mediaId === 'from the event' ? 'The media' : `Media ${mediaId}` + return new PolymorfaConfigurationError( + `${subject} exceeds the ${maxBytes} byte download limit. Increase --max-bytes to allow larger files.`, + 'max-bytes', + ) + } + + return error +} + function outputConflict(output: string): PolymorfaConflictError { return new PolymorfaConflictError( `Output file ${output} already exists. Pass --force to overwrite it.`, ) } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function nodeErrorCode(error: unknown): string | undefined { return (error as {code?: string} | undefined)?.code } diff --git a/test/commands/media.test.ts b/test/commands/media.test.ts index e22160ca..676fe3e6 100644 --- a/test/commands/media.test.ts +++ b/test/commands/media.test.ts @@ -1,9 +1,10 @@ -import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import {createCipheriv, createHash, createHmac, hkdfSync} from 'node:crypto' +import {mkdtemp, readFile, readdir, rm, writeFile} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' import {runCommand} from '@oclif/test' -import {afterEach, beforeEach, describe, expect, test} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import { startHttpSequenceServer, @@ -138,8 +139,9 @@ describe('media commands', () => { ) expect(exitCode(result.error)).toBe(2) - expect(result.stderr).toContain('is 5 bytes') + expect(result.stderr).toContain('exceeds the 4 byte download limit') await expect(readFile(output)).rejects.toMatchObject({code: 'ENOENT'}) + await expect(readdir(directory)).resolves.toEqual([]) }) test('refuses to overwrite a local file unless force is explicit', async () => { @@ -201,6 +203,213 @@ describe('media commands', () => { expect(server.requests[0]?.headers['idempotency-key']).toBe('persist_1') }) + test('rejects a declared Content-Length above the limit before writing', async () => { + const output = join(directory, 'declared.bin') + server = await startHttpSequenceServer([ + {body: {data: mediaInfo(), success: true}}, + { + headers: {'content-length': '5'}, + rawBody: new Uint8Array([1, 2, 3, 4, 5]), + }, + ]) + + const result = await apiKeyEnvironment(() => + runCommand(`media download media_1 -o ${output} --max-bytes 4 --json`), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('is 5 bytes') + await expect(readdir(directory)).resolves.toEqual([]) + }) + + test('follows a storage redirect without forwarding the API key', async () => { + const output = join(directory, 'redirected.bin') + server = await startHttpSequenceServer([ + {body: {data: mediaInfo(), success: true}}, + { + headers: { + location: '/storage/object?sig=abc', + 'request-id': 'req_redirect', + }, + rawBody: '', + status: 302, + }, + {rawBody: new Uint8Array([7, 7, 7, 7])}, + ]) + + const result = await apiKeyEnvironment(() => + runCommand(`media download media_1 --output ${output} --json`), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: {bytes: 4, path: output}, + metadata: {requestId: 'req_redirect', status: 302}, + }) + await expect(readFile(output)).resolves.toEqual(Buffer.from([7, 7, 7, 7])) + expect(server.requests[1]?.headers.authorization).toMatch(/^Bearer /) + expect(server.requests[2]?.url).toBe('/storage/object?sig=abc') + expect(server.requests[2]?.headers.authorization).toBeUndefined() + }) + + test('prints the signed link only with --url and warns on stderr', async () => { + server = await startHttpServer({ + headers: { + location: + 'https://storage.example.com/o?X-Amz-Date=20260917T100000Z&X-Amz-Expires=300&X-Amz-Signature=s', + 'request-id': 'req_url', + }, + rawBody: '', + status: 302, + }) + + const human = await apiKeyEnvironment(() => + runCommand('media download media/1 --url'), + ) + expect(human.error).toBeUndefined() + expect(human.stdout.trim()).toBe( + 'https://storage.example.com/o?X-Amz-Date=20260917T100000Z&X-Amz-Expires=300&X-Amz-Signature=s', + ) + expect(human.stderr).toContain('expires within minutes') + + const json = await apiKeyEnvironment(() => + runCommand('media download media/1 --url --json'), + ) + expect(JSON.parse(json.stdout)).toEqual({ + data: { + expiresAt: '2026-09-17T10:05:00.000Z', + mediaId: 'media/1', + requestId: 'req_url', + url: 'https://storage.example.com/o?X-Amz-Date=20260917T100000Z&X-Amz-Expires=300&X-Amz-Signature=s', + }, + }) + expect(server.requests.map(({url}) => url)).toEqual([ + '/messaging/media/media%2F1', + '/messaging/media/media%2F1', + ]) + }) + + test('explains when the API streams instead of issuing a link', async () => { + server = await startHttpServer({rawBody: new Uint8Array([1])}) + + const result = await apiKeyEnvironment(() => + runCommand('media download media_1 --url --json'), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('issued no signed link') + expect(result.stdout).toBe('') + }) + + test('requires an output path or --url, and rejects conflicting flags', async () => { + server = await startHttpServer({body: {data: mediaInfo(), success: true}}) + + const missing = await apiKeyEnvironment(() => + runCommand('media download media_1 --json'), + ) + expect(exitCode(missing.error)).toBe(2) + expect(missing.stderr).toContain('--output') + + const conflicting = await apiKeyEnvironment(() => + runCommand(`media download media_1 --url -o ${join(directory, 'x')}`), + ) + expect(exitCode(conflicting.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + describe('--from-event', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + test('decrypts media from a stored webhook event without credentials', async () => { + const fixture = whatsAppFixture(Buffer.from('decrypted image bytes')) + const fetch = vi.fn<(input: unknown) => Promise>(() => + Promise.resolve( + new Response(new Blob([new Uint8Array(fixture.encrypted)])), + ), + ) + vi.stubGlobal('fetch', fetch) + const eventFile = join(directory, 'event.json') + await writeFile( + eventFile, + JSON.stringify({ + event: 'message.received', + payload: {id: 'm1', media: fixture.media, type: 'image'}, + }), + ) + const output = join(directory, 'photo.jpg') + + const result = await withEnvironment( + {POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined}, + () => + runCommand( + `media download --from-event ${eventFile} -o ${output} --json`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toEqual({ + data: { + bytes: 21, + mediaKind: 'image', + mimeType: 'image/jpeg', + path: output, + }, + }) + await expect(readFile(output, 'utf8')).resolves.toBe( + 'decrypted image bytes', + ) + expect(String(fetch.mock.calls[0]?.[0])).toBe(fixture.url) + expect(result.stdout).not.toContain(fixture.media) + }) + + test('fails without output when the MAC does not match', async () => { + const fixture = whatsAppFixture(Buffer.from('tampered payload')) + const tampered = Buffer.from(fixture.encrypted) + tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 1 + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve(new Response(new Blob([new Uint8Array(tampered)]))), + ), + ) + const eventFile = join(directory, 'event.json') + await writeFile( + eventFile, + JSON.stringify({media: fixture.media, type: 'image'}), + ) + + const result = await runCommand( + `media download --from-event ${eventFile} -o ${join(directory, 'out.jpg')} --json`, + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('media_enc_hash_mismatch') + expect(result.stderr).not.toContain(fixture.media) + await expect(readdir(directory)).resolves.toEqual(['event.json']) + }) + + test('rejects events without an encrypted media descriptor', async () => { + const eventFile = join(directory, 'event.json') + await writeFile( + eventFile, + JSON.stringify({payload: {mediaUrl: 'https://s3/x', type: 'image'}}), + ) + + const result = await runCommand( + `media download --from-event ${eventFile} -o ${join(directory, 'o')} --json`, + ) + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('downloaded by media ID') + + const both = await runCommand( + `media download media_1 --from-event ${eventFile} -o ${join(directory, 'o')}`, + ) + expect(exitCode(both.error)).toBe(2) + }) + }) + async function apiKeyEnvironment(run: () => Promise): Promise { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( @@ -230,3 +439,34 @@ function mediaInfo() { function exitCode(error: unknown): number | undefined { return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit } + +function whatsAppFixture(plaintext: Buffer): { + readonly encrypted: Buffer + readonly media: string + readonly url: string +} { + const mediaKey = Buffer.alloc(32, 7) + const expanded = Buffer.from( + hkdfSync('sha256', mediaKey, Buffer.alloc(0), 'WhatsApp Image Keys', 112), + ) + const iv = expanded.subarray(0, 16) + const cipher = createCipheriv('aes-256-cbc', expanded.subarray(16, 48), iv) + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]) + const mac = createHmac('sha256', expanded.subarray(48, 80)) + .update(iv) + .update(ciphertext) + .digest() + .subarray(0, 10) + const encrypted = Buffer.concat([ciphertext, mac]) + const url = 'https://mmg.whatsapp.net/v/t62.7118-24/fixture.enc?ccb=11-4' + // ImageMessage field numbers: URL 1, fileSHA256 4, mediaKey 8, fileEncSHA256 9. + const field = (number: number, value: Buffer): Buffer => + Buffer.concat([Buffer.from([(number << 3) | 2, value.length]), value]) + const media = Buffer.concat([ + field(1, Buffer.from(url)), + field(4, createHash('sha256').update(plaintext).digest()), + field(8, mediaKey), + field(9, createHash('sha256').update(encrypted).digest()), + ]).toString('base64') + return {encrypted, media, url} +} From d836d96cad97a766b1352ce4690884b9faf61845 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:16:34 +0200 Subject: [PATCH 2/2] fix(media): honor --timeout and classify unreadable event files for --from-event --- src/commands/media/download.ts | 1 + src/services/media.ts | 32 +++++++++++++++++++++++++++++-- test/commands/media.test.ts | 35 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/commands/media/download.ts b/src/commands/media/download.ts index c10bba2e..a74e088c 100644 --- a/src/commands/media/download.ts +++ b/src/commands/media/download.ts @@ -68,6 +68,7 @@ export default class MediaDownload extends BaseCommand { force: flags.force, maxBytes: flags['max-bytes'], output, + ...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}), }) this.writeValue(result, result.data.path, flags) return diff --git a/src/services/media.ts b/src/services/media.ts index 61b86065..aa65e80d 100644 --- a/src/services/media.ts +++ b/src/services/media.ts @@ -4,6 +4,7 @@ import {resolve} from 'node:path' import { PolymorfaConfigurationError, PolymorfaConflictError, + PolymorfaTimeoutError, PolymorfaValidationError, type MessagingClient, type MessagingMediaInfo, @@ -144,16 +145,28 @@ export async function downloadMediaFromEvent( readonly force: boolean readonly maxBytes: number readonly output: string + readonly timeoutMs?: number }, ): Promise> { const output = resolve(options.output) if (!options.force) await rejectExistingOutput(output) const message = await readMediaEvent(eventFile) + const signal = + options.timeoutMs === undefined + ? undefined + : AbortSignal.timeout(options.timeoutMs) const written = await downloadWhatsAppMediaToFile(message, output, { maxBytes: options.maxBytes, overwrite: options.force, + ...(signal === undefined ? {} : {signal}), }).catch((error: unknown) => { + if (signal?.aborted === true) { + throw new PolymorfaTimeoutError( + `The media download did not finish within ${options.timeoutMs} ms.`, + {cause: error, code: 'request_timeout'}, + ) + } throw mapWriteError(error, output, 'from the event', options.maxBytes) }) @@ -186,16 +199,21 @@ export async function readMediaEvent( path: string, ): Promise { const file = resolve(path) - const stats = await stat(file) + const stats = await stat(file).catch((error: unknown) => { + throw eventFileAccessError(file, error) + }) if (!stats.isFile() || stats.size > MAX_EVENT_FILE_BYTES) { throw new PolymorfaValidationError( `${file} must be a regular JSON file no larger than 10 MiB.`, ) } + const text = await readFile(file, 'utf8').catch((error: unknown) => { + throw eventFileAccessError(file, error) + }) let parsed: unknown try { - parsed = JSON.parse(await readFile(file, 'utf8')) as unknown + parsed = JSON.parse(text) as unknown } catch { throw new PolymorfaValidationError(`${file} is not valid JSON.`) } @@ -215,6 +233,16 @@ export async function readMediaEvent( return {media: payload.media, type: payload.type} } +function eventFileAccessError( + file: string, + error: unknown, +): PolymorfaValidationError { + const code = nodeErrorCode(error) + return new PolymorfaValidationError( + `${file} could not be read${code === undefined ? '' : ` (${code})`}. Pass --from-event a readable JSON file.`, + ) +} + async function rejectExistingOutput(output: string): Promise { try { await stat(output) diff --git a/test/commands/media.test.ts b/test/commands/media.test.ts index 676fe3e6..43b3b2b0 100644 --- a/test/commands/media.test.ts +++ b/test/commands/media.test.ts @@ -408,6 +408,41 @@ describe('media commands', () => { ) expect(exitCode(both.error)).toBe(2) }) + + test('reports a missing event file as invalid input', async () => { + const result = await runCommand( + `media download --from-event ${join(directory, 'missing.json')} -o ${join(directory, 'o')} --json`, + ) + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('could not be read (ENOENT)') + }) + + test('honors --timeout for the CDN download', async () => { + const fixture = whatsAppFixture(Buffer.from('slow')) + vi.stubGlobal( + 'fetch', + vi.fn( + (_input: unknown, init?: {signal?: AbortSignal}) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(init.signal?.reason as Error) + }) + }), + ), + ) + const eventFile = join(directory, 'event.json') + await writeFile( + eventFile, + JSON.stringify({media: fixture.media, type: 'image'}), + ) + + const result = await runCommand( + `media download --from-event ${eventFile} -o ${join(directory, 'out.jpg')} --timeout 50 --json`, + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('did not finish within 50 ms') + await expect(readdir(directory)).resolves.toEqual(['event.json']) + }) }) async function apiKeyEnvironment(run: () => Promise): Promise {