diff --git a/apps/ios/Sources/Chat/IOSMediaArtifactLoader.swift b/apps/ios/Sources/Chat/IOSMediaArtifactLoader.swift index 3cf7ccbbb4ac..2e1eac4d9a2c 100644 --- a/apps/ios/Sources/Chat/IOSMediaArtifactLoader.swift +++ b/apps/ios/Sources/Chat/IOSMediaArtifactLoader.swift @@ -25,7 +25,6 @@ struct IOSMediaArtifactLoader: Sendable { static let maximumImageBytes = 12 * 1024 * 1024 static let maximumAudioBytes = 16 * 1024 * 1024 static let maximumVideoBytes = 16 * 1024 * 1024 - private static let managedMediaPathPrefix = "/api/chat/media/outgoing/" private let connectionProvider: ConnectionProvider private let requestFactory: RequestFactory @@ -71,8 +70,10 @@ struct IOSMediaArtifactLoader: Sendable { let path = response.url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let connection = await self.connectionProvider(), connection.gatewayID == expectedGatewayID, - let sourceURL = Self.managedMediaURL(config: connection.config, path: path), - let url = Self.playbackURL(sourceURL, mode: playback) + let url = OpenClawChatMediaURL.resolve( + gatewayURL: connection.config.url, + ticketedPath: path, + playback: playback) else { throw LoadError.invalidSource } let headers = url.scheme?.lowercased() == "https" @@ -140,38 +141,4 @@ struct IOSMediaArtifactLoader: Sendable { case .video: self.maximumVideoBytes } } - - private static func managedMediaURL(config: GatewayConnectConfig, path: String) -> URL? { - guard path.hasPrefix(self.managedMediaPathPrefix), - let relative = URLComponents(string: path), - relative.scheme == nil, - relative.host == nil, - relative.fragment == nil, - relative.percentEncodedPath.hasPrefix(Self.managedMediaPathPrefix), - relative.queryItems?.contains(where: { - $0.name == "mediaTicket" && $0.value?.isEmpty == false - }) == true, - var base = URLComponents(url: config.url, resolvingAgainstBaseURL: false), - base.host != nil - else { return nil } - switch base.scheme?.lowercased() { - case "wss", "https": base.scheme = "https" - case "ws", "http": base.scheme = "http" - default: return nil - } - base.percentEncodedPath = relative.percentEncodedPath - base.percentEncodedQuery = relative.percentEncodedQuery - base.fragment = nil - return base.url - } - - private static func playbackURL(_ url: URL, mode: OpenClawChatPlaybackMode?) -> URL? { - guard mode == .transcode else { return url } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } - var queryItems = components.queryItems ?? [] - queryItems.removeAll { $0.name == "playback" } - queryItems.append(URLQueryItem(name: "playback", value: "1")) - components.queryItems = queryItems - return components.url - } } diff --git a/apps/ios/Tests/IOSMediaArtifactLoaderTests.swift b/apps/ios/Tests/IOSMediaArtifactLoaderTests.swift index a8ca24cadc12..8d7b29b1e135 100644 --- a/apps/ios/Tests/IOSMediaArtifactLoaderTests.swift +++ b/apps/ios/Tests/IOSMediaArtifactLoaderTests.swift @@ -7,8 +7,11 @@ import Testing @Suite("iOS managed media artifact loader") struct IOSMediaArtifactLoaderTests { - @Test @MainActor func `loads ticketed image with proxy headers and without a gateway bearer`() async throws { - let gatewayURL = try #require(URL(string: "wss://gateway.example")) + @Test(arguments: Self.gatewayRoutes) + @MainActor func `loads ticketed image with proxy headers and without a gateway bearer`( + route: (gateway: String, media: String)) async throws + { + let gatewayURL = try #require(URL(string: route.gateway)) let config = Self.config(url: gatewayURL) let loader = IOSMediaArtifactLoader( connectionProvider: { @@ -20,7 +23,7 @@ struct IOSMediaArtifactLoaderTests { requestFactory: { _, maximumBytes in #expect(maximumBytes == 12 * 1024 * 1024) return { request in - #expect(request.url?.absoluteString == Self.ticketedAbsoluteURL) + #expect(request.url?.absoluteString == route.media + Self.ticketedPath) #expect(request.value(forHTTPHeaderField: "Accept") == "image/*") #expect(request.value(forHTTPHeaderField: "Authorization") == nil) #expect(request.value(forHTTPHeaderField: "X-Proxy-Token") == "proxy") @@ -41,8 +44,11 @@ struct IOSMediaArtifactLoaderTests { #expect(media.mimeType == "image/png") } - @Test @MainActor func `returns direct video stream when route needs no pinned session`() async throws { - let config = try Self.config(url: #require(URL(string: "wss://gateway.example"))) + @Test(arguments: Self.gatewayRoutes) + @MainActor func `returns direct video stream when route needs no pinned session`( + route: (gateway: String, media: String)) async throws + { + let config = try Self.config(url: #require(URL(string: route.gateway))) let loader = IOSMediaArtifactLoader( connectionProvider: { IOSMediaArtifactLoader.Connection( @@ -64,7 +70,7 @@ struct IOSMediaArtifactLoaderTests { Issue.record("video should use the ticketed stream URL") return } - #expect(stream.url.absoluteString == Self.ticketedAbsoluteURL) + #expect(stream.url.absoluteString == route.media + Self.ticketedPath) #expect(stream.mimeType == "video/mp4") #expect(stream.sizeBytes == 4096) } @@ -135,8 +141,11 @@ struct IOSMediaArtifactLoaderTests { #expect(media.mimeType == "video/mp4") } - @Test @MainActor func `requests playback rendition for buffered audio`() async throws { - let config = try Self.config(url: #require(URL(string: "wss://gateway.example"))) + @Test(arguments: Self.gatewayRoutes) + @MainActor func `requests playback rendition for buffered audio`( + route: (gateway: String, media: String)) async throws + { + let config = try Self.config(url: #require(URL(string: route.gateway))) let loader = IOSMediaArtifactLoader( connectionProvider: { IOSMediaArtifactLoader.Connection( @@ -146,7 +155,7 @@ struct IOSMediaArtifactLoaderTests { }, requestFactory: { _, _ in { request in - #expect(request.url?.absoluteString == Self.ticketedPlaybackAbsoluteURL) + #expect(request.url?.absoluteString == route.media + Self.ticketedPath + "&playback=1") #expect(request.value(forHTTPHeaderField: "Range") == nil) return try Self.response( for: request, @@ -299,6 +308,17 @@ struct IOSMediaArtifactLoaderTests { } } + private static let gatewayRoutes = [ + (gateway: "wss://gateway.example", media: "https://gateway.example"), + (gateway: "wss://gateway.example/", media: "https://gateway.example"), + ( + gateway: "wss://gateway.example:8443/tenant%20gateway/gw", + media: "https://gateway.example:8443/tenant%20gateway/gw"), + ( + gateway: "wss://gateway.example/tenant%2Fgateway//gw/", + media: "https://gateway.example/tenant%2Fgateway//gw/"), + (gateway: "wss://gateway.example/tenant%FFgateway/gw", media: "https://gateway.example/tenant%FFgateway/gw"), + ] private static let ticketedPath = "/api/chat/media/outgoing/main/11111111-1111-4111-8111-111111111111/full?mediaTicket=ticket" private static let ticketedAbsoluteURL = "https://gateway.example\(ticketedPath)" diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection+ManagedImages.swift b/apps/macos/Sources/OpenClaw/GatewayConnection+ManagedImages.swift index d99fa547be77..fec09759c714 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection+ManagedImages.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection+ManagedImages.swift @@ -3,8 +3,6 @@ import OpenClawChatUI import OpenClawKit import OpenClawProtocol -private let gatewayManagedMediaPathPrefix = "/api/chat/media/outgoing/" - extension GatewayConnection { func loadMediaArtifact( sessionKey: String, @@ -43,10 +41,10 @@ extension GatewayConnection { return .data(OpenClawChatMediaData(data: data, mimeType: declaredMIME)) } guard let ticketedPath = response.url?.trimmingCharacters(in: .whitespacesAndNewlines), - let sourceURL = Self.managedMediaURL( + let url = OpenClawChatMediaURL.resolve( gatewayURL: lease.route.url, - ticketedPath: ticketedPath), - let url = Self.playbackURL(sourceURL, mode: playback) + ticketedPath: ticketedPath, + playback: playback) else { return nil } let canStreamDirectly = kind == .video && @@ -107,37 +105,4 @@ extension GatewayConnection { case .audio, .video: 16 * 1024 * 1024 } } - - private static func managedMediaURL(gatewayURL: URL, ticketedPath: String) -> URL? { - guard ticketedPath.hasPrefix(gatewayManagedMediaPathPrefix), - let relative = URLComponents(string: ticketedPath), - relative.scheme == nil, - relative.host == nil, - relative.fragment == nil, - relative.queryItems?.contains(where: { - $0.name == "mediaTicket" && $0.value?.isEmpty == false - }) == true, - var base = URLComponents(url: gatewayURL, resolvingAgainstBaseURL: false), - base.host != nil - else { return nil } - switch base.scheme?.lowercased() { - case "wss", "https": base.scheme = "https" - case "ws", "http": base.scheme = "http" - default: return nil - } - base.percentEncodedPath = relative.percentEncodedPath - base.percentEncodedQuery = relative.percentEncodedQuery - base.fragment = nil - return base.url - } - - private static func playbackURL(_ url: URL, mode: OpenClawChatPlaybackMode?) -> URL? { - guard mode == .transcode else { return url } - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return nil } - var queryItems = components.queryItems ?? [] - queryItems.removeAll { $0.name == "playback" } - queryItems.append(URLQueryItem(name: "playback", value: "1")) - components.queryItems = queryItems - return components.url - } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMediaURL.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMediaURL.swift new file mode 100644 index 000000000000..03eacb187759 --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMediaURL.swift @@ -0,0 +1,41 @@ +import Foundation + +public enum OpenClawChatMediaURL { + public static func resolve( + gatewayURL: URL, + ticketedPath: String, + playback: OpenClawChatPlaybackMode?) -> URL? + { + let prefix = "/api/chat/media/outgoing/" + guard ticketedPath.hasPrefix(prefix), + let relative = URLComponents(string: ticketedPath), + relative.scheme == nil, + relative.host == nil, + relative.fragment == nil, + relative.percentEncodedPath.hasPrefix(prefix), + relative.queryItems?.contains(where: { + $0.name == "mediaTicket" && $0.value?.isEmpty == false + }) == true, + var base = URLComponents(url: gatewayURL, resolvingAgainstBaseURL: false), + base.host != nil + else { return nil } + switch base.scheme?.lowercased() { + case "wss", "https": base.scheme = "https" + case "ws", "http": base.scheme = "http" + default: return nil + } + // The Gateway returns a root-relative route, but the proxy owns its prefix. + // Preserve encoded octets and repeated slashes just as the WebSocket does. + let contextPath = base.percentEncodedPath == "/" ? "" : base.percentEncodedPath + base.percentEncodedPath = contextPath + relative.percentEncodedPath + base.percentEncodedQuery = relative.percentEncodedQuery + base.fragment = nil + if playback == .transcode { + var queryItems = base.queryItems ?? [] + queryItems.removeAll { $0.name == "playback" } + queryItems.append(URLQueryItem(name: "playback", value: "1")) + base.queryItems = queryItems + } + return base.url + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMediaURLTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMediaURLTests.swift new file mode 100644 index 000000000000..d5ed4e071cde --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMediaURLTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import OpenClawChatUI + +struct ChatMediaURLTests { + @Test(arguments: [ + ("ws://gateway.example:18789", "http://gateway.example:18789"), + ("http://gateway.example/", "http://gateway.example"), + ("wss://gateway.example/tenant%20gateway/gw", "https://gateway.example/tenant%20gateway/gw"), + ("https://gateway.example/tenant%2Fgateway//gw/", "https://gateway.example/tenant%2Fgateway//gw/"), + ("wss://gateway.example/tenant%FFgateway/gw", "https://gateway.example/tenant%FFgateway/gw"), + ]) + func `preserves gateway route and ticket`(route: (gateway: String, media: String)) throws { + let gateway = try #require(URL(string: route.gateway)) + let ticketedPath = Self.mediaPath + "?mediaTicket=synthetic%2Fticket%3D&download=1" + let url = OpenClawChatMediaURL.resolve(gatewayURL: gateway, ticketedPath: ticketedPath, playback: nil) + #expect(url?.absoluteString == route.media + ticketedPath) + } + + @Test func `rendition replaces playback without gateway query`() throws { + let gateway = try #require(URL(string: "wss://gateway.example/proxy?ignored=1#ignored")) + let ticketedPath = Self.mediaPath + "?mediaTicket=synthetic%2Fticket%3D&playback=0&playback=2" + let url = try #require(OpenClawChatMediaURL.resolve( + gatewayURL: gateway, + ticketedPath: ticketedPath, + playback: .transcode)) + #expect(url.scheme == "https") + #expect(url.path == "/proxy" + Self.mediaPath) + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + #expect(components.queryItems == [ + URLQueryItem(name: "mediaTicket", value: "synthetic/ticket="), + URLQueryItem(name: "playback", value: "1"), + ]) + #expect(components.fragment == nil) + } + + @Test(arguments: [ + "https://other.example/api/chat/media/outgoing/main/id/full?mediaTicket=ticket", + "//other.example/api/chat/media/outgoing/main/id/full?mediaTicket=ticket", + "/api/chat/media/outgoing/main/id/full?mediaTicket=ticket#fragment", + "/api/chat/media/outgoing/main/id/full", + "/api/chat/media/outgoing/main/id/full?mediaTicket=", + "/unrelated?mediaTicket=ticket", + ]) + func `rejects non ticket routes`(ticketedPath: String) throws { + let gateway = try #require(URL(string: "wss://gateway.example/proxy")) + #expect(OpenClawChatMediaURL.resolve( + gatewayURL: gateway, + ticketedPath: ticketedPath, + playback: nil) == nil) + } + + @Test(arguments: ["ftp://gateway.example/proxy", "file:///proxy", "/proxy"]) + func `rejects non gateway UR ls`(gateway: String) throws { + let gatewayURL = try #require(URL(string: gateway)) + #expect(OpenClawChatMediaURL.resolve( + gatewayURL: gatewayURL, + ticketedPath: Self.mediaPath + "?mediaTicket=ticket", + playback: .transcode) == nil) + } + + private static let mediaPath = "/api/chat/media/outgoing/main/11111111-1111-4111-8111-111111111111/full" +} diff --git a/docs/nodes/media-playback.md b/docs/nodes/media-playback.md index 6f10f76ab0fa..d86edbc7833a 100644 --- a/docs/nodes/media-playback.md +++ b/docs/nodes/media-playback.md @@ -71,6 +71,11 @@ artifact through `artifacts.download`, which returns inline base64 bytes when the artifact is byte-backed or a short-lived, ticketed URL when it is Gateway-managed. +Native clients resolve ticketed media against the connected Gateway URL, +preserving its reverse-proxy path prefix. A Gateway reached at +`wss://gateway.example/openclaw` loads managed media beneath +`https://gateway.example/openclaw/api/chat/media/outgoing/`, not the server root. + The ticketed byte routes support: - `Range` requests with HTTP `206 Partial Content` for seeking