From 19b31fc060d306b196ebcd31682338f712fffee8 Mon Sep 17 00:00:00 2001 From: Nikolaj Goodger Date: Mon, 19 Oct 2026 13:09:15 +1100 Subject: [PATCH] feat(media): name video posters by hash in imeta Desktop and mobile now add `image-x ` next to a video's `image` poster URL, so the relay can find the poster by hash and restrict who may read it to the channels the video is attached in. Requires a relay that accepts `image-x` (block/buzz#8444); older relays reject unknown imeta keys. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Nikolaj Goodger --- .../messages/lib/imetaMediaMarkdown.test.mjs | 16 ++++++++++++++++ .../features/messages/lib/imetaMediaMarkdown.ts | 9 +++++++++ mobile/lib/shared/relay/media_upload.dart | 5 +++++ mobile/test/shared/relay/media_upload_test.dart | 16 ++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index 7c27b22107e..3601f79ddc2 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -181,6 +181,22 @@ test("buildImetaTags keeps media filenames in imeta", () => { ); }); +test("buildImetaTags names a video poster by hash", () => { + const poster = "c".repeat(64); + const [tag] = buildImetaTags([ + { + url: `https://b/media/${"a".repeat(64)}.mp4`, + type: "video/mp4", + sha256: "a".repeat(64), + size: 10, + uploaded: 1, + image: `https://b/media/${poster}.jpg`, + }, + ]); + assert.ok(tag.includes(`image https://b/media/${poster}.jpg`)); + assert.ok(tag.includes(`image-x ${poster}`)); +}); + test("formatImetaMediaLine: video mime → ![video] line (regardless of URL suffix)", () => { assert.equal( formatImetaMediaLine({ url: "https://cdn/blob/xyz", type: "video/mp4" }), diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 425b66bd9ae..f1ce21d765e 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -102,10 +102,19 @@ export function buildImetaTags( ...(d.thumb ? [`thumb ${d.thumb}`] : []), ...(d.duration != null ? [`duration ${d.duration}`] : []), ...(d.image ? [`image ${d.image}`] : []), + ...posterHashEntry(d.image), ...(d.filename ? [`filename ${d.filename}`] : []), ]); } +const MEDIA_HASH_RE = /\/media\/([0-9a-f]{64})\./; + +/** `image-x ` names the poster by hash so the relay can scope its reads. */ +function posterHashEntry(image: string | undefined): string[] { + const hash = image?.match(MEDIA_HASH_RE)?.[1]; + return hash ? [`image-x ${hash}`] : []; +} + const MEDIA_LINE_RE = /^(?:\|\|)?!\[(?:image|video)\]\(([^)\s]+)\)(?:\|\|)?\s*$/; const SPOILERED_MEDIA_LINE_RE = diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 21f5de4df2d..6ae9c5bc20f 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -218,9 +218,14 @@ class BlobDescriptor { if (thumb != null) 'thumb $thumb', if (duration != null) 'duration $duration', if (image != null) 'image $image', + if (_posterHash case final hash?) 'image-x $hash', if (filename != null) 'filename $filename', ]; + /// Names the poster by hash so the relay can scope who may read it. + String? get _posterHash => + RegExp(r'/media/([0-9a-f]{64})\.').firstMatch(image ?? '')?.group(1); + String toMarkdownImage() { final label = (filename ?? 'file').replaceAllMapped( RegExp(r'[\\\[\]]'), diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index dd72efe73ee..648bfc49de5 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -1222,6 +1222,22 @@ void main() { }); }); + group('toImetaTag', () { + test('names a video poster by hash', () { + final poster = 'c' * 64; + final descriptor = BlobDescriptor( + url: 'https://relay.example/media/${'a' * 64}.mp4', + sha256: 'a' * 64, + size: 3, + type: 'video/mp4', + uploaded: 1, + image: 'https://relay.example/media/$poster.jpg', + ); + + expect(descriptor.toImetaTag(), contains('image-x $poster')); + }); + }); + group('uploadVoiceNote', () { test('keeps ordinary audio attachments on inline audio markdown', () { const descriptor = BlobDescriptor(