Fix encrypted audio attachment playback (#610)

This commit is contained in:
Gaurav Chaudhary 2026-09-22 15:48:04 +00:00 committed by hodlbod
parent 8cc970906d
commit d67309650c
4 changed files with 91 additions and 18 deletions

View file

@ -22,6 +22,7 @@
import Link from "@lib/components/Link.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
import ContentLinkBlockAudio from "@app/components/ContentLinkBlockAudio.svelte"
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
import {pushModal} from "@app/modal"
@ -78,7 +79,7 @@
{#if isRoomOrRelay}
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
{:else if isAudio}
<audio controls src={url} preload="metadata" class="my-2 w-full max-w-xl"></audio>
<ContentLinkBlockAudio {url} {event} />
{:else if isVideo}
<Link {external} {href} class="my-2 block">
<video

View file

@ -0,0 +1,57 @@
<script lang="ts">
import {onDestroy} from "svelte"
import {tagSpec, tagValue} from "@welshman/util"
import {displayUrl} from "@welshman/lib"
import type {TrustedEvent} from "@welshman/util"
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import {decryptUrl, getUrlTags} from "@app/content"
type Props = {url: string; event: TrustedEvent}
const {url, event}: Props = $props()
const meta = getUrlTags(url, event)
const algorithm = tagValue(tagSpec("encryption-algorithm"), meta)
const controller = new AbortController()
let source = $state<string>(algorithm ? "" : url)
let failed = $state(false)
let loading = $state(false)
const load = async () => {
loading = true
try {
source = await decryptUrl(url, event, controller.signal)
} catch (error) {
if (!controller.signal.aborted) {
console.error(error)
failed = true
}
} finally {
loading = false
}
}
const onError = () => {
failed = true
}
onDestroy(() => {
controller.abort()
if (source?.startsWith("blob:")) {
URL.revokeObjectURL(source)
}
})
</script>
{#if failed}
<a href={url} class="link-content whitespace-nowrap">{displayUrl(url)}</a>
{:else if source}
<audio controls src={source} preload="metadata" class="my-2 w-full max-w-xl" onerror={onError}
></audio>
{:else}
<Button class="button button-link my-2" disabled={loading} onclick={load}>
<Spinner {loading}>Load audio</Spinner>
</Button>
{/if}

View file

@ -1,11 +1,11 @@
<script lang="ts">
import {onMount, onDestroy} from "svelte"
import {displayUrl, once} from "@welshman/lib"
import {getBlob, decryptFile, makeBlossomAuthEvent, tagSpec, tagValue} from "@welshman/util"
import {getBlob, makeBlossomAuthEvent, tagSpec, tagValue} from "@welshman/util"
import LinkRound from "@assets/icons/link-round.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import {user} from "@app/core"
import {getUrlTags} from "@app/content"
import {decryptUrl, getUrlTags} from "@app/content"
const {value, event, ...props} = $props()
@ -14,10 +14,7 @@
// Fallback to filename if hash was omitted from the message for interoperability
const hash = tagValue(tagSpec("x"), meta) || url.split(/[\/\.]/).slice(-2)[0]
const key = tagValue(tagSpec("decryption-key"), meta)
const nonce = tagValue(tagSpec("decryption-nonce"), meta)
const algorithm = tagValue(tagSpec("encryption-algorithm"), meta)
const mime = tagValue(tagSpec("m"), meta)
const controller = new AbortController()
const fileName =
tagValue(tagSpec("filename"), meta) ||
tagValue(tagSpec("name"), meta) ||
@ -57,23 +54,18 @@
let src = $state("")
onMount(async () => {
// If we have an encryption algorithm, fetch and decrypt
if (algorithm === "aes-gcm" && key && nonce) {
try {
const response = await fetch(url)
const ciphertext = new Uint8Array(await response.arrayBuffer())
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
setBlobSrc(new Uint8Array(decryptedData), mime)
} catch {
try {
src = await decryptUrl(url, event, controller.signal)
} catch (error) {
if (!controller.signal.aborted) {
console.error(error)
hasError = true
}
} else {
src = url
}
})
onDestroy(() => {
controller.abort()
revokeSrc()
})
</script>

View file

@ -16,6 +16,7 @@ import {
ZAP_GOAL,
ZAP_RECEIPT,
matchTags,
decryptFile,
tagSpec,
tagValue,
} from "@welshman/util"
@ -57,6 +58,28 @@ export const getUrlContentType = (url: string, event: TrustedEvent) => {
return tagValue(tagSpec("m"), tags) || tagValue(tagSpec("file-type"), tags) || ""
}
export const decryptUrl = async (url: string, event: TrustedEvent, signal?: AbortSignal) => {
const tags = getUrlTags(url, event)
const algorithm = tagValue(tagSpec("encryption-algorithm"), tags)
const key = tagValue(tagSpec("decryption-key"), tags)
const nonce = tagValue(tagSpec("decryption-nonce"), tags)
if (algorithm === "aes-gcm" && key && nonce) {
const response = await fetch(url, {signal})
if (!response.ok) {
throw new Error(`Attachment download failed (HTTP ${response.status}).`)
}
const ciphertext = new Uint8Array(await response.arrayBuffer())
const data = await decryptFile({ciphertext, key, nonce, algorithm})
signal?.throwIfAborted()
return URL.createObjectURL(
new Blob([new Uint8Array(data)], {type: getUrlContentType(url, event)}),
)
}
return url
}
export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
kinds: [COMMENT],
"#K": kinds.map(String),