Source code for the WriteFreely SwiftUI app for iOS, iPadOS, and macOS
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

328 lines
12 KiB

  1. import Foundation
  2. import WriteFreely
  3. import Security
  4. // MARK: - WriteFreelyModel
  5. class WriteFreelyModel: ObservableObject {
  6. @Published var account = AccountModel()
  7. @Published var preferences = PreferencesModel()
  8. @Published var store = PostStore()
  9. @Published var collections = CollectionListModel()
  10. @Published var isLoggingIn: Bool = false
  11. @Published var selectedPost: Post?
  12. private var client: WFClient?
  13. private let defaults = UserDefaults.standard
  14. init() {
  15. // Set the color scheme based on what's been saved in UserDefaults.
  16. DispatchQueue.main.async {
  17. self.preferences.appearance = self.defaults.integer(forKey: self.preferences.colorSchemeIntegerKey)
  18. }
  19. #if DEBUG
  20. // for post in testPostData { store.add(post) }
  21. #endif
  22. DispatchQueue.main.async {
  23. self.account.restoreState()
  24. if self.account.isLoggedIn {
  25. guard let serverURL = URL(string: self.account.server) else {
  26. print("Server URL not found")
  27. return
  28. }
  29. guard let token = self.fetchTokenFromKeychain(
  30. username: self.account.username,
  31. server: self.account.server
  32. ) else {
  33. print("Could not fetch token from Keychain")
  34. return
  35. }
  36. self.account.login(WFUser(token: token, username: self.account.username))
  37. self.client = WFClient(for: serverURL)
  38. self.client?.user = self.account.user
  39. self.collections.clearUserCollection()
  40. self.fetchUserCollections()
  41. self.fetchUserPosts()
  42. }
  43. }
  44. }
  45. }
  46. // MARK: - WriteFreelyModel API
  47. extension WriteFreelyModel {
  48. func login(to server: URL, as username: String, password: String) {
  49. isLoggingIn = true
  50. account.server = server.absoluteString
  51. client = WFClient(for: server)
  52. client?.login(username: username, password: password, completion: loginHandler)
  53. }
  54. func logout() {
  55. guard let loggedInClient = client else {
  56. do {
  57. try purgeTokenFromKeychain(username: account.username, server: account.server)
  58. account.logout()
  59. } catch {
  60. fatalError("Failed to log out persisted state")
  61. }
  62. return
  63. }
  64. loggedInClient.logout(completion: logoutHandler)
  65. }
  66. func fetchUserCollections() {
  67. guard let loggedInClient = client else { return }
  68. loggedInClient.getUserCollections(completion: fetchUserCollectionsHandler)
  69. }
  70. func fetchUserPosts() {
  71. guard let loggedInClient = client else { return }
  72. loggedInClient.getPosts(completion: fetchUserPostsHandler)
  73. }
  74. func publish(post: Post) {
  75. guard let loggedInClient = client else { return }
  76. if let existingPostId = post.wfPost.postId {
  77. // This is an existing post.
  78. loggedInClient.updatePost(
  79. postId: existingPostId,
  80. updatedPost: post.wfPost,
  81. completion: publishHandler
  82. )
  83. } else {
  84. // This is a new local draft.
  85. loggedInClient.createPost(
  86. post: post.wfPost, in: post.collection?.wfCollection?.alias, completion: publishHandler
  87. )
  88. }
  89. }
  90. func updateFromServer(post: Post) {
  91. guard let loggedInClient = client else { return }
  92. guard let postId = post.wfPost.postId else { return }
  93. DispatchQueue.main.async {
  94. self.selectedPost = post
  95. }
  96. loggedInClient.getPost(byId: postId, completion: updateFromServerHandler)
  97. }
  98. }
  99. private extension WriteFreelyModel {
  100. func loginHandler(result: Result<WFUser, Error>) {
  101. DispatchQueue.main.async {
  102. self.isLoggingIn = false
  103. }
  104. do {
  105. let user = try result.get()
  106. fetchUserCollections()
  107. fetchUserPosts()
  108. saveTokenToKeychain(user.token, username: user.username, server: account.server)
  109. DispatchQueue.main.async {
  110. self.account.login(user)
  111. }
  112. } catch WFError.notFound {
  113. DispatchQueue.main.async {
  114. self.account.currentError = AccountError.usernameNotFound
  115. }
  116. } catch WFError.unauthorized {
  117. DispatchQueue.main.async {
  118. self.account.currentError = AccountError.invalidPassword
  119. }
  120. } catch {
  121. if (error as NSError).domain == NSURLErrorDomain,
  122. (error as NSError).code == -1003 {
  123. DispatchQueue.main.async {
  124. self.account.currentError = AccountError.serverNotFound
  125. }
  126. }
  127. }
  128. }
  129. func logoutHandler(result: Result<Bool, Error>) {
  130. do {
  131. _ = try result.get()
  132. do {
  133. try purgeTokenFromKeychain(username: account.user?.username, server: account.server)
  134. client = nil
  135. DispatchQueue.main.async {
  136. self.account.logout()
  137. self.collections.clearUserCollection()
  138. self.store.purgeAllPosts()
  139. }
  140. } catch {
  141. print("Something went wrong purging the token from the Keychain.")
  142. }
  143. } catch WFError.notFound {
  144. // The user token is invalid or doesn't exist, so it's been invalidated by the server. Proceed with
  145. // purging the token from the Keychain, destroying the client object, and setting the AccountModel to its
  146. // logged-out state.
  147. do {
  148. try purgeTokenFromKeychain(username: account.user?.username, server: account.server)
  149. client = nil
  150. DispatchQueue.main.async {
  151. self.account.logout()
  152. self.collections.clearUserCollection()
  153. self.store.purgeAllPosts()
  154. }
  155. } catch {
  156. print("Something went wrong purging the token from the Keychain.")
  157. }
  158. } catch {
  159. // We get a 'cannot parse response' (similar to what we were seeing in the Swift package) NSURLError here,
  160. // so we're using a hacky workaround — if we get the NSURLError, but the AccountModel still thinks we're
  161. // logged in, try calling the logout function again and see what we get.
  162. // Conditional cast from 'Error' to 'NSError' always succeeds but is the only way to check error properties.
  163. if (error as NSError).domain == NSURLErrorDomain,
  164. (error as NSError).code == NSURLErrorCannotParseResponse {
  165. if account.isLoggedIn {
  166. self.logout()
  167. }
  168. }
  169. }
  170. }
  171. func fetchUserCollectionsHandler(result: Result<[WFCollection], Error>) {
  172. do {
  173. let fetchedCollections = try result.get()
  174. var fetchedCollectionsArray: [PostCollection] = []
  175. for fetchedCollection in fetchedCollections {
  176. let postCollection = PostCollection(title: fetchedCollection.title)
  177. postCollection.wfCollection = fetchedCollection
  178. fetchedCollectionsArray.append(postCollection)
  179. DispatchQueue.main.async {
  180. let localCollection = WFACollection(context: PersistenceManager.persistentContainer.viewContext)
  181. localCollection.alias = fetchedCollection.alias
  182. localCollection.blogDescription = fetchedCollection.description
  183. localCollection.email = fetchedCollection.email
  184. localCollection.isPublic = fetchedCollection.isPublic ?? false
  185. localCollection.styleSheet = fetchedCollection.styleSheet
  186. localCollection.title = fetchedCollection.title
  187. localCollection.url = fetchedCollection.url
  188. }
  189. }
  190. DispatchQueue.main.async {
  191. // self.collections = CollectionListModel(with: fetchedCollectionsArray)
  192. PersistenceManager().saveContext()
  193. }
  194. } catch {
  195. print(error)
  196. }
  197. }
  198. func fetchUserPostsHandler(result: Result<[WFPost], Error>) {
  199. do {
  200. let fetchedPosts = try result.get()
  201. var fetchedPostsArray: [Post] = []
  202. for fetchedPost in fetchedPosts {
  203. var post: Post
  204. if let matchingAlias = fetchedPost.collectionAlias {
  205. let postCollection = PostCollection(title: (
  206. collections.userCollections.filter { $0.alias == matchingAlias }
  207. ).first?.title ?? "NO TITLE")
  208. post = Post(wfPost: fetchedPost, in: postCollection)
  209. } else {
  210. post = Post(wfPost: fetchedPost)
  211. }
  212. fetchedPostsArray.append(post)
  213. }
  214. DispatchQueue.main.async {
  215. self.store.updateStore(with: fetchedPostsArray)
  216. }
  217. } catch {
  218. print(error)
  219. }
  220. }
  221. func publishHandler(result: Result<WFPost, Error>) {
  222. do {
  223. let wfPost = try result.get()
  224. let foundPostIndex = store.posts.firstIndex(where: {
  225. $0.wfPost.title == wfPost.title && $0.wfPost.body == wfPost.body
  226. })
  227. guard let index = foundPostIndex else { return }
  228. DispatchQueue.main.async {
  229. self.store.posts[index].wfPost = wfPost
  230. }
  231. } catch {
  232. print(error)
  233. }
  234. }
  235. func updateFromServerHandler(result: Result<WFPost, Error>) {
  236. do {
  237. let fetchedPost = try result.get()
  238. DispatchQueue.main.async {
  239. guard let selectedPost = self.selectedPost else { return }
  240. self.store.replace(post: selectedPost, with: fetchedPost)
  241. }
  242. } catch {
  243. print(error)
  244. }
  245. }
  246. }
  247. private extension WriteFreelyModel {
  248. // MARK: - Keychain Helpers
  249. func saveTokenToKeychain(_ token: String, username: String?, server: String) {
  250. let query: [String: Any] = [
  251. kSecClass as String: kSecClassGenericPassword,
  252. kSecValueData as String: token.data(using: .utf8)!,
  253. kSecAttrAccount as String: username ?? "anonymous",
  254. kSecAttrService as String: server
  255. ]
  256. let status = SecItemAdd(query as CFDictionary, nil)
  257. guard status == errSecDuplicateItem || status == errSecSuccess else {
  258. fatalError("Error storing in Keychain with OSStatus: \(status)")
  259. }
  260. }
  261. func purgeTokenFromKeychain(username: String?, server: String) throws {
  262. let query: [String: Any] = [
  263. kSecClass as String: kSecClassGenericPassword,
  264. kSecAttrAccount as String: username ?? "anonymous",
  265. kSecAttrService as String: server
  266. ]
  267. let status = SecItemDelete(query as CFDictionary)
  268. guard status == errSecSuccess || status == errSecItemNotFound else {
  269. fatalError("Error deleting from Keychain with OSStatus: \(status)")
  270. }
  271. }
  272. func fetchTokenFromKeychain(username: String?, server: String) -> String? {
  273. let query: [String: Any] = [
  274. kSecClass as String: kSecClassGenericPassword,
  275. kSecAttrAccount as String: username ?? "anonymous",
  276. kSecAttrService as String: server,
  277. kSecMatchLimit as String: kSecMatchLimitOne,
  278. kSecReturnAttributes as String: true,
  279. kSecReturnData as String: true
  280. ]
  281. var secItem: CFTypeRef?
  282. let status = SecItemCopyMatching(query as CFDictionary, &secItem)
  283. guard status != errSecItemNotFound else {
  284. return nil
  285. }
  286. guard status == errSecSuccess else {
  287. fatalError("Error fetching from Keychain with OSStatus: \(status)")
  288. }
  289. guard let existingSecItem = secItem as? [String: Any],
  290. let tokenData = existingSecItem[kSecValueData as String] as? Data,
  291. let token = String(data: tokenData, encoding: .utf8) else {
  292. return nil
  293. }
  294. return token
  295. }
  296. }