| author | Eric Roberts
<eric@devl.me> 2026-08-21 17:26:57 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-08-21 20:52:19 UTC |
| parent | 706388c897bd1ee58e14d7b8b0dca9cddc2aa8a3 |
| borogove/AttachmentUploadError.hx | +26 | -0 |
| borogove/AttachmentUploadErrorCode.hx | +10 | -0 |
| borogove/Client.hx | +83 | -13 |
| browserjs.hxml | +2 | -0 |
| nodejs.hxml | +2 | -0 |
| npm/index.ts | +2 | -0 |
| test/FakeHttpClient.hx | +38 | -0 |
| test/TestClient.hx | +129 | -0 |
diff --git a/borogove/AttachmentUploadError.hx b/borogove/AttachmentUploadError.hx new file mode 100644 index 0000000..7217cb9 --- /dev/null +++ b/borogove/AttachmentUploadError.hx @@ -0,0 +1,26 @@ +package borogove; + +/** + Describes a failure while obtaining or using an HTTP Upload slot. + + When `code` is `all-services-failed`, `failures` contains the per-service + errors in discovery order. A `no-service` error has an empty `failures` + array because no upload service was discovered. +**/ +@:expose +class AttachmentUploadError extends haxe.Exception { + public final code: AttachmentUploadErrorCode; + public final serviceId: Null<String>; + public final statusCode: Null<Int>; + public final cause: Dynamic; + public final failures: Array<AttachmentUploadError>; + + public function new(code: AttachmentUploadErrorCode, message: String, ?serviceId: String, ?statusCode: Int, ?cause: Dynamic, ?failures: Array<AttachmentUploadError>) { + super(message); + this.code = code; + this.serviceId = serviceId; + this.statusCode = statusCode; + this.cause = cause; + this.failures = failures ?? []; + } +} diff --git a/borogove/AttachmentUploadErrorCode.hx b/borogove/AttachmentUploadErrorCode.hx new file mode 100644 index 0000000..38b1aed --- /dev/null +++ b/borogove/AttachmentUploadErrorCode.hx @@ -0,0 +1,10 @@ +package borogove; + +@:expose +enum abstract AttachmentUploadErrorCode(String) from String to String { + var NoService = "no-service"; + var InvalidSlot = "invalid-slot"; + var HttpFailure = "http-failure"; + var NetworkFailure = "network-failure"; + var AllServicesFailed = "all-services-failed"; +} diff --git a/borogove/Client.hx b/borogove/Client.hx index b062fc6..1e47b58 100644 --- a/borogove/Client.hx +++ b/borogove/Client.hx @@ -121,7 +121,6 @@ class Client extends EventEmitter { private var rosterVer: Null<String> = null; private final pendingCaps: Map<String, Array<(Null<Caps>)->Chat>> = []; private final brokenAvatars: Map<String, JID> = []; - #if !NO_OMEMO @:allow(borogove) private final omemo: OMEMO; @@ -1217,6 +1216,9 @@ class Client extends EventEmitter { @param source The AttachmentSource to use @param encrypt Should the data be encrypted with a fresh key? @returns Promise resolving to a ChatAttachment + @throws AttachmentUploadError when no upload service is available or all + discovered services fail. Inspect `code` and, for + `all-services-failed`, the per-service `failures`. **/ public function prepareAttachment(source: AttachmentSource, encrypt: Bool = true): Promise<ChatAttachment> { return persistence.findServicesWithFeature(accountId(), "urn:xmpp:http:upload:0").then((services) -> { @@ -1252,27 +1254,45 @@ class Client extends EventEmitter { }); } - private function prepareAttachmentFor(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>): Promise<String> { + private function prepareAttachmentFor(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>, ?httpClient: tink.http.Client.ClientObject): Promise<String> { if (services.length < 1) { trace("No HTTP upload service found"); - return Promise.reject("failed"); + return Promise.reject(new AttachmentUploadError( + NoService, + "No HTTP Upload service was discovered" + )); } - final httpUploadSlot = new HttpUploadSlot(services[0].serviceId, name, size, mime); + final serviceId = services[0].serviceId; + final httpUploadSlot = new HttpUploadSlot(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); + tryNextAttachmentService(source, name, size, mime, services, new AttachmentUploadError( + InvalidSlot, + "HTTP Upload service returned an invalid or missing upload slot", + serviceId + ), httpClient).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): + putAttachment(slot.put, slot.putHeaders, source, size, httpClient).then(statusCode -> { + if (statusCode == 201) { resolve(slot.get); - default: - prepareAttachmentFor(source, name, size, mime, services.slice(1)).then(resolve, reject); + } else { + tryNextAttachmentService(source, name, size, mime, services, new AttachmentUploadError( + HttpFailure, + "HTTP Upload PUT failed with status " + statusCode, + serviceId, + statusCode + ), httpClient).then(resolve, reject); + } + }, e -> { + tryNextAttachmentService(source, name, size, mime, services, new AttachmentUploadError( + NetworkFailure, + "HTTP Upload PUT request failed", + serviceId, + null, + e + ), httpClient).then(resolve, reject); }); } }); @@ -1280,6 +1300,56 @@ class Client extends EventEmitter { }); } + private function putAttachment(url: String, headers: Array<tink.http.Header.HeaderField>, source: tink.io.Source.RealSource, size: Int, ?httpClient: tink.http.Client.ClientObject): Promise<Int> { + return new Promise((resolve, reject) -> { + tink.http.Client.fetch(url, { + method: PUT, + headers: headers.concat([new tink.http.Header.HeaderField("Content-Length", size)]), + body: tink.io.Source.RealSourceTools.idealize(source, (e) -> throw e), + client: httpClient == null ? null : Custom(httpClient) + }).handle(o -> switch o { + case Success(res): + tink.io.Source.RealSourceTools.all(res.body).handle(body -> switch body { + case Success(_): resolve(cast res.header.statusCode); + case Failure(e): reject(e); + }); + case Failure(e): reject(e); + }); + }); + } + + private function tryNextAttachmentService(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>, failure: AttachmentUploadError, ?httpClient: tink.http.Client.ClientObject): Promise<String> { + final remaining = services.slice(1); + if (remaining.length < 1) { + return Promise.reject(new AttachmentUploadError( + AllServicesFailed, + "All discovered HTTP Upload services failed", + null, + null, + failure.cause, + [failure] + )); + } + + return prepareAttachmentFor(source, name, size, mime, remaining, httpClient).then(result -> result, e -> { + final next = Std.isOfType(e, AttachmentUploadError) ? cast e : new AttachmentUploadError( + NetworkFailure, + "HTTP Upload request failed", + null, + null, + e + ); + return Promise.reject(new AttachmentUploadError( + AllServicesFailed, + "All discovered HTTP Upload services failed", + null, + null, + next.cause, + [failure].concat(next.code == AllServicesFailed ? next.failures : [next]) + )); + }); + } + /** @returns array of open chats, sorted by last activity **/ diff --git a/browserjs.hxml b/browserjs.hxml index c762a2c..2f9d26d 100644 --- a/browserjs.hxml +++ b/browserjs.hxml @@ -11,6 +11,8 @@ --library uuidv7 borogove.Client +borogove.AttachmentUploadError +borogove.AttachmentUploadErrorCode borogove.Register borogove.Push borogove.Version diff --git a/nodejs.hxml b/nodejs.hxml index 58e8b97..e628e7a 100644 --- a/nodejs.hxml +++ b/nodejs.hxml @@ -12,6 +12,8 @@ --library uuidv7 borogove.Client +borogove.AttachmentUploadError +borogove.AttachmentUploadErrorCode borogove.Register borogove.Push borogove.Version diff --git a/npm/index.ts b/npm/index.ts index 19307a1..a23b8a1 100644 --- a/npm/index.ts +++ b/npm/index.ts @@ -1,4 +1,5 @@ export { + borogove_AttachmentUploadErrorCode as AttachmentUploadErrorCode, borogove_ChatMessageEvent as ChatMessageEvent, borogove_EncryptionStatus as EncryptionStatus, borogove_MessageDirection as MessageDirection, @@ -11,6 +12,7 @@ export { } from "./borogove-enums.js"; export { borogove_AvailableChat as AvailableChat, + borogove_AttachmentUploadError as AttachmentUploadError, borogove_AvailableChatIterator as AvailableChatIterator, borogove_Channel as Channel, borogove_Chat as Chat, diff --git a/test/FakeHttpClient.hx b/test/FakeHttpClient.hx new file mode 100644 index 0000000..604c0ec --- /dev/null +++ b/test/FakeHttpClient.hx @@ -0,0 +1,38 @@ +package test; + +import tink.http.Client.ClientObject; +import tink.http.Request.OutgoingRequest; +import tink.http.Response.IncomingResponse; +import tink.core.Promise; + +class FakeHttpClient implements ClientObject { + public final requests:Array<OutgoingRequest> = []; + final handler:OutgoingRequest->Promise<IncomingResponse>; + + public function new(handler:OutgoingRequest->Promise<IncomingResponse>) { + this.handler = handler; + } + + public function request(request:OutgoingRequest):Promise<IncomingResponse> { + requests.push(request); + return handler(request); + } + + public static function response(statusCode:Int, ?body:String):Promise<IncomingResponse> { + return Promise.resolve(new IncomingResponse( + new tink.http.Response.ResponseHeader(statusCode), + (body ?? "" : tink.io.Source.RealSource) + )); + } + + public static function bodyFailure(error:tink.core.Error):Promise<IncomingResponse> { + return Promise.resolve(new IncomingResponse( + new tink.http.Response.ResponseHeader(201), + tink.io.Source.ofError(error) + )); + } + + public static function requestFailure(error:tink.core.Error):Promise<IncomingResponse> { + return Promise.reject(error); + } +} diff --git a/test/TestClient.hx b/test/TestClient.hx index ec56090..ab07140 100644 --- a/test/TestClient.hx +++ b/test/TestClient.hx @@ -18,6 +18,11 @@ import borogove.Stanza; import borogove.Status; import borogove.persistence.Dummy; import borogove.Chat.OutgoingE2EEPreference; +import borogove.AttachmentUploadError; +import borogove.AttachmentUploadErrorCode; +#if !NO_OMEMO +import borogove.XEP0454.put; +#end using Lambda; @@ -94,6 +99,130 @@ class TestClient extends utest.Test { Assert.equals("test@example.com", client.accountId()); } + @:timeout(3000) + public function testPrepareAttachmentNoUploadService(async: Async) { + final client = new Client("test@example.com", new Dummy()); + client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", []) + .then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> { + final error: AttachmentUploadError = cast e; + Assert.equals(NoService, error.code); + async.done(); + return null; + }); + } + + @:timeout(3000) + public function testPrepareAttachmentRejectsMalformedSlot(async: Async) { + final client = new Client("test@example.com", new Dummy()); + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.getChild("request", "urn:xmpp:http:upload:0") != null) { + client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") })); + } + return EventHandled; + }); + client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "upload.example.com" }]) + .then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> { + final error: AttachmentUploadError = cast e; + Assert.equals(AllServicesFailed, error.code); + Assert.equals(InvalidSlot, error.failures[0].code); + async.done(); + return null; + }); + } + + @:timeout(3000) + public function testPrepareAttachmentFallsBackAfterHttpFailure(async: Async) { + var putCount = 0; + final http = new FakeHttpClient(_ -> FakeHttpClient.response(++putCount == 1 ? 500 : 201)); + final client = new Client("test@example.com", new Dummy()); + client.stream.on("sendStanza", (stanza: Stanza) -> { + final request = stanza.getChild("request", "urn:xmpp:http:upload:0"); + if (request != null) { + final service = stanza.attr.get("to"); + client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") }) + .tag("slot", { xmlns: "urn:xmpp:http:upload:0" }) + .tag("put", { url: "http://" + service + "/put" }).up() + .tag("get", { url: "https://" + service + "/get" }).up() + .up()); + } + return EventHandled; + }); + client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "first" }, { serviceId: "second" }], http) + .then(url -> { + Assert.equals("https://second/get", url); + async.done(); + return null; + }, e -> { + Assert.fail(Std.string(e)); + async.done(); + return null; + }); + } + + @:timeout(3000) + public function testPrepareAttachmentReportsHttpFailure(async: Async) { + final http = new FakeHttpClient(_ -> FakeHttpClient.response(503)); + final client = new Client("test@example.com", new Dummy()); + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.getChild("request", "urn:xmpp:http:upload:0") != null) { + client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") }) + .tag("slot", { xmlns: "urn:xmpp:http:upload:0" }) + .tag("put", { url: "http://upload.example.com/put" }).up() + .tag("get", { url: "https://upload.example.com/get" }).up() + .up()); + } + return EventHandled; + }); + client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "upload.example.com" }], http) + .then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> { + final error: AttachmentUploadError = cast e; + Assert.equals(AllServicesFailed, error.code); + Assert.equals(HttpFailure, error.failures[0].code); + Assert.equals(503, error.failures[0].statusCode); + async.done(); + return null; + }); + } + + @:timeout(3000) + public function testPrepareAttachmentReportsNetworkFailure(async: Async) { + final bodyFailure = new tink.core.Error("body failed"); + final http = new FakeHttpClient(_ -> FakeHttpClient.bodyFailure(bodyFailure)); + final client = new Client("test@example.com", new Dummy()); + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.getChild("request", "urn:xmpp:http:upload:0") != null) { + client.stream.onStanza(new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id") }) + .tag("slot", { xmlns: "urn:xmpp:http:upload:0" }) + .tag("put", { url: "http://upload.example.com/put" }).up() + .tag("get", { url: "https://upload.example.com/get" }).up() + .up()); + } + return EventHandled; + }); + client.prepareAttachmentFor(("body" : tink.io.Source.RealSource), "file", 4, "text/plain", [{ serviceId: "upload.example.com" }], http) + .then(_ -> { Assert.fail("expected upload to fail"); return null; }, e -> { + final error: AttachmentUploadError = cast e; + Assert.equals(AllServicesFailed, error.code); + Assert.equals(NetworkFailure, error.failures[0].code); + Assert.equals(bodyFailure, error.failures[0].cause); + async.done(); + return null; + }); + } + + #if !NO_OMEMO + @:timeout(3000) + public function testEncryptedAttachmentPreservesUploadError(async: Async) { + final uploadError = new AttachmentUploadError(HttpFailure, "upload failed", "upload.example.com", 503); + put(("body" : tink.io.Source.RealSource), (_, _) -> Promise.reject(uploadError)) + .then(_ -> { Assert.fail("expected encryption upload to fail"); return null; }, e -> { + Assert.isTrue(e == uploadError); + async.done(); + return null; + }); + } + #end + public function testModerateMessage(async: Async) { final persistence = new MessageMockPersistence(); final client = new Client("test@example.com", persistence);