1052 lines
40 KiB
Swift
1052 lines
40 KiB
Swift
import Foundation
|
|
import Combine
|
|
import Security
|
|
|
|
@MainActor
|
|
class PDSService: ObservableObject {
|
|
private var credentials: PDSCredentials?
|
|
private var baseURL: URL?
|
|
private var authHeader: String?
|
|
private var session = URLSession.shared
|
|
|
|
// 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 {
|
|
if let data = value.data(using: .utf8) {
|
|
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
|
|
}
|
|
return false
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
print("Credentials saved to keychain")
|
|
}
|
|
|
|
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 {
|
|
print("No credentials found in keychain")
|
|
return
|
|
}
|
|
|
|
print("Credentials loaded from keychain")
|
|
|
|
// 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 fetchInviteCodes()
|
|
await fetchUsers()
|
|
}
|
|
}
|
|
|
|
private func clearCredentialsFromKeychain() {
|
|
_ = deleteFromKeychain(key: KeychainConstants.usernameKey)
|
|
_ = deleteFromKeychain(key: KeychainConstants.passwordKey)
|
|
_ = deleteFromKeychain(key: KeychainConstants.serverURLKey)
|
|
_ = deleteFromKeychain(key: KeychainConstants.authHeaderKey)
|
|
print("Credentials cleared from keychain")
|
|
}
|
|
|
|
// 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) {
|
|
print("PDSService: setting isAuthenticated to \(value)")
|
|
self.isAuthenticated = value
|
|
}
|
|
|
|
func login(credentials: PDSCredentials) async -> Bool {
|
|
print("Attempting to connect to server: \(credentials.serverURL)")
|
|
self.credentials = credentials
|
|
self.isLoading = true
|
|
defer { self.isLoading = false }
|
|
|
|
// Clear any previous errors
|
|
self.errorMessage = nil
|
|
|
|
// Validate the URL format
|
|
guard let components = URLComponents(string: credentials.serverURL) else {
|
|
setError("Invalid URL format: The server URL is not properly formatted")
|
|
return false
|
|
}
|
|
|
|
// Ensure scheme is either http or https
|
|
guard let scheme = components.scheme, (scheme == "http" || scheme == "https") else {
|
|
setError("Invalid URL scheme: URL must begin with http:// or https://")
|
|
return false
|
|
}
|
|
|
|
// Ensure there's a host
|
|
guard let host = components.host, !host.isEmpty else {
|
|
setError("Invalid URL: Missing or empty host")
|
|
return false
|
|
}
|
|
|
|
guard let url = URL(string: credentials.serverURL) else {
|
|
setError("Invalid URL format")
|
|
return false
|
|
}
|
|
|
|
// Test if we can connect to a known reliable server
|
|
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
|
|
let authString = "\(credentials.username):\(credentials.password)"
|
|
guard let authData = authString.data(using: .utf8) else {
|
|
setError("Invalid authentication data")
|
|
return false
|
|
}
|
|
|
|
let base64Auth = authData.base64EncodedString()
|
|
self.authHeader = "Basic \(base64Auth)"
|
|
|
|
// Just check if the server exists and is running, no auth needed for this endpoint
|
|
guard let testURL = URL(string: "\(credentials.serverURL)/xrpc/com.atproto.server.describeServer") else {
|
|
setError("Invalid server URL")
|
|
return false
|
|
}
|
|
|
|
print("Testing connectivity to: \(testURL.absoluteString)")
|
|
|
|
// Create a URLSession with shorter timeouts
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 10 // 10 seconds timeout
|
|
config.timeoutIntervalForResource = 10
|
|
let quickTimeoutSession = URLSession(configuration: config)
|
|
|
|
var request = URLRequest(url: testURL)
|
|
request.httpMethod = "GET"
|
|
|
|
do {
|
|
print("Sending request...")
|
|
let (data, response) = try await quickTimeoutSession.data(for: request)
|
|
print("Got response: \(response)")
|
|
|
|
// Debug: print response data as string
|
|
if let dataString = String(data: data, encoding: .utf8) {
|
|
print("Response data: \(dataString)")
|
|
}
|
|
|
|
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
|
|
// verify auth credentials when user accesses protected endpoints
|
|
if httpResponse.statusCode == 200 {
|
|
setAuthenticated(true)
|
|
|
|
// Save credentials securely
|
|
saveCredentialsToKeychain()
|
|
|
|
self.errorMessage = nil
|
|
print("Login successful!")
|
|
return true
|
|
} else {
|
|
let errorMessage = "Server error: \(httpResponse.statusCode)"
|
|
print(errorMessage)
|
|
setError(errorMessage)
|
|
return false
|
|
}
|
|
} catch let error as NSError {
|
|
// Handle specific network errors with better messages
|
|
print("Connection error: \(error.domain) code: \(error.code) - \(error.localizedDescription)")
|
|
|
|
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)")
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Test basic internet connectivity by trying to reach a reliable server
|
|
private func testInternetConnectivity() async -> Bool {
|
|
print("Testing general internet connectivity...")
|
|
|
|
// 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
|
|
}
|
|
|
|
let config = URLSessionConfiguration.default
|
|
config.timeoutIntervalForRequest = 5
|
|
config.waitsForConnectivity = false
|
|
let session = URLSession(configuration: config)
|
|
|
|
do {
|
|
let (_, response) = try await session.data(for: URLRequest(url: connectivityURL))
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
return false
|
|
}
|
|
|
|
print("Internet connectivity test: status code \(httpResponse.statusCode)")
|
|
return httpResponse.statusCode == 200
|
|
} catch {
|
|
print("Internet connectivity test failed: \(error.localizedDescription)")
|
|
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: - Invite Codes
|
|
|
|
func fetchInviteCodes() async {
|
|
guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return }
|
|
|
|
self.isLoading = true
|
|
|
|
defer {
|
|
self.isLoading = false
|
|
}
|
|
|
|
// Construct the URL for the invite codes endpoint
|
|
guard let inviteCodesURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.getInviteCodes") else {
|
|
setError("Invalid invite codes URL")
|
|
return
|
|
}
|
|
|
|
// Add query parameters
|
|
var components = URLComponents(url: inviteCodesURL, resolvingAgainstBaseURL: true)
|
|
components?.queryItems = [
|
|
URLQueryItem(name: "sort", value: "recent"),
|
|
URLQueryItem(name: "limit", value: "100"),
|
|
URLQueryItem(name: "includeDisabled", value: "true") // Always include disabled codes
|
|
]
|
|
|
|
guard let finalURL = components?.url else {
|
|
setError("Invalid invite codes URL with parameters")
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: finalURL)
|
|
request.httpMethod = "GET"
|
|
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response from server")
|
|
return
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 {
|
|
let decoder = JSONDecoder()
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
let codesResponse = try decoder.decode(InviteCodesResponse.self, from: data)
|
|
|
|
let dateFormatter = ISO8601DateFormatter()
|
|
|
|
let parsedCodes = codesResponse.codes.map { codeResp -> InviteCode in
|
|
let createdDate = dateFormatter.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
|
|
)
|
|
}
|
|
|
|
self.inviteCodes = parsedCodes
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to fetch invite codes: \(httpResponse.statusCode) - \(responseString)")
|
|
}
|
|
} catch {
|
|
setError("Failed to fetch invite codes: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
func createInviteCode(maxUses: Int = 1) async -> InviteCode? {
|
|
guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return nil }
|
|
|
|
// Construct the URL for creating an invite code
|
|
guard let createURL = URL(string: "\(baseURL)/xrpc/com.atproto.server.createInviteCode") else {
|
|
setError("Invalid create invite code URL")
|
|
return nil
|
|
}
|
|
|
|
var request = URLRequest(url: createURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
let createBody = ["useCount": maxUses]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: createBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response from server")
|
|
return nil
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 || httpResponse.statusCode == 201 {
|
|
// Parse the response to get the new code
|
|
let decoder = JSONDecoder()
|
|
let codeResponse = try decoder.decode(CreateCodeResponse.self, from: data)
|
|
|
|
// 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)
|
|
|
|
return newCode
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to create invite code: \(httpResponse.statusCode) - \(responseString)")
|
|
return nil
|
|
}
|
|
} catch {
|
|
setError("Failed to create invite code: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func disableInviteCode(_ code: String) async -> Bool {
|
|
guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false }
|
|
|
|
guard let disableURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.disableInviteCodes") else {
|
|
setError("Invalid disable invite code URL")
|
|
return false
|
|
}
|
|
|
|
var request = URLRequest(url: disableURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
// Create the request body with an array of codes
|
|
let disableBody = ["codes": [code]]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: disableBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response from server")
|
|
return false
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 {
|
|
// Refresh the invite codes
|
|
await fetchInviteCodes()
|
|
return true
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to disable invite code: \(httpResponse.statusCode) - \(responseString)")
|
|
return false
|
|
}
|
|
} catch {
|
|
setError("Failed to disable invite code: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Users
|
|
|
|
func fetchUsers() async {
|
|
guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return }
|
|
|
|
self.isLoading = true
|
|
|
|
defer {
|
|
self.isLoading = false
|
|
}
|
|
|
|
// Construct the URL for the repos endpoint
|
|
guard let reposURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.listRepos") else {
|
|
setError("Invalid list repos URL")
|
|
return
|
|
}
|
|
|
|
// Add query parameters
|
|
var components = URLComponents(url: reposURL, resolvingAgainstBaseURL: true)
|
|
components?.queryItems = [
|
|
URLQueryItem(name: "limit", value: "100")
|
|
]
|
|
|
|
guard let finalURL = components?.url else {
|
|
setError("Invalid repos URL with parameters")
|
|
return
|
|
}
|
|
|
|
var request = URLRequest(url: finalURL)
|
|
request.httpMethod = "GET"
|
|
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response from server")
|
|
return
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 {
|
|
let decoder = JSONDecoder()
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
let reposResponse = try decoder.decode(RepoResponse.self, from: data)
|
|
|
|
// Fetch details for each user
|
|
var fetchedUsers: [PDSUser] = []
|
|
|
|
for repo in reposResponse.repos {
|
|
if let user = await fetchUserProfile(did: repo.did, isActive: repo.active) {
|
|
fetchedUsers.append(user)
|
|
}
|
|
}
|
|
|
|
// Sort users by join date (newest first)
|
|
fetchedUsers.sort { $0.joinedAt > $1.joinedAt }
|
|
|
|
self.users = fetchedUsers
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to fetch users: \(httpResponse.statusCode) - \(responseString)")
|
|
}
|
|
} catch {
|
|
setError("Failed to fetch users: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func fetchUserProfile(did: String, isActive: Bool = true) async -> PDSUser? {
|
|
guard let baseURL = baseURL, let authHeader = authHeader else {
|
|
print("Cannot fetch user profile: Missing baseURL or authHeader")
|
|
return nil
|
|
}
|
|
|
|
print("Fetching profile for user: \(did)")
|
|
|
|
// First, fetch account info
|
|
do {
|
|
// 1. Fetch account info
|
|
guard let accountURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.getAccountInfo?did=\(did)") else {
|
|
print("Invalid account info URL for did: \(did)")
|
|
return nil
|
|
}
|
|
|
|
print("Requesting account info from: \(accountURL.absoluteString)")
|
|
|
|
var accountRequest = URLRequest(url: accountURL)
|
|
accountRequest.httpMethod = "GET"
|
|
accountRequest.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
let (accountData, accountResponse) = try await session.data(for: accountRequest)
|
|
|
|
guard let httpResponse = accountResponse as? HTTPURLResponse else {
|
|
print("Invalid HTTP response for account info")
|
|
return nil
|
|
}
|
|
|
|
// Print response status and data for debugging
|
|
print("Account info response status: \(httpResponse.statusCode)")
|
|
if let responseString = String(data: accountData, encoding: .utf8) {
|
|
print("Account info response: \(responseString)")
|
|
}
|
|
|
|
guard httpResponse.statusCode == 200 else {
|
|
let statusCode = httpResponse.statusCode
|
|
let responseText = String(data: accountData, encoding: .utf8) ?? "Unknown error"
|
|
print("Account info failed with status \(statusCode): \(responseText)")
|
|
return createBasicUser(did: did, handle: did, isActive: isActive)
|
|
}
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
|
|
|
// Parse the account info
|
|
let accountInfo: AccountInfo
|
|
do {
|
|
accountInfo = try decoder.decode(AccountInfo.self, from: accountData)
|
|
print("Successfully decoded account info for \(accountInfo.handle)")
|
|
} catch {
|
|
print("Error decoding account info: \(error)")
|
|
// If we can't decode the account info, create a basic user with the DID
|
|
return createBasicUser(did: did, handle: did, isActive: isActive)
|
|
}
|
|
|
|
// 2. Try to fetch profile data (optional)
|
|
var displayName = accountInfo.handle
|
|
var description = ""
|
|
var avatarURL: URL? = nil
|
|
|
|
// Try to fetch profile record
|
|
let profileURLString = "\(baseURL)/xrpc/com.atproto.repo.getRecord?collection=app.bsky.actor.profile&repo=\(did)&rkey=self"
|
|
guard let profileURL = URL(string: profileURLString) else {
|
|
print("Invalid profile URL: \(profileURLString)")
|
|
// Still return user with account info
|
|
let dateFormatter = ISO8601DateFormatter()
|
|
let joinedDate = dateFormatter.date(from: accountInfo.indexedAt) ?? Date()
|
|
|
|
return PDSUser(
|
|
id: accountInfo.did,
|
|
handle: accountInfo.handle,
|
|
displayName: displayName,
|
|
description: description,
|
|
joinedAt: joinedDate,
|
|
avatar: avatarURL,
|
|
isActive: isActive
|
|
)
|
|
}
|
|
|
|
print("Fetching profile from: \(profileURL.absoluteString)")
|
|
|
|
var profileRequest = URLRequest(url: profileURL)
|
|
profileRequest.httpMethod = "GET"
|
|
profileRequest.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
do {
|
|
let (profileData, profileResponse) = try await session.data(for: profileRequest)
|
|
|
|
if let httpResponse = profileResponse as? HTTPURLResponse {
|
|
print("Profile response status: \(httpResponse.statusCode)")
|
|
|
|
if let responseString = String(data: profileData, encoding: .utf8) {
|
|
print("Profile response: \(responseString)")
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 {
|
|
// Define the structures to match the expected response
|
|
do {
|
|
// Try to decode the profile data
|
|
let profileRecord = try decoder.decode(ProfileResponse.self, from: profileData)
|
|
|
|
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 {
|
|
// Construct avatar URL
|
|
avatarURL = URL(string: "\(baseURL)/xrpc/com.atproto.sync.getBlob?did=\(did)&cid=\(avatar.ref.link)")
|
|
print("Avatar URL: \(avatarURL?.absoluteString ?? "nil")")
|
|
}
|
|
} catch {
|
|
print("Error decoding profile: \(error)")
|
|
// Continue with basic info if profile decoding fails
|
|
}
|
|
} else {
|
|
// Profile fetch failed, but we can still continue with account info
|
|
print("Profile fetch failed with status \(httpResponse.statusCode)")
|
|
}
|
|
}
|
|
} catch {
|
|
// Continue without profile data
|
|
print("Error fetching profile: \(error.localizedDescription)")
|
|
}
|
|
|
|
let dateFormatter = ISO8601DateFormatter()
|
|
let joinedDate = dateFormatter.date(from: accountInfo.indexedAt) ?? Date()
|
|
|
|
return PDSUser(
|
|
id: accountInfo.did,
|
|
handle: accountInfo.handle,
|
|
displayName: displayName,
|
|
description: description,
|
|
joinedAt: joinedDate,
|
|
avatar: avatarURL,
|
|
isActive: isActive
|
|
)
|
|
} catch {
|
|
print("Error fetching account info: \(error.localizedDescription)")
|
|
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 {
|
|
print("Creating basic user for did: \(did)")
|
|
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, let baseURL = baseURL, let authHeader = authHeader else { return false }
|
|
|
|
guard let updateURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.updateAccountHandle") else {
|
|
setError("Invalid update handle URL")
|
|
return false
|
|
}
|
|
|
|
var request = URLRequest(url: updateURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
// Create the request body
|
|
let updateBody: [String: String] = [
|
|
"did": userId,
|
|
"handle": newHandle
|
|
]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: updateBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response")
|
|
return false
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 {
|
|
// Refresh the users list to show the updated handle
|
|
await fetchUsers()
|
|
return true
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to update handle: \(httpResponse.statusCode) - \(responseString)")
|
|
return false
|
|
}
|
|
} catch {
|
|
setError("Failed to update handle: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
func resetUserPassword(userId: String) async -> Bool {
|
|
guard isAuthenticated, let baseURL = baseURL else { return false }
|
|
|
|
// Construct the URL for resetting a user's password
|
|
guard let resetURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.resetPassword") else {
|
|
setError("Invalid reset password URL")
|
|
return false
|
|
}
|
|
|
|
var request = URLRequest(url: resetURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let resetBody = ["did": userId]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: resetBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response from server")
|
|
return false
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 {
|
|
return true
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to reset password: \(httpResponse.statusCode) - \(responseString)")
|
|
return false
|
|
}
|
|
} catch {
|
|
setError("Failed to reset password: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
func sendResetEmail(userId: String) async -> Bool {
|
|
guard isAuthenticated, let baseURL = baseURL else { return false }
|
|
|
|
// Construct the URL for sending a reset email
|
|
guard let emailURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.sendEmail") else {
|
|
setError("Invalid send email URL")
|
|
return false
|
|
}
|
|
|
|
var request = URLRequest(url: emailURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let emailBody = [
|
|
"recipientDid": userId,
|
|
"subject": "Password Reset",
|
|
"body": "Click the link to reset your password."
|
|
]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: emailBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response from server")
|
|
return false
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 {
|
|
return true
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to send reset email: \(httpResponse.statusCode) - \(responseString)")
|
|
return false
|
|
}
|
|
} catch {
|
|
setError("Failed to send reset email: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
func deleteUser(userId: String) async -> Bool {
|
|
guard isAuthenticated, let baseURL = baseURL else { return false }
|
|
|
|
// Construct the URL for deleting a user
|
|
guard let deleteURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.deleteAccount") else {
|
|
setError("Invalid delete user URL")
|
|
return false
|
|
}
|
|
|
|
var request = URLRequest(url: deleteURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
|
|
let deleteBody = ["did": userId]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: deleteBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response from server")
|
|
return false
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 || httpResponse.statusCode == 204 {
|
|
// Update the local list
|
|
self.users.removeAll { $0.id == userId }
|
|
return true
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to delete user: \(httpResponse.statusCode) - \(responseString)")
|
|
return false
|
|
}
|
|
} catch {
|
|
setError("Failed to delete user: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
func suspendUser(userId: String, reason: String) async -> Bool {
|
|
guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false }
|
|
|
|
guard let suspendURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.disableAccountByDid") else {
|
|
setError("Invalid suspend user URL")
|
|
return false
|
|
}
|
|
|
|
var request = URLRequest(url: suspendURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
// Create the request body
|
|
let suspendBody: [String: Any] = [
|
|
"did": userId,
|
|
"reason": reason
|
|
]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: suspendBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response")
|
|
return false
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 {
|
|
// Refresh the users list to show the updated status
|
|
await fetchUsers()
|
|
return true
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to suspend user: \(httpResponse.statusCode) - \(responseString)")
|
|
return false
|
|
}
|
|
} catch {
|
|
setError("Failed to suspend user: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
func reactivateUser(userId: String) async -> Bool {
|
|
guard isAuthenticated, let baseURL = baseURL, let authHeader = authHeader else { return false }
|
|
|
|
guard let reactivateURL = URL(string: "\(baseURL)/xrpc/com.atproto.admin.enableAccountByDid") else {
|
|
setError("Invalid reactivate user URL")
|
|
return false
|
|
}
|
|
|
|
var request = URLRequest(url: reactivateURL)
|
|
request.httpMethod = "POST"
|
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.addValue(authHeader, forHTTPHeaderField: "Authorization")
|
|
|
|
// Create the request body
|
|
let reactivateBody: [String: String] = [
|
|
"did": userId
|
|
]
|
|
|
|
do {
|
|
let jsonData = try JSONSerialization.data(withJSONObject: reactivateBody)
|
|
request.httpBody = jsonData
|
|
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
setError("Invalid response")
|
|
return false
|
|
}
|
|
|
|
if httpResponse.statusCode == 200 {
|
|
// Refresh the users list to show the updated status
|
|
await fetchUsers()
|
|
return true
|
|
} else {
|
|
let responseString = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
setError("Failed to reactivate user: \(httpResponse.statusCode) - \(responseString)")
|
|
return false
|
|
}
|
|
} catch {
|
|
setError("Failed to reactivate user: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Methods
|
|
|
|
private func setError(_ message: String) {
|
|
self.errorMessage = message
|
|
print("Error: \(message)")
|
|
}
|
|
}
|