| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-07-21 14:53:25 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-07-21 14:53:25 UTC |
| parent | 86f24e44d6c4904e847ecfdbba4bd6ef3dd02e5c |
| borogove/Client.hx | +54 | -38 |
| borogove/Source.hx | +1 | -1 |
| borogove/XEP0454.hx | +24 | -8 |
| borogove/persistence/IDB.js | +2 | -1 |
| borogove/persistence/Sqlite.hx | +3 | -1 |
| borogove/queries/HttpUploadSlot.hx | +1 | -4 |
diff --git a/borogove/Client.hx b/borogove/Client.hx index 8a14c8f..80f8811 100644 --- a/borogove/Client.hx +++ b/borogove/Client.hx @@ -1120,24 +1120,24 @@ class Client extends EventEmitter { // We already have it if (attachment.cachedAt != null) return Promise.resolve(attachment); - return fetchUris( - attachment.uris.copy(), - attachment.hashes.find(h -> h.algorithm == "sha-256") ?? attachment.hashes[0] - ).then(id -> { + return fetchUris( attachment.uris.copy()).then(id -> { attachment.cachedAt = id; return attachment; }); } - private function fetchUris(uris: Array<String>, hash: Null<Hash>): Promise<Null<String>> { + private function fetchUris(uris: Array<String>): Promise<Null<String>> { if (uris.length < 1) return Promise.resolve(null); final uri = uris.shift(); final aesgcm = XEP0454.parse(uri); if (aesgcm != null) { - return XEP0454.fetch(aesgcm, hash).then( + return XEP0454.fetch(aesgcm).then( data -> persistence.storeMedia(aesgcm.mime, data), - e -> fetchUris(uris, hash) + e -> { + trace("fetchAttachment", e); + return fetchUris(uris); + } ); } @@ -1159,59 +1159,75 @@ class Client extends EventEmitter { }); }).then( r -> persistence.storeMedia(r.mime, r.body), - e -> fetchUris(uris, hash) + e -> fetchUris(uris) ); } - return fetchUris(uris, hash); + return fetchUris(uris); } /** Turn a file into a ChatAttachment for attaching to a ChatMessage @param source The AttachmentSource to use - @returns Promise resolving to a ChatAttachment or null + @returns Promise resolving to a ChatAttachment **/ - public function prepareAttachment(source: AttachmentSource): Promise<Null<ChatAttachment>> { + public function prepareAttachment(source: AttachmentSource): Promise<ChatAttachment> { return persistence.findServicesWithFeature(accountId(), "urn:xmpp:http:upload:0").then((services) -> { - final sha256 = new Sha256(); - return new Promise((resolve, reject) -> { - source.tinkSource().chunked().forEach((chunk) -> { - sha256.update(chunk); - return tink.streams.Stream.Handled.Resume; - }).handle((o) -> switch o { - case Depleted: - prepareAttachmentFor(source, services, [new Hash("sha-256", sha256.digest().getData())], resolve); - default: - trace("Error computing attachment hash", o); - reject(o); - }); + final sha256 = Hash.sha256incr(); + final httpPut = (tsource: tink.io.Source.RealSource, size: Int) -> { + return prepareAttachmentFor( + tsource, + source.name, + size, + source.type, + services + ); + }; + + final tinkSource = source.tinkSource().chunked().map((chunk) -> { + sha256.update((chunk : Bytes).getData()); + return chunk; }); + + // Giant attachments go unencrypted for now... + return thenshim.PromiseTools.all(([ + if (source.size < 1000000000) { + XEP0454.put(tinkSource, httpPut).then(o -> new ChatAttachment(source.name, source.type, o.size, [o.uri], [sha256.digest()])); + } else { + httpPut(tinkSource, source.size).then(uri -> new ChatAttachment(source.name, source.type, source.size, [uri], [sha256.digest()])); + }, + persistence.storeMedia(source.type, Source.ofTinkSource(tinkSource)) + ] : Array<Dynamic>)).then(results -> results[0]); }); } - private function prepareAttachmentFor(source: AttachmentSource, services: Array<{ serviceId: String }>, hashes: Array<Hash>, callback: (Null<ChatAttachment>)->Void) { + private function prepareAttachmentFor(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>): Promise<String> { if (services.length < 1) { trace("No HTTP upload service found"); - callback(null); - return; + return Promise.reject("failed"); } - final httpUploadSlot = new HttpUploadSlot(services[0].serviceId, source.name, source.size, source.type, hashes); - httpUploadSlot.onFinished(() -> { - final slot = httpUploadSlot.getResult(); - if (slot == null) { - prepareAttachmentFor(source, services.slice(1), hashes, callback); - } else { - tink.http.Client.fetch(slot.put, { method: PUT, headers: slot.putHeaders.concat([new tink.http.Header.HeaderField("Content-Length", source.size)]), body: tink.io.Source.RealSourceTools.idealize(source.tinkSource(), (e) -> { trace("prepareAttachmentFor ERROR", e); throw e; }) }).all() - .handle((o) -> switch o { + final httpUploadSlot = new HttpUploadSlot(services[0].serviceId, name, size, mime); + return new Promise((resolve, reject) -> { + httpUploadSlot.onFinished(() -> { + final slot = httpUploadSlot.getResult(); + if (slot == null) { + prepareAttachmentFor(source, name, size, mime, services.slice(1)).then(resolve, reject); + } else { + tink.http.Client.fetch( slot.put, { + method: PUT, + headers: slot.putHeaders.concat([new tink.http.Header.HeaderField("Content-Length", size)]), + body: tink.io.Source.RealSourceTools.idealize(source, (e) -> { reject(e); throw e; }) + }).all().handle((o) -> switch o { case Success(res) if (res.header.statusCode == 201): - callback(new ChatAttachment(source.name, source.type, source.size, [slot.get], hashes)); + resolve(slot.get); default: - prepareAttachmentFor(source, services.slice(1), hashes, callback); + prepareAttachmentFor(source, name, size, mime, services.slice(1)).then(resolve, reject); }); - } + } + }); + sendQuery(httpUploadSlot); }); - sendQuery(httpUploadSlot); } /** diff --git a/borogove/Source.hx b/borogove/Source.hx index 53fc8a6..dbc87c9 100644 --- a/borogove/Source.hx +++ b/borogove/Source.hx @@ -29,7 +29,7 @@ abstract Source(UnderlyingSource) { #end } - @:from static function ofTinkSource(source: RealSource) { + @:from public static function ofTinkSource(source: RealSource) { #if js var stream: ReadableStream = null; stream = new ReadableStream({ diff --git a/borogove/XEP0454.hx b/borogove/XEP0454.hx index 6585da4..4f8fb18 100644 --- a/borogove/XEP0454.hx +++ b/borogove/XEP0454.hx @@ -5,7 +5,7 @@ import thenshim.Promise; using StringTools; -function fetch(aesgcm: AesGcm, hash: Null<Hash> = null): Promise<Bytes> { +function fetch(aesgcm: AesGcm): Promise<Bytes> { // Fetch all data into memory because AES-GCM APIs really want to verify // everything before giving access to plaintext. // TODO: This means we ought to refuse to download if the size is too big: @@ -18,13 +18,6 @@ function fetch(aesgcm: AesGcm, hash: Null<Hash> = null): Promise<Bytes> { reject(e); }); }).then((encrypted) -> { - if (hash != null) { - final compare = Hash.mk(hash.algorithm, encrypted); - if (compare != null && !hash.equals(compare)) { - throw "Hash mismatch: " + hash + " != " + compare; - } - } - #if js final subtle: js.html.SubtleCrypto = untyped globalThis.crypto.subtle; return (subtle.importKey("raw", aesgcm.key.getData(), "AES-GCM", false, ["decrypt"]).then(key -> @@ -37,6 +30,29 @@ function fetch(aesgcm: AesGcm, hash: Null<Hash> = null): Promise<Bytes> { }); } +function put(source: tink.io.Source.RealSource, httpPut: (tink.io.Source.RealSource, Int)->Promise<String>): Promise<{ uri: String, size: Int }> { + final iv = haxe.crypto.random.SecureRandom.bytes(12); + final key = haxe.crypto.random.SecureRandom.bytes(32); + + return new Promise((resolve, reject) -> { + tink.io.Source.RealSourceTools.all(source).handle(o -> switch o { + case Success(bytes): resolve((bytes : Bytes)); + case Failure(e): reject(e); + }); + }).then(bytes -> { + #if js + final subtle: js.html.SubtleCrypto = untyped globalThis.crypto.subtle; + final encryptedP: Promise<Bytes> = subtle.importKey("raw", key.getData(), "AES-GCM", false, ["encrypt"]).then(key -> + subtle.encrypt({ name: "AES-GCM", iv: iv.getData() }, key, bytes.getData()) + ).then(encrypted -> Bytes.ofData(encrypted)); + #else + final aes = new haxe.crypto.Aes(key, iv); + final encryptedP = Promise.resolve(aes.encrypt(haxe.crypto.mode.Mode.GCM, bytes, Bytes.alloc(0), 16)); + #end + return encryptedP.then(encrypted -> httpPut(encrypted, encrypted.length).then(uri -> ({ uri: ~/^https?:\/\//.map(uri, _ -> "aesgcm://") + "#" + iv.toHex() + key.toHex(), size: encrypted.length }))); + }); +} + typedef AesGcm = { https: String, iv: haxe.io.Bytes, diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js index 44c2f1f..cef1d01 100644 --- a/borogove/persistence/IDB.js +++ b/borogove/persistence/IDB.js @@ -337,7 +337,7 @@ export default async (dbname, media, tokenize, stemmer) => { message.to = value.to ? borogove_JID.parse(value.to) : message.recipients[0]; message.replyTo = value.replyTo.map((r) => borogove_JID.parse(r)); message.threadId = value.threadId; - message.attachments = (value.attachments ?? []).map(a => new borogove_ChatAttachment(a.name, a.mime, a.size, a.uris, a.hashes)); + message.attachments = (value.attachments ?? []).map(a => new borogove_ChatAttachment(a.name, a.mime, a.size, a.uris, (a.hashes ?? []).map(h => new borogove_Hash(h.algorithm, h.hash)))); message.linkMetadata = value.linkMetadata ?? []; message.reactions = hydrateReactions(value.reactions, message.timestamp); message.text = value.text; @@ -878,6 +878,7 @@ tx.onerror = console.error; } else { store.put(toPut).onerror = console.error; } + await Promise.all(message.attachments.map(a => a.lookup(this))); return message; } diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx index f59a7e5..24d11a9 100644 --- a/borogove/persistence/Sqlite.hx +++ b/borogove/persistence/Sqlite.hx @@ -732,7 +732,9 @@ class Sqlite implements Persistence implements KeyValueStore { ).then(_ -> thenshim.PromiseTools.all(messages.map(m -> fetchFromStub(accountId, m))) ).then(ms -> - hydrateReplyTo(accountId, ms, replyTos) + thenshim.PromiseTools.all(ms.flatMap(m -> m.attachments.map(a -> a.lookup(this)))).then(_ -> + hydrateReplyTo(accountId, ms, replyTos) + ) ).then(ms -> hydrateReactions(accountId, ms) ) diff --git a/borogove/queries/HttpUploadSlot.hx b/borogove/queries/HttpUploadSlot.hx index df0b574..fab3529 100644 --- a/borogove/queries/HttpUploadSlot.hx +++ b/borogove/queries/HttpUploadSlot.hx @@ -19,16 +19,13 @@ class HttpUploadSlot extends GenericQuery { public var responseStanza(default, null):Stanza; private var result: { put: String, putHeaders: Array<tink.http.Header.HeaderField>, get: String }; - public function new(to: String, filename: String, size: Int, mime: String, hashes: Array<Hash>) { + public function new(to: String, filename: String, size: Int, mime: String) { /* Build basic query */ queryId = ID.unique(); queryStanza = new Stanza( "iq", { to: to, type: "get", id: queryId } ).tag("request", { xmlns: xmlns, filename: filename, size: Std.string(size), "content-type": mime }); - for (hash in hashes) { - queryStanza.textTag("hash", Base64.encode(Bytes.ofData(hash.hash)), { xmlns: "urn:xmpp:hashes:2", algo: hash.algorithm }); - } queryStanza.up(); }