Files

378 lines
29 KiB
Swift

import CryptoKit
import CFNetwork
import Foundation
enum GuangyaAPIError: LocalizedError {
case invalidResponse
case http(status: Int, message: String)
case missingField(String)
case noRefreshToken
var isDeviceAuthorizationPending: Bool {
guard case .http(let status, let message) = self, status == 400 else { return false }
let normalized = message.lowercased()
return normalized.contains("authorization_pending") || normalized.contains("slow_down")
}
var errorDescription: String? {
switch self {
case .invalidResponse: return "服务器返回了无法识别的数据"
case .http(let status, let message): return "请求失败(\(status)):\(message)"
case .missingField(let field): return "响应缺少字段:\(field)"
case .noRefreshToken: return "没有可用的刷新令牌"
}
}
}
final class GuangyaAPI: @unchecked Sendable {
static let accountBase = URL(string: "https://account.guangyapan.com")!
static let apiBase = URL(string: "https://api.guangyapan.com")!
static let clientID = "aMe-8VSlkrbQXpUR"
private let session: URLSession
private(set) var accessToken: String
private(set) var refreshTokenValue: String?
private var tokenExpiresAt: Date?
let deviceID: String
init(accessToken: String = "", refreshToken: String? = nil, deviceID: String = GuangyaAPI.generateDeviceID()) {
self.accessToken = accessToken
self.refreshTokenValue = refreshToken
self.deviceID = deviceID
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 30
configuration.waitsForConnectivity = false
configuration.httpAdditionalHeaders = [
"Accept": "application/json, text/plain, */*",
"Content-Type": "application/json",
"did": deviceID,
"dt": "4",
"Origin": "https://www.guangyapan.com",
"Referer": "https://www.guangyapan.com/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
]
session = URLSession(configuration: configuration)
}
func updateTokens(from value: JSONValue) -> AuthTokens? {
guard let access = value.firstStringDeep(["access_token", "accessToken"]) else { return nil }
accessToken = access
refreshTokenValue = value.firstStringDeep(["refresh_token", "refreshToken"]) ?? refreshTokenValue
let expires = value.firstIntDeep(["expires_in", "expiresIn"]).map(Double.init)
tokenExpiresAt = expires.map { Date().addingTimeInterval($0) }
return AuthTokens(accessToken: access, refreshToken: refreshTokenValue, expiresIn: expires)
}
func prepareSession() async {
guard !accessToken.isEmpty, refreshTokenValue != nil, tokenExpiresAt == nil else { return }
_ = try? await refreshToken()
}
// MARK: Authentication
func loginSMSInit(phoneNumber: String, captchaToken: String? = nil) async throws -> JSONValue {
var body: [String: JSONValue] = [
"client_id": .string(Self.clientID),
"action": .string("POST:/v1/auth/verification"),
"device_id": .string(deviceID),
"meta": .object(["phone_number": .string(phoneNumber)])
]
if let captchaToken { body["captcha_token"] = .string(captchaToken) }
return try await accountRequest("/v1/shield/captcha/init", body: .object(body))
}
func loginSMSSend(phoneNumber: String, captchaToken: String, target: String = "ANY") async throws -> JSONValue {
try await accountRequest("/v1/auth/verification", body: .object([
"phone_number": .string(phoneNumber), "target": .string(target), "client_id": .string(Self.clientID)
]), extraHeaders: ["x-captcha-token": captchaToken])
}
func loginSMSVerify(verificationID: String, verificationCode: String) async throws -> JSONValue {
try await accountRequest("/v1/auth/verification/verify", body: .object([
"verification_id": .string(verificationID), "verification_code": .string(verificationCode), "client_id": .string(Self.clientID)
]))
}
func loginSMSSignIn(code: String, verificationToken: String, username: String, captchaToken: String) async throws -> JSONValue {
let result = try await accountRequest("/v1/auth/signin", body: .object([
"verification_code": .string(code), "verification_token": .string(verificationToken), "username": .string(username), "client_id": .string(Self.clientID)
]), extraHeaders: ["x-captcha-token": captchaToken])
_ = updateTokens(from: result)
return result
}
/// OAuth device authorization flow used by the web client QR login.
func loginQRInit() async throws -> JSONValue {
try await accountRequest("/v1/auth/device/code", body: .object([
"scope": .string("user"), "client_id": .string(Self.clientID)
]))
}
func loginQRPoll(token: String) async throws -> JSONValue {
let result = try await accountRequest("/v1/auth/token", body: .object([
"grant_type": .string("urn:ietf:params:oauth:grant-type:device_code"),
"device_code": .string(token),
"client_id": .string(Self.clientID)
]))
_ = updateTokens(from: result)
return result
}
func refreshToken(_ token: String? = nil) async throws -> JSONValue {
guard let token = token ?? refreshTokenValue else { throw GuangyaAPIError.noRefreshToken }
let result = try await accountRequest("/v1/auth/token", body: .object([
"client_id": .string(Self.clientID), "grant_type": .string("refresh_token"), "refresh_token": .string(token)
]), extraHeaders: ["x-action": "401"])
_ = updateTokens(from: result)
return result
}
func userInfo() async throws -> JSONValue {
try await accountRequest("/v1/user/me", body: nil, authenticated: true)
}
// MARK: Cloud download, file system, and sharing
func cloudTaskList(page: Int = 0, pageSize: Int = 50, status: [Int] = [0, 1, 3, 4]) async throws -> JSONValue {
try await apiRequest("/nd.bizcloudcollection.s/v1/list_task", body: object(("page", page), ("pageSize", pageSize), ("status", status)))
}
func cloudResolveURL(_ url: String) async throws -> JSONValue {
try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_res", body: object(("url", url)))
}
func cloudResolveTorrent(data: Data, filename: String = "file.torrent") async throws -> JSONValue {
try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_torrent", method: "POST", multipart: [("torrent", filename, "application/octet-stream", data)])
}
func cloudCreateTask(url: String, parentID: String? = nil) async throws -> JSONValue {
try await apiRequest("/nd.bizcloudcollection.s/v1/create_task", body: object(("url", url), ("parentId", parentID ?? "")))
}
func downloadURL(fileID: String) async throws -> JSONValue {
try await apiRequest("/nd.bizuserres.s/v1/get_res_download_url", body: object(("fileId", fileID)))
}
func taskStatus(taskID: String) async throws -> JSONValue {
try await apiRequest("/nd.bizuserres.s/v1/get_task_status", body: object(("taskId", taskID)))
}
func fsFiles(parentID: String? = nil, page: Int = 0, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0, fileTypes: [Int]? = nil, resType: Int? = nil, dirType: Int? = nil, needPlayRecord: Bool = false) async throws -> JSONValue {
var body: [String: JSONValue] = ["parentId": .string(parentID ?? ""), "page": .number(Double(page)), "pageSize": .number(Double(pageSize)), "orderBy": .number(Double(orderBy)), "sortType": .number(Double(sortType))]
if let fileTypes { body["fileTypes"] = .array(fileTypes.map { .number(Double($0)) }) }
if let resType { body["resType"] = .number(Double(resType)) }
if let dirType { body["dirType"] = .number(Double(dirType)) }
if needPlayRecord { body["needPlayRecord"] = .bool(true) }
return try await apiRequest("/userres/v1/file/get_file_list", body: .object(body))
}
func fsImageList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [1], resType: 1) }
func fsVideoList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [2], resType: 1) }
func fsDocumentList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [4], resType: 1) }
func fsRecycleFiles() async throws -> JSONValue { try await fsFiles(orderBy: 10, dirType: 4) }
func fsCreateDir(name: String, parentID: String? = nil, failIfNameExist: Bool = false) async throws -> JSONValue {
var body = ["dirName": JSONValue.string(name), "parentId": .string(parentID ?? "")]
if failIfNameExist { body["failIfNameExist"] = .bool(true) }
return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body))
}
func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) }
func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) }
func fsDelete(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/delete_file", body: object(("fileIds", fileIDs))) }
func fsRecycle(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/recycle_file", body: object(("fileIds", fileIDs))) }
func fsClearRecycleBin() async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/clear_recycle_bin") }
func fsRename(fileID: String, newName: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/rename", body: object(("fileId", fileID), ("newName", newName))) }
func fsDetail(fileID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_file_detail", body: object(("fileId", fileID))) }
func shareCreate(fileIDs: [String], title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue {
try await apiRequest("/nd.bizuserres.s/v1/share_file", body: object(("fileIds", fileIDs), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType)))
}
func shareUserList(page: Int = 0, pageSize: Int = 50) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_list", body: object(("page", page), ("pageSize", pageSize), ("orderType", 1), ("sortType", 1))) }
func shareUpdate(shareID: String, title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/update_share", body: object(("id", shareID), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) }
func shareDelete(ids: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/delete_share", body: object(("ids", ids))) }
func shareRestore(accessToken: String, fileIDs: [String], parentID: String = "") async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/restore_share", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("parentId", parentID))) }
func shareDownloadURL(fileID: String, accessToken: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_download_url", body: object(("fileId", fileID), ("accessToken", accessToken))) }
func shareFilesSize(accessToken: String, fileIDs: [String], download: Bool = true) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_files_size", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("download", download))) }
func shareSummary(shareID: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_summary", body: object(("shareId", shareID))) }
func shareAccessToken(shareID: String, code: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_access_token", body: object(("shareId", shareID), ("code", code))) }
func shareFilesList(accessToken: String, parentID: String = "", page: Int = 1, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_page_files_list", body: object(("accessToken", accessToken), ("parentId", parentID), ("page", page), ("pageSize", pageSize), ("orderBy", orderBy), ("sortType", sortType))) }
// MARK: Upload
func uploadToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String? = nil) async throws -> JSONValue {
var res: [String: JSONValue] = ["fileSize": .number(Double(fileSize))]
if let md5 { res["md5"] = .string(md5) }
return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": .string(parentID ?? "")]))
}
func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) }
func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID))) }
func tmdbSearch(query: String, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue {
var components = URLComponents(string: "https://api.themoviedb.org/3/search/multi")!
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "include_adult", value: "false")]
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 15
configuration.timeoutIntervalForResource = 30
configuration.waitsForConnectivity = false
let normalizedHost = proxyHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "^https?://", with: "", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if !normalizedHost.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) {
configuration.connectionProxyDictionary = [
kCFNetworkProxiesHTTPEnable as String: true,
kCFNetworkProxiesHTTPProxy as String: normalizedHost,
kCFNetworkProxiesHTTPPort as String: port,
kCFNetworkProxiesHTTPSEnable as String: true,
kCFNetworkProxiesHTTPSProxy as String: normalizedHost,
kCFNetworkProxiesHTTPSPort as String: port
]
}
let tmdbSession = URLSession(configuration: configuration)
let (data, response) = try await tmdbSession.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") }
return try JSONDecoder().decode(JSONValue.self, from: data)
}
func fileUpload(url: URL, parentID: String? = nil, contentType: String = "application/octet-stream", chunkSize: Int = 5 * 1024 * 1024) async throws -> JSONValue {
let values = try Data(contentsOf: url)
let name = url.lastPathComponent
if values.count < 1024 * 1024 {
let digest = Data(Insecure.MD5.hash(data: values)).base64EncodedString()
let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID, md5: digest)
guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") }
return try await uploadInfo(taskID: taskID)
}
let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID)
guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") }
let canFlash = try await checkCanFlashUpload(taskID: taskID, gcid: Self.calculateGCID(data: values))
if canFlash["data"]?["canFlashUpload"]?.boolValue == true { return try await uploadInfo(taskID: taskID) }
_ = try await cdnUpload(data: values, tokenData: token["data"] ?? .null, contentType: contentType, chunkSize: chunkSize)
return try await uploadInfo(taskID: taskID)
}
// MARK: Low-level requests and OSS signing
private func accountRequest(_ path: String, method: String = "POST", body: JSONValue?, extraHeaders: [String: String] = [:], authenticated: Bool = false) async throws -> JSONValue {
var headers = accountHeaders()
headers.merge(extraHeaders) { _, new in new }
if authenticated || !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" }
return try await request(Self.accountBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, refreshOnUnauthorized: false)
}
private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil) async throws -> JSONValue {
if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { _ = try? await refreshToken() }
var headers = ["traceparent": Self.generateTraceparent()]
if !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" }
return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart)
}
private func publicPost(_ path: String, body: JSONValue) async throws -> JSONValue {
try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: "POST", body: body, headers: ["traceparent": Self.generateTraceparent()], refreshOnUnauthorized: false)
}
private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true) async throws -> JSONValue {
var request = URLRequest(url: url)
request.httpMethod = method
headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
if let multipart {
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = Self.multipartBody(parts: multipart, boundary: boundary)
} else if let body { request.httpBody = try JSONEncoder().encode(body) }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse }
if http.statusCode == 401, refreshOnUnauthorized, refreshTokenValue != nil {
_ = try? await refreshToken()
return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false)
}
guard (200..<300).contains(http.statusCode) else {
let message: String
if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) {
message = decoded.firstStringDeep(["message", "msg", "error_description", "error"]) ?? String(data: data, encoding: .utf8) ?? "未知错误"
} else {
message = String(data: data, encoding: .utf8) ?? "未知错误"
}
throw GuangyaAPIError.http(status: http.statusCode, message: message)
}
if data.isEmpty { return .object([:]) }
if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) {
if let code = decoded.firstIntDeep(["code"]), code != 0 {
throw GuangyaAPIError.http(status: code, message: decoded.firstStringDeep(["msg", "message"]) ?? "接口返回错误")
}
return decoded
}
return .string(String(data: data, encoding: .utf8) ?? "")
}
private func accountHeaders() -> [String: String] {
["accept": "*/*", "content-type": "application/json", "origin": "https://www.guangyapan.com", "referer": "https://www.guangyapan.com/", "x-client-id": Self.clientID, "x-client-version": "0.0.1", "x-device-id": deviceID, "x-device-model": "chrome%2F147.0.0.0", "x-device-name": "PC-Chrome", "x-device-sign": "wdi10.\(deviceID)\(Self.randomHex(16))", "x-net-work-type": "NONE", "x-os-version": "MacIntel", "x-platform-version": "1", "x-protocol-version": "301", "x-provider-name": "NONE", "x-sdk-version": "9.0.2"]
}
private func cdnUpload(data: Data, tokenData: JSONValue, contentType: String, chunkSize: Int) async throws -> String {
guard let creds = tokenData["creds"], let key = creds.firstString(["accessKeyID", "accessKeyId"]), let secret = creds.firstString(["secretAccessKey"]), let sessionToken = creds.firstString(["sessionToken"]), let endpoint = tokenData.firstString(["fullEndPoint", "fullEndpoint"]), let bucket = tokenData.firstString(["bucketName"]), let objectPath = tokenData.firstString(["objectPath"]) else { throw GuangyaAPIError.missingField("OSS token data") }
let objectURL = "\(endpoint)/\(objectPath)"
let uploadResponse = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, subResources: ["uploads": ""])
guard let uploadID = Self.xmlValue(uploadResponse.body, tag: "UploadId") else { throw GuangyaAPIError.missingField("UploadId") }
var parts: [(Int, String)] = []
var offset = 0
var number = 1
while offset < data.count {
let end = min(offset + chunkSize, data.count)
let chunk = data.subdata(in: offset..<end)
let contentMD5 = Data(Insecure.MD5.hash(data: chunk)).base64EncodedString()
let response = try await ossRequest(method: "PUT", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, content: chunk, contentType: "application/octet-stream", contentMD5: contentMD5, subResources: ["partNumber": "\(number)", "uploadId": uploadID])
parts.append((number, response.headers["etag"]?.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) ?? ""))
offset = end; number += 1
}
let xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><CompleteMultipartUpload>" + parts.map { "<Part><PartNumber>\($0.0)</PartNumber><ETag>\"\($0.1)\"</ETag></Part>" }.joined() + "</CompleteMultipartUpload>"
let result = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, content: Data(xml.utf8), contentType: "application/xml", contentMD5: Data(Insecure.MD5.hash(data: Data(xml.utf8))).base64EncodedString(), subResources: ["uploadId": uploadID])
return Self.xmlValue(result.body, tag: "ETag") ?? ""
}
private func ossRequest(method: String, url: String, bucket: String, objectKey: String, accessKeyID: String, secret: String, sessionToken: String, content: Data = Data(), contentType: String = "", contentMD5: String = "", subResources: [String: String] = [:]) async throws -> (body: String, headers: [String: String]) {
let date = Self.httpDate()
var canonical = [method.uppercased(), contentMD5, contentType, date, "x-oss-date:\(date)", "x-oss-security-token:\(sessionToken.trimmingCharacters(in: .whitespacesAndNewlines))", "/\(bucket)/\(objectKey)"]
if !subResources.isEmpty { canonical[6] += "?" + subResources.keys.sorted().map { key in let value = subResources[key] ?? ""; return value.isEmpty ? key : "\(key)=\(value)" }.joined(separator: "&") }
let signature = Data(HMAC<Insecure.SHA1>.authenticationCode(for: Data(canonical.joined(separator: "\n").utf8), using: SymmetricKey(data: Data(secret.utf8)))).base64EncodedString()
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = method
request.httpBody = content
request.setValue("OSS \(accessKeyID):\(signature)", forHTTPHeaderField: "Authorization")
request.setValue(date, forHTTPHeaderField: "x-oss-date")
request.setValue(sessionToken, forHTTPHeaderField: "x-oss-security-token")
if !contentType.isEmpty { request.setValue(contentType, forHTTPHeaderField: "Content-Type") }
if !contentMD5.isEmpty { request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") }
subResources.forEach { request.url = URL(string: "\(request.url!.absoluteString)\($0.key == subResources.keys.sorted().first ? "?" : "&")\($0.key)=\($0.value)") }
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse }
guard (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: http.statusCode, message: String(data: data, encoding: .utf8) ?? "OSS 请求失败") }
return (String(data: data, encoding: .utf8) ?? "", http.allHeaderFields.reduce(into: [:]) { result, item in result[String(describing: item.key).lowercased()] = String(describing: item.value) })
}
private static func multipartBody(parts: [(String, String, String, Data)], boundary: String) -> Data {
var body = Data()
for (name, filename, contentType, data) in parts {
body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\nContent-Type: \(contentType)\r\n\r\n".utf8)); body.append(data); body.append(Data("\r\n".utf8))
}
body.append(Data("--\(boundary)--\r\n".utf8)); return body
}
private static func xmlValue(_ xml: String, tag: String) -> String? { let pattern = "<\(tag)>(.*?)</\(tag)>"; guard let range = xml.range(of: pattern, options: .regularExpression) else { return nil }; let value = String(xml[range]); return value.replacingOccurrences(of: "<\(tag)>", with: "").replacingOccurrences(of: "</\(tag)>", with: "") }
private func object(_ values: [String: Any]) -> JSONValue { .object(values.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) }) }
private func object(_ values: (String, Any)...) -> JSONValue { object(values.reduce(into: [:]) { $0[$1.0] = JSONValue(any: $1.1) }) }
private static func generateDeviceID() -> String { Insecure.MD5.hash(data: Data((0..<16).map { _ in UInt8.random(in: 0...255) })).map { String(format: "%02x", $0) }.joined() }
private static func randomHex(_ count: Int) -> String { (0..<count).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined() }
private static func generateTraceparent() -> String { "00-\(randomHex(16))-\(randomHex(8))-01" }
private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) }
private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset..<end))); offset = end }; return Insecure.SHA1.hash(data: hashes).map { String(format: "%02X", $0) }.joined() }
}
private extension JSONValue {
var boolValue: Bool? { if case .bool(let value) = self { return value }; return nil }
}