Files
terebi/Sources/App/MiruroEmbed.swift

206 lines
11 KiB
Swift

import Foundation
import CommonCrypto
import CryptoKit
/// Native counterpart of MegaPlay's deployed e1-player and newclient codecs.
struct MiruroEmbed: Sendable {
let url: URL
// Public embed-origin headers apply to its cross-origin media resources too:
// deployed CDN segment endpoints reject requests without this Referer.
let headers: String
private let key: Data
private let iv: Data
static func resolve(url: URL, now: @Sendable () -> Date = { Date() }) async throws -> MiruroEmbed {
do {
try validateOrigin(url)
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.timeoutIntervalForResource = 25
configuration.httpShouldSetCookies = false
configuration.httpCookieStorage = nil
configuration.urlCache = nil
let session = URLSession(configuration: configuration, delegate: EmbedRedirectPolicy(), delegateQueue: nil)
defer { session.invalidateAndCancel() }
let html = try await text(url, session: session)
guard let player = capture(#"<[^>]+\bid\s*=\s*["']megaplay-player["'][^>]*>"#, html),
let id = capture(#"\bdata-id\s*=\s*["']([^"']+)["']"#, player),
let base = capture(#"\bbase_url\s*:\s*["']([^"']+)["']"#, html),
let baseURL = URL(string: base.replacingOccurrences(of: "\\/", with: "/"), relativeTo: url)?.absoluteURL else {
throw EmbedError()
}
try validateOrigin(baseURL)
// Only read script data; never evaluate provider JavaScript.
let scripts = captures(#"<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["']"#, html)
guard let scriptURL = scripts.compactMap({ URL(string: $0, relativeTo: url)?.absoluteURL })
.first(where: { $0.path.hasSuffix("/e1-player.min.js") }) else { throw EmbedError() }
try validateOrigin(scriptURL)
let script = try await text(scriptURL, session: session)
guard let keyText = capture(#"\bSOURCE_ENC_KEY\s*=\s*"([^"\\]*)""#, script),
let ivText = capture(#"\bSOURCE_ENC_IV\s*=\s*"([^"\\]*)""#, script),
let secret = capture(#"\bCDN_TOKEN_SECRET\s*=\s*"([^"\\]*)""#, script) else { throw EmbedError() }
let key = padded(keyText, count: 32)
let iv = padded(ivText, count: 16)
var endpoint = URLComponents(url: baseURL.appendingPathComponent("stream/getSources"), resolvingAgainstBaseURL: false)
endpoint?.queryItems = [URLQueryItem(name: "id", value: id)]
guard let endpointURL = endpoint?.url else { throw EmbedError() }
let payload = try await request(endpointURL, session: session)
guard let object = try JSONSerialization.jsonObject(with: payload) as? [String: Any],
object["error"] == nil || (object["error"] as? Bool) == false else { throw EmbedError() }
let source: Any
if let encrypted = object["enc"] as? String {
source = try JSONSerialization.jsonObject(with: decrypt(encrypted, key: key, iv: iv))
guard (source as? [String: Any])?["file"] is String else { throw EmbedError() }
} else if let plain = object["sources"] {
source = plain
} else { throw EmbedError() }
guard let file = streamFile(source), let stream = URL(string: file) else { throw EmbedError() }
try validateHTTP(stream)
let tokenEnabled = capture(#"\bcdnTokenEnable\s*:\s*(false|0|"false"|'false'|"0"|'0')\s*[,}]"#, html) == nil
let finalURL = try attachToken(stream, secret: secret, enabled: tokenEnabled, now: now())
try Task.checkCancellation()
return MiruroEmbed(url: finalURL, headers: "Referer: https://megaplay.buzz/\r\nOrigin: https://megaplay.buzz\r\n", key: key, iv: iv)
} catch {
if Task.isCancelled || error is CancellationError || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
throw EmbedError()
}
}
func resourceURL(_ url: URL) throws -> URL {
try Self.validateHTTP(url)
guard let token = Self.capture(#"/segment/([A-Za-z0-9_-]+)"#, url.path) else { return url }
let data = try Self.decrypt(token, key: key, iv: iv)
guard let text = String(data: data, encoding: .utf8), let decoded = URL(string: text) else { throw EmbedError() }
try Self.validateHTTP(decoded)
return decoded
}
func resourceData(_ data: Data, url: URL) throws -> Data {
try Self.validateHTTP(url)
// newclient v4.23 SegmentStrip: only these hosts, and only bodies longer than 252 bytes.
guard let host = url.host,
Self.capture(#"ibyteimg\.com|tiktokcdn\.com|ipstatp\.com|yoot\.akirax\.buzz"#, host) != nil,
data.count > 252 else { return data }
return Data(data.dropFirst(252))
}
private static func request(_ url: URL, session: URLSession) async throws -> Data {
try validateOrigin(url)
var request = URLRequest(url: url)
request.timeoutInterval = 25
request.setValue("https://megaplay.buzz/", forHTTPHeaderField: "Referer")
let (bytes, response) = try await session.bytes(for: request)
let limit = 2 * 1024 * 1024
guard let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode),
response.expectedContentLength <= Int64(limit) else { throw EmbedError() }
var data = Data()
for try await byte in bytes {
try Task.checkCancellation()
guard data.count < limit else { throw EmbedError() }
data.append(byte)
}
return data
}
private static func text(_ url: URL, session: URLSession) async throws -> String {
let data = try await request(url, session: session)
guard let text = String(data: data, encoding: .utf8) else { throw EmbedError() }
return text
}
private static func streamFile(_ source: Any) -> String? {
if let string = source as? String { return string }
if let array = source as? [Any], let first = array.first { return streamFile(first) }
return (source as? [String: Any])?["file"] as? String
}
private static func attachToken(_ url: URL, secret: String, enabled: Bool, now: Date) throws -> URL {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw EmbedError() }
guard enabled, !(parts.queryItems ?? []).contains(where: { $0.name == "token" }),
let pathKey = capture(#"/([a-f0-9]{32}/[a-f0-9]{32})/"#, url.path) else { return url }
let seconds = now.timeIntervalSince1970.rounded(.down)
guard seconds.isFinite, seconds >= 0, seconds < Double(Int64.max - 90) else { throw EmbedError() }
let message = Data("\(Int64(seconds) + 90)|\(pathKey.lowercased())".utf8)
let signature = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: Data(secret.utf8)))
let token = base64URL(message) + "." + base64URL(Data(signature))
var items = parts.queryItems ?? []
items.append(URLQueryItem(name: "token", value: token))
parts.queryItems = items
guard let result = parts.url else { throw EmbedError() }
return result
}
private static func padded(_ string: String, count: Int) -> Data {
var data = Data(string.utf8.prefix(count))
data.append(Data(repeating: 0, count: count - data.count))
return data
}
private static func base64URL(_ data: Data) -> String {
data.base64EncodedString().replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}
private static func decrypt(_ token: String, key: Data, iv: Data) throws -> Data {
var encoded = token.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4)
guard let encrypted = Data(base64Encoded: encoded), !encrypted.isEmpty,
encrypted.count % kCCBlockSizeAES128 == 0 else { throw EmbedError() }
var output = Data(count: encrypted.count + kCCBlockSizeAES128)
let capacity = output.count
var written = 0
let status = output.withUnsafeMutableBytes { destination in
encrypted.withUnsafeBytes { input in
key.withUnsafeBytes { keyBytes in
iv.withUnsafeBytes { ivBytes in
CCCrypt(CCOperation(kCCDecrypt), CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding),
keyBytes.baseAddress, key.count, ivBytes.baseAddress, input.baseAddress, encrypted.count,
destination.baseAddress, capacity, &written)
}
}
}
}
guard status == kCCSuccess else { throw EmbedError() }
output.count = written
return output
}
private static func validateHTTP(_ url: URL) throws {
guard ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
let host = url.host, !host.isEmpty, url.user == nil, url.password == nil else { throw EmbedError() }
}
private static func validateOrigin(_ url: URL) throws {
try validateHTTP(url)
guard url.scheme?.lowercased() == "https", url.host?.lowercased() == "megaplay.buzz",
url.port == nil || url.port == 443 else { throw EmbedError() }
}
private static func capture(_ pattern: String, _ text: String) -> String? {
captures(pattern, text).first
}
private static func captures(_ pattern: String, _ text: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] }
return regex.matches(in: text, range: NSRange(text.startIndex..., in: text)).compactMap { match in
let index = match.numberOfRanges > 1 ? 1 : 0
guard let range = Range(match.range(at: index), in: text) else { return nil }
return String(text[range])
}
}
}
private struct EmbedError: LocalizedError {
var errorDescription: String? { "The embedded provider could not prepare this stream." }
}
private final class EmbedRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}