diff options
author | Patiga <dev@patiga.eu> | 2022-06-28 12:11:17 +0200 |
---|---|---|
committer | fiaxh <git@lightrise.org> | 2024-11-14 10:20:12 -0600 |
commit | aaf4542e6208460c305db4be36b15dc832ddc95a (patch) | |
tree | ec7b60b0f0ea74e21403788e8345336bd0f3939b | |
parent | 909f569318835d11703c49fba7dbe49996759f38 (diff) | |
download | dino-aaf4542e6208460c305db4be36b15dc832ddc95a.tar.gz dino-aaf4542e6208460c305db4be36b15dc832ddc95a.zip |
Implement XEP-0447: Stateless file sharing
-rw-r--r-- | libdino/src/entity/file_transfer.vala | 158 | ||||
-rw-r--r-- | libdino/src/entity/message.vala | 1 | ||||
-rw-r--r-- | libdino/src/service/database.vala | 56 | ||||
-rw-r--r-- | libdino/src/service/file_manager.vala | 263 | ||||
-rw-r--r-- | libdino/src/service/jingle_file_transfers.vala | 2 | ||||
-rw-r--r-- | libdino/src/service/module_manager.vala | 1 | ||||
-rw-r--r-- | main/CMakeLists.txt | 2 | ||||
-rw-r--r-- | main/src/ui/application.vala | 1 | ||||
-rw-r--r-- | main/src/ui/conversation_content_view/file_preview_widget.vala | 90 | ||||
-rw-r--r-- | main/src/ui/conversation_content_view/file_widget.vala | 26 | ||||
-rw-r--r-- | main/src/ui/util/file_metadata_providers.vala | 27 | ||||
-rw-r--r-- | plugins/http-files/src/file_provider.vala | 34 | ||||
-rw-r--r-- | plugins/http-files/src/file_sender.vala | 37 | ||||
-rw-r--r-- | xmpp-vala/CMakeLists.txt | 4 | ||||
-rw-r--r-- | xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala | 49 | ||||
-rw-r--r-- | xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala | 136 | ||||
-rw-r--r-- | xmpp-vala/src/module/xep/0446_file_metadata_element.vala | 120 | ||||
-rw-r--r-- | xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala | 225 |
18 files changed, 1196 insertions, 36 deletions
diff --git a/libdino/src/entity/file_transfer.vala b/libdino/src/entity/file_transfer.vala index 20bc1a7a..c9c7916e 100644 --- a/libdino/src/entity/file_transfer.vala +++ b/libdino/src/entity/file_transfer.vala @@ -14,6 +14,22 @@ public class FileTransfer : Object { FAILED } + public class SerializedSfsSource: Object { + public string type; + public string data; + + public SerializedSfsSource.from_sfs_source(Xep.StatelessFileSharing.SfsSource source) { + this.type = source.type(); + this.data = source.serialize(); + } + + public async Xep.StatelessFileSharing.SfsSource to_sfs_source() { + assert(this.type == Xep.StatelessFileSharing.HttpSource.SOURCE_TYPE); + Xep.StatelessFileSharing.HttpSource http_source = yield Xep.StatelessFileSharing.HttpSource.deserialize(this.data); + return http_source; + } + } + public int id { get; set; default=-1; } public Account account { get; set; } public Jid counterpart { get; set; } @@ -64,13 +80,19 @@ public class FileTransfer : Object { } public string path { get; set; } public string? mime_type { get; set; } - // TODO(hrxi): expand to 64 bit - public int size { get; set; default=-1; } - + public int64 size { get; set; } public State state { get; set; default=State.NOT_STARTED; } public int provider { get; set; } public string info { get; set; } public Cancellable cancellable { get; default=new Cancellable(); } + public string? desc { get; set; } + public DateTime? modification_date { get; set; } + public int width { get; set; default=-1; } + public int height { get; set; default=-1; } + public int64 length { get; set; default=-1; } + public Xep.CryptographicHashes.Hashes hashes { get; set; default=new Xep.CryptographicHashes.Hashes();} + public ListStore sfs_sources { get; set; default=new ListStore(typeof(SerializedSfsSource)); } + public Gee.List<Xep.JingleContentThumbnails.Thumbnail> thumbnails = new Gee.ArrayList<Xep.JingleContentThumbnails.Thumbnail>(); private Database? db; private string storage_dir; @@ -99,10 +121,37 @@ public class FileTransfer : Object { file_name = row[db.file_transfer.file_name]; path = row[db.file_transfer.path]; mime_type = row[db.file_transfer.mime_type]; - size = row[db.file_transfer.size]; + size = (int64) row[db.file_transfer.size]; state = (State) row[db.file_transfer.state]; provider = row[db.file_transfer.provider]; info = row[db.file_transfer.info]; + modification_date = new DateTime.from_unix_utc(row[db.file_transfer.modification_date]); + width = row[db.file_transfer.width]; + height = row[db.file_transfer.height]; + length = (int64) row[db.file_transfer.length]; + + foreach(var hash_row in db.file_hashes.select().with(db.file_hashes.id, "=", id)) { + Xep.CryptographicHashes.Hash hash = new Xep.CryptographicHashes.Hash(); + hash.algo = hash_row[db.file_hashes.algo]; + hash.val = hash_row[db.file_hashes.value]; + hashes.hashes.add(hash); + } + + foreach(var thumbnail_row in db.file_thumbnails.select().with(db.file_thumbnails.id, "=", id)) { + Xep.JingleContentThumbnails.Thumbnail thumbnail = new Xep.JingleContentThumbnails.Thumbnail(); + thumbnail.uri = thumbnail_row[db.file_thumbnails.uri]; + thumbnail.media_type = thumbnail_row[db.file_thumbnails.mime_type]; + thumbnail.width = thumbnail_row[db.file_thumbnails.width]; + thumbnail.height = thumbnail_row[db.file_thumbnails.height]; + thumbnails.add(thumbnail); + } + + foreach(Qlite.Row source_row in db.sfs_sources.select().with(db.sfs_sources.id, "=", id)) { + SerializedSfsSource source = new SerializedSfsSource(); + source.type = source_row[db.sfs_sources.type]; + source.data = source_row[db.sfs_sources.data]; + sfs_sources.append(source as Object); + } notify.connect(on_update); } @@ -121,17 +170,52 @@ public class FileTransfer : Object { .value(db.file_transfer.local_time, (long) local_time.to_unix()) .value(db.file_transfer.encryption, encryption) .value(db.file_transfer.file_name, file_name) - .value(db.file_transfer.size, size) + .value(db.file_transfer.size, (long) size) .value(db.file_transfer.state, state) .value(db.file_transfer.provider, provider) .value(db.file_transfer.info, info); - if (file_name != null) builder.value(db.file_transfer.file_name, file_name); if (path != null) builder.value(db.file_transfer.path, path); if (mime_type != null) builder.value(db.file_transfer.mime_type, mime_type); + if (path != null) builder.value(db.file_transfer.path, path); + if (modification_date != null) builder.value(db.file_transfer.modification_date, (long) modification_date.to_unix()); + if (width != -1) builder.value(db.file_transfer.width, width); + if (height != -1) builder.value(db.file_transfer.height, height); + if (length != -1) builder.value(db.file_transfer.length, (long) length); id = (int) builder.perform(); + + foreach (Xep.CryptographicHashes.Hash hash in hashes.hashes) { + db.file_hashes.insert() + .value(db.file_hashes.id, id) + .value(db.file_hashes.algo, hash.algo) + .value(db.file_hashes.value, hash.val) + .perform(); + } + foreach (Xep.JingleContentThumbnails.Thumbnail thumbnail in thumbnails) { + db.file_thumbnails.insert() + .value(db.file_thumbnails.id, id) + .value(db.file_thumbnails.uri, thumbnail.uri) + .value(db.file_thumbnails.mime_type, thumbnail.media_type) + .value(db.file_thumbnails.width, thumbnail.width) + .value(db.file_thumbnails.height, thumbnail.height) + .perform(); + } + + for(int i = 0; i < sfs_sources.get_n_items(); i++) { + Object source_object = sfs_sources.get_item(i); + SerializedSfsSource source = source_object as SerializedSfsSource; + db.sfs_sources.insert() + .value(db.sfs_sources.id, id) + .value(db.sfs_sources.type, source.type) + .value(db.sfs_sources.data, source.data) + .perform(); + } + notify.connect(on_update); + sfs_sources.items_changed.connect((position, removed, added) => { + on_update_sources_items(this, position, removed, added); + }); } public File get_file() { @@ -161,7 +245,7 @@ public class FileTransfer : Object { case "mime-type": update_builder.set(db.file_transfer.mime_type, mime_type); break; case "size": - update_builder.set(db.file_transfer.size, size); break; + update_builder.set(db.file_transfer.size, (long) size); break; case "state": if (state == State.IN_PROGRESS) return; update_builder.set(db.file_transfer.state, state); break; @@ -169,9 +253,69 @@ public class FileTransfer : Object { update_builder.set(db.file_transfer.provider, provider); break; case "info": update_builder.set(db.file_transfer.info, info); break; + case "modification-date": + update_builder.set(db.file_transfer.modification_date, (long) modification_date.to_unix()); break; + case "width": + update_builder.set(db.file_transfer.width, width); break; + case "height": + update_builder.set(db.file_transfer.height, height); break; + case "length": + update_builder.set(db.file_transfer.length, (long) length); break; } update_builder.perform(); } + + private void on_update_sources_items(FileTransfer file_transfer, uint position, uint removed, uint added) { + for(uint i = position; i < position + added; i++) { + Object source_object = file_transfer.sfs_sources.get_item(i); + SerializedSfsSource source = source_object as SerializedSfsSource; + db.sfs_sources.insert() + .value(db.sfs_sources.id, id) + .value(db.sfs_sources.type, source.type) + .value(db.sfs_sources.data, source.data) + .perform(); + } + } + + public Xep.FileMetadataElement.FileMetadata to_metadata_element() { + Xep.FileMetadataElement.FileMetadata metadata = new Xep.FileMetadataElement.FileMetadata(); + metadata.name = this.file_name; + metadata.mime_type = this.mime_type; + metadata.size = this.size; + metadata.desc = this.desc; + metadata.date = this.modification_date; + metadata.width = this.width; + metadata.height = this.height; + metadata.length = this.length; + metadata.hashes = this.hashes; + metadata.thumbnails = this.thumbnails; + return metadata; + } + + public async Xep.StatelessFileSharing.SfsElement to_sfs_element() { + Xep.StatelessFileSharing.SfsElement sfs_element = new Xep.StatelessFileSharing.SfsElement(); + sfs_element.metadata = this.to_metadata_element(); + for(int i = 0; i < sfs_sources.get_n_items(); i++) { + Object source_object = sfs_sources.get_item(i); + SerializedSfsSource source = source_object as SerializedSfsSource; + sfs_element.sources.add(yield source.to_sfs_source()); + } + + return sfs_element; + } + + public void with_metadata_element(Xep.FileMetadataElement.FileMetadata metadata) { + this.file_name = metadata.name; + this.mime_type = metadata.mime_type; + this.size = metadata.size; + this.desc = metadata.desc; + this.modification_date = metadata.date; + this.width = metadata.width; + this.height = metadata.height; + this.length = metadata.length; + this.hashes = metadata.hashes; + this.thumbnails = metadata.thumbnails; + } } } diff --git a/libdino/src/entity/message.vala b/libdino/src/entity/message.vala index e5aad25f..d48f999b 100644 --- a/libdino/src/entity/message.vala +++ b/libdino/src/entity/message.vala @@ -1,5 +1,6 @@ using Gee; using Xmpp; +using Xmpp.Xep; namespace Dino.Entities { diff --git a/libdino/src/service/database.vala b/libdino/src/service/database.vala index eba8b7ca..30d6d55f 100644 --- a/libdino/src/service/database.vala +++ b/libdino/src/service/database.vala @@ -7,7 +7,7 @@ using Dino.Entities; namespace Dino { public class Database : Qlite.Database { - private const int VERSION = 27; + private const int VERSION = 28; public class AccountTable : Table { public Column<int> id = new Column.Integer("id") { primary_key = true, auto_increment = true }; @@ -191,15 +191,57 @@ public class Database : Qlite.Database { public Column<string> file_name = new Column.Text("file_name"); public Column<string> path = new Column.Text("path"); public Column<string> mime_type = new Column.Text("mime_type"); - public Column<int> size = new Column.Integer("size"); + public Column<long> size = new Column.Long("size") { default = "-1", min_version=28 }; public Column<int> state = new Column.Integer("state"); public Column<int> provider = new Column.Integer("provider"); public Column<string> info = new Column.Text("info"); + public Column<long> modification_date = new Column.Long("modification_date") { default = "-1", min_version=28 }; + public Column<int> width = new Column.Integer("width") { default = "-1", min_version=28 }; + public Column<int> height = new Column.Integer("height") { default = "-1", min_version=28 }; + public Column<long> length = new Column.Integer("length") { default = "-1", min_version=28 }; internal FileTransferTable(Database db) { base(db, "file_transfer"); init({id, account_id, counterpart_id, counterpart_resource, our_resource, direction, time, local_time, - encryption, file_name, path, mime_type, size, state, provider, info}); + encryption, file_name, path, mime_type, size, state, provider, info, modification_date, width, height, + length}); + } + } + + public class FileHashesTable : Table { + public Column<int> id = new Column.Integer("id"); + public Column<string> algo = new Column.Text("algo") { not_null = true }; + public Column<string> value = new Column.Text("value") { not_null = true }; + + internal FileHashesTable(Database db) { + base(db, "file_hashes"); + init({id, algo, value}); + unique({id, algo}, "REPLACE"); + } + } + + public class FileThumbnailsTable : Table { + public Column<int> id = new Column.Integer("id"); + public Column<string> uri = new Column.Text("uri") { not_null = true }; + public Column<string> mime_type = new Column.Text("mime_type"); + public Column<int> width = new Column.Integer("width"); + public Column<int> height = new Column.Integer("height"); + + internal FileThumbnailsTable(Database db) { + base(db, "file_thumbnails"); + init({id, uri, mime_type, width, height}); + } + } + + public class SfsSourcesTable : Table { + public Column<int> id = new Column.Integer("id"); + public Column<string> type = new Column.Text("type") { not_null = true }; + public Column<string> data = new Column.Text("data") { not_null = true }; + + internal SfsSourcesTable(Database db) { + base(db, "sfs_sources"); + init({id, type, data}); + unique({id, type, data}, "REPLACE"); } } @@ -401,6 +443,9 @@ public class Database : Qlite.Database { public RealJidTable real_jid { get; private set; } public OccupantIdTable occupantid { get; private set; } public FileTransferTable file_transfer { get; private set; } + public FileHashesTable file_hashes { get; private set; } + public FileThumbnailsTable file_thumbnails { get; private set; } + public SfsSourcesTable sfs_sources { get; private set; } public CallTable call { get; private set; } public CallCounterpartTable call_counterpart { get; private set; } public ConversationTable conversation { get; private set; } @@ -431,6 +476,9 @@ public class Database : Qlite.Database { occupantid = new OccupantIdTable(this); real_jid = new RealJidTable(this); file_transfer = new FileTransferTable(this); + file_hashes = new FileHashesTable(this); + file_thumbnails = new FileThumbnailsTable(this); + sfs_sources = new SfsSourcesTable(this); call = new CallTable(this); call_counterpart = new CallCounterpartTable(this); conversation = new ConversationTable(this); @@ -443,7 +491,7 @@ public class Database : Qlite.Database { settings = new SettingsTable(this); account_settings = new AccountSettingsTable(this); conversation_settings = new ConversationSettingsTable(this); - init({ account, jid, entity, content_item, message, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, account_settings, conversation_settings }); + init({ account, jid, entity, content_item, message, body_meta, message_correction, reply, real_jid, occupantid, file_transfer, file_hashes, file_thumbnails, sfs_sources, call, call_counterpart, conversation, avatar, entity_identity, entity_feature, roster, mam_catchup, reaction, settings, account_settings, conversation_settings }); try { exec("PRAGMA journal_mode = WAL"); diff --git a/libdino/src/service/file_manager.vala b/libdino/src/service/file_manager.vala index 984fe5fd..32cf23c4 100644 --- a/libdino/src/service/file_manager.vala +++ b/libdino/src/service/file_manager.vala @@ -2,6 +2,7 @@ using Gdk; using Gee; using Xmpp; +using Xmpp.Xep; using Dino.Entities; namespace Dino { @@ -19,6 +20,7 @@ public class FileManager : StreamInteractionModule, Object { private Gee.List<FileEncryptor> file_encryptors = new ArrayList<FileEncryptor>(); private Gee.List<FileDecryptor> file_decryptors = new ArrayList<FileDecryptor>(); private Gee.List<FileProvider> file_providers = new ArrayList<FileProvider>(); + private Gee.List<FileMetadataProvider> file_metadata_providers = new ArrayList<FileMetadataProvider>(); public static void start(StreamInteractor stream_interactor, Database db) { FileManager m = new FileManager(stream_interactor, db); @@ -36,9 +38,121 @@ public class FileManager : StreamInteractionModule, Object { this.add_provider(new JingleFileProvider(stream_interactor)); this.add_sender(new JingleFileSender(stream_interactor)); + this.add_metadata_provider(new GenericFileMetadataProvider()); + this.add_metadata_provider(new ImageFileMetadataProvider()); + this.stream_interactor.account_added.connect((account) => { + on_account_added(account); + }); + } + + public const int HTTP_PROVIDER_ID = 0; + public const int SFS_PROVIDER_ID = 2; + + private FileProvider? select_file_provider(FileTransfer file_transfer) { + bool http_usable = file_transfer.provider == SFS_PROVIDER_ID; + foreach (FileProvider file_provider in this.file_providers) { + if (file_transfer.provider == file_provider.get_id()) { + return file_provider; + } + if (http_usable && file_provider.get_id() == HTTP_PROVIDER_ID) { + return file_provider; + } + } + return null; } - public async HashMap<int, long> get_file_size_limits(Conversation conversation) { + // For receiving out of band data as sfs + private async void on_backwards_compatible_sfs(FileProvider file_provider, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta) { + Xep.StatelessFileSharing.SfsElement sfs_element = new Xep.StatelessFileSharing.SfsElement(); + + Xep.StatelessFileSharing.HttpSource source = new Xep.StatelessFileSharing.HttpSource(); + HttpFileReceiveData http_receive_data = receive_data as HttpFileReceiveData; + source.url = http_receive_data.url; + sfs_element.sources.add(source); + + FileTransfer file_transfer = new FileTransfer(); + + if (is_jid_trustworthy(from, conversation)) { + try { + file_meta = yield file_provider.get_meta_info(file_transfer, http_receive_data, file_meta); + } catch (Error e) { + warning("Can't accept oob data as stateless file sharing due to failed http request\n"); + } + } + + sfs_element.metadata.size = file_meta.size; + sfs_element.metadata.name = file_meta.file_name; + sfs_element.metadata.mime_type = file_meta.mime_type; + // Encryption unused in http file transfers + + yield on_receive_sfs(from, conversation, sfs_element, null); + } + + private async void on_receive_sfs(Jid from, Conversation conversation, Xep.StatelessFileSharing.SfsElement sfs_element, string? id) { + FileTransfer file_transfer = new FileTransfer(); + file_transfer.account = conversation.account; + file_transfer.counterpart = file_transfer.direction == FileTransfer.DIRECTION_RECEIVED ? from : conversation.counterpart; + if (conversation.type_.is_muc_semantic()) { + file_transfer.ourpart = stream_interactor.get_module(MucManager.IDENTITY).get_own_jid(conversation.counterpart, conversation.account) ?? conversation.account.bare_jid; + file_transfer.direction = from.equals(file_transfer.ourpart) ? FileTransfer.DIRECTION_SENT : FileTransfer.DIRECTION_RECEIVED; + } else { + file_transfer.ourpart = conversation.account.full_jid; + file_transfer.direction = from.equals_bare(file_transfer.ourpart) ? FileTransfer.DIRECTION_SENT : FileTransfer.DIRECTION_RECEIVED; + } + file_transfer.time = new DateTime.now_utc(); + // TODO: get time from message + file_transfer.local_time = new DateTime.now_utc(); + file_transfer.provider = SFS_PROVIDER_ID; + file_transfer.with_metadata_element(sfs_element.metadata); + foreach (Xep.StatelessFileSharing.SfsSource source in sfs_element.sources) { + file_transfer.sfs_sources.append(new FileTransfer.SerializedSfsSource.from_sfs_source(source) as Object); + } + // FileTransfer.info stores the id of the MessageStanza for future SfsSourceAttachments + // Prior to sfs, info stored the id of the Message entity for oob + file_transfer.info = id; + + stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); + + if (is_sender_trustworthy(file_transfer, conversation)) { + if (file_transfer.size >= 0 && file_transfer.size < 500) { + FileProvider? file_provider = this.select_file_provider(file_transfer); + download_file_internal.begin(file_provider, file_transfer, conversation, (_, res) => { + download_file_internal.end(res); + }); + } + } + + conversation.last_active = file_transfer.time; + received_file(file_transfer, conversation); + } + + private void on_receive_sfs_attachment(Jid from, Conversation conversation, Xep.StatelessFileSharing.SfsSourceAttachment attachment) { + foreach (Qlite.Row file_transfer_row in this.db.file_transfer.select() + .with(db.file_transfer.info, "=", attachment.sfs_id)) { + FileTransfer file_transfer = new FileTransfer.from_row(this.db, file_transfer_row, FileManager.get_storage_dir()); + if (file_transfer.hashes.supported_hashes().is_empty) { + return; + } + foreach (StatelessFileSharing.SfsSource source in attachment.sources) { + file_transfer.sfs_sources.append(new FileTransfer.SerializedSfsSource.from_sfs_source(source) as Object); + } + } + + } + + private void on_account_added(Account account) { + Xep.StatelessFileSharing.Module fsf_module = stream_interactor.module_manager.get_module(account, Xep.StatelessFileSharing.Module.IDENTITY); + fsf_module.received_sfs.connect((from, to, sfs_element, message) => { + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(from, to, account, message.type_); + on_receive_sfs(from, conversation, sfs_element, message.id); + }); + fsf_module.received_sfs_attachment.connect((from, to, sfs_attachment, message) => { + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).approx_conversation_for_stanza(from, to, account, message.type_); + on_receive_sfs_attachment(from, conversation, sfs_attachment); + }); + } + + public async HashMap<int, long> get_file_size_limits(Conversation conversation) { HashMap<int, long> ret = new HashMap<int, long>(); foreach (FileSender sender in file_senders) { ret[sender.get_id()] = yield sender.get_file_size_limit(conversation); @@ -60,11 +174,15 @@ public class FileManager : StreamInteractionModule, Object { file_transfer.local_time = new DateTime.now_utc(); file_transfer.encryption = conversation.encryption; + Xep.FileMetadataElement.FileMetadata metadata = new Xep.FileMetadataElement.FileMetadata(); + foreach (FileMetadataProvider file_metadata_provider in this.file_metadata_providers) { + if (file_metadata_provider.supports_file(file)) { + yield file_metadata_provider.fill_metadata(file, metadata); + } + } + file_transfer.with_metadata_element(metadata); + try { - FileInfo file_info = file.query_info("*", FileQueryInfoFlags.NONE); - file_transfer.file_name = file_info.get_display_name(); - file_transfer.mime_type = file_info.get_content_type(); - file_transfer.size = (int)file_info.get_size(); file_transfer.input_stream = yield file.read_async(); yield save_file(file_transfer); @@ -130,12 +248,7 @@ public class FileManager : StreamInteractionModule, Object { public async void download_file(FileTransfer file_transfer) { Conversation conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(file_transfer.counterpart.bare_jid, file_transfer.account); - FileProvider? file_provider = null; - foreach (FileProvider fp in file_providers) { - if (file_transfer.provider == fp.get_id()) { - file_provider = fp; - } - } + FileProvider? file_provider = this.select_file_provider(file_transfer); yield download_file_internal(file_provider, file_transfer, conversation); } @@ -152,7 +265,12 @@ public class FileManager : StreamInteractionModule, Object { public void add_provider(FileProvider file_provider) { file_providers.add(file_provider); file_provider.file_incoming.connect((info, from, time, local_time, conversation, receive_data, file_meta) => { - handle_incoming_file.begin(file_provider, info, from, time, local_time, conversation, receive_data, file_meta); + if (receive_data is HttpFileReceiveData) { + printerr("Handling oob data as stateless file sharing"); + this.on_backwards_compatible_sfs.begin(file_provider, from, time, local_time, conversation, receive_data, file_meta); + } else { + handle_incoming_file.begin(file_provider, info, from, time, local_time, conversation, receive_data, file_meta); + } }); } @@ -174,12 +292,14 @@ public class FileManager : StreamInteractionModule, Object { file_decryptors.add(decryptor); } - public bool is_sender_trustworthy(FileTransfer file_transfer, Conversation conversation) { - if (file_transfer.direction == FileTransfer.DIRECTION_SENT) return true; + public void add_metadata_provider(FileMetadataProvider file_metadata_provider) { + file_metadata_providers.add(file_metadata_provider); + } + private bool is_jid_trustworthy(Jid from, Conversation conversation) { Jid relevant_jid = conversation.counterpart; if (conversation.type_ == Conversation.Type.GROUPCHAT) { - relevant_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(file_transfer.from, conversation.account); + relevant_jid = stream_interactor.get_module(MucManager.IDENTITY).get_real_jid(from, conversation.account); } if (relevant_jid == null) return false; @@ -187,6 +307,12 @@ public class FileManager : StreamInteractionModule, Object { return in_roster; } + public bool is_sender_trustworthy(FileTransfer file_transfer, Conversation conversation) { + if (file_transfer.direction == FileTransfer.DIRECTION_SENT) return true; + + return is_jid_trustworthy(file_transfer.from, conversation); + } + private async FileMeta get_file_meta(FileProvider file_provider, FileTransfer file_transfer, Conversation conversation, FileReceiveData receive_data_) throws FileReceiveError { FileReceiveData receive_data = receive_data_; FileMeta file_meta = file_provider.get_file_meta(file_transfer); @@ -211,7 +337,7 @@ public class FileManager : StreamInteractionModule, Object { private async void download_file_internal(FileProvider file_provider, FileTransfer file_transfer, Conversation conversation) { try { // Get meta info - FileReceiveData receive_data = file_provider.get_file_receive_data(file_transfer); + FileReceiveData receive_data = yield file_provider.get_file_receive_data(file_transfer); FileDecryptor? file_decryptor = null; foreach (FileDecryptor decryptor in file_decryptors) { if (decryptor.can_decrypt_file(conversation, file_transfer, receive_data)) { @@ -379,7 +505,7 @@ public interface FileProvider : Object { public abstract Encryption get_encryption(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta); public abstract FileMeta get_file_meta(FileTransfer file_transfer) throws FileReceiveError; - public abstract FileReceiveData? get_file_receive_data(FileTransfer file_transfer); + public abstract async FileReceiveData? get_file_receive_data(FileTransfer file_transfer); public abstract async FileMeta get_meta_info(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError; public abstract async InputStream download(FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta) throws FileReceiveError; @@ -415,4 +541,107 @@ public interface FileDecryptor : Object { public abstract async InputStream decrypt_file(InputStream encrypted_stream, Conversation conversation, FileTransfer file_transfer, FileReceiveData receive_data) throws FileReceiveError; } +public interface FileMetadataProvider : Object { + public abstract bool supports_file(File file); + + public abstract async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata); +} + +class GenericFileMetadataProvider: Dino.FileMetadataProvider, Object { + public bool supports_file(File file) { + return true; + } + + public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) { + FileInfo info = file.query_info("*", FileQueryInfoFlags.NONE); + + metadata.name = info.get_display_name(); + metadata.mime_type = info.get_content_type(); + metadata.size = info.get_size(); + metadata.date = info.get_modification_date_time(); + + Bytes file_data = file.load_bytes(); + metadata.hashes.hashes.add(new CryptographicHashes.Hash.from_data(GLib.ChecksumType.SHA256, file_data.get_data())); + metadata.hashes.hashes.add(new CryptographicHashes.Hash.from_data(GLib.ChecksumType.SHA512, file_data.get_data())); + } +} + +public class ImageFileMetadataProvider: Dino.FileMetadataProvider, Object { + public bool supports_file(File file) { + return file.query_info("*", FileQueryInfoFlags.NONE).get_content_type().has_prefix("image"); + } + + private const int[] THUMBNAIL_DIMS = { 1, 2, 3, 4, 8 }; + private const string IMAGE_TYPE = "png"; + private const string MIME_TYPE = "image/png"; + + public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) { + Pixbuf pixbuf = new Pixbuf.from_stream(yield file.read_async()); + metadata.width = pixbuf.get_width(); + metadata.height = pixbuf.get_height(); + float ratio = (float)metadata.width / (float) metadata.height; + + int thumbnail_width = -1; + int thumbnail_height = -1; + float diff = float.INFINITY; + for (int i = 0; i < THUMBNAIL_DIMS.length; i++) { + int test_width = THUMBNAIL_DIMS[i]; + int test_height = THUMBNAIL_DIMS[THUMBNAIL_DIMS.length - 1 - i]; + float test_ratio = (float)test_width / (float)test_height; + float test_diff = (test_ratio - ratio).abs(); + if (test_diff < diff) { + thumbnail_width = test_width; + thumbnail_height = test_height; + diff = test_diff; + } + } + + Pixbuf thumbnail_pixbuf = pixbuf.scale_simple(thumbnail_width, thumbnail_height, InterpType.BILINEAR); + uint8[] buffer; + thumbnail_pixbuf.save_to_buffer(out buffer, IMAGE_TYPE); + string base_64 = GLib.Base64.encode(buffer); + string uri = @"data:$MIME_TYPE;base64,$base_64"; + Xep.JingleContentThumbnails.Thumbnail thumbnail = new Xep.JingleContentThumbnails.Thumbnail(); + thumbnail.uri = uri; + thumbnail.media_type = MIME_TYPE; + thumbnail.width = thumbnail_width; + thumbnail.height = thumbnail_height; + metadata.thumbnails.add(thumbnail); + } + + public static Pixbuf? parse_thumbnail(Xep.JingleContentThumbnails.Thumbnail thumbnail) { + string[] splits = thumbnail.uri.split(":", 2); + if (splits.length != 2) { + printerr("Thumbnail parsing error: ':' not found"); + return null; + } + if (splits[0] != "data") { + printerr("Unsupported thumbnail: unimplemented uri type\n"); + return null; + } + splits = splits[1].split(";", 2); + if (splits.length != 2) { + printerr("Thumbnail parsing error: ';' not found"); + return null; + } + if (splits[0] != MIME_TYPE) { + printerr("Unsupported thumbnail: unsupported mime-type\n"); + return null; + } + splits = splits[1].split(",", 2); + if (splits.length != 2) { + printerr("Thumbnail parsing error: ',' not found"); + return null; + } + if (splits[0] != "base64") { + printerr("Unsupported thumbnail: data is not base64 encoded\n"); + return null; + } + uint8[] data = Base64.decode(splits[1]); + MemoryInputStream input_stream = new MemoryInputStream.from_data(data); + Pixbuf pixbuf = new Pixbuf.from_stream(input_stream); + return pixbuf; + } +} + } diff --git a/libdino/src/service/jingle_file_transfers.vala b/libdino/src/service/jingle_file_transfers.vala index 624be607..daccd309 100644 --- a/libdino/src/service/jingle_file_transfers.vala +++ b/libdino/src/service/jingle_file_transfers.vala @@ -74,7 +74,7 @@ public class JingleFileProvider : FileProvider, Object { return file_meta; } - public FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { + public async FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { return new FileReceiveData(); } diff --git a/libdino/src/service/module_manager.vala b/libdino/src/service/module_manager.vala index eb9e4fbc..28ccf8ef 100644 --- a/libdino/src/service/module_manager.vala +++ b/libdino/src/service/module_manager.vala @@ -86,6 +86,7 @@ public class ModuleManager { module_map[account].add(new Xep.Muji.Module()); module_map[account].add(new Xep.CallInvites.Module()); module_map[account].add(new Xep.Coin.Module()); + module_map[account].add(new Xep.StatelessFileSharing.Module()); initialize_account_modules(account, module_map[account]); } } diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index fe7528cf..fa5f56e8 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -185,6 +185,7 @@ SOURCES src/ui/conversation_content_view/date_separator_populator.vala src/ui/conversation_content_view/file_default_widget.vala src/ui/conversation_content_view/file_image_widget.vala + src/ui/conversation_content_view/file_preview_widget.vala src/ui/conversation_content_view/file_widget.vala src/ui/conversation_content_view/item_actions.vala src/ui/conversation_content_view/message_widget.vala @@ -221,6 +222,7 @@ SOURCES src/ui/util/accounts_combo_box.vala src/ui/util/config.vala src/ui/util/data_forms.vala + src/ui/util/file_metadata_providers.vala src/ui/util/helper.vala src/ui/util/label_hybrid.vala src/ui/util/preference_group.vala diff --git a/main/src/ui/application.vala b/main/src/ui/application.vala index 918fb26c..3c816a77 100644 --- a/main/src/ui/application.vala +++ b/main/src/ui/application.vala @@ -69,6 +69,7 @@ public class Dino.Ui.Application : Adw.Application, Dino.Application { } } }); + stream_interactor.get_module(FileManager.IDENTITY).add_metadata_provider(new Util.AudioVideoFileMetadataProvider()); }); activate.connect(() => { diff --git a/main/src/ui/conversation_content_view/file_preview_widget.vala b/main/src/ui/conversation_content_view/file_preview_widget.vala new file mode 100644 index 00000000..e7cee93a --- /dev/null +++ b/main/src/ui/conversation_content_view/file_preview_widget.vala @@ -0,0 +1,90 @@ +using Gee; +using Gdk; +using Gtk; +using Xmpp; + +using Dino.Entities; + +namespace Dino.Ui { + + public class FilePreviewWidget : Box { + + private ScalingImage image; + FileDefaultWidget file_default_widget; + FileDefaultWidgetController file_default_widget_controller; + + public FilePreviewWidget() { + this.halign = Align.START; + + this.add_css_class("file-preview-widget"); + } + + public async void load_from_thumbnail(FileTransfer file_transfer, StreamInteractor stream_interactor, int MAX_WIDTH=600, int MAX_HEIGHT=300) throws GLib.Error { + Thread<ScalingImage?> thread = new Thread<ScalingImage?> (null, () => { + ScalingImage image = new ScalingImage() { halign=Align.START, visible = true, max_width = MAX_WIDTH, max_height = MAX_HEIGHT }; + Gdk.Pixbuf? pixbuf = null; + foreach (Xep.JingleContentThumbnails.Thumbnail thumbnail in file_transfer.thumbnails) { + pixbuf = ImageFileMetadataProvider.parse_thumbnail(thumbnail); + if (pixbuf != null) { + break; + } + } + if (pixbuf == null) { + warning("Can't load thumbnails of file %s", file_transfer.file_name); + Idle.add(load_from_thumbnail.callback); + throw new Error(-1, 0, "Error loading preview image"); + } + // TODO: should this be executed? If yes, before or after scaling + pixbuf = pixbuf.apply_embedded_orientation(); + + if (file_transfer.width > 0 && file_transfer.height > 0) { + pixbuf = pixbuf.scale_simple(file_transfer.width, file_transfer.height, InterpType.BILINEAR); + } else { + warning("Preview: Not scaling image, width: %d, height: %d\n", file_transfer.width, file_transfer.height); + } + if (pixbuf == null) { + warning("Can't scale thumbnail %s", file_transfer.file_name); + throw new Error(-1, 0, "Error scaling preview image"); + } + + image.load(pixbuf); + Idle.add(load_from_thumbnail.callback); + return image; + }); + yield; + this.image = thread.join(); + + file_default_widget = new FileDefaultWidget() { valign=Align.END, vexpand=false, visible=false }; + file_default_widget.image_stack.visible = false; + file_default_widget_controller = new FileDefaultWidgetController(file_default_widget); + file_default_widget_controller.set_file_transfer(file_transfer, stream_interactor); + + Overlay overlay = new Overlay(); + overlay.set_child(image); + overlay.add_overlay(file_default_widget); + overlay.set_measure_overlay(image, true); + overlay.set_clip_overlay(file_default_widget, true); + + EventControllerMotion this_motion_events = new EventControllerMotion(); + this.add_controller(this_motion_events); + this_motion_events.enter.connect(() => { + file_default_widget.visible = true; + }); + this_motion_events.leave.connect(() => { + if (file_default_widget.file_menu.popover != null && file_default_widget.file_menu.popover.visible) return; + + file_default_widget.visible = false; + }); + GestureClick gesture_click_controller = new GestureClick(); + gesture_click_controller.set_button(1); // listen for left clicks + this.add_controller(gesture_click_controller); + gesture_click_controller.pressed.connect((n_press, x, y) => { + // Check whether the click was inside the file menu. Otherwise, open the file. + this.file_default_widget.clicked(); + }); + + this.append(overlay); + } + } + +} diff --git a/main/src/ui/conversation_content_view/file_widget.vala b/main/src/ui/conversation_content_view/file_widget.vala index 02c9407a..69781f30 100644 --- a/main/src/ui/conversation_content_view/file_widget.vala +++ b/main/src/ui/conversation_content_view/file_widget.vala @@ -43,6 +43,7 @@ public class FileWidget : SizeRequestBox { enum State { IMAGE, + IMAGE_PREVIEW, DEFAULT } @@ -108,7 +109,26 @@ public class FileWidget : SizeRequestBox { } catch (Error e) { } } - if (!show_image() && state != State.DEFAULT) { + if (show_preview() && state != State.IMAGE_PREVIEW) { + var content_bak = content; + + FilePreviewWidget file_preview_widget = null; + try { + file_preview_widget = new FilePreviewWidget() { visible=true }; + yield file_preview_widget.load_from_thumbnail(file_transfer, stream_interactor); + + // If the widget changed in the meanwhile, stop + if (content != content_bak) return; + + if (content != null) this.remove(content); + content = file_preview_widget; + state = State.IMAGE_PREVIEW; + this.append(content); + return; + } catch (Error e) { } + } + + if (!show_image() && state != State.DEFAULT && state != State.IMAGE_PREVIEW) { if (content != null) this.remove(content); FileDefaultWidget default_file_widget = new FileDefaultWidget(); default_widget_controller = new FileDefaultWidgetController(default_file_widget); @@ -140,6 +160,10 @@ public class FileWidget : SizeRequestBox { return false; } + private bool show_preview() { + return !this.file_transfer.thumbnails.is_empty; + } + public override void dispose() { if (default_widget_controller != null) default_widget_controller.dispose(); default_widget_controller = null; diff --git a/main/src/ui/util/file_metadata_providers.vala b/main/src/ui/util/file_metadata_providers.vala new file mode 100644 index 00000000..9e8d9640 --- /dev/null +++ b/main/src/ui/util/file_metadata_providers.vala @@ -0,0 +1,27 @@ +using Dino.Entities; +using Xmpp; +using Xmpp.Xep; +using Gtk; + +namespace Dino.Ui.Util { + +public class AudioVideoFileMetadataProvider: Dino.FileMetadataProvider, Object { + public bool supports_file(File file) { + string? mime_type = file.query_info("*", FileQueryInfoFlags.NONE).get_content_type(); + if (mime_type == null) { + return false; + } + return mime_type.has_prefix("audio") || mime_type.has_prefix("video"); + } + + public async void fill_metadata(File file, Xep.FileMetadataElement.FileMetadata metadata) { + MediaFile media = MediaFile.for_file(file); + media.notify["prepared"].connect((object, pspec) => { + Idle.add(fill_metadata.callback); + }); + yield; + metadata.length = media.duration / 1000; + } +} + +}
\ No newline at end of file diff --git a/plugins/http-files/src/file_provider.vala b/plugins/http-files/src/file_provider.vala index d62e3561..bbee8e50 100644 --- a/plugins/http-files/src/file_provider.vala +++ b/plugins/http-files/src/file_provider.vala @@ -38,6 +38,9 @@ public class FileProvider : Dino.FileProvider, Object { } public override async bool run(Entities.Message message, Xmpp.MessageStanza stanza, Conversation conversation) { + if (Xep.StatelessFileSharing.MessageFlag.get_flag(stanza) != null) { + return true; + } string? oob_url = Xmpp.Xep.OutOfBandData.get_url_from_message(stanza); bool normal_file = oob_url != null && oob_url == message.body && FileProvider.http_url_regex.match(message.body); bool omemo_file = FileProvider.omemo_url_regex.match(message.body); @@ -180,6 +183,16 @@ public class FileProvider : Dino.FileProvider, Object { } public FileMeta get_file_meta(FileTransfer file_transfer) throws FileReceiveError { + // TODO: replace '2' with constant? + if (file_transfer.provider == 2) { + var file_meta = new HttpFileMeta(); + file_meta.size = file_transfer.size; + file_meta.mime_type = file_transfer.mime_type; + file_meta.file_name = file_transfer.file_name; + file_meta.message = null; + return file_meta; + } + Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(file_transfer.counterpart.bare_jid, file_transfer.account); if (conversation == null) throw new FileReceiveError.GET_METADATA_FAILED("No conversation"); @@ -197,7 +210,26 @@ public class FileProvider : Dino.FileProvider, Object { return file_meta; } - public FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { + public async FileReceiveData? get_file_receive_data(FileTransfer file_transfer) { + // TODO: replace '2' with constant? + if (file_transfer.provider == 2) { + Xep.StatelessFileSharing.HttpSource http_source = null; + for(int i = 0; i < file_transfer.sfs_sources.get_n_items(); i++) { + Object source_object = file_transfer.sfs_sources.get_item(i); + FileTransfer.SerializedSfsSource source = source_object as FileTransfer.SerializedSfsSource; + if (source.type == Xep.StatelessFileSharing.HttpSource.SOURCE_TYPE) { + http_source = yield Xep.StatelessFileSharing.HttpSource.deserialize(source.data); + assert(source != null); + } + } + if (http_source == null) { + printerr("Sfs file transfer has no http sources attached!"); + return null; + } + var receive_data = new HttpFileReceiveData(); + receive_data.url = http_source.url; + return receive_data; + } Conversation? conversation = stream_interactor.get_module(ConversationManager.IDENTITY).get_conversation(file_transfer.counterpart.bare_jid, file_transfer.account); if (conversation == null) return null; diff --git a/plugins/http-files/src/file_sender.vala b/plugins/http-files/src/file_sender.vala index a2f4769b..91b50944 100644 --- a/plugins/http-files/src/file_sender.vala +++ b/plugins/http-files/src/file_sender.vala @@ -45,11 +45,38 @@ public class HttpFileSender : FileSender, Object { yield upload(file_transfer, send_data, file_meta); - Entities.Message message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(send_data.url_down, conversation); - file_transfer.info = message.id.to_string(); - - message.encryption = send_data.encrypt_message ? conversation.encryption : Encryption.NONE; - stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(message, conversation); + if (send_data.encrypt_message && conversation.encryption != Encryption.NONE) { + Entities.Message message = stream_interactor.get_module(MessageProcessor.IDENTITY).create_out_message(send_data.url_down, conversation); + file_transfer.info = message.id.to_string(); + + message.encryption = conversation.encryption; + stream_interactor.get_module(MessageProcessor.IDENTITY).send_xmpp_message(message, conversation); + } else { + // Use stateless file sharing for unencrypted file sharing + Xep.StatelessFileSharing.HttpSource source = new Xep.StatelessFileSharing.HttpSource(); + source.url = send_data.url_down; + file_transfer.sfs_sources.append(new FileTransfer.SerializedSfsSource.from_sfs_source(source) as Object); + this.db.sfs_sources.insert() + .value(db.sfs_sources.id, file_transfer.id) + .value(db.sfs_sources.type, source.type()) + .value(db.sfs_sources.data, source.serialize()) + .perform(); + XmppStream stream = stream_interactor.get_stream(conversation.account); + Xep.StatelessFileSharing.Module sfs_module = stream.get_module(Xep.StatelessFileSharing.Module.IDENTITY); + string message_type; + if (conversation.type_ == Conversation.Type.GROUPCHAT) { + message_type = MessageStanza.TYPE_GROUPCHAT; + } else { + message_type = MessageStanza.TYPE_CHAT; + } + MessageStanza sfs_message = new MessageStanza() { to=conversation.counterpart, type_=message_type }; + // TODO: is this the correct way of adding out-of-band-data? + sfs_message.body = source.url; + Xep.OutOfBandData.add_url_to_message(sfs_message, source.url); + // TODO: message hint correct? + Xep.MessageProcessingHints.set_message_hint(sfs_message, Xep.MessageProcessingHints.HINT_STORE); + sfs_module.send_stateless_file_transfer(stream, sfs_message, yield file_transfer.to_sfs_element()); + } } public async bool can_send(Conversation conversation, FileTransfer file_transfer) { diff --git a/xmpp-vala/CMakeLists.txt b/xmpp-vala/CMakeLists.txt index 4a213fda..824b667e 100644 --- a/xmpp-vala/CMakeLists.txt +++ b/xmpp-vala/CMakeLists.txt @@ -125,10 +125,12 @@ SOURCES "src/module/xep/0249_direct_muc_invitations.vala" "src/module/xep/0260_jingle_socks5_bytestreams.vala" "src/module/xep/0261_jingle_in_band_bytestreams.vala" + "src/module/xep/0264_jingle_content_thumbnails.vala" "src/module/xep/0272_muji.vala" "src/module/xep/0280_message_carbons.vala" "src/module/xep/0297_stanza_forwarding.vala" "src/module/xep/0298_coin.vala" + "src/module/xep/0300_cryptographic_hashes.vala" "src/module/xep/0308_last_message_correction.vala" "src/module/xep/0313_message_archive_management.vala" "src/module/xep/0313_2_message_archive_management.vala" @@ -143,6 +145,8 @@ SOURCES "src/module/xep/0421_occupant_ids.vala" "src/module/xep/0428_fallback_indication.vala" "src/module/xep/0444_reactions.vala" + "src/module/xep/0446_file_metadata_element.vala" + "src/module/xep/0447_stateless_file_sharing.vala" "src/module/xep/0461_replies.vala" "src/module/xep/0482_call_invites.vala" "src/module/xep/pixbuf_storage.vala" diff --git a/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala b/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala new file mode 100644 index 00000000..cb281f80 --- /dev/null +++ b/xmpp-vala/src/module/xep/0264_jingle_content_thumbnails.vala @@ -0,0 +1,49 @@ +namespace Xmpp.Xep.JingleContentThumbnails { + public const string NS_URI = "urn:xmpp:thumbs:1"; + public const string STANZA_NAME = "thumbnail"; + + public class Thumbnail { + public string uri; + public string? media_type; + public int width; + public int height; + + const string URI_ATTRIBUTE = "uri"; + const string MIME_ATTRIBUTE = "media-type"; + const string WIDTH_ATTRIBUTE = "width"; + const string HEIGHT_ATTRIBUTE = "height"; + + public StanzaNode to_stanza_node() { + StanzaNode node = new StanzaNode.build(STANZA_NAME, NS_URI).add_self_xmlns() + .put_attribute(URI_ATTRIBUTE, this.uri); + if (this.media_type != null) { + node.put_attribute(MIME_ATTRIBUTE, this.media_type); + } + if (this.width != -1) { + node.put_attribute(WIDTH_ATTRIBUTE, this.width.to_string()); + } + if (this.height != -1) { + node.put_attribute(HEIGHT_ATTRIBUTE, this.height.to_string()); + } + return node; + } + + public static Thumbnail? from_stanza_node(StanzaNode node) { + Thumbnail thumbnail = new Thumbnail(); + thumbnail.uri = node.get_attribute(URI_ATTRIBUTE); + if (thumbnail.uri == null) { + return null; + } + thumbnail.media_type = node.get_attribute(MIME_ATTRIBUTE); + string? width = node.get_attribute(WIDTH_ATTRIBUTE); + if (width != null) { + thumbnail.width = int.parse(width); + } + string? height = node.get_attribute(HEIGHT_ATTRIBUTE); + if (height != null) { + thumbnail.height = int.parse(height); + } + return thumbnail; + } + } +}
\ No newline at end of file diff --git a/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala b/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala new file mode 100644 index 00000000..00f9e2ee --- /dev/null +++ b/xmpp-vala/src/module/xep/0300_cryptographic_hashes.vala @@ -0,0 +1,136 @@ +using GLib; +using Gee; + +namespace Xmpp.Xep.CryptographicHashes { + public const string NS_URI = "urn:xmpp:hashes:2"; + + public enum HashCmp { + Match, + Mismatch, + None, + } + + public class Hash { + public string algo; + // hash encoded in Base64 + public string val; + + public static string hash_name(ChecksumType type) { + switch(type) { + case ChecksumType.MD5: + return "md5"; + case ChecksumType.SHA1: + return "sha-1"; + case ChecksumType.SHA256: + return "sha-256"; + case ChecksumType.SHA384: + return "sha-384"; + case ChecksumType.SHA512: + return "sha-512"; + } + return "(null)"; + } + + public static ChecksumType? supported_hash(string hash) { + switch (hash) { + case "sha-1": + return ChecksumType.SHA1; + case "sha-256": + return ChecksumType.SHA256; + case "sha-384": + return ChecksumType.SHA384; + case "sha-512": + return ChecksumType.SHA512; + } + return null; + } + + public Hash.from_data(GLib.ChecksumType type, uint8[] data) { + GLib.Checksum checksum = new GLib.Checksum(type); + checksum.update(data, data.length); + // 64 * 8 = 512 (sha-512 is the longest hash variant) + uint8[] digest = new uint8[64]; + size_t length = digest.length; + checksum.get_digest(digest, ref length); + this.algo = hash_name(type); + this.val = GLib.Base64.encode(digest[0:length]); + } + + public HashCmp compare(Hash other) { + if (this.algo != other.algo) { + return HashCmp.None; + } + if (this.val == other.val) { + return HashCmp.Match; + } else { + return HashCmp.Mismatch; + } + } + + public StanzaNode to_stanza_node() { + return new StanzaNode.build("hash", NS_URI).add_self_xmlns() + .put_attribute("algo", this.algo) + .put_node(new StanzaNode.text(this.val)); + } + + public Hash.from_stanza_node(StanzaNode node) { + this.algo = node.get_attribute("algo"); + this.val = node.get_string_content(); + } + } + + public class Hashes { + public Gee.List<Hash> hashes = new ArrayList<Hash>(); + + public Gee.List<ChecksumType> supported_hashes() { + Gee.List<ChecksumType> supported = new ArrayList<ChecksumType>(); + foreach (Hash hash in this.hashes) { + ChecksumType? hash_type = Hash.supported_hash(hash.algo); + if (hash_type != null) { + supported.add(hash_type); + } + } + return supported; + } + + public Hashes.from_data(Gee.List<ChecksumType> types, uint8[] data) { + foreach (ChecksumType type in types) { + this.hashes.add(new Hash.from_data(type, data)); + } + } + + public HashCmp compare(Hashes other) { + HashCmp cmp = HashCmp.None; + foreach (Hash this_hash in this.hashes) { + foreach (Hash other_hash in other.hashes) { + switch (this_hash.compare(other_hash)) { + case HashCmp.Mismatch: + return HashCmp.Mismatch; + case HashCmp.Match: + cmp = HashCmp.Match; + break; + case HashCmp.None: + continue; + } + } + } + return cmp; + } + + public Gee.List<StanzaNode> to_stanza_nodes() { + Gee.List<StanzaNode> nodes = new ArrayList<StanzaNode>(); + foreach (Hash hash in this.hashes) { + nodes.add(hash.to_stanza_node()); + } + return nodes; + } + + public Hashes.from_stanza_subnodes(StanzaNode node) { + Gee.List<StanzaNode> subnodes = node.get_subnodes("hash", NS_URI); + this.hashes = new ArrayList<Hash>(); + foreach (StanzaNode subnode in subnodes) { + this.hashes.add(new Hash.from_stanza_node(subnode)); + } + } + } +} diff --git a/xmpp-vala/src/module/xep/0446_file_metadata_element.vala b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala new file mode 100644 index 00000000..d7fbb06f --- /dev/null +++ b/xmpp-vala/src/module/xep/0446_file_metadata_element.vala @@ -0,0 +1,120 @@ +using Xmpp.Xep.CryptographicHashes; + +namespace Xmpp.Xep.FileMetadataElement { + public const string NS_URI = "urn:xmpp:file:metadata:0"; + + public class FileMetadata { + public string name { get; set; } + public string? mime_type { get; set; } + public int64 size { get; set; default=-1; } + public string? desc { get; set; } + public DateTime? date { get; set; } + public int width { get; set; default=-1; } // Width of image in pixels + public int height { get; set; default=-1; } // Height of image in pixels + public CryptographicHashes.Hashes hashes = new CryptographicHashes.Hashes(); + public int64 length { get; set; default=-1; } // Length of audio/video in milliseconds + public Gee.List<Xep.JingleContentThumbnails.Thumbnail> thumbnails = new Gee.ArrayList<Xep.JingleContentThumbnails.Thumbnail>(); + + public StanzaNode to_stanza_node() { + StanzaNode node = new StanzaNode.build("file", NS_URI).add_self_xmlns() + .put_node(new StanzaNode.build("name", NS_URI).put_node(new StanzaNode.text(this.name))); + if (this.mime_type != null) { + node.put_node(new StanzaNode.build("media_type", NS_URI).put_node(new StanzaNode.text(this.mime_type))); + } + if (this.size != -1) { + node.put_node(new StanzaNode.build("size", NS_URI).put_node(new StanzaNode.text(this.size.to_string()))); + } + if (this.date != null) { + node.put_node(new StanzaNode.build("date", NS_URI).put_node(new StanzaNode.text(this.date.to_string()))); + } + if (this.desc != null) { + node.put_node(new StanzaNode.build("desc", NS_URI).put_node(new StanzaNode.text(this.desc))); + } + if (this.width != -1) { + node.put_node(new StanzaNode.build("width", NS_URI).put_node(new StanzaNode.text(this.width.to_string()))); + } + if (this.height != -1) { + node.put_node(new StanzaNode.build("height", NS_URI).put_node(new StanzaNode.text(this.height.to_string()))); + } + if (this.length != -1) { + node.put_node(new StanzaNode.build("length", NS_URI).put_node(new StanzaNode.text(this.length.to_string()))); + } + node.sub_nodes.add_all(this.hashes.to_stanza_nodes()); + foreach (Xep.JingleContentThumbnails.Thumbnail thumbnail in this.thumbnails) { + node.put_node(thumbnail.to_stanza_node()); + } + return node; + } + + public void add_to_message(MessageStanza message) { + StanzaNode node = this.to_stanza_node(); + printerr("Attaching file metadata:\n"); + printerr("%s\n", node.to_ansi_string(true)); + message.stanza.put_node(node); + } + + public static FileMetadata? from_stanza_node(StanzaNode node) { + FileMetadata metadata = new FileMetadata(); + // TODO: null checks on final values + StanzaNode? name_node = node.get_subnode("name"); + if (name_node == null || name_node.get_string_content() == null) { + return null; + } else { + metadata.name = name_node.get_string_content(); + } + StanzaNode? desc_node = node.get_subnode("desc"); + if (desc_node != null && desc_node.get_string_content() != null) { + metadata.desc = desc_node.get_string_content(); + } + StanzaNode? mime_node = node.get_subnode("media_type"); + if (mime_node != null && mime_node.get_string_content() != null) { + metadata.mime_type = mime_node.get_string_content(); + } + StanzaNode? size_node = node.get_subnode("size"); + if (size_node != null && size_node.get_string_content() != null) { + metadata.size = int64.parse(size_node.get_string_content()); + } + StanzaNode? date_node = node.get_subnode("date"); + if (date_node != null && date_node.get_string_content() != null) { + metadata.date = new DateTime.from_iso8601(date_node.get_string_content(), null); + } + StanzaNode? width_node = node.get_subnode("width"); + if (width_node != null && width_node.get_string_content() != null) { + metadata.width = int.parse(width_node.get_string_content()); + } + StanzaNode? height_node = node.get_subnode("height"); + if (height_node != null && height_node.get_string_content() != null) { + metadata.height = int.parse(height_node.get_string_content()); + } + StanzaNode? length_node = node.get_subnode("length"); + if (length_node != null && length_node.get_string_content() != null) { + metadata.length = int.parse(length_node.get_string_content()); + } + foreach (StanzaNode thumbnail_node in node.get_subnodes(Xep.JingleContentThumbnails.STANZA_NAME, Xep.JingleContentThumbnails.NS_URI)) { + Xep.JingleContentThumbnails.Thumbnail? thumbnail = Xep.JingleContentThumbnails.Thumbnail.from_stanza_node(thumbnail_node); + if (thumbnail != null) { + metadata.thumbnails.add(thumbnail); + } + } + metadata.hashes = new CryptographicHashes.Hashes.from_stanza_subnodes(node); + return metadata; + } + + public static FileMetadata? from_message(MessageStanza message) { + StanzaNode? node = message.stanza.get_subnode("file", NS_URI); + if (node == null) { + return null; + } + printerr("Parsing metadata from message:\n"); + printerr("%s\n", node.to_xml()); + FileMetadata metadata = FileMetadata.from_stanza_node(node); + if (metadata != null) { + printerr("Parsed metadata:\n"); + printerr("%s\n", metadata.to_stanza_node().to_ansi_string(true)); + } else { + printerr("Failed to parse metadata!\n"); + } + return FileMetadata.from_stanza_node(node); + } + } +} diff --git a/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala b/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala new file mode 100644 index 00000000..285bfe78 --- /dev/null +++ b/xmpp-vala/src/module/xep/0447_stateless_file_sharing.vala @@ -0,0 +1,225 @@ +using Xmpp; + +namespace Xmpp.Xep.StatelessFileSharing { + + + public const string STANZA_NAME = "file-transfer"; + + public interface SfsSource: Object { + public abstract string type(); + public abstract string serialize(); + + public abstract StanzaNode to_stanza_node(); + } + + public class HttpSource: Object, SfsSource { + public string url; + + public const string HTTP_NS_URI = "http://jabber.org/protocol/url-data"; + public const string HTTP_STANZA_NAME = "url-data"; + public const string HTTP_URL_ATTRIBUTE = "target"; + public const string SOURCE_TYPE = "http"; + + public string type() { + return SOURCE_TYPE; + } + + public string serialize() { + return this.to_stanza_node().to_xml(); + } + + public StanzaNode to_stanza_node() { + StanzaNode node = new StanzaNode.build(HTTP_STANZA_NAME, HTTP_NS_URI).add_self_xmlns(); + node.put_attribute(HTTP_URL_ATTRIBUTE, this.url); + return node; + } + + public static async HttpSource deserialize(string data) { + StanzaNode node = yield new StanzaReader.for_string(data).read_stanza_node(); + HttpSource source = HttpSource.from_stanza_node(node); + assert(source != null); + return source; + } + + public static HttpSource? from_stanza_node(StanzaNode node) { + string? url = node.get_attribute(HTTP_URL_ATTRIBUTE); + if (url == null) { + return null; + } + HttpSource source = new HttpSource(); + source.url = url; + return source; + } + + public static Gee.List<HttpSource> extract_sources(StanzaNode node) { + Gee.List<HttpSource> sources = new Gee.ArrayList<HttpSource>(); + foreach (StanzaNode http_node in node.get_subnodes(HTTP_STANZA_NAME, HTTP_NS_URI)) { + HttpSource? source = HttpSource.from_stanza_node(http_node); + if (source != null) { + sources.add(source); + } + } + return sources; + } + } + + public class SfsElement { + public Xep.FileMetadataElement.FileMetadata metadata = new Xep.FileMetadataElement.FileMetadata(); + public Gee.List<SfsSource> sources = new Gee.ArrayList<SfsSource>(); + + public static SfsElement? from_stanza_node(StanzaNode node) { + SfsElement element = new SfsElement(); + StanzaNode? metadata_node = node.get_subnode("file", Xep.FileMetadataElement.NS_URI); + if (metadata_node == null) { + return null; + } + Xep.FileMetadataElement.FileMetadata metadata = Xep.FileMetadataElement.FileMetadata.from_stanza_node(metadata_node); + if (metadata == null) { + return null; + } + element.metadata = metadata; + StanzaNode? sources_node = node.get_subnode("sources"); + if (sources_node == null) { + return null; + } + Gee.List<HttpSource> sources = HttpSource.extract_sources(sources_node); + if (sources.is_empty) { + return null; + } + element.sources = sources; + return element; + } + + public StanzaNode to_stanza_node() { + StanzaNode node = new StanzaNode.build(STANZA_NAME, NS_URI).add_self_xmlns(); + node.put_node(this.metadata.to_stanza_node()); + StanzaNode sources_node = new StanzaNode.build("sources", NS_URI); + Gee.List<StanzaNode> sources = new Gee.ArrayList<StanzaNode>(); + foreach (SfsSource source in this.sources) { + sources.add(source.to_stanza_node()); + } + sources_node.sub_nodes = sources; + node.put_node(sources_node); + return node; + } + } + + public class SfsSourceAttachment { + public string sfs_id; + public Gee.List<SfsSource> sources = new Gee.ArrayList<SfsSource>(); + + public const string ATTACHMENT_NS_URI = "urn:xmpp:message-attaching:1"; + public const string ATTACH_TO_STANZA_NAME = "attach-to"; + public const string SOURCES_STANZA_NAME = "sources"; + public const string ID_ATTRIBUTE_NAME = "id"; + + + public static SfsSourceAttachment? from_message_stanza(MessageStanza stanza) { + StanzaNode? attach_to = stanza.stanza.get_subnode(ATTACH_TO_STANZA_NAME, ATTACHMENT_NS_URI); + StanzaNode? sources = stanza.stanza.get_subnode(SOURCES_STANZA_NAME, NS_URI); + if (attach_to == null || sources == null) { + return null; + } + string? id = attach_to.get_attribute(ID_ATTRIBUTE_NAME, ATTACHMENT_NS_URI); + if (id == null) { + return null; + } + SfsSourceAttachment attachment = new SfsSourceAttachment(); + attachment.sfs_id = id; + Gee.List<HttpSource> http_sources = HttpSource.extract_sources(sources); + if (http_sources.is_empty) { + return null; + } + attachment.sources = http_sources; + return attachment; + } + + public MessageStanza to_message_stanza(Jid to, string message_type) { + MessageStanza stanza = new MessageStanza() { to=to, type_=message_type }; + Xep.MessageProcessingHints.set_message_hint(stanza, Xep.MessageProcessingHints.HINT_STORE); + + StanzaNode attach_to = new StanzaNode.build(ATTACH_TO_STANZA_NAME, ATTACHMENT_NS_URI); + attach_to.add_attribute(new StanzaAttribute.build(ATTACHMENT_NS_URI, "id", this.sfs_id)); + stanza.stanza.put_node(attach_to); + + StanzaNode sources = new StanzaNode.build(SOURCES_STANZA_NAME, NS_URI); + Gee.List<StanzaNode> sources_nodes = new Gee.ArrayList<StanzaNode>(); + foreach (SfsSource source in this.sources) { + sources_nodes.add(source.to_stanza_node()); + } + sources.sub_nodes = sources_nodes; + stanza.stanza.put_node(sources); + + return stanza; + } + } + + public class MessageFlag : Xmpp.MessageFlag { + public const string ID = "stateless_file_sharing"; + + public static MessageFlag? get_flag(MessageStanza message) { + return (MessageFlag) message.get_flag(NS_URI, ID); + } + + public override string get_ns() { + return NS_URI; + } + + public override string get_id() { + return ID; + } + } + + public class Module : XmppStreamModule { + public static ModuleIdentity<Module> IDENTITY = new ModuleIdentity<Module>(NS_URI, "stateless_file_sharing"); + + public signal void received_sfs(Jid from, Jid to, SfsElement sfs_element, MessageStanza message); + public signal void received_sfs_attachment(Jid from, Jid to, SfsSourceAttachment attachment, MessageStanza message); + + public void send_stateless_file_transfer(XmppStream stream, MessageStanza sfs_message, SfsElement sfs_element) { + StanzaNode sfs_node = sfs_element.to_stanza_node(); + printerr(sfs_node.to_ansi_string(true)); + + sfs_message.stanza.put_node(sfs_node); + printerr("Sending message:\n"); + printerr(sfs_message.stanza.to_ansi_string(true)); + stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, sfs_message); + } + + public void send_stateless_file_transfer_attachment(XmppStream stream, Jid to, string message_type, SfsSourceAttachment attachment) { + MessageStanza message = attachment.to_message_stanza(to, message_type); + + printerr("Sending message:\n"); + printerr(message.stanza.to_ansi_string(true)); + stream.get_module(MessageModule.IDENTITY).send_message.begin(stream, message); + } + + private void on_received_message(XmppStream stream, MessageStanza message) { + StanzaNode? sfs_node = message.stanza.get_subnode(STANZA_NAME, NS_URI); + if (sfs_node != null) { + SfsElement? sfs_element = SfsElement.from_stanza_node(sfs_node); + if (sfs_element == null) { + return; + } + message.add_flag(new MessageFlag()); + received_sfs(message.from, message.to, sfs_element, message); + } + SfsSourceAttachment? attachment = SfsSourceAttachment.from_message_stanza(message); + if (attachment != null) { + received_sfs_attachment(message.from, message.to, attachment, message); + } + + } + + public override void attach(XmppStream stream) { + stream.get_module(MessageModule.IDENTITY).received_message.connect(on_received_message); + } + + public override void detach(XmppStream stream) { + stream.get_module(MessageModule.IDENTITY).received_message.disconnect(on_received_message); + } + + public override string get_ns() { return NS_URI; } + public override string get_id() { return IDENTITY.id; } + } +} |