Skip to content

Commit

Permalink
feat: support remote attachments
Browse files Browse the repository at this point in the history
  • Loading branch information
dmccartney committed Feb 3, 2024
1 parent b7af067 commit b090d9c
Show file tree
Hide file tree
Showing 3 changed files with 206 additions and 0 deletions.
29 changes: 29 additions & 0 deletions lib/src/client.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -124,6 +128,7 @@ class Client implements Codec<DecodedContent> {
CompositeCodec(),
ReplyCodec(),
ReactionCodec(),
RemoteAttachmentCodec(),
AttachmentCodec(),
];
for (var codec in commonCodecs..addAll(customCodecs)) {
Expand Down Expand Up @@ -257,6 +262,30 @@ class Client implements Codec<DecodedContent> {
Future<bool> 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<DecodedContent> 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<RemoteAttachment> 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].
Expand Down
125 changes: 125 additions & 0 deletions lib/src/content/remote_attachment_codec.dart
Original file line number Diff line number Diff line change
@@ -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<List<int>> Function(String url);

/// This should upload the [data] and return the URL.
typedef RemoteUploader = Future<String> Function(List<int> data);

/// This is a remote encrypted file URL.
class RemoteAttachment {
final List<int> salt;
final List<int> nonce;
final List<int> 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<RemoteAttachment> 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<xmtp.EncodedContent> 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<RemoteAttachment> {
@override
xmtp.ContentTypeId get contentType => contentTypeRemoteAttachment;

@override
Future<RemoteAttachment> 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<xmtp.EncodedContent> 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.";
}
}
52 changes: 52 additions & 0 deletions test/client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -560,6 +561,44 @@ 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",
Expand Down Expand Up @@ -842,6 +881,19 @@ void main() {
);
}

/// Helper to store and retrieve remote files.
class TestRemoteFiles {
var files = {};

Future<String> upload(List<int> data) async {
var url = "https://example.com/${Random.secure().nextInt(1000000)}";
files[url] = data;
return url;
}

Future<List<int>> download(String url) async => files[url];
}

/// Helper to seed random conversations in a test account.
Future _startRandomConversationsWith(
String recipientAddress, {
Expand Down

0 comments on commit b090d9c

Please sign in to comment.