mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
597 lines
22 KiB
Swift
597 lines
22 KiB
Swift
import Foundation
|
|
|
|
public final class ForgejoClient: Sendable {
|
|
public let serverURL: String
|
|
public let username: String
|
|
|
|
private enum AuthCredential {
|
|
case basic(base64: String)
|
|
case token(String)
|
|
case bearer(String)
|
|
}
|
|
|
|
private let credential: AuthCredential
|
|
private let serverHost: String?
|
|
private let urlSessionManager: URLSessionManager
|
|
|
|
private init(serverURL: String, username: String, credential: AuthCredential, allowSelfSignedCertificates: Bool) {
|
|
let normalized = Self.normalizeServerURL(serverURL)
|
|
self.serverURL = normalized
|
|
self.username = username
|
|
self.credential = credential
|
|
serverHost = URL(string: normalized)?.host
|
|
urlSessionManager = URLSessionManager(
|
|
allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
trustedHost: URL(string: normalized)?.host,
|
|
)
|
|
}
|
|
|
|
public convenience init(
|
|
serverURL: String, username: String, password: String,
|
|
allowSelfSignedCertificates: Bool = false,
|
|
) {
|
|
let credentials = "\(username):\(password)"
|
|
self.init(
|
|
serverURL: serverURL, username: username,
|
|
credential: .basic(base64: Data(credentials.utf8).base64EncodedString()),
|
|
allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
)
|
|
}
|
|
|
|
public convenience init(
|
|
serverURL: String, username: String, token: String,
|
|
allowSelfSignedCertificates: Bool = false,
|
|
) {
|
|
self.init(
|
|
serverURL: serverURL, username: username,
|
|
credential: .token(token),
|
|
allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
)
|
|
}
|
|
|
|
/// Creates a client authenticated with an OAuth2 access token.
|
|
///
|
|
/// OAuth2 access tokens are sent with the `Authorization: Bearer` scheme,
|
|
/// unlike personal access tokens which use `Authorization: token`.
|
|
public convenience init(
|
|
serverURL: String, username: String, bearerToken: String,
|
|
allowSelfSignedCertificates: Bool = false,
|
|
) {
|
|
self.init(
|
|
serverURL: serverURL, username: username,
|
|
credential: .bearer(bearerToken),
|
|
allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
)
|
|
}
|
|
|
|
public static func login(
|
|
serverURL: String, username: String, password: String,
|
|
allowSelfSignedCertificates: Bool = false,
|
|
) async throws -> LoginResult {
|
|
let basicClient = ForgejoClient(
|
|
serverURL: serverURL, username: username,
|
|
password: password, allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
)
|
|
let user = try await basicClient.validateCredentials()
|
|
let token = try await basicClient.createAPIToken(otp: nil)
|
|
let tokenClient = ForgejoClient(
|
|
serverURL: serverURL, username: username,
|
|
token: token, allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
)
|
|
return LoginResult(client: tokenClient, user: user, token: token)
|
|
}
|
|
|
|
public static func loginWithOTP(
|
|
serverURL: String, username: String, password: String, otp: String,
|
|
allowSelfSignedCertificates: Bool = false,
|
|
) async throws -> LoginResult {
|
|
let basicClient = ForgejoClient(
|
|
serverURL: serverURL, username: username,
|
|
password: password, allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
)
|
|
let user = try await basicClient.validateCredentials(otp: otp)
|
|
let token = try await basicClient.createAPIToken(otp: otp)
|
|
let tokenClient = ForgejoClient(
|
|
serverURL: serverURL, username: username,
|
|
token: token, allowSelfSignedCertificates: allowSelfSignedCertificates,
|
|
)
|
|
return LoginResult(client: tokenClient, user: user, token: token)
|
|
}
|
|
|
|
public func fetchCurrentUser() async throws -> User {
|
|
try await validateCredentials()
|
|
}
|
|
|
|
private static let certificateErrorCodes: Set<URLError.Code> = [
|
|
.serverCertificateUntrusted, .secureConnectionFailed,
|
|
.serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
|
|
]
|
|
|
|
private func validateCredentials(otp: String? = nil) async throws -> User {
|
|
guard let url = URL(string: "\(serverURL)/api/v1/user") else {
|
|
throw AuthenticationError.invalidURL
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
applyAuthHeader(to: &request)
|
|
if let otp {
|
|
request.setValue(otp, forHTTPHeaderField: "X-Forgejo-OTP")
|
|
}
|
|
|
|
do {
|
|
let (data, response) = try await urlSessionManager.session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw AuthenticationError.invalidResponse
|
|
}
|
|
|
|
switch httpResponse.statusCode {
|
|
case 200:
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
|
return try decoder.decode(User.self, from: data)
|
|
default:
|
|
let body = String(data: data, encoding: .utf8) ?? ""
|
|
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
|
}
|
|
} catch let error as URLError where Self.certificateErrorCodes.contains(error.code) {
|
|
throw AuthenticationError.certificateError
|
|
}
|
|
}
|
|
|
|
private func applyAuthHeader(to request: inout URLRequest) {
|
|
request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization")
|
|
}
|
|
|
|
/// The `Authorization` header value for this client's credential.
|
|
/// Exposed internally so tests can assert the scheme without issuing a request.
|
|
var authorizationHeaderValue: String {
|
|
switch credential {
|
|
case let .basic(base64):
|
|
"Basic \(base64)"
|
|
case let .token(token):
|
|
"token \(token)"
|
|
case let .bearer(token):
|
|
"Bearer \(token)"
|
|
}
|
|
}
|
|
|
|
private static let tokenScopes = [
|
|
"read:user", "write:user",
|
|
"read:repository", "write:repository",
|
|
"read:issue", "write:issue",
|
|
"read:notification", "write:notification",
|
|
"read:organization",
|
|
]
|
|
|
|
private func createAPIToken(otp: String?) async throws -> String {
|
|
let encodedUsername = Self.encodedPathSegment(username)
|
|
let urlString = "\(serverURL)/api/v1/users/\(encodedUsername)/tokens"
|
|
|
|
guard let url = URL(string: urlString) else {
|
|
throw AuthenticationError.invalidURL
|
|
}
|
|
|
|
let suffix = String((0 ..< 6).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
|
|
let tokenName = "Forji-\(suffix)"
|
|
return try await postTokenRequest(url: url, name: tokenName, otp: otp)
|
|
}
|
|
|
|
private func postTokenRequest(url: URL, name: String, otp: String?, retryCount: Int = 0) async throws -> String {
|
|
guard case let .basic(base64) = credential else {
|
|
throw AuthenticationError.invalidResponse
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "POST"
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
|
|
if let otp { request.setValue(otp, forHTTPHeaderField: "X-Forgejo-OTP") }
|
|
request.httpBody = try JSONSerialization.data(
|
|
withJSONObject: ["name": name, "scopes": Self.tokenScopes] as [String: Any],
|
|
)
|
|
|
|
let (data, response) = try await urlSessionManager.session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw AuthenticationError.invalidResponse
|
|
}
|
|
|
|
switch httpResponse.statusCode {
|
|
case 201:
|
|
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let sha1 = json["sha1"] as? String
|
|
else { throw AuthenticationError.invalidResponse }
|
|
return sha1
|
|
case 400, 422:
|
|
// Token name already taken, Forgejo returns 400, some versions use 422
|
|
let body = String(data: data, encoding: .utf8) ?? ""
|
|
guard body.contains("name") else {
|
|
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
|
}
|
|
guard retryCount < 3 else {
|
|
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
|
}
|
|
let retryName = "\(name)-\(Int(Date().timeIntervalSince1970))"
|
|
return try await postTokenRequest(
|
|
url: url, name: retryName, otp: otp, retryCount: retryCount + 1,
|
|
)
|
|
default:
|
|
let body = String(data: data, encoding: .utf8) ?? ""
|
|
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
|
}
|
|
}
|
|
|
|
func authenticatedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method
|
|
if let serverHost, let requestHost = url.host,
|
|
serverHost.lowercased() == requestHost.lowercased()
|
|
{
|
|
applyAuthHeader(to: &request)
|
|
}
|
|
if let body {
|
|
request.httpBody = body
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
}
|
|
return request
|
|
}
|
|
|
|
private func executeRequest(
|
|
url: URL, method: String = "GET", body: Data? = nil,
|
|
) async throws -> (Data, HTTPURLResponse) {
|
|
let request = authenticatedRequest(url: url, method: method, body: body)
|
|
let session = urlSessionManager.session
|
|
let (data, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw ServiceError.invalidResponse
|
|
}
|
|
return (data, httpResponse)
|
|
}
|
|
|
|
private func validateStatus(_ httpResponse: HTTPURLResponse, data: Data) throws {
|
|
guard (200 ... 299).contains(httpResponse.statusCode) else {
|
|
let serverMessage = String(data: data, encoding: .utf8) ?? ""
|
|
throw ServiceError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
|
|
}
|
|
}
|
|
|
|
func performRequest<T: Decodable>(
|
|
url: URL, method: String = "GET",
|
|
body: Data? = nil, responseType _: T.Type,
|
|
) async throws -> T {
|
|
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
|
|
try validateStatus(httpResponse, data: data)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
|
|
|
do {
|
|
return try decoder.decode(T.self, from: data)
|
|
} catch let error as DecodingError {
|
|
throw ServiceError.decodingFailed(detail: describeDecodingError(error))
|
|
}
|
|
}
|
|
|
|
func performRequestNoContent(
|
|
url: URL, method: String = "GET",
|
|
body: Data? = nil, validateStatus shouldValidate: Bool = false,
|
|
) async throws -> Int {
|
|
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
|
|
if shouldValidate {
|
|
try validateStatus(httpResponse, data: data)
|
|
}
|
|
return httpResponse.statusCode
|
|
}
|
|
|
|
func performRequestRawText(url: URL, method: String = "GET") async throws -> String {
|
|
let (data, httpResponse) = try await executeRequest(url: url, method: method)
|
|
try validateStatus(httpResponse, data: data)
|
|
|
|
guard let text = String(data: data, encoding: .utf8) else {
|
|
throw ServiceError.invalidResponse
|
|
}
|
|
|
|
return text
|
|
}
|
|
|
|
func performRequestData(url: URL, method: String = "GET", body: Data? = nil) async throws -> Data {
|
|
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
|
|
try validateStatus(httpResponse, data: data)
|
|
return data
|
|
}
|
|
|
|
/// Issues a GET to `url` with redirects disabled and returns the absolute
|
|
/// `Location` header from the response, if any.
|
|
///
|
|
/// Used to discover server-side redirects (e.g. Forgejo's
|
|
/// `RedirectToLatestAttempt`) without consuming the redirect target.
|
|
func discoverRedirectLocation(url: URL) async throws -> URL? {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = "GET"
|
|
if let serverHost, let requestHost = url.host,
|
|
serverHost.lowercased() == requestHost.lowercased()
|
|
{
|
|
applyAuthHeader(to: &request)
|
|
}
|
|
let delegate = NoRedirectDelegate()
|
|
let session = URLSession(
|
|
configuration: .default, delegate: delegate, delegateQueue: nil,
|
|
)
|
|
defer { session.finishTasksAndInvalidate() }
|
|
let (_, response) = try await session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse,
|
|
(300 ... 399).contains(httpResponse.statusCode),
|
|
let location = httpResponse.value(forHTTPHeaderField: "Location")
|
|
else {
|
|
return nil
|
|
}
|
|
return URL(string: location, relativeTo: url)?.absoluteURL
|
|
}
|
|
}
|
|
|
|
private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|
func urlSession(
|
|
_: URLSession, task _: URLSessionTask,
|
|
willPerformHTTPRedirection _: HTTPURLResponse,
|
|
newRequest _: URLRequest,
|
|
completionHandler: @escaping (URLRequest?) -> Void,
|
|
) {
|
|
completionHandler(nil)
|
|
}
|
|
}
|
|
|
|
extension ForgejoClient {
|
|
/// Characters allowed in a single URL path segment (`.urlPathAllowed` minus `/`).
|
|
private static let pathSegmentAllowed: CharacterSet = {
|
|
var allowed = CharacterSet.urlPathAllowed
|
|
allowed.remove(charactersIn: "/")
|
|
return allowed
|
|
}()
|
|
|
|
/// Percent-encodes a single path segment (e.g. owner or repo name) for safe URL interpolation.
|
|
static func encodedPathSegment(_ segment: String) -> String {
|
|
segment.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) ?? segment
|
|
}
|
|
|
|
func makeURL(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
|
guard var components = URLComponents(string: "\(serverURL)\(path)") else {
|
|
throw ServiceError.invalidURL
|
|
}
|
|
if !queryItems.isEmpty { components.queryItems = queryItems }
|
|
guard let url = components.url else { throw ServiceError.invalidURL }
|
|
return url
|
|
}
|
|
|
|
func makeRepoURL(owner: String, repo: String, path: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
|
let enc = (Self.encodedPathSegment(owner), Self.encodedPathSegment(repo))
|
|
return try makeURL(path: "/api/v1/repos/\(enc.0)/\(enc.1)\(path)", queryItems: queryItems)
|
|
}
|
|
|
|
func encodeRequestBody(_ body: some Encodable) throws -> Data {
|
|
try JSONEncoder().encode(body)
|
|
}
|
|
|
|
/// Builds a multipart/form-data body with a single file part named "attachment".
|
|
static func buildMultipartBody(
|
|
fileData: Data,
|
|
fileName: String,
|
|
mimeType: String,
|
|
boundary: String,
|
|
) -> Data {
|
|
var body = Data()
|
|
body.append(contentsOf: "--\(boundary)\r\n".utf8)
|
|
let disposition = "Content-Disposition: form-data; name=\"attachment\"; filename=\"\(fileName)\"\r\n"
|
|
body.append(contentsOf: disposition.utf8)
|
|
body.append(contentsOf: "Content-Type: \(mimeType)\r\n\r\n".utf8)
|
|
body.append(fileData)
|
|
body.append(contentsOf: "\r\n--\(boundary)--\r\n".utf8)
|
|
return body
|
|
}
|
|
|
|
func performMultipartRequest<T: Decodable>(
|
|
url: URL,
|
|
fileData: Data,
|
|
fileName: String,
|
|
mimeType: String,
|
|
boundary: String = UUID().uuidString,
|
|
responseType: T.Type,
|
|
) async throws -> T {
|
|
let body = Self.buildMultipartBody(
|
|
fileData: fileData, fileName: fileName, mimeType: mimeType, boundary: boundary,
|
|
)
|
|
var request = authenticatedRequest(url: url, method: "POST")
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = body
|
|
|
|
let (data, response) = try await urlSessionManager.session.data(for: request)
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
throw ServiceError.invalidResponse
|
|
}
|
|
try validateStatus(httpResponse, data: data)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
|
do {
|
|
return try decoder.decode(responseType, from: data)
|
|
} catch let error as DecodingError {
|
|
throw ServiceError.decodingFailed(detail: describeDecodingError(error))
|
|
}
|
|
}
|
|
|
|
public static func normalizeServerURL(_ url: String) -> String {
|
|
var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
while normalized.hasSuffix("/") {
|
|
normalized = String(normalized.dropLast())
|
|
}
|
|
if !normalized.hasPrefix("http://"), !normalized.hasPrefix("https://") {
|
|
normalized = "https://" + normalized
|
|
}
|
|
return normalized
|
|
}
|
|
}
|
|
|
|
public struct LoginResult: Sendable {
|
|
public let client: ForgejoClient
|
|
public let user: User
|
|
public let token: String
|
|
}
|
|
|
|
public enum HTTPErrorCategory: Equatable, Sendable {
|
|
case authentication
|
|
case permissionDenied
|
|
case notFound
|
|
case server
|
|
case other
|
|
|
|
public init(statusCode: Int) {
|
|
switch statusCode {
|
|
case 401:
|
|
self = .authentication
|
|
case 403:
|
|
self = .permissionDenied
|
|
case 404:
|
|
self = .notFound
|
|
case 500 ... 599:
|
|
self = .server
|
|
default:
|
|
self = .other
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum AuthenticationError: LocalizedError, Sendable, Equatable {
|
|
case invalidURL
|
|
case invalidCredentials
|
|
case otpRequired
|
|
case serverNotFound
|
|
case invalidResponse
|
|
case certificateError
|
|
case unknownError(statusCode: Int)
|
|
|
|
static func from(statusCode: Int, body: String = "") -> AuthenticationError {
|
|
switch statusCode {
|
|
case 401:
|
|
body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials
|
|
case 404:
|
|
.serverNotFound
|
|
default:
|
|
.unknownError(statusCode: statusCode)
|
|
}
|
|
}
|
|
|
|
public var httpStatusCode: Int? {
|
|
switch self {
|
|
case .invalidCredentials, .otpRequired:
|
|
401
|
|
case .serverNotFound:
|
|
404
|
|
case let .unknownError(statusCode):
|
|
statusCode
|
|
case .invalidURL, .invalidResponse, .certificateError:
|
|
nil
|
|
}
|
|
}
|
|
|
|
public var httpErrorCategory: HTTPErrorCategory? {
|
|
guard let httpStatusCode else {
|
|
return nil
|
|
}
|
|
return HTTPErrorCategory(statusCode: httpStatusCode)
|
|
}
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL:
|
|
"Invalid server URL"
|
|
case .invalidCredentials:
|
|
"Invalid username or password"
|
|
case .otpRequired:
|
|
"Two-factor authentication code required"
|
|
case .serverNotFound:
|
|
"Server not found. Please check the URL."
|
|
case .invalidResponse:
|
|
"Invalid response from server"
|
|
case .certificateError:
|
|
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
|
|
case let .unknownError(statusCode):
|
|
switch HTTPErrorCategory(statusCode: statusCode) {
|
|
case .permissionDenied:
|
|
"Permission denied (Status: \(statusCode))"
|
|
case .server:
|
|
"Server error occurred (Status: \(statusCode))"
|
|
case .authentication, .notFound, .other:
|
|
"Unknown error occurred (Status: \(statusCode))"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum ServiceError: LocalizedError, Sendable, Equatable {
|
|
case noActiveInstance
|
|
case invalidURL
|
|
case invalidResponse
|
|
case httpError(statusCode: Int, message: String = "")
|
|
case notMergeable
|
|
case mergeConflict
|
|
case decodingFailed(detail: String)
|
|
|
|
public var httpStatusCode: Int? {
|
|
guard case let .httpError(statusCode, _) = self else {
|
|
return nil
|
|
}
|
|
return statusCode
|
|
}
|
|
|
|
public var httpErrorCategory: HTTPErrorCategory? {
|
|
guard let httpStatusCode else {
|
|
return nil
|
|
}
|
|
return HTTPErrorCategory(statusCode: httpStatusCode)
|
|
}
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noActiveInstance:
|
|
"No active Forgejo instance"
|
|
case .invalidURL:
|
|
"Invalid URL"
|
|
case .invalidResponse:
|
|
"Invalid response from server"
|
|
case let .httpError(statusCode, message):
|
|
if let message = Self.normalizedHTTPMessage(message) {
|
|
"HTTP \(statusCode): \(message)"
|
|
} else {
|
|
"HTTP error: \(statusCode)"
|
|
}
|
|
case .notMergeable:
|
|
"This pull request cannot be merged"
|
|
case .mergeConflict:
|
|
"There are merge conflicts that must be resolved"
|
|
case let .decodingFailed(detail):
|
|
"Decoding failed: \(detail)"
|
|
}
|
|
}
|
|
|
|
private struct ForgejoErrorBody: Decodable {
|
|
let message: String?
|
|
}
|
|
|
|
static func normalizedHTTPMessage(_ message: String) -> String? {
|
|
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
return nil
|
|
}
|
|
guard
|
|
let data = trimmed.data(using: .utf8),
|
|
let body = try? JSONDecoder().decode(ForgejoErrorBody.self, from: data)
|
|
else {
|
|
return trimmed
|
|
}
|
|
guard let jsonMessage = body.message else {
|
|
return trimmed
|
|
}
|
|
let trimmedJSONMessage = jsonMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmedJSONMessage.isEmpty ? nil : trimmedJSONMessage
|
|
}
|
|
}
|