378 lines
29 KiB
Swift
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 }
|
|
}
|