using Gdk; using Gee; using Xmpp; using Xmpp.Xep; using Dino.Entities; namespace Dino { public class FileManager : StreamInteractionModule, Object { public static ModuleIdentity IDENTITY = new ModuleIdentity("file"); public string id { get { return IDENTITY.id; } } public signal void upload_available(Account account); public signal void received_file(FileTransfer file_transfer, Conversation conversation); private StreamInteractor stream_interactor; private Database db; private Gee.List file_senders = new ArrayList(); private Gee.List file_encryptors = new ArrayList(); private Gee.List file_decryptors = new ArrayList(); private Gee.List file_providers = new ArrayList(); private Gee.List file_metadata_providers = new ArrayList(); public static void start(StreamInteractor stream_interactor, Database db) { FileManager m = new FileManager(stream_interactor, db); stream_interactor.add_module(m); } public static string get_storage_dir() { return Path.build_filename(Dino.get_storage_dir(), "files"); } private FileManager(StreamInteractor stream_interactor, Database db) { this.stream_interactor = stream_interactor; this.db = db; DirUtils.create_with_parents(get_storage_dir(), 0700); 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; } // 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 get_file_size_limits(Conversation conversation) { HashMap ret = new HashMap(); foreach (FileSender sender in file_senders) { ret[sender.get_id()] = yield sender.get_file_size_limit(conversation); } return ret; } public async void send_file(File file, Conversation conversation) { FileTransfer file_transfer = new FileTransfer(); file_transfer.account = conversation.account; file_transfer.counterpart = 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; } else { file_transfer.ourpart = conversation.account.full_jid; } file_transfer.direction = FileTransfer.DIRECTION_SENT; file_transfer.time = new DateTime.now_utc(); 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 { file_transfer.input_stream = yield file.read_async(); yield save_file(file_transfer); stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); conversation.last_active = file_transfer.time; received_file(file_transfer, conversation); } catch (Error e) { file_transfer.state = FileTransfer.State.FAILED; warning("Error saving outgoing file: %s", e.message); return; } try { var file_meta = new FileMeta(); file_meta.size = file_transfer.size; file_meta.mime_type = file_transfer.mime_type; FileSender file_sender = null; FileEncryptor file_encryptor = null; foreach (FileSender sender in file_senders) { if (yield sender.can_send(conversation, file_transfer)) { if (file_transfer.encryption == Encryption.NONE || yield sender.can_encrypt(conversation, file_transfer)) { file_sender = sender; break; } else { foreach (FileEncryptor encryptor in file_encryptors) { if (encryptor.can_encrypt_file(conversation, file_transfer)) { file_encryptor = encryptor; break; } } if (file_encryptor != null) { file_sender = sender; break; } } } } if (file_sender == null) { throw new FileSendError.UPLOAD_FAILED("No sender/encryptor combination available"); } if (file_encryptor != null) { file_meta = file_encryptor.encrypt_file(conversation, file_transfer); } FileSendData file_send_data = yield file_sender.prepare_send_file(conversation, file_transfer, file_meta); if (file_encryptor != null) { file_send_data = file_encryptor.preprocess_send_file(conversation, file_transfer, file_send_data, file_meta); } yield file_sender.send_file(conversation, file_transfer, file_send_data, file_meta); } catch (Error e) { warning("Send file error: %s", e.message); file_transfer.state = FileTransfer.State.FAILED; } } 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 = this.select_file_provider(file_transfer); yield download_file_internal(file_provider, file_transfer, conversation); } public async bool is_upload_available(Conversation? conversation) { if (conversation == null) return false; foreach (FileSender file_sender in file_senders) { if (yield file_sender.is_upload_available(conversation)) return true; } return false; } 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) => { 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); } }); } public void add_sender(FileSender file_sender) { file_senders.add(file_sender); file_sender.upload_available.connect((account) => { upload_available(account); }); file_senders.sort((a, b) => { return (int) (b.get_priority() - a.get_priority()); }); } public void add_file_encryptor(FileEncryptor encryptor) { file_encryptors.add(encryptor); } public void add_file_decryptor(FileDecryptor decryptor) { file_decryptors.add(decryptor); } 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(from, conversation.account); } if (relevant_jid == null) return false; bool in_roster = stream_interactor.get_module(RosterManager.IDENTITY).get_roster_item(conversation.account, relevant_jid) != null; 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); if (file_meta.size == -1) { foreach (FileDecryptor file_decryptor in file_decryptors) { if (file_decryptor.can_decrypt_file(conversation, file_transfer, receive_data)) { receive_data = file_decryptor.prepare_get_meta_info(conversation, file_transfer, receive_data); break; } } file_meta = yield file_provider.get_meta_info(file_transfer, receive_data, file_meta); file_transfer.size = (int)file_meta.size; file_transfer.file_name = file_meta.file_name; file_transfer.mime_type = file_meta.mime_type; } return file_meta; } private async void download_file_internal(FileProvider file_provider, FileTransfer file_transfer, Conversation conversation) { try { // Get meta info 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)) { file_decryptor = decryptor; break; } } if (file_decryptor != null) { receive_data = file_decryptor.prepare_get_meta_info(conversation, file_transfer, receive_data); } FileMeta file_meta = yield get_file_meta(file_provider, file_transfer, conversation, receive_data); InputStream? input_stream = null; // Download and decrypt file file_transfer.state = FileTransfer.State.IN_PROGRESS; if (file_decryptor != null) { file_meta = file_decryptor.prepare_download_file(conversation, file_transfer, receive_data, file_meta); } input_stream = yield file_provider.download(file_transfer, receive_data, file_meta); if (file_decryptor != null) { input_stream = yield file_decryptor.decrypt_file(input_stream, conversation, file_transfer, receive_data); } // Save file string filename = Random.next_int().to_string("%x") + "_" + file_transfer.file_name; File file = File.new_for_path(Path.build_filename(get_storage_dir(), filename)); OutputStream os = file.create(FileCreateFlags.REPLACE_DESTINATION); uint8[] buffer = new uint8[1024]; ssize_t read; while ((read = yield input_stream.read_async(buffer, Priority.LOW, file_transfer.cancellable)) > 0) { buffer.length = (int) read; yield os.write_async(buffer, Priority.LOW, file_transfer.cancellable); buffer.length = 1024; } yield input_stream.close_async(Priority.LOW, file_transfer.cancellable); yield os.close_async(Priority.LOW, file_transfer.cancellable); file_transfer.path = file.get_basename(); file_transfer.input_stream = yield file.read_async(); FileInfo file_info = file_transfer.get_file().query_info("*", FileQueryInfoFlags.NONE); file_transfer.mime_type = file_info.get_content_type(); file_transfer.state = FileTransfer.State.COMPLETE; } catch (Error e) { warning("Error downloading file: %s", e.message); file_transfer.state = FileTransfer.State.FAILED; } } private async void handle_incoming_file(FileProvider file_provider, string info, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta) { 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 { if (from.equals_bare(conversation.account.bare_jid)) { file_transfer.ourpart = from; file_transfer.direction = FileTransfer.DIRECTION_SENT; } else { file_transfer.ourpart = conversation.account.full_jid; file_transfer.direction = FileTransfer.DIRECTION_RECEIVED; } } file_transfer.time = time; file_transfer.local_time = local_time; file_transfer.provider = file_provider.get_id(); file_transfer.file_name = file_meta.file_name; file_transfer.size = (int)file_meta.size; file_transfer.info = info; var encryption = file_provider.get_encryption(file_transfer, receive_data, file_meta); if (encryption != Encryption.NONE) file_transfer.encryption = encryption; foreach (FileDecryptor decryptor in file_decryptors) { if (decryptor.can_decrypt_file(conversation, file_transfer, receive_data)) { file_transfer.encryption = decryptor.get_encryption(); } } stream_interactor.get_module(FileTransferStorage.IDENTITY).add_file(file_transfer); if (is_sender_trustworthy(file_transfer, conversation)) { try { yield get_file_meta(file_provider, file_transfer, conversation, receive_data); } catch (Error e) { warning("Error downloading file: %s", e.message); file_transfer.state = FileTransfer.State.FAILED; } if (file_transfer.size >= 0 && file_transfer.size < 5000000) { 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 async void save_file(FileTransfer file_transfer) throws FileSendError { try { string filename = Random.next_int().to_string("%x") + "_" + file_transfer.file_name; File file = File.new_for_path(Path.build_filename(get_storage_dir(), filename)); OutputStream os = file.create(FileCreateFlags.REPLACE_DESTINATION); yield os.splice_async(file_transfer.input_stream, OutputStreamSpliceFlags.CLOSE_SOURCE|OutputStreamSpliceFlags.CLOSE_TARGET); file_transfer.state = FileTransfer.State.COMPLETE; file_transfer.path = filename; file_transfer.input_stream = yield file.read_async(); } catch (Error e) { throw new FileSendError.SAVE_FAILED("Saving file error: %s".printf(e.message)); } } } public errordomain FileSendError { ENCRYPTION_FAILED, UPLOAD_FAILED, SAVE_FAILED } public errordomain FileReceiveError { GET_METADATA_FAILED, DECRYPTION_FAILED, DOWNLOAD_FAILED } public class FileMeta { public int64 size = -1; public string? mime_type = null; public string? file_name = null; public Encryption encryption = Encryption.NONE; } public class HttpFileMeta : FileMeta { public Message message; } public class FileSendData { } public class HttpFileSendData : FileSendData { public string url_down { get; set; } public string url_up { get; set; } public HashMap headers { get; set; } public bool encrypt_message { get; set; default=true; } } public class FileReceiveData { } public class HttpFileReceiveData : FileReceiveData { public string url { get; set; } } public interface FileProvider : Object { public signal void file_incoming(string info, Jid from, DateTime time, DateTime local_time, Conversation conversation, FileReceiveData receive_data, FileMeta file_meta); 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 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; public abstract int get_id(); } public interface FileSender : Object { public signal void upload_available(Account account); public abstract async bool is_upload_available(Conversation conversation); public abstract async long get_file_size_limit(Conversation conversation); public abstract async bool can_send(Conversation conversation, FileTransfer file_transfer); public abstract async FileSendData? prepare_send_file(Conversation conversation, FileTransfer file_transfer, FileMeta file_meta) throws FileSendError; public abstract async void send_file(Conversation conversation, FileTransfer file_transfer, FileSendData file_send_data, FileMeta file_meta) throws FileSendError; public abstract async bool can_encrypt(Conversation conversation, FileTransfer file_transfer); public abstract int get_id(); public abstract float get_priority(); } public interface FileEncryptor : Object { public abstract bool can_encrypt_file(Conversation conversation, FileTransfer file_transfer); public abstract FileMeta encrypt_file(Conversation conversation, FileTransfer file_transfer) throws FileSendError; public abstract FileSendData? preprocess_send_file(Conversation conversation, FileTransfer file_transfer, FileSendData file_send_data, FileMeta file_meta) throws FileSendError; } public interface FileDecryptor : Object { public abstract Encryption get_encryption(); public abstract FileReceiveData prepare_get_meta_info(Conversation conversation, FileTransfer file_transfer, FileReceiveData receive_data); public abstract FileMeta prepare_download_file(Conversation conversation, FileTransfer file_transfer, FileReceiveData receive_data, FileMeta file_meta); public abstract bool can_decrypt_file(Conversation conversation, FileTransfer file_transfer, FileReceiveData receive_data); 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; } } }