1
0
Fork 0
pdsman-ios/PDSMan/Services/PDSService.swift
2025-03-20 10:52:32 -06:00

851 lines
28 KiB
Swift

import Foundation
import Combine
import Security
@MainActor
class PDSService: ObservableObject {
private var credentials: PDSCredentials?
private var baseURL: URL?
private var authHeader: String?
// Use a shared URLSession configuration for performance
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 30
config.waitsForConnectivity = true
config.requestCachePolicy = .returnCacheDataElseLoad
return URLSession(configuration: config)
}()
// Session for quick connectivity checks
private lazy var quickSession: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 10
config.timeoutIntervalForResource = 10
config.waitsForConnectivity = false
return URLSession(configuration: config)
}()
// Published properties for UI to observe
@Published var isAuthenticated = false
@Published var inviteCodes: [InviteCode] = []
@Published var users: [PDSUser] = []
@Published var isLoading = false
@Published var errorMessage: String?
// MARK: - Keychain Constants
private struct KeychainConstants {
static let service = "com.atproto.pdsmanager"
static let usernameKey = "username"
static let passwordKey = "password"
static let serverURLKey = "serverURL"
static let authHeaderKey = "authHeader"
}
init() {
// Try to restore previous session on launch
loadCredentialsFromKeychain()
}
// MARK: - Keychain Access
private func saveToKeychain(key: String, value: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: KeychainConstants.service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
// First delete any existing item
SecItemDelete(query as CFDictionary)
// Then add the new item
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}
private func loadFromKeychain(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: KeychainConstants.service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let data = result as? Data, let value = String(data: data, encoding: .utf8) {
return value
}
return nil
}
private func deleteFromKeychain(key: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: KeychainConstants.service,
kSecAttrAccount as String: key
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
private func saveCredentialsToKeychain() {
guard let credentials = credentials else { return }
_ = saveToKeychain(key: KeychainConstants.usernameKey, value: credentials.username)
_ = saveToKeychain(key: KeychainConstants.passwordKey, value: credentials.password)
_ = saveToKeychain(key: KeychainConstants.serverURLKey, value: credentials.serverURL)
if let authHeader = authHeader {
_ = saveToKeychain(key: KeychainConstants.authHeaderKey, value: authHeader)
}
}
private func loadCredentialsFromKeychain() {
guard let username = loadFromKeychain(key: KeychainConstants.usernameKey),
let password = loadFromKeychain(key: KeychainConstants.passwordKey),
let serverURL = loadFromKeychain(key: KeychainConstants.serverURLKey),
let authHeader = loadFromKeychain(key: KeychainConstants.authHeaderKey) else {
return
}
// Restore the credentials
self.credentials = PDSCredentials(serverURL: serverURL, username: username, password: password)
self.baseURL = URL(string: serverURL)
self.authHeader = authHeader
self.isAuthenticated = true
// Refresh data
Task {
await refreshData()
}
}
private func clearCredentialsFromKeychain() {
_ = deleteFromKeychain(key: KeychainConstants.usernameKey)
_ = deleteFromKeychain(key: KeychainConstants.passwordKey)
_ = deleteFromKeychain(key: KeychainConstants.serverURLKey)
_ = deleteFromKeychain(key: KeychainConstants.authHeaderKey)
}
// MARK: - Response Structures
// Invite Code structures
struct InviteCodesResponse: Codable {
var codes: [InviteCodeResponse]
}
struct InviteCodeResponse: Codable {
var code: String
var available: Int
var disabled: Bool
var createdAt: String
var createdBy: String
var uses: [CodeUse]?
}
struct CodeUse: Codable {
var usedBy: String
var usedAt: String
}
struct CreateCodeResponse: Codable {
var code: String
}
// User structures
struct RepoResponse: Codable {
var cursor: String?
var repos: [Repo]
}
struct Repo: Codable {
var did: String
var head: String
var rev: String
var active: Bool
}
struct AccountInfo: Codable {
var did: String
var handle: String
var email: String?
var emailConfirmedAt: String?
var indexedAt: String
var invitedBy: InviteInfo?
var invites: [String]?
var invitesDisabled: Bool?
var createdAt: String {
return indexedAt
}
}
struct InviteInfo: Codable {
var code: String
var available: Int
var disabled: Bool
var forAccount: String
var createdBy: String
var createdAt: String
var uses: [CodeUse]?
}
struct ProfileResponse: Codable {
var value: ProfileValue
}
struct ProfileValue: Codable {
var displayName: String?
var description: String?
var avatar: Avatar?
}
struct Avatar: Codable {
var ref: AvatarRef
}
struct AvatarRef: Codable {
var link: String
enum CodingKeys: String, CodingKey {
case link = "$link"
}
}
// MARK: - Authentication
private func setAuthenticated(_ value: Bool) {
self.isAuthenticated = value
}
func login(credentials: PDSCredentials) async -> Bool {
self.credentials = credentials
self.isLoading = true
defer { self.isLoading = false }
// Clear any previous errors
self.errorMessage = nil
// Validate URL
guard let url = validateServerURL(credentials.serverURL) else {
return false
}
// Test internet connectivity
if await !testInternetConnectivity() {
setError("Network connectivity issue: Cannot reach the internet. Please check your network connection.")
return false
}
self.baseURL = url
// Create Basic Auth header from credentials
guard let authHeader = createAuthHeader(username: credentials.username, password: credentials.password) else {
setError("Invalid authentication data")
return false
}
self.authHeader = authHeader
// Test server connectivity
guard let testURL = URL(string: "\(credentials.serverURL)/xrpc/com.atproto.server.describeServer") else {
setError("Invalid server URL")
return false
}
var request = URLRequest(url: testURL)
request.httpMethod = "GET"
do {
let (data, response) = try await quickSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
setError("Invalid response from server")
return false
}
// If the server responds with 200, we consider it a success
if httpResponse.statusCode == 200 {
setAuthenticated(true)
// Save credentials securely
saveCredentialsToKeychain()
self.errorMessage = nil
// Fetch initial data
await refreshData()
return true
} else {
setError("Server error: \(httpResponse.statusCode)")
return false
}
} catch let error as NSError {
handleNetworkError(error)
return false
}
}
private func refreshData() async {
await fetchInviteCodes()
await fetchUsers()
}
private func validateServerURL(_ urlString: String) -> URL? {
// Validate the URL format
guard let components = URLComponents(string: urlString) else {
setError("Invalid URL format: The server URL is not properly formatted")
return nil
}
// Ensure scheme is https (we now assume all URLs use HTTPS)
guard let scheme = components.scheme, scheme == "https" else {
setError("Invalid URL scheme: URL must use HTTPS")
return nil
}
// Ensure there's a host
guard let host = components.host, !host.isEmpty else {
setError("Invalid URL: Missing or empty host")
return nil
}
return URL(string: urlString)
}
private func createAuthHeader(username: String, password: String) -> String? {
let authString = "\(username):\(password)"
guard let authData = authString.data(using: .utf8) else {
return nil
}
let base64Auth = authData.base64EncodedString()
return "Basic \(base64Auth)"
}
// Test basic internet connectivity by trying to reach a reliable server
private func testInternetConnectivity() async -> Bool {
// Use Apple's captive portal detection as a reliable server to test connectivity
guard let connectivityURL = URL(string: "https://www.apple.com/library/test/success.html") else {
return false
}
do {
let (_, response) = try await quickSession.data(for: URLRequest(url: connectivityURL))
guard let httpResponse = response as? HTTPURLResponse else {
return false
}
return httpResponse.statusCode == 200
} catch {
return false
}
}
func logout() {
// Clear authentication state
setAuthenticated(false)
self.authHeader = nil
self.credentials = nil
self.baseURL = nil
self.users = []
self.inviteCodes = []
self.errorMessage = nil
// Clear saved credentials
clearCredentialsFromKeychain()
}
// MARK: - Network Request Helper
private func performRequest<T: Decodable>(
endpoint: String,
method: String = "GET",
queryItems: [URLQueryItem]? = nil,
body: [String: Any]? = nil,
requiresAuth: Bool = true
) async throws -> T {
guard let baseURL = baseURL else {
throw URLError(.badURL)
}
// Build URL with components
var components = URLComponents(string: "\(baseURL)/xrpc/\(endpoint)")
if let queryItems = queryItems {
components?.queryItems = queryItems
}
guard let url = components?.url else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = method
// Add authorization if required
if requiresAuth, let authHeader = authHeader {
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
}
// Add body if provided
if let body = body {
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200...299 ~= httpResponse.statusCode else {
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
throw NSError(
domain: "PDSServiceError",
code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Server returned \(httpResponse.statusCode): \(errorMessage)"]
)
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(T.self, from: data)
}
// Simplified request for endpoints that return no content
private func performEmptyRequest(
endpoint: String,
method: String = "POST",
body: [String: Any]? = nil,
requiresAuth: Bool = true
) async throws {
guard let baseURL = baseURL else {
throw URLError(.badURL)
}
guard let url = URL(string: "\(baseURL)/xrpc/\(endpoint)") else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.httpMethod = method
// Add authorization if required
if requiresAuth, let authHeader = authHeader {
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
}
// Add body if provided
if let body = body {
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200...299 ~= httpResponse.statusCode else {
let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error"
throw NSError(
domain: "PDSServiceError",
code: httpResponse.statusCode,
userInfo: [NSLocalizedDescriptionKey: "Server returned \(httpResponse.statusCode): \(errorMessage)"]
)
}
}
// MARK: - Error Handling
private func handleNetworkError(_ error: NSError) {
if error.domain == NSURLErrorDomain {
switch error.code {
case NSURLErrorCannotFindHost:
setError("Cannot find host: The server URL appears to be invalid or the server is unreachable")
case NSURLErrorCannotConnectToHost:
setError("Cannot connect to host: The server exists but isn't responding")
case NSURLErrorNetworkConnectionLost:
setError("Connection lost: The network connection was lost during the request")
case NSURLErrorNotConnectedToInternet:
setError("No internet connection: Please check your network settings")
case NSURLErrorTimedOut:
setError("Connection timed out: The server took too long to respond")
default:
setError("Network error (\(error.code)): \(error.localizedDescription)")
}
} else {
setError("Connection error: \(error.localizedDescription)")
}
}
private func setError(_ message: String) {
self.errorMessage = message
}
// MARK: - Invite Codes
func fetchInviteCodes() async {
self.isLoading = true
defer { self.isLoading = false }
guard isAuthenticated else { return }
do {
let queryItems = [
URLQueryItem(name: "sort", value: "recent"),
URLQueryItem(name: "limit", value: "100"),
URLQueryItem(name: "includeDisabled", value: "true")
]
let codesResponse: InviteCodesResponse = try await performRequest(
endpoint: "com.atproto.admin.getInviteCodes",
queryItems: queryItems
)
// Map the response to our model
let parsedCodes = codesResponse.codes.map { codeResp -> InviteCode in
let createdDate = ISO8601DateFormatter.shared.date(from: codeResp.createdAt) ?? Date()
// Convert the uses array
let inviteUses = codeResp.uses?.map { use -> PDSMan.CodeUse in
return PDSMan.CodeUse(usedBy: use.usedBy, usedAt: use.usedAt)
}
return InviteCode(
id: codeResp.code,
uses: inviteUses,
createdAt: createdDate,
disabled: codeResp.disabled
)
}
// Update the inviteCodes property
self.inviteCodes = parsedCodes
self.objectWillChange.send()
} catch {
setError("Error fetching invite codes: \(error.localizedDescription)")
}
}
func createInviteCode(maxUses: Int = 1) async -> InviteCode? {
guard isAuthenticated else { return nil }
do {
let createBody = ["useCount": maxUses]
let codeResponse: CreateCodeResponse = try await performRequest(
endpoint: "com.atproto.server.createInviteCode",
method: "POST",
body: createBody
)
// Create a new InviteCode object
let newCode = InviteCode(
id: codeResponse.code,
uses: [] as [PDSMan.CodeUse]?,
createdAt: Date(),
disabled: false
)
// Update the local list
self.inviteCodes.append(newCode)
self.objectWillChange.send()
// Also refresh the full list to ensure we have the most up-to-date data
await fetchInviteCodes()
return newCode
} catch {
setError("Failed to create invite code: \(error.localizedDescription)")
return nil
}
}
func disableInviteCode(_ code: String) async -> Bool {
guard isAuthenticated else { return false }
do {
// Create the request body
let disableBody = ["codes": [code]]
try await performEmptyRequest(
endpoint: "com.atproto.admin.disableInviteCodes",
body: disableBody
)
// Refresh the invite codes list
await fetchInviteCodes()
return true
} catch {
setError("Failed to disable code: \(error.localizedDescription)")
return false
}
}
// MARK: - Users
func fetchUsers() async {
self.isLoading = true
defer { self.isLoading = false }
guard isAuthenticated else { return }
do {
// First, get a list of all repos (users) on the server
let reposResult: RepoResponse = try await performRequest(
endpoint: "com.atproto.sync.listRepos"
)
// Fetch individual user profiles
var fetchedUsers: [PDSUser] = []
// Use task groups for concurrent fetching of user profiles
await withTaskGroup(of: PDSUser?.self) { group in
for repo in reposResult.repos {
group.addTask {
await self.fetchUserProfile(did: repo.did, isActive: repo.active)
}
}
// Collect results as they complete
for await user in group {
if let user = user {
fetchedUsers.append(user)
}
}
}
// Sort users by join date (newest first)
fetchedUsers.sort { $0.joinedAt > $1.joinedAt }
// Update the users property
self.users = fetchedUsers
self.objectWillChange.send()
} catch {
setError("Error fetching users: \(error.localizedDescription)")
}
}
private func fetchUserProfile(did: String, isActive: Bool = true) async -> PDSUser? {
guard isAuthenticated else { return nil }
do {
// 1. Fetch account info
let queryItems = [URLQueryItem(name: "did", value: did)]
let accountInfo: AccountInfo = try await performRequest(
endpoint: "com.atproto.admin.getAccountInfo",
queryItems: queryItems
)
// 2. Try to fetch profile data (optional)
var displayName = accountInfo.handle
var description = ""
var avatarURL: URL? = nil
// Try to fetch profile record
do {
let profileQueryItems = [
URLQueryItem(name: "collection", value: "app.bsky.actor.profile"),
URLQueryItem(name: "repo", value: did),
URLQueryItem(name: "rkey", value: "self")
]
let profileRecord: ProfileResponse = try await performRequest(
endpoint: "com.atproto.repo.getRecord",
queryItems: profileQueryItems
)
if let name = profileRecord.value.displayName, !name.isEmpty {
displayName = name
}
if let desc = profileRecord.value.description, !desc.isEmpty {
description = desc
}
if let avatar = profileRecord.value.avatar, let baseURL = baseURL {
// Construct avatar URL
avatarURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.getBlob?did=\(did)&cid=\(avatar.ref.link)")
}
} catch {
// Continue with basic info if profile fetch fails
}
let joinedDate = ISO8601DateFormatter.shared.date(from: accountInfo.indexedAt) ?? Date()
return PDSUser(
id: accountInfo.did,
handle: accountInfo.handle,
displayName: displayName,
description: description,
joinedAt: joinedDate,
avatar: avatarURL,
isActive: isActive
)
} catch {
// If account info fetch fails, create a basic user
return createBasicUser(did: did, handle: did, isActive: isActive)
}
}
// Helper method to create a basic user with minimal info
private func createBasicUser(did: String, handle: String, isActive: Bool) -> PDSUser {
return PDSUser(
id: did,
handle: handle,
displayName: handle,
description: "",
joinedAt: Date(),
avatar: nil,
isActive: isActive
)
}
func editUserHandle(userId: String, newHandle: String) async -> Bool {
guard isAuthenticated else { return false }
do {
// Create the request body
let updateBody: [String: String] = [
"did": userId,
"handle": newHandle
]
try await performEmptyRequest(
endpoint: "com.atproto.admin.updateAccountHandle",
body: updateBody
)
// Refresh the users list to show the updated handle
await fetchUsers()
return true
} catch {
setError("Failed to update handle: \(error.localizedDescription)")
return false
}
}
func resetUserPassword(userId: String) async -> Bool {
guard isAuthenticated else { return false }
do {
let resetBody = ["did": userId]
try await performEmptyRequest(
endpoint: "com.atproto.admin.resetPassword",
body: resetBody
)
return true
} catch {
setError("Failed to reset password: \(error.localizedDescription)")
return false
}
}
func sendResetEmail(userId: String) async -> Bool {
guard isAuthenticated else { return false }
do {
let emailBody = [
"recipientDid": userId,
"subject": "Password Reset",
"body": "Click the link to reset your password."
]
try await performEmptyRequest(
endpoint: "com.atproto.admin.sendEmail",
body: emailBody
)
return true
} catch {
setError("Failed to send reset email: \(error.localizedDescription)")
return false
}
}
func deleteUser(userId: String) async -> Bool {
guard isAuthenticated else { return false }
do {
let deleteBody = ["did": userId]
try await performEmptyRequest(
endpoint: "com.atproto.admin.deleteAccount",
body: deleteBody
)
// Update the local list
self.users.removeAll { $0.id == userId }
return true
} catch {
setError("Failed to delete user: \(error.localizedDescription)")
return false
}
}
func suspendUser(userId: String, reason: String) async -> Bool {
guard isAuthenticated else { return false }
do {
// Create the request body
let suspendBody: [String: Any] = [
"did": userId,
"reason": reason
]
try await performEmptyRequest(
endpoint: "com.atproto.admin.disableAccountByDid",
body: suspendBody
)
// Refresh the users list to show the updated status
await fetchUsers()
return true
} catch {
setError("Failed to suspend user: \(error.localizedDescription)")
return false
}
}
func reactivateUser(userId: String) async -> Bool {
guard isAuthenticated else { return false }
do {
// Create the request body
let reactivateBody: [String: String] = [
"did": userId
]
try await performEmptyRequest(
endpoint: "com.atproto.admin.enableAccountByDid",
body: reactivateBody
)
// Refresh the users list to show the updated status
await fetchUsers()
return true
} catch {
setError("Failed to reactivate user: \(error.localizedDescription)")
return false
}
}
}