diff --git a/ntfy/Persistence/Store.swift b/ntfy/Persistence/Store.swift index 0529953..f99af7a 100644 --- a/ntfy/Persistence/Store.swift +++ b/ntfy/Persistence/Store.swift @@ -81,6 +81,11 @@ class Store: ObservableObject { return try? context.fetch(fetchRequest).first } + func delete(user: User) { + context.delete(user) + try? context.save() + } + func delete(subscription: Subscription) { context.delete(subscription) try? context.save() diff --git a/ntfy/Utils/ApiService.swift b/ntfy/Utils/ApiService.swift index b419e44..43f525b 100644 --- a/ntfy/Utils/ApiService.swift +++ b/ntfy/Utils/ApiService.swift @@ -64,23 +64,32 @@ class ApiService { }.resume() } - func checkAuth(baseUrl: String, topic: String, user: BasicUser?, completionHandler: @escaping(AuthCheckResponse?, Error?) -> Void) { + func checkAuth(baseUrl: String, topic: String, user: BasicUser?, completionHandler: @escaping(AuthResult) -> Void) { guard let url = URL(string: topicAuthUrl(baseUrl: baseUrl, topic: topic)) else { return } let request = newRequest(url: url, user: user) Log.d(tag, "Checking auth for \(url) with user \(user?.username ?? "anonymous")") URLSession.shared.dataTask(with: request) { (data, response, error) in if let error = error { Log.e(self.tag, "Error checking auth: \(error)") - completionHandler(nil, error) - } - if let data = data { + completionHandler(.Error(error.localizedDescription)) + } else if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + completionHandler(.Unauthorized) + } else { + completionHandler(.Error("Unexpected response from server: \(httpResponse.statusCode)")) + } + } else if let data = data { do { let result = try JSONDecoder().decode(AuthCheckResponse.self, from: data) Log.d(self.tag, "Auth result: \(result)") - completionHandler(result, nil) + if result.success == true { + completionHandler(.Success) + } else { + completionHandler(.Error("Unexpected response from server")) + } } catch { Log.e(self.tag, "Error handling auth response: \(error)") - completionHandler(nil, error) + completionHandler(.Error("Unexpected response from server. Is this a ntfy server?")) } } }.resume() @@ -128,6 +137,12 @@ struct BasicUser { } } +enum AuthResult { + case Success + case Unauthorized + case Error(String) +} + struct AuthCheckResponse: Codable { let success: Bool? let code: Int? diff --git a/ntfy/Utils/Helpers.swift b/ntfy/Utils/Helpers.swift index 9973f01..18cb11f 100644 --- a/ntfy/Utils/Helpers.swift +++ b/ntfy/Utils/Helpers.swift @@ -6,9 +6,7 @@ func topicUrl(baseUrl: String, topic: String) -> String { } func topicShortUrl(baseUrl: String, topic: String) -> String { - return topicUrl(baseUrl: baseUrl, topic: topic) - .replacingOccurrences(of: "http://", with: "") - .replacingOccurrences(of: "https://", with: "") + return shortUrl(url: topicUrl(baseUrl: baseUrl, topic: topic)) } func topicAuthUrl(baseUrl: String, topic: String) -> String { @@ -21,6 +19,12 @@ func topicHash(baseUrl: String, topic: String) -> String { return digest.compactMap { String(format: "%02x", $0)}.joined() } +func shortUrl(url: String) -> String { + return url + .replacingOccurrences(of: "http://", with: "") + .replacingOccurrences(of: "https://", with: "") +} + func parseAllTags(_ tags: String?) -> [String] { return (tags?.components(separatedBy: ",") ?? []) .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } diff --git a/ntfy/Views/SettingsView.swift b/ntfy/Views/SettingsView.swift index b5b6b62..177def7 100644 --- a/ntfy/Views/SettingsView.swift +++ b/ntfy/Views/SettingsView.swift @@ -12,7 +12,10 @@ struct SettingsView: View { Text("Manage users") } }*/ - Section(header: Text("Users")) { + Section( + header: Text("Users"), + footer: Text("To access read-protected topics, you may add or edit users here. All topics for a given server will use the same user.") + ) { UsersView() } Section(header: Text("About")) { @@ -20,7 +23,7 @@ struct SettingsView: View { Text("Version") .foregroundColor(.gray) Spacer() - Text("ntfy 1.1") + Text("ntfy \(Config.version) (\(Config.build))") } } } @@ -58,6 +61,7 @@ struct UsersView: View { Image(systemName: "plus") Text("Add user") } + .padding(.all, 4) .onTapGesture { showDialog = true } @@ -65,20 +69,20 @@ struct UsersView: View { .sheet(isPresented: $showDialog) { NavigationView { Form { - Section(footer: - Text("You can add a user here. All topics for the given server will use this user.") + Section( + footer: (selectedUser == nil) + ? Text("You can add a user here. All topics for the given server will use this user.") + : Text("Edit the username or password for \(shortUrl(url: baseUrl)) here. This user is used for all topics of this server. Leave the password blank to leave it unchanged.") ) { if selectedUser == nil { - TextField("Service URL, e.g. https://ntfy.example.com", text: $baseUrl) + TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl) .disableAutocapitalization() .disableAutocorrection(true) } TextField("Username", text: $username) .disableAutocapitalization() .disableAutocorrection(true) - TextField("Password", text: $password) - .disableAutocapitalization() - .disableAutocorrection(true) + SecureField("Password", text: $password) } } .navigationTitle(selectedUser == nil ? "Add user" : "Edit user") @@ -101,6 +105,10 @@ struct UsersView: View { } private func saveAction() { + var password = password + if let user = selectedUser, password == "" { + password = user.password ?? "?" // If password is blank, leave unchanged + } store.saveUser(baseUrl: baseUrl, username: username, password: password) resetAndHide() } @@ -110,22 +118,52 @@ struct UsersView: View { } private func isValid() -> Bool { - return true // FIXME: validate + if selectedUser == nil { // New user + if baseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil { + return false + } else if username.isEmpty || password.isEmpty { + return false + } + } else { // Existing user + if username.isEmpty { + return false + } + } + return true } private func resetAndHide() { - selectedUser = nil - baseUrl = "" - username = "" - password = "" showDialog = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Hide first and then reset, otherwise we'll see the text fields change + selectedUser = nil + baseUrl = "" + username = "" + password = "" + } } } struct UserRowView: View { + @EnvironmentObject private var store: Store @ObservedObject var user: User var body: some View { + if #available(iOS 15.0, *) { + userRow + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + store.delete(user: user) + } label: { + Label("Delete", systemImage: "trash.circle") + } + } + } else { + userRow + } + } + + private var userRow: some View { HStack { Image(systemName: "person.fill") VStack(alignment: .leading, spacing: 0) { diff --git a/ntfy/Views/SubscriptionAddView.swift b/ntfy/Views/SubscriptionAddView.swift index 824d944..7822bf3 100644 --- a/ntfy/Views/SubscriptionAddView.swift +++ b/ntfy/Views/SubscriptionAddView.swift @@ -13,6 +13,11 @@ struct SubscriptionAddView: View { @State private var showLogin: Bool = false @State private var username: String = "" @State private var password: String = "" + + @State private var loading = false + @State private var addError: String? + @State private var loginError: String? + private var subscriptionManager: SubscriptionManager { return SubscriptionManager(store: store) @@ -20,10 +25,27 @@ struct SubscriptionAddView: View { var body: some View { NavigationView { + // This is a little weird, but it works. The nagivation link for the login view + // is rendered in the backgroun (it's hidden), abd we toggle it manually. + // If anyone has a better way to do a two-page layout let me know. + + addView + .background(Group { + NavigationLink( + destination: loginView, + isActive: $showLogin + ) { + EmptyView() + } + }) + } + } + + private var addView: some View { + VStack(alignment: .leading, spacing: 0) { Form { Section( - footer: - Text("Topics are not password protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications") + footer: Text("Topics are not password protected, so choose a name that's not easy to guess. Once subscribed, you can PUT/POST notifications") ) { TextField("Topic name, e.g. phil_alerts", text: $topic) .disableAutocapitalization() @@ -35,50 +57,56 @@ struct SubscriptionAddView: View { ) { Toggle("Use another server", isOn: $useAnother) if useAnother { - TextField("Server URL, e.g. https://ntfy.example.com", text: $baseUrl) + TextField("Service URL, e.g. https://ntfy.home.io", text: $baseUrl) .disableAutocapitalization() .disableAutocorrection(true) } } } - .navigationTitle("Add subscription") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: cancelAction) { - Text("Cancel") - } - } - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: subscribeOrShowLoginAction) { - Text("Subscribe") - } - .disabled(!isValid()) + if let error = addError { + ErrorView(error: error) + } + } + .navigationTitle("Add subscription") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: cancelAction) { + Text("Cancel") } } - .background(Group { - NavigationLink( - destination: loginView, - isActive: $showLogin - ) { - EmptyView() + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: subscribeOrShowLoginAction) { + VStack { + if loading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + Text("Subscribe") + } + } + .fixedSize(horizontal: true, vertical: false) + } - }) + .disabled(!isAddViewValid()) + } } } private var loginView: some View { - Form { - Section( - footer: - Text("This topic requires that you login with username and password. The user will be stored on your device, and will be re-used for other topics.") - ) { - TextField("Username", text: $username) - .disableAutocapitalization() - .disableAutocorrection(true) - TextField("Password", text: $password) - .disableAutocapitalization() - .disableAutocorrection(true) + VStack(alignment: .leading, spacing: 0) { + Form { + Section( + footer: Text("This topic requires that you login with username and password. The user will be stored on your device, and will be re-used for other topics.") + ) { + TextField("Username", text: $username) + .disableAutocapitalization() + .disableAutocorrection(true) + SecureField("Password", text: $password) + } + } + if let error = loginError { + ErrorView(error: error) } } .navigationTitle("Login required") @@ -86,9 +114,14 @@ struct SubscriptionAddView: View { .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: subscribeWithUserAction) { - Text("Subscribe") + if loading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } else { + Text("Subscribe") + } } - .disabled(!isValid()) + .disabled(!isLoginViewValid()) } } } @@ -97,53 +130,111 @@ struct SubscriptionAddView: View { return topic.trimmingCharacters(in: .whitespaces) } - private func isValid() -> Bool { + private func isAddViewValid() -> Bool { if sanitizedTopic.isEmpty { return false } else if sanitizedTopic.range(of: "^[-_A-Za-z0-9]{1,64}$", options: .regularExpression, range: nil, locale: nil) == nil { return false + } else if selectedBaseUrl.range(of: "^https?://.+", options: .regularExpression, range: nil, locale: nil) == nil { + return false } else if store.getSubscription(baseUrl: selectedBaseUrl, topic: topic) != nil { return false } return true } + private func isLoginViewValid() -> Bool { + if username.isEmpty || password.isEmpty { + return false + } + return true + } + private func subscribeOrShowLoginAction() { + loading = true + addError = nil let user = store.getUser(baseUrl: selectedBaseUrl)?.toBasicUser() - ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { (response, error) in - if response?.success == true { + ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in + switch result { + case .Success: DispatchQueue.global(qos: .background).async { subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic) + resetAndHide() } - isShowing = false - } else { - showLogin = true + // Do not reset "loading", because resetAndHide() will do that after everything is done + case .Unauthorized: + if let user = user { + addError = "User \(user.username) is not authorized to read this topic" + } else { + addError = nil // Reset + showLogin = true + } + loading = false + case .Error(let err): + addError = err + loading = false } } } private func subscribeWithUserAction() { + loading = true + loginError = nil let user = BasicUser(username: username, password: password) - ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { (response, error) in - if response?.success == true { + ApiService.shared.checkAuth(baseUrl: selectedBaseUrl, topic: topic, user: user) { result in + switch result { + case .Success: DispatchQueue.global(qos: .background).async { store.saveUser(baseUrl: selectedBaseUrl, username: username, password: password) subscriptionManager.subscribe(baseUrl: selectedBaseUrl, topic: sanitizedTopic) + resetAndHide() } - isShowing = false - } else { - showLogin = true + // Do not reset "loading", because resetAndHide() will do that after everything is done + case .Unauthorized: + loginError = "Invalid credentials, or user \(username) is not authorized to read this topic" + loading = false + case .Error(let err): + loginError = err + loading = false } } } private func cancelAction() { - isShowing = false + resetAndHide() } private var selectedBaseUrl: String { return (useAnother) ? baseUrl : Config.appBaseUrl } + + private func resetAndHide() { + isShowing = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { + // Hide first and then reset, otherwise we'll see the text fields change + addError = nil + loginError = nil + loading = false + baseUrl = "" + topic = "" + useAnother = false + } + } +} + +struct ErrorView: View { + var error: String + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + .font(.title2) + Text(error) + .font(.subheadline) + } + .padding([.leading, .trailing], 20) + .padding([.top, .bottom], 10) + } } struct SubscriptionAddView_Previews: PreviewProvider {