Fix encrypted audio attachment playback (#610)
This commit is contained in:
parent
8cc970906d
commit
d67309650c
4 changed files with 91 additions and 18 deletions
|
|
@ -22,6 +22,7 @@
|
||||||
import Link from "@lib/components/Link.svelte"
|
import Link from "@lib/components/Link.svelte"
|
||||||
import Spinner from "@lib/components/Spinner.svelte"
|
import Spinner from "@lib/components/Spinner.svelte"
|
||||||
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
import ContentLinkDetail from "@app/components/ContentLinkDetail.svelte"
|
||||||
|
import ContentLinkBlockAudio from "@app/components/ContentLinkBlockAudio.svelte"
|
||||||
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
import ContentLinkUrl from "@app/components/ContentLinkUrl.svelte"
|
||||||
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
import ContentLinkBlockImage from "@app/components/ContentLinkBlockImage.svelte"
|
||||||
import {pushModal} from "@app/modal"
|
import {pushModal} from "@app/modal"
|
||||||
|
|
@ -78,7 +79,7 @@
|
||||||
{#if isRoomOrRelay}
|
{#if isRoomOrRelay}
|
||||||
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
<ContentLinkUrl {url} class="link-content whitespace-nowrap" />
|
||||||
{:else if isAudio}
|
{:else if isAudio}
|
||||||
<audio controls src={url} preload="metadata" class="my-2 w-full max-w-xl"></audio>
|
<ContentLinkBlockAudio {url} {event} />
|
||||||
{:else if isVideo}
|
{:else if isVideo}
|
||||||
<Link {external} {href} class="my-2 block">
|
<Link {external} {href} class="my-2 block">
|
||||||
<video
|
<video
|
||||||
|
|
|
||||||
57
src/app/components/ContentLinkBlockAudio.svelte
Normal file
57
src/app/components/ContentLinkBlockAudio.svelte
Normal 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}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {onMount, onDestroy} from "svelte"
|
import {onMount, onDestroy} from "svelte"
|
||||||
import {displayUrl, once} from "@welshman/lib"
|
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 LinkRound from "@assets/icons/link-round.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import {user} from "@app/core"
|
import {user} from "@app/core"
|
||||||
import {getUrlTags} from "@app/content"
|
import {decryptUrl, getUrlTags} from "@app/content"
|
||||||
|
|
||||||
const {value, event, ...props} = $props()
|
const {value, event, ...props} = $props()
|
||||||
|
|
||||||
|
|
@ -14,10 +14,7 @@
|
||||||
|
|
||||||
// Fallback to filename if hash was omitted from the message for interoperability
|
// Fallback to filename if hash was omitted from the message for interoperability
|
||||||
const hash = tagValue(tagSpec("x"), meta) || url.split(/[\/\.]/).slice(-2)[0]
|
const hash = tagValue(tagSpec("x"), meta) || url.split(/[\/\.]/).slice(-2)[0]
|
||||||
const key = tagValue(tagSpec("decryption-key"), meta)
|
const controller = new AbortController()
|
||||||
const nonce = tagValue(tagSpec("decryption-nonce"), meta)
|
|
||||||
const algorithm = tagValue(tagSpec("encryption-algorithm"), meta)
|
|
||||||
const mime = tagValue(tagSpec("m"), meta)
|
|
||||||
const fileName =
|
const fileName =
|
||||||
tagValue(tagSpec("filename"), meta) ||
|
tagValue(tagSpec("filename"), meta) ||
|
||||||
tagValue(tagSpec("name"), meta) ||
|
tagValue(tagSpec("name"), meta) ||
|
||||||
|
|
@ -57,23 +54,18 @@
|
||||||
let src = $state("")
|
let src = $state("")
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// If we have an encryption algorithm, fetch and decrypt
|
try {
|
||||||
if (algorithm === "aes-gcm" && key && nonce) {
|
src = await decryptUrl(url, event, controller.signal)
|
||||||
try {
|
} catch (error) {
|
||||||
const response = await fetch(url)
|
if (!controller.signal.aborted) {
|
||||||
const ciphertext = new Uint8Array(await response.arrayBuffer())
|
console.error(error)
|
||||||
const decryptedData = await decryptFile({ciphertext, key, nonce, algorithm})
|
|
||||||
|
|
||||||
setBlobSrc(new Uint8Array(decryptedData), mime)
|
|
||||||
} catch {
|
|
||||||
hasError = true
|
hasError = true
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
src = url
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
controller.abort()
|
||||||
revokeSrc()
|
revokeSrc()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
ZAP_GOAL,
|
ZAP_GOAL,
|
||||||
ZAP_RECEIPT,
|
ZAP_RECEIPT,
|
||||||
matchTags,
|
matchTags,
|
||||||
|
decryptFile,
|
||||||
tagSpec,
|
tagSpec,
|
||||||
tagValue,
|
tagValue,
|
||||||
} from "@welshman/util"
|
} from "@welshman/util"
|
||||||
|
|
@ -57,6 +58,28 @@ export const getUrlContentType = (url: string, event: TrustedEvent) => {
|
||||||
return tagValue(tagSpec("m"), tags) || tagValue(tagSpec("file-type"), tags) || ""
|
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 = {}) => ({
|
export const makeCommentFilter = (kinds: number[], extra: Filter = {}) => ({
|
||||||
kinds: [COMMENT],
|
kinds: [COMMENT],
|
||||||
"#K": kinds.map(String),
|
"#K": kinds.map(String),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue