Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` 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
Expand Down
94 changes: 82 additions & 12 deletions src/commands/media/download.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,110 @@
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<void> {
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,
Comment thread
purpshell marked this conversation as resolved.
Resolved
...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}),
})
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 <path>, or --url to print a signed link.',
)
}
return output
}
6 changes: 6 additions & 0 deletions src/errors/present.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PolymorfaConflictError,
PolymorfaConnectionError,
PolymorfaError,
PolymorfaMediaIntegrityError,
PolymorfaNotFoundError,
PolymorfaPaymentRequiredError,
PolymorfaRateLimitError,
Expand Down Expand Up @@ -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}
}
Expand Down
Loading