diff --git a/CHANGELOG.md b/CHANGELOG.md index 26ea0749..56279b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Uplynk + - Added `orderedPreplayParameters` to `UplynkSSAIConfiguration`, which can be used to maintain the order of preplay parameters when making a request. + +### Fixed + +- Uplynk + - Improved URL encoding for characters such as `%`, `&`, `=`, `+` and `,` to preserve pre-encoded values and prevent server-side double decoding issues. + +### Changed + +- Uplynk + - When ping feature is not configured, the player will now send an empty string instead of `"&ad.pingc=0"` to prevent unsigned parameters that could break signature validation. + - Deprecated the old `preplayParameters` in favor of the new `orderedPreplayParameters` + ## [10.8.0.1] - 2026-01-20 ### Fixed diff --git a/Code/Uplynk/Source/Internal/UplynkSSAIConfiguration+Extensions.swift b/Code/Uplynk/Source/Internal/UplynkSSAIConfiguration+Extensions.swift index fab531df..7944ebc6 100644 --- a/Code/Uplynk/Source/Internal/UplynkSSAIConfiguration+Extensions.swift +++ b/Code/Uplynk/Source/Internal/UplynkSSAIConfiguration+Extensions.swift @@ -9,31 +9,26 @@ import Foundation extension UplynkSSAIConfiguration { - var drmParameters: String { - contentProtected ? "&manifest=m3u8&rmt=fps" : "" - } - - var urlParameters: String { - guard !preplayParameters.isEmpty else { - return "" - } - let joinedParameters = preplayParameters.map { - "\($0.key)=\($0.value)" - }.joined(separator: "&") - - return "&\(joinedParameters)" + var drmParameters: [URLQueryItem] { + guard contentProtected else {return []} + return [ + URLQueryItem(name: "manifest", value: "m3u8"), + URLQueryItem(name: "rmt", value: "fps") + ] } var pingFeature: UplynkPingFeature { UplynkPingFeature(ssaiConfiguration: self) } - var pingParameters: String { + var pingParameters: [URLQueryItem] { let pingFeature = pingFeature - if pingFeature == .noPing { - return "&ad.pingc=0" - } else { - return "&ad.pingc=1&ad.pingf=\(pingFeature.rawValue)" + if pingFeature == .noPing { return [] } + else { + return [ + URLQueryItem(name: "ad.cping", value: "1"), + URLQueryItem(name: "ad.pingf", value: pingFeature.rawValue.description) + ] } } diff --git a/Code/Uplynk/Source/Internal/UplynkSSAIURLBuilder.swift b/Code/Uplynk/Source/Internal/UplynkSSAIURLBuilder.swift index 7798a5da..8433cefb 100644 --- a/Code/Uplynk/Source/Internal/UplynkSSAIURLBuilder.swift +++ b/Code/Uplynk/Source/Internal/UplynkSSAIURLBuilder.swift @@ -9,6 +9,7 @@ import Foundation class UplynkSSAIURLBuilder { private static let DEFAULT_PREFIX: String = "https://content.uplynk.com" + private static let versionQueryItem = CollectionOfOne(URLQueryItem(name: "v", value: "2")) private let ssaiConfiguration: UplynkSSAIConfiguration @@ -22,17 +23,24 @@ class UplynkSSAIURLBuilder { private var urlAssetType: String { ssaiConfiguration.urlAssetType } private var urlAssetID: String { ssaiConfiguration.urlAssetID } - private var drmParameters: String { ssaiConfiguration.drmParameters } - private var pingParameters: String { ssaiConfiguration.pingParameters } - private var urlParameters: String { ssaiConfiguration.urlParameters } + private var drmParameters: [URLQueryItem] { ssaiConfiguration.drmParameters } + private var pingParameters: [URLQueryItem] { ssaiConfiguration.pingParameters } + private var preplayParameters: [URLQueryItem] { ssaiConfiguration.orderedPreplayParameters.map(URLQueryItem.init) } private var id: UplynkSSAIConfiguration.ID { ssaiConfiguration.id } + var queryString: String { + let items = Self.versionQueryItem + drmParameters + pingParameters + preplayParameters + var urlBuilder = URLComponents() + urlBuilder.percentEncodedQueryItems = items.map(\.encodedForUplynk) + return urlBuilder.percentEncodedQuery! + } + func buildPreplayVODURL() -> String { - return "\(prefix)/preplay/\(urlAssetID)?v=2\(drmParameters)\(pingParameters)\(urlParameters)" + return "\(prefix)/preplay/\(urlAssetID)?\(queryString)" } func buildPreplayLiveURL() -> String { - return "\(prefix)/preplay/\(urlAssetType)/\(urlAssetID)?v=2\(drmParameters)\(pingParameters)\(urlParameters)" + return "\(prefix)/preplay/\(urlAssetType)/\(urlAssetID)?\(queryString)" } func buildAssetInfoURLs( @@ -81,3 +89,15 @@ class UplynkSSAIURLBuilder { "\(prefix)/session/ping/\(sessionID).json?v=3&pt=\(currentTimeSeconds)" } } + +extension CharacterSet { + fileprivate static let uplynkUrlQueryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&+=?,")) +} +extension URLQueryItem { + fileprivate var encodedForUplynk: URLQueryItem { + URLQueryItem( + name: name, + value: value?.addingPercentEncoding(withAllowedCharacters: .uplynkUrlQueryValueAllowed) + ) + } +} diff --git a/Code/Uplynk/Source/UplynkSSAIConfiguration.swift b/Code/Uplynk/Source/UplynkSSAIConfiguration.swift index 62cd2dd6..4e93c13a 100644 --- a/Code/Uplynk/Source/UplynkSSAIConfiguration.swift +++ b/Code/Uplynk/Source/UplynkSSAIConfiguration.swift @@ -26,13 +26,14 @@ public class UplynkSSAIConfiguration: CustomServerSideAdInsertionConfiguration { public let id: ID public let prefix: String? - public let preplayParameters: [String: String] + public let orderedPreplayParameters: [(String, String)] public let assetType: AssetType public let contentProtected: Bool public let assetInfo: Bool public let pingConfiguration: UplynkPingConfiguration public let playbackURLParameters: [(String, String)] + @available(*, deprecated, message: "Use the initializer with orderedPreplayParameters instead.") public init( id: ID, assetType: AssetType, @@ -46,10 +47,35 @@ public class UplynkSSAIConfiguration: CustomServerSideAdInsertionConfiguration { self.id = id self.assetType = assetType self.prefix = prefix - self.preplayParameters = preplayParameters + self.orderedPreplayParameters = Array(preplayParameters) self.contentProtected = contentProtected self.assetInfo = assetInfo self.pingConfiguration = uplynkPingConfiguration self.playbackURLParameters = playbackURLParameters } + + public init( + id: ID, + assetType: AssetType, + orderedPreplayParameters: [(String, String)], + prefix: String? = nil, + contentProtected: Bool = false, + assetInfo: Bool = false, + uplynkPingConfiguration: UplynkPingConfiguration = .init(), + playbackURLParameters: [(String, String)] = [] + ) { + self.id = id + self.assetType = assetType + self.orderedPreplayParameters = orderedPreplayParameters + self.prefix = prefix + self.contentProtected = contentProtected + self.assetInfo = assetInfo + self.pingConfiguration = uplynkPingConfiguration + self.playbackURLParameters = playbackURLParameters + } + + @available(*, deprecated, renamed: "orderedPreplayParameters", message: "Passing preplayParameters as a dictionary is no longer supported. Use orderedPreplayParameters instead.") + var preplayParameters: [String: String] { + Dictionary(orderedPreplayParameters) { left, right in left } + } } diff --git a/Code/Uplynk/Tests/Mocks/MockAdBreak.swift b/Code/Uplynk/Tests/Mocks/MockAdBreak.swift index 75f075e2..86738737 100644 --- a/Code/Uplynk/Tests/Mocks/MockAdBreak.swift +++ b/Code/Uplynk/Tests/Mocks/MockAdBreak.swift @@ -9,6 +9,7 @@ import Foundation import THEOplayerSDK struct MockAdBreak: AdBreak { + let id: String? = UUID().uuidString var ads: [Ad] = [] var maxDuration: Int = 0 var maxRemainingDuration: Double = 0 @@ -18,6 +19,7 @@ struct MockAdBreak: AdBreak { } struct MockAd: Ad { + let isSlate = false var adBreak: AdBreak = MockAdBreak() var companions: [CompanionAd] = [] var type: String = "" diff --git a/Code/Uplynk/Tests/UplynkSSAIConfigurationURLBuilderTests.swift b/Code/Uplynk/Tests/UplynkSSAIConfigurationURLBuilderTests.swift index 542b24e2..b7b0f018 100644 --- a/Code/Uplynk/Tests/UplynkSSAIConfigurationURLBuilderTests.swift +++ b/Code/Uplynk/Tests/UplynkSSAIConfigurationURLBuilderTests.swift @@ -63,8 +63,7 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase { let prefix = "https://content.uplynk.com" let assetID = "a123" - let validNoPingQueryParameter = "ad.pingc=0" - let validPingQueryParameter = "ad.pingc=1&ad.pingf=\(pingFeature.rawValue)" + let validPingQueryParameter = "ad.cping=1&ad.pingf=\(pingFeature.rawValue)" let pingConfiguration = switch pingFeature { case .noPing: @@ -89,7 +88,7 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase { let builtPreplayURL = UplynkSSAIURLBuilder(ssaiConfiguration: configurationWithAssetID).buildPreplayVODURL() switch (pingFeature) { case .noPing: - XCTAssertTrue(builtPreplayURL.contains(validNoPingQueryParameter)) + XCTAssertFalse(builtPreplayURL.contains(validPingQueryParameter), "built PreplayURL should not contain the ping query parameter \(validPingQueryParameter). url: \(builtPreplayURL)") default: XCTAssertTrue(builtPreplayURL.contains(validPingQueryParameter)) @@ -139,4 +138,164 @@ final class UplynkSSAIConfigurationURLBuilderTests: XCTestCase { XCTAssertTrue(builtPreplayURL.contains(validPrePlayParameters) || builtPreplayURL.contains(anotherValidPrePlayParameters)) } + + /// Makes sure Uplynk URLs do not contain the sequence "?&" + /// FIFA raised an issue that the Uplynk backend does not support those kind of URLs. + /// See [THEOSD-16266] [OPTI-1771] + func testEmptyQueryParameter() { + let hasEmptyParameter: (String)->Bool = { $0.contains("?&") } + let faultyURL = "https://content.uplynk.com/preplay/ID?&sig=signature" + XCTAssert(hasEmptyParameter(faultyURL)) + + let assetID = "a123" + let vodBuilder = UplynkSSAIURLBuilder( + ssaiConfiguration: UplynkSSAIConfiguration( + id: .asset(ids: [assetID]), + assetType: .asset + ) + ) + let liveBuilder = UplynkSSAIURLBuilder( + ssaiConfiguration: UplynkSSAIConfiguration( + id: .asset(ids: [assetID]), + assetType: .channel + ) + ) + + let preplayVod = vodBuilder.buildPreplayVODURL() + let preplayFaultyLive = vodBuilder.buildPreplayLiveURL() + print(preplayVod) + XCTAssertFalse(hasEmptyParameter(preplayVod)) + print(preplayFaultyLive) + XCTAssertFalse(hasEmptyParameter(preplayFaultyLive)) + + let preplayFaultyVod = liveBuilder.buildPreplayVODURL() + let preplayLive = liveBuilder.buildPreplayLiveURL() + print(preplayFaultyVod) + XCTAssertFalse(hasEmptyParameter(preplayFaultyVod)) + print(preplayLive) + XCTAssertFalse(hasEmptyParameter(preplayLive)) + } + + func testPreplayArray() { + let normalParameter = ("keyA", "valueA") + let specialParameter = ("special","?&+=,%") + let specialEncodedParameter = (specialParameter.0, "%3F%26%2B%3D%2C%25") + let mixedParameters = [normalParameter, specialParameter] + let mixedEncodedParamteres = [normalParameter, specialEncodedParameter] + + let configs: [TestConfig] = [ + TestConfig(assetType: .asset, preplayArray: [normalParameter], expectedParams: [normalParameter]), + TestConfig(assetType: .asset, preplayArray: [specialParameter], expectedParams: [specialEncodedParameter]), + TestConfig(assetType: .asset, preplayArray: mixedParameters, expectedParams: mixedEncodedParamteres), + TestConfig(assetType: .channel, preplayArray: [normalParameter], expectedParams: [normalParameter]), + TestConfig(assetType: .channel, preplayArray: [specialParameter], expectedParams: [specialEncodedParameter]), + TestConfig(assetType: .channel, preplayArray: mixedParameters, expectedParams: mixedEncodedParamteres) + ] + + for config in configs { + config.assertUrlContainsPreplayParams() + } + + let emptyParams = TestConfig(assetType: .asset, preplayArray: [], expectedParams: []) + XCTAssertFalse(emptyParams.url.contains("&"), "A config without params should not contain an `&` character") + } + + func testPreplayParameterOrdering() { + let preplayParams = (0..<20).map { index in + ("key\(index)", "value\(index)") + } + let vod = TestConfig(assetType: .asset, preplayArray: preplayParams, expectedParams: []).url + + var reversedOrder = Array(preplayParams.reversed()) + var lastIndex = vod.startIndex + while let item = reversedOrder.popLast() { + if let range = vod.range(of: item.0) { + let newIndex = range.upperBound + if newIndex < lastIndex { + XCTFail("Parameter \(item.0) is out of order. ExpectedOrder: \(preplayParams.map(\.0)), Actual: \(vod)") + } + lastIndex = newIndex + } else { + XCTFail("Missing parameter: \(item)") + } + } + } + + func testUrlWithDrmAndPing() { + let assetID = UplynkSSAIConfiguration.ID.asset(ids: ["a123"]) + let extraParams = [ + ("a", "1"), + ("b", "2") + ] + let drmAndPingConfig = UplynkSSAIConfiguration( + id: assetID, + assetType: .asset, + orderedPreplayParameters: extraParams, + contentProtected: true, + uplynkPingConfiguration: .init(adImpressions: true, freeWheelVideoViews: true, linearAdData: true) + ) + XCTAssertEqual( + UplynkSSAIURLBuilder(ssaiConfiguration: drmAndPingConfig).buildPreplayVODURL(), + "https://content.uplynk.com/preplay/a123.json?v=2&manifest=m3u8&rmt=fps&ad.cping=1&ad.pingf=3&a=1&b=2" + ) + + let drmConfig = UplynkSSAIConfiguration( + id: assetID, + assetType: .asset, + orderedPreplayParameters: extraParams, + contentProtected: true + ) + XCTAssertEqual( + UplynkSSAIURLBuilder(ssaiConfiguration: drmConfig).buildPreplayVODURL(), + "https://content.uplynk.com/preplay/a123.json?v=2&manifest=m3u8&rmt=fps&a=1&b=2" + ) + + let liveDrmConfig = UplynkSSAIConfiguration( + id: assetID, + assetType: .channel, + orderedPreplayParameters: extraParams, + contentProtected: true + ) + XCTAssertEqual( + UplynkSSAIURLBuilder(ssaiConfiguration: liveDrmConfig).buildPreplayLiveURL(), + "https://content.uplynk.com/preplay/channel/a123.json?v=2&manifest=m3u8&rmt=fps&a=1&b=2" + ) + } +} + +struct TestConfig { + let assetType: UplynkSSAIConfiguration.AssetType + let preplayArray: [(String,String)] + let expectedParams: [(String,String)] + + var url: String { + let assetID = UplynkSSAIConfiguration.ID.asset(ids: ["a123"]) + let config = UplynkSSAIConfiguration(id: assetID, assetType: assetType, orderedPreplayParameters: preplayArray) + let builder = UplynkSSAIURLBuilder(ssaiConfiguration: config) + switch assetType { + case .asset: return builder.buildPreplayVODURL() + case .channel: return builder.buildPreplayLiveURL() + } + } + + func assertUrlContainsPreplayParams() { + let url = self.url + for (key, value) in expectedParams { + let exptectation = "\(key)=\(value)" + if !url.contains(exptectation) { + XCTFail("Generated url (\(url)) does not contain \(exptectation)") + } + } + guard let parsedUrl = URLComponents(string: url) else { + return XCTFail("Could not parse the generated URL \(url)") + } + let parsedQueryItems = parsedUrl.queryItems ?? [] + for (key, value) in preplayArray { + guard parsedQueryItems.contains(where: { item in + item.name == key && item.value == value + }) else { + return XCTFail("Generated URL does not contain preplay param \(key)=\(value)") + } + } + } } diff --git a/Code/Uplynk/docs/preplay.md b/Code/Uplynk/docs/preplay.md index 28b87cfc..c3d18649 100644 --- a/Code/Uplynk/docs/preplay.md +++ b/Code/Uplynk/docs/preplay.md @@ -33,6 +33,8 @@ We start by creating an `UplynkSSAIConfiguration` object that describes how to c - `preplayParameters`: The `preplayParameters` object should have string-key-string-value combinations, which will be used as query parameters for the Preplay API call. Nested objects are not supported. +- `orderedPreplayParameters`: The `orderedPreplayParameters` object should have string-key-string-value combinations, which will be used as query parameters for the Preplay API call. Nested objects are not supported. Unlike `preplayParameters`, `orderedPreplayParameters` preserves the order of parameters while making a request, which is neccessary to prevent unsigned parameters that could break signature validation. + - `contentProtected`: Boolean value which will internally set any necessary content-protection information. No content-protection details have to be specified by the customer. - **A Preplay request must include all parameters defined within the playback request, hence these parameters must be included in the THEOplayer source**. This request must also include a digital signature if the 'Require a token for playback' option is enabled in the back-end on the corresponding live channel. (See also : [Signing a Playback URL Tutorial](https://docs.uplynk.com/docs/sign-playback-url)) @@ -62,7 +64,7 @@ Ad specific parameters can be passed in the `preplayParameters` argument of the assetType: ..., prefix: ..., preplayParameters: [ - // Parameters here should specify the necessary ad parameters for the Preplay API + // Parameters here should specify the necessary ad parameters for the Preplay API. Use `orderedPreplayParameters` instead to pass these in the order given. "ad.param1": "param_val1", "ad.param2": "param_val2" ],