diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift index 2c13170002..a3375e16c5 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/CreateListRecord.swift @@ -9,4 +9,158 @@ import Foundation extension ATProtoBluesky { + /// A convenience method to create a list record to the user account in Bluesky. + /// + /// This can be used instead of creating your own method if you wish not to do so. + /// + /// # Creating a List + /// + /// After you authenticate into Bluesky, you can create a list by using the `name` and + /// `listType` arguments: + /// + /// ```swift + /// do { + /// let listResult = try await atProtoBluesky.createListRecord( + /// named: "Book Authors", + /// ofType: .reference + /// ) + /// + /// print(listResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// You can optionally add a description and avatar image for the list. + /// + /// - Note: Names can be up to 64 characters long. \ + /// \ + /// Descriptions can be up to 300 characters long.\ + /// \ + /// List avatar images can be either .jpg or .png and can be up to 1 MB large. + /// + /// # Types of Lists + /// + /// There are three types of lists that can be created: + /// - Moderation lists: These are lists where the user accounts listed will be muted + /// or blocked. + /// - Curated lists: These are lists where the user accounts listed are used for curation: + /// things like allowlists for interaction or regular feeds. + /// - Reference feeds: These are lists where the user accounts listed will be used as a + /// reference, such as with a starter pack. + /// + /// - Parameters: + /// - name: The name of the list. + /// - listType: The list's type. + /// - description: The list's description. Optional. Defaults to `nil`. + /// - listAvatarImage: The avatar image of the list. Optional. Defaults to `nil`. + /// - labels: An array of labels made by the user. Optional. Defaults to `nil`. + /// - creationDate: The date of the post record. Defaults to `Date.now`. + /// - recordKey: The record key of the collection. Optional. Defaults to `nil`. + /// - shouldValidate: Indicates whether the record should be validated. Optional. + /// Defaults to `true`. + /// - swapCommit: Swaps out an operation based on the CID. Optional. Defaults to `nil`. + /// - Returns: A strong reference, which contains the newly-created record's URI and CID hash. + public func createListRecord( + named name: String, + ofType listType: ListType, + description: String? = nil, + listAvatarImage: ATProtoTools.ImageQuery? = nil, + labels: ATUnion.ListLabelsUnion? = nil, + creationDate: Date = Date(), + recordKey: String? = nil, + shouldValidate: Bool? = true, + swapCommit: String? = nil + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let session else { + throw ATRequestPrepareError.missingActiveSession + } + + guard let sessionURL = session.pdsURL else { + throw ATRequestPrepareError.invalidPDS + } + + // listPurpose + let listPurpose: AppBskyLexicon.Graph.ListPurpose + switch listType { + case .moderation: + listPurpose = .modlist + case .curation: + listPurpose = .curatelist + case .reference: + listPurpose = .referencelist + } + + // name + // Truncate the number of characters to 64. + let nameText = name.truncated(toLength: 64) + + // description and descriptionFacets + var descriptionText: String? = nil + var descriptionFacets: [AppBskyLexicon.RichText.Facet]? = nil + + if let description = description { + // Truncate the number of characters to 300. + let truncatedDescriptionText = description.truncated(toLength: 300) + descriptionText = truncatedDescriptionText + + let facets = await ATFacetParser.parseFacets(from: truncatedDescriptionText, pdsURL: session.pdsURL ?? "https://bsky.social") + descriptionFacets = facets + } + + // listAvatarImage + var avatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let listAvatarImage = listAvatarImage { + let postEmbed = try await uploadImages( + [listAvatarImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let avatarImageContainer = imagesDefinition + avatarImage = avatarImageContainer.images[0].imageBlob + default: + break + } + } + + let listRecord = AppBskyLexicon.Graph.ListRecord( + purpose: listPurpose, + name: nameText, + description: descriptionText, + descriptionFacets: descriptionFacets, + avatarImageBlob: avatarImage, + labels: labels, + createdAt: creationDate + ) + + do { + return try await atProtoKitInstance.createRecord( + repositoryDID: session.sessionDID, + collection: "app.bsky.graph.list", + recordKey: recordKey ?? nil, + shouldValidate: shouldValidate, + record: UnknownType.record(listRecord), + swapCommit: swapCommit ?? nil + ) + } catch { + throw error + } + } + + /// The list's type. + public enum ListType { + + /// Indicates the list is used for muting or blocking the list of user accounts. + case moderation + + /// Indicates the list is used for curation purposes, such as list feeds or + /// interaction gating. + case curation + + /// Indicates the list is used for reference purposes (such as within a starter pack). + case reference + } } diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift index d703032428..710542206d 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/DeleteListRecord.swift @@ -9,4 +9,14 @@ import Foundation extension ATProtoBluesky { + /// Deletes a list record. + /// + /// This can also be used to validate if a list record has been deleted. + /// + /// - Note: This can be either the URI of the list record, or the full record object itself. + /// + /// - Parameter record: The list record that needs to be deleted. + public func deleteListRecord(_ record: RecordIdentifier) async throws { + return try await deleteLikeRecord(record) + } } diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift new file mode 100644 index 0000000000..d672b0f7fb --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ListRecord/UpdateListRecord.swift @@ -0,0 +1,118 @@ +// +// UpdateListRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + + /// A convenience method to update a list record to the user account in Bluesky. + /// + /// This can be used instead of creating your own method if you wish not to do so. + /// + /// - Parameters: + /// - listURI: The URI of the post. + /// - name: The name of the list. + /// - listType: The list's type. + /// - description: The list's description. Optional. Defaults to `nil`. + /// - listAvatarImage: The avatar image of the list. Optional. Defaults to `nil`. + /// - labels: An array of labels made by the user. Optional. Defaults to `nil`. + /// - Returns: A strong reference, which contains the newly-created record's URI and CID hash. + public func updateListRecord( + listURI: String, + name: String, + listType: ListType, + description: String? = nil, + listAvatarImage: ATProtoTools.ImageQuery? = nil, + labels: ATUnion.ListLabelsUnion? = nil + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let session else { + throw ATRequestPrepareError.missingActiveSession + } + + guard let sessionURL = session.pdsURL else { + throw ATRequestPrepareError.invalidPDS + } + + // listPurpose + let listPurpose: AppBskyLexicon.Graph.ListPurpose + switch listType { + case .moderation: + listPurpose = .modlist + case .curation: + listPurpose = .curatelist + case .reference: + listPurpose = .referencelist + } + + // name + // Truncate the number of characters to 64. + let nameText = name.truncated(toLength: 64) + + // description and descriptionFacets + var descriptionText: String? = nil + var descriptionFacets: [AppBskyLexicon.RichText.Facet]? = nil + + if let description = description { + // Truncate the number of characters to 300. + let truncatedDescriptionText = description.truncated(toLength: 300) + descriptionText = truncatedDescriptionText + + let facets = await ATFacetParser.parseFacets(from: truncatedDescriptionText, pdsURL: session.pdsURL ?? "https://bsky.social") + descriptionFacets = facets + } + + // listAvatarImage + var postEmbed: ATUnion.PostEmbedUnion? = nil + var avatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let listAvatarImage = listAvatarImage { + postEmbed = try await uploadImages( + [listAvatarImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let avatarImageContainer = imagesDefinition + avatarImage = avatarImageContainer.images[0].imageBlob + default: + break + } + } + + let listRecord = AppBskyLexicon.Graph.ListRecord( + purpose: listPurpose, + name: nameText, + description: descriptionText, + descriptionFacets: descriptionFacets, + avatarImageBlob: avatarImage, + labels: labels, + createdAt: Date() + ) + + do { + let uri = try ATProtoTools().parseURI(listURI) + + guard try await atProtoKitInstance.getRepositoryRecord( + from: uri.repository, + collection: uri.collection, + recordKey: uri.recordKey + ).value != nil else { + throw ATProtoBlueskyError.postNotFound(message: "List record (\(listURI)) not found.") + } + + return try await atProtoKitInstance.putRecord( + repository: session.sessionDID, + collection: "app.bsky.graph.list", + recordKey: uri.recordKey, + record: UnknownType.record(listRecord) + ) + } catch { + throw error + } + } +} diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift index 0033d38cea..1bc6e72d6b 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostRecord/CreatePostRecord.swift @@ -13,8 +13,8 @@ extension ATProtoBluesky { /// /// This can be used instead of creating your own method if you wish not to do so. /// - /// ## Creating a Post - /// After you authenticate into Bluesky, you can create a post by using the `text` field: + /// # Creating a Post + /// After you authenticate into Bluesky, you can create a post by using the `text` argument: /// ```swift /// do { /// let postResult = try await atProtoBluesky.createPostRecord( @@ -68,11 +68,11 @@ extension ATProtoBluesky { /// - Note: `startPostion` and `endPostion` must be UTF-8 values. /// /// - /// ## Adding Embedded Content + /// # Adding Embedded Content /// You can embed various kinds of content in your post, from media to external links, /// to other records. /// - /// ### Images + /// ## Images /// Use ``ATProtoTools/ImageQuery`` to add details to the image, such as /// alt text, then attach it to the post record. /// @@ -81,7 +81,7 @@ extension ATProtoBluesky { /// let image = ATProtoTools.ImageQuery( /// imageData: Data(contentsOf: "/path/to/file/cat.jpg"), /// fileName: "cat.jpg", - /// altText: "A cat looking annoyed, waring a hat." + /// altText: "A cat looking annoyed, wearing a hat." /// ) /// /// let postResult = try await atProtoBluesky.createPostRecord( @@ -99,7 +99,7 @@ extension ATProtoBluesky { /// /// Up to four images can be attached to a post. All images need to be a .jpg format. /// - /// ### Videos + /// ## Videos /// Similar to images, you can add videos to a post. /// /// ```swift @@ -121,7 +121,7 @@ extension ATProtoBluesky { /// You can upload up to 25 videos per day and the 25 videos can't exceed a total of 500 MB /// for the day. /// - /// ### External Links + /// ## External Links /// You can attach a website card to the post. /// /// ```swift @@ -159,7 +159,7 @@ extension ATProtoBluesky { /// If there are any links in the post's text, the method will not convert it into a /// website card; you will need to manually achieve this. /// - /// ## Creating a Quote Post + /// # Creating a Quote Post /// Quote posts are also embeds: you simply need to embed the record's strong reference to it. /// /// ```swift @@ -180,7 +180,7 @@ extension ATProtoBluesky { /// Only one record can be embedded. This isn't limited to post records, though: you can embed /// any Bluesky-related record that you wish. /// - /// ## Creating a Reply + /// # Creating a Reply /// To create a reply, pass the reply reference of the post's reply reference to the post record. /// /// ```swift diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift index f0f0d31dcd..2a82397f0f 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/PostgateRecord/CreatePostgateRecord.swift @@ -37,7 +37,7 @@ extension ATProtoBluesky { /// } /// ``` /// - /// ## Managing Embedding Post Options + /// # Managing Embedding Post Options /// /// You can detact the post from quote posts by using the `detachedEmbeddingURIs` argument. /// When doing so, Bluesky will display a "Removed by author" warning and the quote post will diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift new file mode 100644 index 0000000000..9d5f961cb0 --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/CreateProfileRecord.swift @@ -0,0 +1,219 @@ +// +// CreateProfileRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + + /// A convenience method to create a profile record to the user account in Bluesky. + /// + /// This can be used instead of creating your own method if you wish not to do so. + /// + /// # Creating a Profile + /// + /// After you authenticate into Bluesky, you can create a profile. You don't have to enter + /// anything: Bluesky will still accept it: + /// ```swift + /// do { + /// let profileResult = try await atProtoBluesky.createProfileRecord() + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// # Adding Details + /// + /// In most cases, however, you will want to add different details. You can enter in the + /// display name and description using the `displayName` and `description` + /// arguments respectively: + /// + /// ```swift + /// do { + /// let profileResult = try await atProtoBluesky.createProfileRecord( + /// displayName: "Alexy Carter", + /// description: "Big fan of music, movies, and weekend road trips. Just trying to enjoy life one day at a time." + /// ) + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// - Note: You can have up to 64 characters for the display name and up to 300 characters for + /// the description. + /// + /// You can also add the images for profile pictures and banners: + /// ```swift + /// do { + /// let profileResult = try await atProtoBluesky.createProfileRecord( + /// displayName: "Alexy Carter", + /// description: "Big fan of music, movies, and weekend road trips. Just trying to enjoy life one day at a time.", + /// avatarImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_avatar.jpg"), + /// fileName: "alexycarter_profile.jpg", + /// altText: "Image of me with glasses, with my dog." + /// ), + /// bannerImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_banner.jpg"), + /// fileName: alexycarter_banner.jpg, + /// altText: "A baseball stadium." + /// ) + /// ) + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// - Note: Both the avatar and banner images can be up to 1 MB in size. You can upload either + /// .jpg or .png. + /// + /// # Pinning and Unpinning Posts + /// + /// If you want to pin a post, create a strong reference and attach it to the + /// `pinnedPost` argument. The easiest way to do this is to get the URI of the post, then use + /// ``ATProtoTools/createStrongReference(from:pdsURL:)``: + /// + /// ```swift + /// do { + /// let pinnedPostStrongReference = ATProtoTools.createStrongReference(from: uri) + /// let profileResult = try await atProtoBluesky.createProfileRecord( + /// displayName: "Alexy Carter", + /// description: "Big fan of music, movies, and weekend road trips. Just trying to enjoy life one day at a time.", + /// avatarImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_avatar.jpg"), + /// fileName: "alexycarter_profile.jpg", + /// altText: "Image of me with glasses, with my dog." + /// ), + /// bannerImage: ATProtoTools.ImageQuery( + /// imageData: Data(contentsOf: "/path/to/file/alexycarter_banner.jpg"), + /// fileName: alexycarter_banner.jpg, + /// altText: "A baseball stadium." + /// ), + /// pinnedPost: pinnedPostStrongReference + /// ) + /// + /// print(profileResult) + /// } catch { + /// throw error + /// } + /// ``` + /// + /// If you want to unpin the post, simply update the profile record and set + /// `pinnedPost` to `nil`. + /// + /// - Parameters: + /// - displayName: A display name for the profile. Optional. Defaults to `nil`. + /// - description: A description for the profile. Optional. Defaults to `nil`. + /// - avatarImage: An image used for the profile picture. Optional. Defaults to `nil`. + /// - bannerImage: An image used for the banner image. Optional,Defaults to `nil`. + /// - labels: An array of user-defined labels. Optional. Defaults to `nil`. + /// - joinedViaStarterPack: A strong reference to the starter pack the user used to + /// join Bluesky. + /// - pinnedPost: A strong reference to a post the user account has. Optional. + /// Defaults to `nil`. + /// - recordKey: The record key of the collection. Optional. Defaults to `nil`. + /// - shouldValidate: Indicates whether the record should be validated. Optional. + /// Defaults to `true`. + /// - swapCommit: Swaps out an operation based on the CID. Optional. Defaults to `nil`. + /// - Returns: A strong reference, which contains the newly-created record's URI and CID hash. + public func createProfileRecord( + with displayName: String? = nil, + description: String? = nil, + avatarImage: ATProtoTools.ImageQuery? = nil, + bannerImage: ATProtoTools.ImageQuery? = nil, + labels: [ComAtprotoLexicon.Label.SelfLabelsDefinition]? = nil, + joinedViaStarterPack: ComAtprotoLexicon.Repository.StrongReference? = nil, + pinnedPost: ComAtprotoLexicon.Repository.StrongReference? = nil, + recordKey: String? = nil, + shouldValidate: Bool? = true, + swapCommit: String? = nil + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + guard let session else { + throw ATRequestPrepareError.missingActiveSession + } + + guard let sessionURL = session.pdsURL else { + throw ATRequestPrepareError.invalidPDS + } + + // displayText + var displayNameText: String? = nil + if let displayName = displayName { + displayNameText = displayName.truncated(toLength: 64) + } + + // description + var descriptionText: String? = nil + if let description = description { + descriptionText = description.truncated(toLength: 256) + } + + // avatarImage + var profileAvatarImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let avatarImage = avatarImage { + let postEmbed = try await uploadImages( + [avatarImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let avatarImageContainer = imagesDefinition + profileAvatarImage = avatarImageContainer.images[0].imageBlob + default: + break + } + } + + // bannerImage + var profileBannerImage: ComAtprotoLexicon.Repository.UploadBlobOutput? = nil + if let bannerImage = bannerImage { + let postEmbed = try await uploadImages( + [bannerImage], + pdsURL: sessionURL, + accessToken: session.accessToken + ) + + switch postEmbed { + case .images(let imagesDefinition): + let bannerImageContainer = imagesDefinition + profileBannerImage = bannerImageContainer.images[0].imageBlob + default: + break + } + } + + let profileRecord = AppBskyLexicon.Actor.ProfileRecord( + displayName: displayNameText, + description: descriptionText, + avatarBlob: profileAvatarImage, + bannerBlob: profileBannerImage, + labels: labels, + joinedViaStarterPack: joinedViaStarterPack, + pinnedPost: pinnedPost, + createdAt: Date() + ) + + do { + return try await atProtoKitInstance.createRecord( + repositoryDID: session.sessionDID, + collection: "app.bsky.actor.profile", + recordKey: recordKey ?? nil, + shouldValidate: shouldValidate, + record: UnknownType.record(profileRecord), + swapCommit: swapCommit ?? nil + ) + } catch { + throw error + } + } +} diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/DeleteProfileRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/DeleteProfileRecord.swift new file mode 100644 index 0000000000..78ce99527f --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/DeleteProfileRecord.swift @@ -0,0 +1,22 @@ +// +// DeleteProfileRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + + /// Deletes a profile record. + /// + /// This can also be used to validate if a profile record has been deleted. + /// + /// - Note: This can be either the URI of the profile record, or the full record object itself. + /// + /// - Parameter record: The profile record that needs to be deleted. + public func deleteProfileRecord(_ record: RecordIdentifier) async throws { + return try await deleteActionRecord(record) + } +} diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift new file mode 100644 index 0000000000..72237d1506 --- /dev/null +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ProfileRecord/UpdateProfileRecord.swift @@ -0,0 +1,12 @@ +// +// UpdateProfileRecord.swift +// +// +// Created by Christopher Jr Riley on 2025-02-28. +// + +import Foundation + +extension ATProtoBluesky { + +} diff --git a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift index ea0119c0e1..16700e2e5d 100644 --- a/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift +++ b/Sources/ATProtoKit/APIReference/ATProtoBlueskyAPI/ThreadgateRecord/CreateThreadgateRecord.swift @@ -21,7 +21,7 @@ extension ATProtoBluesky { /// After that, you can use the ``ComAtprotoLexicon/Repository/StrongReference/recordURI`` /// property as the value for the `postURI` argument. /// - /// ## Managing Allowlist Options + /// # Managing Allowlist Options /// /// With the `replyControls` argument, you can specifiy the specific retrictions you want /// for the post. diff --git a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift index 5b105d3e39..f1249e8949 100644 --- a/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift +++ b/Sources/ATProtoKit/APIReference/SessionManager/SessionConfiguration.swift @@ -6,15 +6,48 @@ // import Foundation +import Logging /// Defines the requirements for session configurations within ATProtoKit. public protocol SessionConfiguration { - /// The user's unique handle used for authentication purposes. - var handle: String { get } + /// The user's handle within the AT Protocol. + var handle: String { get set } - /// The app password associated with the user's account, used for authentication. - var password: String { get } + /// The password associated with the user's account, used for authentication. + var password: String { get set } + + /// The decentralized identifier (DID), serving as a persistent and long-term account + /// identifier according to the W3C standard. + var sessionDID: String { get set } + + /// The user's email address. Optional. + var email: String? { get set } + + /// Indicates whether the user's email address has been confirmed. Optional. + var isEmailConfirmed: Bool? { get set } + + /// Indicates whether Two-Factor Authentication (via email) is enabled. Optional. + var isEmailAuthenticationFactorEnabled: Bool? { get set } + + /// The access token used for API requests that requests authentication. + var accessToken: String? { get set } + + /// The refresh token used to generate a new access token. + var refreshToken: String? { get set } + + /// The DID document associated with the user, which contains AT Protocol-specific + /// information. Optional. + var didDocument: DIDDocument? { get set } + + /// Indicates whether the user account is active. Optional. + var isActive: Bool? { get set } + + /// Indicates the possible reason for why the user account is inactive. Optional. + var status: UserAccountStatus? { get set } + + /// The user account's endpoint used for sending authentication requests. + var serviceEndpoint: URL { get set } /// The base URL of the Personal Data Server (PDS) with which the AT Protocol interacts. /// @@ -22,10 +55,30 @@ public protocol SessionConfiguration { /// session creation, refresh, and deletion. var pdsURL: String { get } - /// The object attached to the configuration class that holds the session. Optional. + /// Specifies the logger that will be used for emitting log messages. Optional. /// - /// This also includes things such as retry limits and logging. - var session: UserSession? { get } + /// - Note: This is not included when initalizing `UserSession`. Instead, it's added + /// after the successful initalizing. + var logger: Logger? { get } + + /// The number of times a request can be attempted before it's considered a failure. + /// + /// By default, ATProtoKit will retry a request attempt for 1 second. + /// + /// - Note: This is not included when initalizing `UserSession`. Instead, it's added + /// after the successful initalizing. + var maxRetryCount: Int? { get } + + /// The length of time to wait before attempting to retry a request. + /// + /// By default, ATProtoKit will wait for 1 second before attempting to retry a request. + /// ATProtoKit will change the number exponentally in order to help prevent overloading + /// the server. + /// + /// - Note: This is not included when initalizing `UserSession`. Instead, it's added + /// after the successful initalizing. + var retryTimeDelay: TimeInterval? { get } + /// Attempts to authenticate with the PDS using the `handle` and `appPassword`. /// @@ -39,4 +92,373 @@ public protocol SessionConfiguration { /// /// - Throws: An error if there are issues creating the request or communicating with the PDS. func authenticate(authenticationFactorToken: String?) async throws + + /// Creates an a new account for the user. + /// + /// - Note: `plcOp` may be updated when full account migration is implemented. + /// + /// - Bug: `plcOp` is currently broken: there's nothing that can be used for this at the + /// moment while Bluesky continues to work on account migration. Until everything settles + /// and they have a concrete example of what to do, don't use it. In the meantime, leave it + /// at `nil`. + /// + /// - Parameters: + /// - email: The email of the user. Optional + /// - handle: The handle the user wishes to use. + /// - existingDID: A decentralized identifier (DID) that has existed before and will be + /// used to be imported to the new account. Optional. + /// - inviteCode: The invite code for the user. Optional. + /// - verificationCode: A verification code. + /// - verificationPhone: A code that has come from a text message in the user's + /// phone. Optional. + /// - password: The password the user will use for the account. Optional. + /// - recoveryKey: DID PLC rotation key (aka, recovery key) to be included in PLC + /// creation operation. Optional. + /// - plcOperation: A signed DID PLC operation to be submitted as part of importing an + /// existing account to this instance. Optional. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func createAccount( + email: String?, + handle: String, + existingDID: String?, + inviteCode: String?, + verificationCode: String?, + verificationPhone: String?, + password: String?, + recoveryKey: String?, + plcOperation: UnknownType? + ) async throws + + /// Fetches an existing session using an access token. + /// + /// If the access token is invalid, then a new one will be created, either by refeshing a + /// session, or by re-authenticating. + /// + /// - Parameters: + /// - accessToken: The access token used for the session. Optional. + /// Defaults to `nil`. + /// - authenticationFactorToken: A token used for Two-Factor Authentication. Optional. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func getSession(by accessToken: String?, authenticationFactorToken: String?) async throws + + /// Refreshes the user's session using a refresh token. + /// + /// If the refresh token is invalid, the method will re-authenticate and try again. + /// + /// - Note: If the method throws an error saying that an authentication token is required, + /// re-trying the method with the `authenticationFactorToken` argument filled should + /// solve the issue. + /// + /// - Note: If you rely on ``ATProtocolConfiguration/session`` for managing the session, + /// there's no need to use the `refreshToken` argument. + /// + /// When the method completes, ``ATProtocolConfiguration/session`` will be updated with a + /// new instance of an authenticated user session within the AT Protocol. It may also have + /// logging information, as well as the URL of the Personal Data Server (PDS). + /// + /// - Parameters: + /// - refreshToken: The refresh token used for the session. Optional. + /// Defaults to `nil`. + /// - authenticationFactorToken: A token used for Two-Factor Authentication. Optional. + /// Defaults to `nil`. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func refreshSession(by refreshToken: String?, authenticationFactorToken: String?) async throws + + /// Resumes a session. + /// + /// This is useful for cases where a user is opening the app and they've already logged in. + /// + /// While inserting the access token is optional, the refresh token is not, as it lasts much + /// longer than the refresh token. + /// + /// - Warning: This is an experimental method. This may be removed at a later date if + /// it doesn't prove to be helpful. + /// + /// If the refresh token fails for whatever reason, it's recommended to call + /// ``ATProtocolConfiguration/authenticate(authenticationFactorToken:)`` + /// in the `catch` block. + /// + /// - Parameters: + /// - accessToken: The access token of the session. Optional. + /// - refreshToken: The refresh token of the session. + /// - pdsURL: The URL of the Personal Data Server (PDS). Defaults to `https://bsky.social`. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func resumeSession(accessToken: String?, refreshToken: String) async throws + + /// Deletes the user session. + /// + /// - Note: If you rely on ``ATProtocolConfiguration/session`` for managing the session, + /// there's no need to use the `refreshToken` argument. + /// + /// - Parameter refreshToken: The refresh token used for the session. Optional. + /// Defaults to `nil`. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError`` and ``ATRequestPrepareError`` for more details. + func deleteSession(with refreshToken: String?) async throws +} + +extension SessionConfiguration { + + public mutating func authenticate(authenticationFactorToken: String? = nil) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + do { + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).createSession( + with: self.handle, + and: self.password, + authenticationFactorToken: authenticationFactorToken + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + var status: UserAccountStatus? = nil + + switch response.status { + case .suspended: + status = .suspended + case .takedown: + status = .takedown + case .deactivated: + status = .deactivated + default: + status = nil + } + + self.handle = response.handle + self.sessionDID = response.did + self.email = response.email + self.isEmailConfirmed = response.isEmailConfirmed + self.isEmailAuthenticationFactorEnabled = response.isEmailAuthenticatedFactor + self.accessToken = response.accessToken + self.refreshToken = response.refreshToken + self.didDocument = didDocument + self.isActive = response.isActive + self.status = status + self.serviceEndpoint = serviceEndpoint + } catch { + throw error + } + } + + public mutating func createAccount( + email: String?, + handle: String, + existingDID: String?, + inviteCode: String?, + verificationCode: String?, + verificationPhone: String?, + password: String?, + recoveryKey: String?, + plcOperation: UnknownType? + ) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).createAccount( + email: email, + handle: handle, + existingDID: existingDID, + inviteCode: inviteCode, + verificationCode: verificationCode, + verificationPhone: verificationPhone, + password: password, + recoveryKey: recoveryKey, + plcOperation: plcOperation + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + self.handle = response.handle + self.sessionDID = response.did + self.email = email + self.accessToken = response.accessToken + self.refreshToken = response.refreshToken + self.didDocument = didDocument + self.serviceEndpoint = serviceEndpoint + } + + public mutating func getSession(by accessToken: String? = nil, authenticationFactorToken: String? = nil) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + var sessionToken: String = "" + do { + sessionToken = try sessionConfiguration.getValidAccessToken(from: accessToken) + } catch { + throw error + } + + do { + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).getSession( + by: sessionToken + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + var status: UserAccountStatus? = nil + + switch response.status { + case .suspended: + status = .suspended + case .takedown: + status = .takedown + case .deactivated: + status = .deactivated + default: + status = nil + } + + self.handle = response.handle + self.sessionDID = response.did + self.email = response.email + self.isEmailConfirmed = response.isEmailConfirmed + self.isEmailAuthenticationFactorEnabled = response.isEmailAuthenticationFactor + self.accessToken = sessionToken + self.didDocument = didDocument + self.isActive = response.isActive + self.status = status + self.serviceEndpoint = serviceEndpoint + } catch let apiError as ATAPIError { + guard case .badRequest(let errorDetails) = apiError, + errorDetails.error == "ExpiredToken" else { + throw apiError + } + + _ = try await sessionConfiguration.handleExpiredTokenFromGetSession(authenticationFactorToken: authenticationFactorToken) + } + } + + public mutating func refreshSession( + by refreshToken: String? = nil, + authenticationFactorToken: String? = nil + ) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + var sessionToken: String = "" + + let token = sessionConfiguration.getValidRefreshToken(from: self.refreshToken) + + if let token = sessionConfiguration.getValidRefreshToken(from: self.refreshToken) { + sessionToken = token + } else { + do { + try await self.authenticate(authenticationFactorToken: authenticationFactorToken) + } catch { + throw error + } + } + + do { + let response = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).refreshSession( + refreshToken: sessionToken + ) + + guard let didDocument = sessionConfiguration.convertDIDDocument(response.didDocument) else { + throw DIDDocument.DIDDocumentError.emptyArray + } + + let atService = try didDocument.checkServiceForATProto() + let serviceEndpoint = atService.serviceEndpoint + + var status: UserAccountStatus? = nil + + switch response.status { + case .suspended: + status = .suspended + case .takedown: + status = .takedown + case .deactivated: + status = .deactivated + default: + status = nil + } + + self.handle = response.handle + self.sessionDID = response.did + self.accessToken = response.accessToken + self.refreshToken = response.refreshToken + self.didDocument = didDocument + self.isActive = response.isActive + self.status = status + self.serviceEndpoint = serviceEndpoint + } catch let apiError as ATAPIError { + // If the token expires, re-authenticate and try refreshing the token again. + guard case .badRequest(let errorDetails) = apiError, + errorDetails.error == "ExpiredToken" else { + throw apiError + } + + _ = try await sessionConfiguration.handleExpiredTokenFromRefreshSession(authenticationFactorToken: authenticationFactorToken) + } + } + + public mutating func resumeSession( + accessToken: String? = nil, + refreshToken: String, + pdsURL: String = "https://bsky.social" + ) async throws { + let sessionConfiguration = SessionConfigurationTools(sessionConfiguration: self) + + if let sessionToken = accessToken ?? self.accessToken { + let expiryDate = try SessionToken(sessionToken: sessionToken).payload.expiresAt + let currentDate = Date() + + if currentDate > expiryDate { + do { + try await sessionConfiguration.checkRefreshToken(refreshToken: refreshToken, pdsURL: pdsURL) + } catch { + throw error + } + } + + _ = try await self.getSession(by: accessToken) + } else { + do { + try await sessionConfiguration.checkRefreshToken(refreshToken: refreshToken) + } catch { + throw error + } + } + } + + public mutating func deleteSession(with refreshToken: String? = nil) async throws { + do { + var token: String + + if let refreshToken = refreshToken { + token = refreshToken + } else if let sessionToken = self.refreshToken { + token = sessionToken + } else { + return + } + + _ = try await ATProtoKit(pdsURL: self.pdsURL, canUseBlueskyRecords: false).deleteSession( + refreshToken: token + ) + } catch { + throw error + } + } } diff --git a/Sources/ATProtoKit/Errors/ATProtoError.swift b/Sources/ATProtoKit/Errors/ATProtoError.swift index 47c27c6f3d..a0080341ce 100644 --- a/Sources/ATProtoKit/Errors/ATProtoError.swift +++ b/Sources/ATProtoKit/Errors/ATProtoError.swift @@ -409,6 +409,23 @@ extension ATProtocolConfiguration { } } +extension SessionConfigurationTools { + + /// An error type related to ``ATProtocolConfiguration``. + public enum SessionConfigurationToolsError: ATProtoError { + + /// No token was found. + /// + /// - Parameter message: The message for the error. + case noSessionToken(message: String) + + /// The access and refresh tokens have both expired. + /// + /// - Parameter message: The message for the error. + case tokensExpired(message: String) + } +} + /// An error type related to CBOR processing issues. public enum CBORProcessingError: Error { diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift index ae1c4f13bb..c2ba40508d 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Actor/AppBskyActorProfile.swift @@ -37,7 +37,7 @@ extension AppBskyLexicon.Actor { /// description text." public let description: String? - /// The avatar image URL of the profile. Optional. + /// The avatar image blob of the profile. Optional. /// /// - Note: Only JPEGs and PNGs are accepted. /// @@ -45,9 +45,9 @@ extension AppBskyLexicon.Actor { /// /// - Note: According to the AT Protocol specifications: "Small image to be displayed next /// to posts from account. AKA, 'profile picture'" - public let avatarBlob: ComAtprotoLexicon.Repository.BlobContainer? + public let avatarBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? - /// The banner image URL of the profile. Optional. + /// The banner image blob of the profile. Optional. /// /// - Note: Only JPEGs and PNGs are accepted. /// @@ -55,7 +55,7 @@ extension AppBskyLexicon.Actor { /// /// - Note: According to the AT Protocol specifications: "Larger horizontal image to /// display behind profile view." - public let bannerBlob: ComAtprotoLexicon.Repository.BlobContainer? + public let bannerBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? /// An array of user-defined labels. Optional. /// @@ -72,13 +72,27 @@ extension AppBskyLexicon.Actor { /// The date and time the profile was created. Optional. public let createdAt: Date? + public init(displayName: String?, description: String?, avatarBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, + bannerBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, labels: [ComAtprotoLexicon.Label.SelfLabelsDefinition]?, + joinedViaStarterPack: ComAtprotoLexicon.Repository.StrongReference?, pinnedPost: ComAtprotoLexicon.Repository.StrongReference?, + createdAt: Date?) { + self.displayName = displayName + self.description = description + self.avatarBlob = avatarBlob + self.bannerBlob = bannerBlob + self.labels = labels + self.joinedViaStarterPack = joinedViaStarterPack + self.pinnedPost = pinnedPost + self.createdAt = createdAt + } + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.displayName = try container.decodeIfPresent(String.self, forKey: .displayName) self.description = try container.decodeIfPresent(String.self, forKey: .description) - self.avatarBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .avatarBlob) - self.bannerBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.BlobContainer.self, forKey: .bannerBlob) + self.avatarBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .avatarBlob) + self.bannerBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .bannerBlob) self.labels = try container.decodeIfPresent([ComAtprotoLexicon.Label.SelfLabelsDefinition].self, forKey: .labels) self.joinedViaStarterPack = try container.decodeIfPresent(ComAtprotoLexicon.Repository.StrongReference.self, forKey: .joinedViaStarterPack) self.pinnedPost = try container.decodeIfPresent(ComAtprotoLexicon.Repository.StrongReference.self, forKey: .pinnedPost) diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift index 27dfbb6780..65efbc810e 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphDefs.swift @@ -429,12 +429,12 @@ extension AppBskyLexicon.Graph { /// [github]: https://github.com/bluesky-social/atproto/blob/main/lexicons/app/bsky/graph/defs.json public enum ListPurpose: String, Sendable, Codable { - /// An array of actors to apply an aggregate moderation action (mute/block) on. + /// A list of actors to apply an aggregate moderation action (mute/block) on. /// /// - Note: The documentation is taken directly from the lexicon itself. case modlist = "app.bsky.graph.defs#modlist" - /// An array of actors used for curation purposes such as list feeds or interaction gating. + /// A list of actors used for curation purposes such as list feeds or interaction gating. /// /// - Note: The documentation is taken directly from the lexicon itself. case curatelist = "app.bsky.graph.defs#curatelist" diff --git a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift index a3c8ecaad8..be3691b491 100644 --- a/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift +++ b/Sources/ATProtoKit/Models/Lexicons/app.bsky/Graph/AppBskyGraphList.swift @@ -51,13 +51,13 @@ extension AppBskyLexicon.Graph { public let avatarImageBlob: ComAtprotoLexicon.Repository.UploadBlobOutput? /// The user-defined labels for the list. Optional. - public let labels: ATUnion.ListLabelsUnion + public let labels: ATUnion.ListLabelsUnion? /// The date and time the list was created. public let createdAt: Date public init(purpose: ListPurpose, name: String, description: String?, descriptionFacets: [AppBskyLexicon.RichText.Facet]?, - avatarImageBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, labels: ATUnion.ListLabelsUnion, createdAt: Date) { + avatarImageBlob: ComAtprotoLexicon.Repository.UploadBlobOutput?, labels: ATUnion.ListLabelsUnion?, createdAt: Date) { self.purpose = purpose self.name = name self.description = description @@ -75,7 +75,7 @@ extension AppBskyLexicon.Graph { self.description = try container.decodeIfPresent(String.self, forKey: .description) self.descriptionFacets = try container.decodeIfPresent([AppBskyLexicon.RichText.Facet].self, forKey: .descriptionFacets) self.avatarImageBlob = try container.decodeIfPresent(ComAtprotoLexicon.Repository.UploadBlobOutput.self, forKey: .avatarImageBlob) - self.labels = try container.decode(ATUnion.ListLabelsUnion.self, forKey: .labels) + self.labels = try container.decodeIfPresent(ATUnion.ListLabelsUnion.self, forKey: .labels) self.createdAt = try container.decodeDate(forKey: .createdAt) } @@ -85,10 +85,10 @@ extension AppBskyLexicon.Graph { try container.encode(self.purpose, forKey: .purpose) try container.encode(self.name, forKey: .name) try container.truncatedEncode(self.name, forKey: .name, upToCharacterLength: 64) - try container.truncatedEncodeIfPresent(self.description, forKey: .description, upToCharacterLength: 30) + try container.truncatedEncodeIfPresent(self.description, forKey: .description, upToCharacterLength: 300) try container.encodeIfPresent(self.descriptionFacets, forKey: .descriptionFacets) try container.encodeIfPresent(self.avatarImageBlob, forKey: .avatarImageBlob) - try container.encode(self.labels, forKey: .labels) + try container.encodeIfPresent(self.labels, forKey: .labels) try container.encodeDate(self.createdAt, forKey: .createdAt) } diff --git a/Sources/ATProtoKit/Utilities/ATProtoTools.swift b/Sources/ATProtoKit/Utilities/ATProtoTools.swift index 75e8689cb9..0dc8f7dc0e 100644 --- a/Sources/ATProtoKit/Utilities/ATProtoTools.swift +++ b/Sources/ATProtoKit/Utilities/ATProtoTools.swift @@ -357,6 +357,32 @@ public class ATProtoTools { } } + /// Creates a ``ComAtprotoLexicon/Repository/StrongReference`` from a URI. + /// + /// - Parameters: + /// - recordURI: The URI of the record. + /// - pdsURL: The URL of the Personal Data Server (PDS). Optional. + /// Defaults to `https://api.bsky.app`. + /// - Returns: A strong reference of the record. + public static func createStrongReference( + from recordURI: String, + pdsURL: String = "https://api.bsky.app" + ) async throws -> ComAtprotoLexicon.Repository.StrongReference { + let query = try ATProtoTools().parseURI(recordURI) + + do { + let record = try await ATProtoKit(pdsURL: pdsURL, canUseBlueskyRecords: false).getRepositoryRecord( + from: query.repository, + collection: query.collection, + recordKey: query.recordKey + ) + + return ComAtprotoLexicon.Repository.StrongReference(recordURI: record.uri, cidHash: record.cid) + } catch { + throw error + } + } + /// Generates a random alphanumeric string with a specified length /// /// A maximum of 25 characters can be created for the string. This is useful for generating diff --git a/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift new file mode 100644 index 0000000000..7431a90f34 --- /dev/null +++ b/Sources/ATProtoKit/Utilities/SessionConfigurationTools.swift @@ -0,0 +1,238 @@ +// +// SessionConfigurationTools.swift +// ATProtoKit +// +// Created by Christopher Jr Riley on 2025-02-02. +// + +import Foundation + +/// A class of helper methods for ``SessionConfiguration``-conforming classes. +public class SessionConfigurationTools { + + /// An instance of ``SessionConfiguration``. + public let sessionConfiguration: SessionConfiguration + + /// Creates an instance for the class. + /// + /// - Parameter sessionConfiguration: An instance of ``SessionConfiguration``. + public init(sessionConfiguration: SessionConfiguration) { + self.sessionConfiguration = sessionConfiguration + } + + // MARK: - Common Helpers + /// Converts the DID document from an ``UnknownType`` object to a ``DIDDocument`` object. + /// + /// - Parameter didDocument: The DID document as an ``UnknownType`` object. Optional. + /// Defaults to `nil`. + /// - Returns: A ``DIDDocument`` object (if there's a value) or `nil` (if not). + public func convertDIDDocument(_ didDocument: UnknownType? = nil) -> DIDDocument? { + var decodedDidDocument: DIDDocument? = nil + + do { + if let didDocument = didDocument, + let jsonData = try didDocument.toJSON() { + let decoder = JSONDecoder() + decodedDidDocument = try decoder.decode(DIDDocument.self, from: jsonData) + } + } catch { + return nil + } + + return decodedDidDocument + } + + /// Checks the refresh token and refreshes the session. + /// + /// - Parameter refreshToken: The refresh token of the session. + /// + /// - Throws: ``ATProtocolConfigurationError`` if the current date is past the token's + /// expiry date. + public func checkRefreshToken(refreshToken: String, pdsURL: String = "https://bsky.social") async throws { + let expiryDate = try SessionToken(sessionToken: refreshToken).payload.expiresAt + let currentDate = Date() + + if currentDate > expiryDate { + throw SessionConfigurationToolsError.tokensExpired(message: "The access and refresh tokens have expired.") + } + + do { + _ = try await self.sessionConfiguration.refreshSession(by: refreshToken, authenticationFactorToken: nil) + } catch { + throw error + } + } + + // MARK: - getSession Helpers + /// Validates and retrieves a valid access token from the provided argument or the session object. + /// + /// - Parameter accessToken: An optional access token to validate. + /// - Returns: A valid access token. + /// + /// - Throws: An `ATProtoError` if no access token is available. + public func getValidAccessToken(from accessToken: String?) throws -> String { + if let token = accessToken { + return token + } + + if let token = self.sessionConfiguration.accessToken { + return token + } + + throw SessionConfigurationToolsError.noSessionToken(message: "No session token available.") + } + + /// Handles re-authentication and session refresh when the token is expired. + /// + /// - Parameter authenticationFactorToken: A token used for Two-Factor Authentication. + /// Optional. Defaults to `nil`. + /// - Returns: A refreshed session object. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError``, ``ATRequestPrepareError``, and + /// ``ATProtocolConfigurationError`` for more details. + public func handleExpiredTokenFromGetSession( + authenticationFactorToken: String? = nil + ) async throws -> ComAtprotoLexicon.Server.GetSessionOutput { + do { + _ = try await self.sessionConfiguration.refreshSession(by: nil, authenticationFactorToken: authenticationFactorToken) + + guard let didDocument = self.sessionConfiguration.didDocument else { + throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") + } + + var refreshedSessionStatus: ComAtprotoLexicon.Server.GetSession.UserAccountStatus? = nil + + // UserAccountStatus conversion. + let sessionStatus = self.sessionConfiguration.status + switch sessionStatus { + case .suspended: + refreshedSessionStatus = .suspended + case .takedown: + refreshedSessionStatus = .takedown + case .deactivated: + refreshedSessionStatus = .deactivated + default: + refreshedSessionStatus = nil + } + + // DIDDocument conversion. + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try encoder.encode(didDocument) + + guard let rawDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Failed to serialize DIDDocument into [String: Any].") + ) + } + + var codableDictionary = [String: CodableValue]() + for (key, value) in rawDictionary { + codableDictionary[key] = try CodableValue.fromAny(value) + } + + let unknownDIDDocument = UnknownType.unknown(codableDictionary) + + return ComAtprotoLexicon.Server + .GetSessionOutput( + handle: self.sessionConfiguration.handle, + did: self.sessionConfiguration.sessionDID, + email: self.sessionConfiguration.email, + isEmailConfirmed: self.sessionConfiguration.isEmailConfirmed, + isEmailAuthenticationFactor: self.sessionConfiguration.isEmailAuthenticationFactorEnabled, + didDocument: unknownDIDDocument, + isActive: self.sessionConfiguration.isActive, + status: refreshedSessionStatus + ) + } catch { + throw error + } + } + + // MARK: - refreshSession Helpers + /// Validates and retrieves a valid access token from the provided argument or the session object. + /// + /// - Parameter accessToken: An optional access token to validate. + /// - Returns: A valid access token, or `nil`. + public func getValidRefreshToken(from refreshToken: String?) -> String? { + if let token = refreshToken { + return token + } + + if let token = self.sessionConfiguration.refreshToken { + return token + } + + return nil + } + + /// Handles re-authentication and session refresh when the token is expired. + /// + /// - Parameter authenticationFactorToken: A token used for Two-Factor Authentication. + /// Optional. Defaults to `nil`. + /// - Returns: A refreshed session object. + /// + /// - Throws: An ``ATProtoError``-conforming error type, depending on the issue. Go to + /// ``ATAPIError``, ``ATRequestPrepareError``, and + /// ``ATProtocolConfigurationError`` for more details. + public func handleExpiredTokenFromRefreshSession( + authenticationFactorToken: String? = nil + ) async throws -> ComAtprotoLexicon.Server.RefreshSessionOutput { + do { + try await self.sessionConfiguration.authenticate(authenticationFactorToken: authenticationFactorToken) + + guard let refreshToken = sessionConfiguration.refreshToken, + let didDocument = self.sessionConfiguration.didDocument else { + throw SessionConfigurationToolsError.noSessionToken(message: "No session token found after re-authentication attempt.") + } + + var refreshedSessionStatus: ComAtprotoLexicon.Server.RefreshSession.UserAccountStatus? = nil + + // UserAccountStatus conversion. + let sessionStatus = self.sessionConfiguration.status + switch sessionStatus { + case .suspended: + refreshedSessionStatus = .suspended + case .takedown: + refreshedSessionStatus = .takedown + case .deactivated: + refreshedSessionStatus = .deactivated + default: + refreshedSessionStatus = nil + } + + // DIDDocument conversion. + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + + let jsonData = try encoder.encode(didDocument) + + guard let rawDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Failed to serialize DIDDocument into [String: Any].") + ) + } + + var codableDictionary = [String: CodableValue]() + for (key, value) in rawDictionary { + codableDictionary[key] = try CodableValue.fromAny(value) + } + + let unknownDIDDocument = UnknownType.unknown(codableDictionary) + + return ComAtprotoLexicon.Server.RefreshSessionOutput( + accessToken: self.sessionConfiguration.accessToken!, + refreshToken: refreshToken, + handle: self.sessionConfiguration.handle, + did: self.sessionConfiguration.sessionDID, + didDocument: unknownDIDDocument, + isActive: self.sessionConfiguration.isActive, + status: refreshedSessionStatus + ) + } catch { + throw error + } + } +}