diff --git a/lib/src/client.dart b/lib/src/client.dart index f177e1b..cb62856 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -1,5 +1,8 @@ +import 'dart:typed_data'; + import 'package:web3dart/credentials.dart'; import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; +import 'package:http/http.dart' as http; import 'auth.dart'; import 'common/api.dart'; @@ -10,6 +13,7 @@ import 'content/codec.dart'; import 'content/codec_registry.dart'; import 'content/composite_codec.dart'; import 'content/reaction_codec.dart'; +import 'content/remote_attachment_codec.dart'; import 'content/reply_codec.dart'; import 'content/text_codec.dart'; import 'content/decoded.dart'; @@ -124,6 +128,7 @@ class Client implements Codec { CompositeCodec(), ReplyCodec(), ReactionCodec(), + RemoteAttachmentCodec(), AttachmentCodec(), ]; for (var codec in commonCodecs..addAll(customCodecs)) { @@ -257,6 +262,30 @@ class Client implements Codec { Future refreshContactConsentPreferences({bool fullRefresh = false}) => _contacts.refreshConsents(_auth.keys, fullRefresh: fullRefresh); + /// This downloads the [attachment] and returns the [DecodedContent]. + /// If [downloader] is specified then that will be used to fetch the payload. + Future download( + RemoteAttachment attachment, { + RemoteDownloader? downloader, + }) async { + downloader ??= (url) => http.readBytes(Uri.parse(url)); + var decrypted = await attachment.download(downloader); + return decode(decrypted); + } + + /// This uploads the [attachment] and returns the [RemoteAttachment]. + /// The [uploader] will be used to upload the payload and produce the URL. + /// It will be uploaded after applying the specified [compression]. + Future upload( + Attachment attachment, + RemoteUploader uploader, { + xmtp.Compression? compression = xmtp.Compression.COMPRESSION_GZIP, + }) async { + var content = DecodedContent(contentTypeAttachment, attachment); + var encoded = await _codecs.encode(content, compression: compression); + return RemoteAttachment.upload(attachment.filename, encoded, uploader); + } + /// This lists messages sent to the [conversation]. /// /// For listing multiple conversations, see [listBatchMessages]. diff --git a/lib/src/content/remote_attachment_codec.dart b/lib/src/content/remote_attachment_codec.dart new file mode 100644 index 0000000..d0d8979 --- /dev/null +++ b/lib/src/content/remote_attachment_codec.dart @@ -0,0 +1,125 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:web3dart/crypto.dart'; +import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; +import '../common/crypto.dart' as crypto; + +import 'codec.dart'; + +final contentTypeRemoteAttachment = xmtp.ContentTypeId( + authorityId: "xmtp.org", + typeId: "remoteStaticAttachment", + versionMajor: 1, + versionMinor: 0, +); + +/// This should download the [url] and return the data. +typedef RemoteDownloader = Future> Function(String url); + +/// This should upload the [data] and return the URL. +typedef RemoteUploader = Future Function(List data); + +/// This is a remote encrypted file URL. +class RemoteAttachment { + final List salt; + final List nonce; + final List secret; + final String scheme; + final String url; + final String filename; + final int contentLength; + final String contentDigest; + + RemoteAttachment({ + required this.salt, + required this.nonce, + required this.secret, + required this.scheme, + required this.url, + required this.filename, + required this.contentLength, + required this.contentDigest, + }); + + /// This uploads the [encoded] file using the [uploader]. + /// See [Client.upload] for typical usage. + static Future upload( + String filename, + xmtp.EncodedContent encoded, + RemoteUploader uploader, + ) async { + var secret = crypto.generateRandomBytes(32); + var encrypted = await crypto.encrypt( + secret, + encoded.writeToBuffer(), + ); + var url = await uploader(encrypted.aes256GcmHkdfSha256.payload); + return RemoteAttachment( + salt: encrypted.aes256GcmHkdfSha256.hkdfSalt, + nonce: encrypted.aes256GcmHkdfSha256.gcmNonce, + secret: secret, + scheme: "https://", + url: url, + filename: filename, + contentLength: encrypted.aes256GcmHkdfSha256.payload.length, + contentDigest: + bytesToHex(crypto.sha256(encrypted.aes256GcmHkdfSha256.payload)), + ); + } + + /// This downloads the file from the [url] and decrypts it. + /// See [Client.download] for typical usage. + Future download(RemoteDownloader downloader) async { + var payload = await downloader(url); + var decrypted = await crypto.decrypt( + secret, + xmtp.Ciphertext( + aes256GcmHkdfSha256: xmtp.Ciphertext_Aes256gcmHkdfsha256( + hkdfSalt: salt, + gcmNonce: nonce, + payload: payload, + ), + )); + return xmtp.EncodedContent.fromBuffer(decrypted); + } +} + +/// This is a [Codec] that encodes a remote encrypted file URL as the content. +class RemoteAttachmentCodec extends Codec { + @override + xmtp.ContentTypeId get contentType => contentTypeRemoteAttachment; + + @override + Future decode(xmtp.EncodedContent encoded) async => + RemoteAttachment( + url: utf8.decode(encoded.content), + filename: encoded.parameters["filename"] ?? "", + salt: hexToBytes(encoded.parameters["salt"] ?? ""), + nonce: hexToBytes(encoded.parameters["nonce"] ?? ""), + secret: hexToBytes(encoded.parameters["secret"] ?? ""), + contentLength: int.parse(encoded.parameters["contentLength"] ?? "0"), + contentDigest: encoded.parameters["contentDigest"] ?? "", + scheme: "https://", + ); + + @override + Future encode(RemoteAttachment decoded) async => + xmtp.EncodedContent( + type: contentTypeRemoteAttachment, + parameters: { + "filename": decoded.filename, + "secret": bytesToHex(decoded.secret), + "salt": bytesToHex(decoded.salt), + "nonce": bytesToHex(decoded.nonce), + "contentLength": decoded.contentLength.toString(), + "contentDigest": decoded.contentDigest, + }, + content: utf8.encode(decoded.url), + ); + + @override + String? fallback(RemoteAttachment content) { + return "Can’t display \"${content.filename}\". This app doesn’t support attachments."; + } +} diff --git a/test/client_test.dart b/test/client_test.dart index 1008840..3c58e4b 100644 --- a/test/client_test.dart +++ b/test/client_test.dart @@ -15,6 +15,7 @@ import 'package:xmtp/src/content/attachment_codec.dart'; import 'package:xmtp/src/content/codec.dart'; import 'package:xmtp/src/content/decoded.dart'; import 'package:xmtp/src/content/reaction_codec.dart'; +import 'package:xmtp/src/content/remote_attachment_codec.dart'; import 'package:xmtp/src/content/reply_codec.dart'; import 'package:xmtp/src/content/text_codec.dart'; import 'package:xmtp/src/client.dart'; @@ -560,6 +561,45 @@ void main() { }, ); + test( + skip: skipUnlessTestServerEnabled, + "remote attachments: uploading and downloading attachments should work", + () async { + var aliceWallet = EthPrivateKey.createRandom(Random.secure()).asSigner(); + var aliceApi = createTestServerApi(); + var bobWallet = EthPrivateKey.createRandom(Random.secure()).asSigner(); + var bobApi = createTestServerApi(); + var alice = await Client.createFromWallet(aliceApi, aliceWallet); + var bob = await Client.createFromWallet(bobApi, bobWallet); + var files = TestRemoteFiles(); + + + // If Alice sends a message and then a remote attachment... + var convo = await alice.newConversation(bob.address.hex); + await alice.sendMessage(convo, "Here's an attachment for you:"); + var attachment = Attachment("foo.txt", "text/plain", utf8.encode("bar")); + var remote = await alice.upload(attachment, files.upload); + await alice.sendMessage(convo, remote, + contentType: contentTypeRemoteAttachment); + await delayToPropagate(); + + // ... then Bob should see the messages. + var messages = await bob.listMessages(convo); + expect(messages.length, 2); + expect((messages[0].content as RemoteAttachment).filename, "foo.txt"); + expect((messages[1].content as String), "Here's an attachment for you:"); + + // And he should be able to download the remote attachment. + var downloaded = await bob.download( + messages[0].content as RemoteAttachment, + downloader: files.download, + ); + expect((downloaded.content as Attachment).filename, "foo.txt"); + expect((downloaded.content as Attachment).mimeType, "text/plain"); + expect(utf8.decode((downloaded.content as Attachment).data), "bar"); + }, + ); + test( skip: skipUnlessTestServerEnabled, "codecs: sending codec encoded message to user", @@ -842,6 +882,19 @@ void main() { ); } +/// Helper to store and retrieve remote files. +class TestRemoteFiles { + var files = {}; + + Future upload(List data) async { + var url = "https://example.com/${Random.secure().nextInt(1000000)}"; + files[url] = data; + return url; + } + + Future> download(String url) async => files[url]; +} + /// Helper to seed random conversations in a test account. Future _startRandomConversationsWith( String recipientAddress, {