Source code for the WriteFreely SwiftUI app for iOS, iPadOS, and macOS
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 
 

328 wiersze
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. }