Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support remote attachments #116

Merged
merged 1 commit into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading