fix(apple): load chat media behind reverse-proxy paths (#130755)

Share ticketed media URL resolution between iOS and macOS, retaining the connected gateway encoded context path and transport-owned TLS and credential policy. Remove the duplicate native URL and playback resolvers.

Fixes #130746. Related to Android #129957.
This commit is contained in:
Peter Steinberger
2026-08-27 00:21:17 -07:00
committed by GitHub
parent 2052b401d1
commit ac71743ca4
6 changed files with 145 additions and 84 deletions
@@ -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
}
}
@@ -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)"
@@ -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
}
}
@@ -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
}
}
@@ -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"
}
+5
View File
@@ -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