mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
2052b401d1
commit
ac71743ca4
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user