2021-02-02 08:38:54 +01:00
//
// A u t h e n t i c a t i o n V i e w M o d e l . s w i f t
// M a s t o d o n
//
// C r e a t e d b y M a i n a s u K C i r n o o n 2 0 2 1 / 2 / 1 .
//
2021-02-02 12:31:10 +01:00
import os . log
import UIKit
2021-02-03 09:01:08 +01:00
import CoreData
import CoreDataStack
2021-02-02 08:38:54 +01:00
import Combine
2021-02-02 12:31:10 +01:00
import MastodonSDK
2021-02-02 08:38:54 +01:00
final class AuthenticationViewModel {
var disposeBag = Set < AnyCancellable > ( )
// i n p u t
2021-02-02 12:31:10 +01:00
let context : AppContext
let coordinator : SceneCoordinator
2021-02-03 09:01:08 +01:00
let isAuthenticationExist : Bool
2021-02-02 08:38:54 +01:00
let input = CurrentValueSubject < String , Never > ( " " )
// o u t p u t
2021-02-03 09:01:08 +01:00
let viewHierarchyShouldReset : Bool
2021-02-02 08:38:54 +01:00
let domain = CurrentValueSubject < String ? , Never > ( nil )
2021-02-05 04:53:21 +01:00
let isDomainValid = CurrentValueSubject < Bool , Never > ( false )
2021-02-02 12:31:10 +01:00
let isAuthenticating = CurrentValueSubject < Bool , Never > ( false )
2021-02-05 10:53:00 +01:00
let isRegistering = CurrentValueSubject < Bool , Never > ( false )
let isIdle = CurrentValueSubject < Bool , Never > ( true )
2021-02-03 09:01:08 +01:00
let authenticated = PassthroughSubject < ( domain : String , account : Mastodon . Entity . Account ) , Never > ( )
2021-02-02 12:31:10 +01:00
let error = CurrentValueSubject < Error ? , Never > ( nil )
2021-06-04 12:31:57 +02:00
2021-02-03 09:01:08 +01:00
init ( context : AppContext , coordinator : SceneCoordinator , isAuthenticationExist : Bool ) {
2021-02-02 12:31:10 +01:00
self . context = context
self . coordinator = coordinator
2021-02-03 09:01:08 +01:00
self . isAuthenticationExist = isAuthenticationExist
self . viewHierarchyShouldReset = isAuthenticationExist
2021-02-02 12:31:10 +01:00
2021-02-02 08:38:54 +01:00
input
. map { input in
2021-03-06 07:21:52 +01:00
AuthenticationViewModel . parseDomain ( from : input )
2021-02-02 08:38:54 +01:00
}
. assign ( to : \ . value , on : domain )
. store ( in : & disposeBag )
2021-02-05 10:53:00 +01:00
Publishers . CombineLatest (
isAuthenticating . eraseToAnyPublisher ( ) ,
isRegistering . eraseToAnyPublisher ( )
)
. map { ! $0 && ! $1 }
. assign ( to : \ . value , on : self . isIdle )
. store ( in : & disposeBag )
2021-02-02 08:38:54 +01:00
domain
. map { $0 != nil }
2021-02-05 04:53:21 +01:00
. assign ( to : \ . value , on : isDomainValid )
2021-02-02 08:38:54 +01:00
. store ( in : & disposeBag )
2021-02-02 12:31:10 +01:00
}
}
2021-03-06 07:21:52 +01:00
extension AuthenticationViewModel {
static func parseDomain ( from input : String ) -> String ? {
let trimmed = input . trimmingCharacters ( in : . whitespacesAndNewlines ) . lowercased ( )
guard ! trimmed . isEmpty else { return nil }
let urlString = trimmed . hasPrefix ( " https:// " ) ? trimmed : " https:// " + trimmed
guard let url = URL ( string : urlString ) ,
let host = url . host else {
return nil
}
let components = host . components ( separatedBy : " . " )
guard ! components . contains ( where : { $0 . isEmpty } ) else { return nil }
guard components . count >= 2 else { return nil }
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: input host: %s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , host )
return host
}
}
2021-02-05 08:58:48 +01:00
extension AuthenticationViewModel {
enum AuthenticationError : Error , LocalizedError {
case badCredentials
case registrationClosed
var errorDescription : String ? {
switch self {
case . badCredentials : return " Bad Credentials "
case . registrationClosed : return " Registration Closed "
}
}
var failureReason : String ? {
switch self {
case . badCredentials : return " Credentials invalid. "
case . registrationClosed : return " Server disallow registration. "
}
}
var helpAnchor : String ? {
switch self {
case . badCredentials : return " Please try again. "
case . registrationClosed : return " Please try another domain. "
}
}
}
}
2021-02-02 12:31:10 +01:00
extension AuthenticationViewModel {
struct AuthenticateInfo {
let domain : String
let clientID : String
let clientSecret : String
2021-02-05 08:58:48 +01:00
let authorizeURL : URL
2021-06-04 12:31:57 +02:00
let redirectURI : String
2021-02-05 08:58:48 +01:00
2021-06-04 12:31:57 +02:00
init ? (
domain : String ,
application : Mastodon . Entity . Application ,
redirectURI : String = MastodonAuthenticationController . callbackURL
) {
2021-02-05 08:58:48 +01:00
self . domain = domain
guard let clientID = application . clientID ,
let clientSecret = application . clientSecret else { return nil }
self . clientID = clientID
self . clientSecret = clientSecret
self . authorizeURL = {
2021-06-04 12:31:57 +02:00
let query = Mastodon . API . OAuth . AuthorizeQuery ( clientID : clientID , redirectURI : redirectURI )
2021-02-05 08:58:48 +01:00
let url = Mastodon . API . OAuth . authorizeURL ( domain : domain , query : query )
return url
} ( )
2021-06-04 12:31:57 +02:00
self . redirectURI = redirectURI
2021-02-05 08:58:48 +01:00
}
2021-02-02 12:31:10 +01:00
}
func authenticate ( info : AuthenticateInfo , pinCodePublisher : PassthroughSubject < String , Never > ) {
pinCodePublisher
. handleEvents ( receiveOutput : { [ weak self ] _ in
guard let self = self else { return }
self . isAuthenticating . value = true
} )
. compactMap { [ weak self ] code -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > ? in
guard let self = self else { return nil }
return self . context . apiService
. userAccessToken (
domain : info . domain ,
clientID : info . clientID ,
clientSecret : info . clientSecret ,
2021-06-04 12:31:57 +02:00
redirectURI : info . redirectURI ,
2021-02-02 12:31:10 +01:00
code : code
)
. flatMap { response -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > in
let token = response . value
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: sign in success. Token: %s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , token . accessToken )
return AuthenticationViewModel . verifyAndSaveAuthentication (
context : self . context ,
info : info ,
2021-02-03 09:01:08 +01:00
userToken : token
2021-02-02 12:31:10 +01:00
)
}
. eraseToAnyPublisher ( )
}
. switchToLatest ( )
. receive ( on : DispatchQueue . main )
. sink { [ weak self ] completion in
guard let self = self else { return }
switch completion {
case . failure ( let error ) :
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: swap user access token swap fail: %s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , error . localizedDescription )
self . isAuthenticating . value = false
self . error . value = error
case . finished :
break
}
} receiveValue : { [ weak self ] response in
guard let self = self else { return }
let account = response . value
2021-02-03 09:01:08 +01:00
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: user %s sign in success " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , account . username )
self . authenticated . send ( ( domain : info . domain , account : account ) )
2021-02-02 12:31:10 +01:00
}
. store ( in : & self . disposeBag )
}
static func verifyAndSaveAuthentication (
context : AppContext ,
info : AuthenticateInfo ,
2021-02-03 09:01:08 +01:00
userToken : Mastodon . Entity . Token
2021-02-02 12:31:10 +01:00
) -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > {
2021-02-03 09:01:08 +01:00
let authorization = Mastodon . API . OAuth . Authorization ( accessToken : userToken . accessToken )
let managedObjectContext = context . backgroundManagedObjectContext
2021-02-02 12:31:10 +01:00
return context . apiService . accountVerifyCredentials (
domain : info . domain ,
authorization : authorization
)
2021-02-03 09:01:08 +01:00
. flatMap { response -> AnyPublisher < Mastodon . Response . Content < Mastodon . Entity . Account > , Error > in
let account = response . value
let mastodonUserRequest = MastodonUser . sortedFetchRequest
mastodonUserRequest . predicate = MastodonUser . predicate ( domain : info . domain , id : account . id )
mastodonUserRequest . fetchLimit = 1
guard let mastodonUser = try ? managedObjectContext . fetch ( mastodonUserRequest ) . first else {
2021-02-05 08:58:48 +01:00
return Fail ( error : AuthenticationError . badCredentials ) . eraseToAnyPublisher ( )
2021-02-03 09:01:08 +01:00
}
let property = MastodonAuthentication . Property (
domain : info . domain ,
userID : mastodonUser . id ,
username : mastodonUser . username ,
appAccessToken : userToken . accessToken , // TODO: s w a p a p p t o k e n
userAccessToken : userToken . accessToken ,
clientID : info . clientID ,
clientSecret : info . clientSecret
)
return managedObjectContext . performChanges {
_ = APIService . CoreData . createOrMergeMastodonAuthentication (
into : managedObjectContext ,
for : mastodonUser ,
in : info . domain ,
property : property ,
networkDate : response . networkDate
)
}
. setFailureType ( to : Error . self )
. tryMap { result in
switch result {
case . failure ( let error ) : throw error
case . success : return response
}
}
. eraseToAnyPublisher ( )
}
. eraseToAnyPublisher ( )
2021-02-02 08:38:54 +01:00
}
}