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 = [ .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( 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( 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 } }