HuaweiOBSUploader.swift 7.94 KB
import Foundation
import OBS

enum HuaweiOBSResourceType {
    case image
    case video

    fileprivate var fileType: String {
        switch self {
        case .image: return "png"
        case .video: return "mp4"
        }
    }

    fileprivate var contentType: OBSContentType {
        switch self {
        case .image: return .PNG
        case .video: return .MP4
        }
    }
}

enum HuaweiOBSUploadError: LocalizedError {
    case invalidServerURL
    case invalidFilePath(String)
    case missingAccessToken
    case tokenRequestFailed(statusCode: Int, message: String?)
    case invalidTokenResponse
    case uploadFailed(statusCode: String?, underlyingError: Error?)

    var errorDescription: String? {
        switch self {
        case .invalidServerURL:
            return "Invalid OBS token server URL."
        case .invalidFilePath(let path):
            return "Upload file does not exist or is not a regular file: \(path)"
        case .missingAccessToken:
            return "An access token is required before uploading a file."
        case .tokenRequestFailed(let statusCode, let message):
            return "Requesting OBS credentials failed (HTTP \(statusCode))\(message.map { ": \($0)" } ?? "")."
        case .invalidTokenResponse:
            return "The OBS credential response is incomplete."
        case .uploadFailed(let statusCode, let underlyingError):
            if let underlyingError {
                return "OBS upload failed: \(underlyingError.localizedDescription)"
            }
            return "OBS upload failed with status \(statusCode ?? "unknown")."
        }
    }
}

/// Requests a short-lived OBS credential from DoubleFeel and uploads a local file
/// with Huawei Cloud's OBS SDK.
final class HuaweiOBSUploader {
    static let shared = HuaweiOBSUploader()

    private let session: URLSession
    private let clientLock = NSLock()
    private var retainedClients: [UUID: OBSClient] = [:]

    init(session: URLSession = .shared) {
        self.session = session
    }

    func upload(filePath: String, resourceType: HuaweiOBSResourceType) async throws -> String {
        var isDirectory: ObjCBool = false
        guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDirectory),
              !isDirectory.boolValue else {
            throw HuaweiOBSUploadError.invalidFilePath(filePath)
        }

        let config = try await requestUploadConfig(resourceType: resourceType)
        try await putFile(at: filePath, resourceType: resourceType, config: config)
        return config.fileURL
    }

    private func requestUploadConfig(resourceType: HuaweiOBSResourceType) async throws -> UploadConfig {
        guard let baseURL = URL(string: AppShared.shared.baseUrl) else {
            throw HuaweiOBSUploadError.invalidServerURL
        }
        guard let accessToken = AppShared.shared.token,
              !accessToken.isEmpty else {
            throw HuaweiOBSUploadError.missingAccessToken
        }

        let url = baseURL.appendingPathComponent("client/doublefeel/obs/temp_token/")
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue(accessToken, forHTTPHeaderField: "access_token")
        request.httpBody = try JSONSerialization.data(withJSONObject: [
            "file_type": resourceType.fileType,
            "scene": "avatar",
            "count": 1
        ])

        let (data, response) = try await session.data(for: request)
        guard let response = response as? HTTPURLResponse else {
            throw HuaweiOBSUploadError.tokenRequestFailed(statusCode: -1, message: nil)
        }
        guard 200..<300 ~= response.statusCode else {
            throw HuaweiOBSUploadError.tokenRequestFailed(
                statusCode: response.statusCode,
                message: String(data: data, encoding: .utf8)
            )
        }

        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        let tokenInfo = try decoder.decode(OBSTempTokenInfo.self, from: data)
        guard let token = tokenInfo.token,
              let accessKey = token.accessKey, !accessKey.isEmpty,
              let secretKey = token.secretKey, !secretKey.isEmpty,
              let securityToken = token.securityToken, !securityToken.isEmpty,
              let objectKey = tokenInfo.keys?.first, !objectKey.isEmpty,
              let host = tokenInfo.host, !host.isEmpty,
              let endpoint = tokenInfo.endpoint, !endpoint.isEmpty,
              let bucket = tokenInfo.bucket, !bucket.isEmpty else {
            throw HuaweiOBSUploadError.invalidTokenResponse
        }

        return UploadConfig(
            accessKey: accessKey,
            secretKey: secretKey,
            securityToken: securityToken,
            objectKey: objectKey,
            host: normalizedHTTPSURL(host),
            endpoint: normalizedHTTPSURL(endpoint),
            bucket: bucket
        )
    }

    private func putFile(
        at filePath: String,
        resourceType: HuaweiOBSResourceType,
        config: UploadConfig
    ) async throws {
        let credentialProvider = OBSStaticCredentialProvider(
            accessKey: config.accessKey,
            secretKey: config.secretKey
        )
        credentialProvider?.securityToken = config.securityToken

        let serviceConfiguration = OBSServiceConfiguration(
            urlString: config.endpoint,
            credentialProvider: credentialProvider
        )
        guard let client = OBSClient(configuration: serviceConfiguration),
              let uploadRequest = OBSPutObjectWithFileRequest(
                bucketName: config.bucket,
                objectKey: config.objectKey,
                uploadFilePath: filePath
              ) else {
            throw HuaweiOBSUploadError.invalidTokenResponse
        }
        uploadRequest.contentType = resourceType.contentType

        let clientID = UUID()
        retain(client, id: clientID)
        defer { releaseClient(id: clientID) }

        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            client.putObject(uploadRequest) { response, error in
                if let error {
                    continuation.resume(throwing: HuaweiOBSUploadError.uploadFailed(
                        statusCode: response?.statusCode,
                        underlyingError: error
                    ))
                } else if response?.statusCode == "200" {
                    continuation.resume(returning: ())
                } else {
                    continuation.resume(throwing: HuaweiOBSUploadError.uploadFailed(
                        statusCode: response?.statusCode,
                        underlyingError: nil
                    ))
                }
            }
        }
    }

    private func normalizedHTTPSURL(_ value: String) -> String {
        value.hasPrefix("http://") || value.hasPrefix("https://") ? value : "https://\(value)"
    }

    private func retain(_ client: OBSClient, id: UUID) {
        clientLock.lock()
        retainedClients[id] = client
        clientLock.unlock()
    }

    private func releaseClient(id: UUID) {
        clientLock.lock()
        retainedClients[id] = nil
        clientLock.unlock()
    }
}

private extension HuaweiOBSUploader {
    struct UploadConfig {
        let accessKey: String
        let secretKey: String
        let securityToken: String
        let objectKey: String
        let host: String
        let endpoint: String
        let bucket: String

        var fileURL: String {
            "\(host.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/\(objectKey)"
        }
    }

    struct OBSTempTokenInfo: Decodable {
        let token: OBSToken?
        let keys: [String]?
        let host: String?
        let endpoint: String?
        let bucket: String?
    }

    struct OBSToken: Decodable {
        let accessKey: String?
        let secretKey: String?
        let securityToken: String?
    }
}