diff --git a/docs/sso.md b/docs/sso.md index 5a1fd24..6eacb8b 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -261,5 +261,19 @@ the `ipodderx` application, and send `CF-Access-Client-Id` and `CF-Access-Client API token as who is asking. Do not bypass Access for `/api/*` instead; the token would then be the only thing between the internet and the API. +### `ipxapi.sdf1.net`, for the iOS app (#419) + +The app's player cannot send an Access service token's pair of headers as well as sign in, and its +cookie from Access ends mid-drive, so it has an address of its own: `ipxapi.sdf1.net`, a route of +the `rays-unraid` tunnel to the same `192.168.1.130:8099` as `ipodderx.sdf1.net`, with a proxied +CNAME to the tunnel and **no Access application**. That is the exposure the paragraph above warns +against, held narrow another way: a custom firewall rule on the `sdf1.net` zone (Security, WAF) +blocks every request to that host unless it carries `Authorization: Bearer` and is for +`/api/feeds`, `/api/me`, `/api/art`, `/api/entries/*` or `/media/*`. So the page, the sign-in form, +`/api/login`, `?token=` sign-in and the admin API answer 403 there, and ipx still checks the +token on what is let through. A new path the app needs is added to that rule's expression. The +rule is on Cloudflare, not in this repository; if the hostname is ever served without it, the +whole of ipx is on the internet behind bearer tokens alone. + See also [users.md](users.md) for what several people share, [configuration.md](configuration.md) for every `[web]` key, and [cli.md](cli.md) for the `ipx user` commands. diff --git a/ios/.envrc b/ios/.envrc new file mode 100644 index 0000000..51a9672 --- /dev/null +++ b/ios/.envrc @@ -0,0 +1,2 @@ +export CF_ACCESS_CLIENT_ID=aaed67f65e3c3a2ce582cb9251c18b49.access +export CF_ACCESS_CLIENT_SECRET=cfast_xaYyFIccZElf8AnrKFZ9Bd0e8f3auXJh8fEOUJhY2947cfc9 diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 89985a4..18bbbb0 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -7,4 +7,5 @@ The iOS app, `ios/`. Format: [Keep a Changelog](https://keepachangelog.com/en/1. ### Added - iPX on the iPhone: the page in an app, playing through the phone's own player, so an episode plays on with the screen locked and the lock screen, Control Center and a car's buttons drive it (#418). -- Player tokens (shake, then Player Tokens): an ipx API token and a Cloudflare Access service token, kept in the keychain, which the player sends instead of the page's sign-in cookie, so playback does not stop when that cookie expires (#419). +- Player token (shake, then Player Token): an ipx API token, kept in the keychain, which the player, the badge and CarPlay send to `https://ipxapi.sdf1.net` instead of using the page's sign-in cookie, so playback does not stop when that cookie expires (#419). +- The icon shows All Subscriptions' unread count, as the page's badge does: set when the app comes forward, after an item is marked read, and by a refresh iOS runs now and then while the app is closed, so it can be hours behind (#421). diff --git a/ios/README.md b/ios/README.md index 2aed1b5..f3544e6 100644 --- a/ios/README.md +++ b/ios/README.md @@ -29,15 +29,19 @@ Shake the phone to change it. Plain `http://` only on your own network that follows, through Cloudflare Access or ipx's own form; the cookies that leaves are what the player uses (`CookieBridge`). `-ipx.server ` as a launch argument overrides it for a run. -## Player tokens +## Player token The player normally rides on the web view's cookie, which ends with Cloudflare Access's session. -To make it outlast that, shake the phone, choose Player Tokens and enter three things, all kept -in the keychain (`Credentials.swift`): an ipx API token (the page's Settings, API tokens), and the -Client ID and Client Secret of an Access service token (Zero Trust, Access, Service credentials), -which the `ipodderx` application needs a *Service Auth* policy for. Without all three the player -sends only the cookie, as before. Playback's own requests use them (the audio, positions, the -artwork); the page still signs in as it did. +To make it outlast that, shake the phone, choose Player Token and enter an ipx API token (the +page's Settings, API tokens) and the address to send it to, `https://ipxapi.sdf1.net`. The token is +kept in the keychain (`Credentials.swift`). The player's audio, positions, read flags, artwork and +the unread badge go to that address with `Authorization: Bearer`; the page still signs in as it +did, at the server address. Without a token the player sends only the cookie, as before. + +`ipxapi.sdf1.net` is the tunnel's route to the same ipx-web as `ipodderx.sdf1.net`, with no Access +application in front, which would turn a bearer header away. Cloudflare's firewall (a custom rule +on the `sdf1.net` zone) blocks every request to it except a bearer-token one for `/api/feeds`, +`/api/me`, `/api/art`, `/api/entries/*` and `/media/*`; ipx still checks the token. ## How it hangs together @@ -83,7 +87,7 @@ its own clock, so both happen while the page is frozen. Video stays in the page. * Episodes kept on the device (#412) are the page's service worker's, which a `WKWebView` does not run without App-Bound Domains; the app plays them from the server. -* Without the player tokens the cookie is the only credential: when Cloudflare Access's expires +* Without the player token the cookie is the only credential: when Cloudflare Access's expires mid-drive, playback stops with "Sign in again", and opening the app signs in. * CarPlay's dashboard app needs Apple's entitlement first; then it is a second scene over the same `Playback`. diff --git a/ios/Sources/App.swift b/ios/Sources/App.swift index d60b173..1c4b03a 100644 --- a/ios/Sources/App.swift +++ b/ios/Sources/App.swift @@ -1,7 +1,13 @@ import UIKit @main -final class AppDelegate: UIResponder, UIApplicationDelegate {} +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + Badge.registerBackgroundRefresh() + return true + } +} /// The one window. iOS will not run an app that has not adopted scenes, and a CarPlay app would /// be a second scene beside this one. @@ -16,4 +22,10 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { window.makeKeyAndVisible() self.window = window } + + func sceneDidBecomeActive(_ scene: UIScene) { + Badge.requestPermission() + Badge.scheduleBackgroundRefresh() + Task { await Badge.refresh() } + } } diff --git a/ios/Sources/Badge.swift b/ios/Sources/Badge.swift new file mode 100644 index 0000000..6dd97db --- /dev/null +++ b/ios/Sources/Badge.swift @@ -0,0 +1,75 @@ +import BackgroundTasks +import UIKit +import UserNotifications + +/// The number on the icon: All Subscriptions' unread count, as the page's badge shows it (#421). +/// +/// Set when the app comes forward, after the player marks an item read, and by a background +/// refresh while it is closed. iOS decides when that runs (a few times a day for an app that is +/// opened often), so the number can be hours behind; push would make it exact and needs a sender +/// in ipx. +enum Badge { + static let taskId = "net.sdf1.ipx.refresh" + + private struct Row: Decodable { + let id: String + let group: String? + let unread: Int? + let hide_from_all: Bool? + } + + /// web/src/feeds.ts `unreadInAll`: a feed counts unless it, or the OPML it came from, is + /// hidden from All Subscriptions. Nil when the body is not a feed list, so an error page + /// does not turn the badge to zero. + static func unread(inAll body: Data) -> Int? { + guard let rows = try? JSONDecoder().decode([Row].self, from: body) else { return nil } + let hidden = Set(rows.filter { $0.hide_from_all == true }.map(\.id)) + return rows.reduce(0) { n, f in + if f.hide_from_all == true || f.group.map(hidden.contains) == true { return n } + return n + max(0, f.unread ?? 0) + } + } + + /// Asks once, for the badge alone: no alerts, no sounds. + static func requestPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + } + + /// Asks ipx and sets the icon. Left as it is when ipx cannot be reached or does not answer + /// with a feed list: an old number is better than a wrong one. + @discardableResult + static func refresh() async -> Bool { + guard let url = Server.resolveForPlayer("/api/feeds") else { return false } + var req = URLRequest(url: url) + req.timeoutInterval = 20 + req.setValue("application/json", forHTTPHeaderField: "Accept") + Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) } + let cfg = URLSessionConfiguration.default + cfg.httpCookieStorage = .shared + guard let (data, resp) = try? await URLSession(configuration: cfg).data(for: req), + (resp as? HTTPURLResponse)?.statusCode == 200, + let n = unread(inAll: data) else { return false } + try? await UNUserNotificationCenter.current().setBadgeCount(n) + return true + } + + // MARK: - while the app is closed + + /// Before the app finishes launching, which is when iOS wants the handler registered. + static func registerBackgroundRefresh() { + BGTaskScheduler.shared.register(forTaskWithIdentifier: taskId, using: nil) { task in + // The next one first, so a run that fails or is cut short does not end the chain. + scheduleBackgroundRefresh() + let work = Task { + task.setTaskCompleted(success: await refresh()) + } + task.expirationHandler = { work.cancel() } + } + } + + static func scheduleBackgroundRefresh() { + let req = BGAppRefreshTaskRequest(identifier: taskId) + req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) + try? BGTaskScheduler.shared.submit(req) + } +} diff --git a/ios/Sources/Bridge.swift b/ios/Sources/Bridge.swift index 8b7fdde..c5206f3 100644 --- a/ios/Sources/Bridge.swift +++ b/ios/Sources/Bridge.swift @@ -41,7 +41,7 @@ final class Bridge: NSObject, WKScriptMessageHandler { if let r = m["rate"] as? Double { playback.setRate(Float(r)) } if let v = m["volume"] as? Double { playback.setVolume(Float(v)) } case "load": - guard let path = m["url"] as? String, let url = Server.resolve(path), + guard let path = m["url"] as? String, let url = Server.resolveForPlayer(path), let enc = m["enc"] as? Int, let feedId = m["feedId"] as? String, let guid = m["guid"] as? String else { return } playback.load(.init( diff --git a/ios/Sources/Credentials.swift b/ios/Sources/Credentials.swift index 4f20e9d..9100226 100644 --- a/ios/Sources/Credentials.swift +++ b/ios/Sources/Credentials.swift @@ -4,24 +4,17 @@ import Security /// What the player and CarPlay send instead of the web view's cookie (#419). /// /// The cookie Cloudflare Access leaves in the web view lasts as long as its session, and when it -/// ends mid-drive the player has nothing. Two things that do not end by themselves replace it: -/// an ipx API token (Settings, API tokens), which ipx reads as `Authorization: Bearer`, and an -/// Access service token (Zero Trust, Service Auth), which Cloudflare reads as -/// `CF-Access-Client-Id` and `CF-Access-Client-Secret` before the request reaches ipx. Either -/// half alone is refused somewhere, so `headers` is empty unless it has all three, and the player -/// then falls back to the cookie as before. +/// ends mid-drive the player has nothing. An ipx API token (Settings, API tokens), which ipx reads +/// as `Authorization: Bearer`, does not end by itself. Access would turn a request carrying only +/// that away, so the player sends it to a second address with no Access in front, which +/// Cloudflare's firewall holds to the paths the player uses (`Server.apiURL`). enum Credentials { - enum Field: String, CaseIterable { - case apiToken = "ipx.api-token" - case accessId = "ipx.access-client-id" - case accessSecret = "ipx.access-client-secret" - } - private static let service = "net.sdf1.ipx" + private static let account = "ipx.api-token" - static subscript(field: Field) -> String? { + static var apiToken: String? { get { - var query = base(field) + var query = base query[kSecReturnData as String] = true query[kSecMatchLimit as String] = kSecMatchLimitOne var out: CFTypeRef? @@ -30,10 +23,10 @@ enum Credentials { return String(data: data, encoding: .utf8) } set { - SecItemDelete(base(field) as CFDictionary) + SecItemDelete(base as CFDictionary) guard let value = newValue?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return } - var item = base(field) + var item = base item[kSecValueData as String] = Data(value.utf8) // After first unlock, not when unlocked: CarPlay and a locked phone both play. item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock @@ -41,19 +34,17 @@ enum Credentials { } } - private static func base(_ field: Field) -> [String: Any] { + private static var base: [String: Any] { [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, - kSecAttrAccount as String: field.rawValue] + kSecAttrAccount as String: account] } - /// The headers for a request to the server, or none when any of the three is missing. + /// Empty unless there is a token, and an address to send it to: sent to the page's own + /// address it would only be turned away by Access. static var headers: [String: String] { - guard let token = self[.apiToken], let id = self[.accessId], - let secret = self[.accessSecret] else { return [:] } - return ["Authorization": "Bearer \(token)", - "CF-Access-Client-Id": id, - "CF-Access-Client-Secret": secret] + guard let token = apiToken, Server.apiURL != nil else { return [:] } + return ["Authorization": "Bearer \(token)"] } static var isSet: Bool { !headers.isEmpty } diff --git a/ios/Sources/Playback.swift b/ios/Sources/Playback.swift index 6176669..aca5356 100644 --- a/ios/Sources/Playback.swift +++ b/ios/Sources/Playback.swift @@ -95,7 +95,7 @@ final class Playback: NSObject { // second has something to do about it. let msg = pi.error?.localizedDescription ?? "the file could not be played" let status = await self.request("GET", "/api/me") - let expired = Credentials.isSet ? "Check the tokens in Settings" : "Sign in again" + let expired = Credentials.isSet ? "Check the player token in Settings" : "Sign in again" self.onError?(status == 401 || status == 403 ? expired : msg) default: break } @@ -204,7 +204,10 @@ final class Playback: NSObject { private func markRead() { guard !markedRead, let item else { return } markedRead = true - Task { await request("POST", entryPath(item, "flags"), ["read": true]) } + Task { + await request("POST", entryPath(item, "flags"), ["read": true]) + await Badge.refresh() + } } // MARK: - the lock screen and the car @@ -256,7 +259,7 @@ final class Playback: NSObject { let centre = MPNowPlayingInfoCenter.default() centre.nowPlayingInfo = info - guard let art = item.artwork, let url = Server.resolve(art) else { return } + guard let art = item.artwork, let url = Server.resolveForPlayer(art) else { return } Task { var artReq = URLRequest(url: url) Credentials.headers.forEach { artReq.setValue($1, forHTTPHeaderField: $0) } @@ -295,7 +298,7 @@ final class Playback: NSObject { /// arrived. A position that does not save is saved again ten seconds on. @discardableResult private func request(_ method: String, _ path: String, _ json: [String: Any]? = nil) async -> Int? { - guard let url = Server.resolve(path) else { return nil } + guard let url = Server.resolveForPlayer(path) else { return nil } var req = URLRequest(url: url) req.httpMethod = method Credentials.headers.forEach { req.setValue($1, forHTTPHeaderField: $0) } diff --git a/ios/Sources/Server.swift b/ios/Sources/Server.swift index 996c302..6c31d43 100644 --- a/ios/Sources/Server.swift +++ b/ios/Sources/Server.swift @@ -40,4 +40,22 @@ enum Server { guard let base = url else { return nil } return URL(string: path, relativeTo: base)?.absoluteURL } + + /// Where the player, the badge and CarPlay talk to ipx, which is not where the page is: an + /// address with no Cloudflare Access in front, for the API token alone (#419). Always https, + /// since the token travels in a header. + private static let apiKey = "ipx.api" + static let suggestedAPI = "https://ipxapi.sdf1.net" + + static var apiURL: URL? { + get { UserDefaults.standard.string(forKey: apiKey).flatMap(normalise).flatMap { $0.scheme == "https" ? $0 : nil } } + set { UserDefaults.standard.set(newValue?.absoluteString, forKey: apiKey) } + } + + /// A path against the player's address when there are credentials to send there, else the + /// page's, where the web view's cookie does the signing in. + static func resolveForPlayer(_ path: String) -> URL? { + guard Credentials.isSet, let base = apiURL else { return resolve(path) } + return URL(string: path, relativeTo: base)?.absoluteURL + } } diff --git a/ios/Sources/WebViewController.swift b/ios/Sources/WebViewController.swift index 22ca204..66defcf 100644 --- a/ios/Sources/WebViewController.swift +++ b/ios/Sources/WebViewController.swift @@ -63,7 +63,7 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg f.clearButtonMode = .whileEditing } if Server.url != nil { alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) } - alert.addAction(UIAlertAction(title: "Player Tokens…", style: .default) { [weak self] _ in + alert.addAction(UIAlertAction(title: "Player Token…", style: .default) { [weak self] _ in self?.askForCredentials() }) alert.addAction(UIAlertAction(title: "Open", style: .default) { [weak self, weak alert] _ in @@ -81,36 +81,41 @@ final class WebViewController: UIViewController, WKNavigationDelegate, WKUIDeleg present(alert, animated: true) } - /// The tokens the player and CarPlay use in place of the page's cookie (#419). Left as they - /// are when a field is empty; all three emptied clears them. - private func askForCredentials() { + /// The token the player and CarPlay use in place of the page's cookie, and the address they + /// send it to (#419). An empty token field keeps the one there is; Clear removes it. + private func askForCredentials(_ problem: String? = nil) { guard presentedViewController == nil else { return } let alert = UIAlertController( - title: "Player tokens", - message: "An ipx API token (Settings, API tokens) and a Cloudflare Access service token, " - + "so playback and CarPlay keep working when the page's sign-in ends. " - + (Credentials.isSet ? "Set now; leave a field empty to keep it." : "Not set."), + title: "Player token", + message: problem ?? "An ipx API token (Settings, API tokens) so playback and CarPlay keep " + + "working when the page's sign-in ends. " + + (Credentials.isSet ? "Set now; leave the token empty to keep it." : "Not set."), preferredStyle: .alert) - let fields: [(String, Credentials.Field)] = [ - ("ipx API token", .apiToken), ("Access client ID", .accessId), - ("Access client secret", .accessSecret), - ] - for (placeholder, _) in fields { - alert.addTextField { f in - f.placeholder = placeholder - f.isSecureTextEntry = true - f.autocapitalizationType = .none - f.autocorrectionType = .no - } + alert.addTextField { f in + f.placeholder = "ipx API token" + f.isSecureTextEntry = true + f.autocapitalizationType = .none + f.autocorrectionType = .no + } + alert.addTextField { f in + f.text = Server.apiURL?.absoluteString ?? Server.suggestedAPI + f.keyboardType = .URL + f.autocapitalizationType = .none + f.autocorrectionType = .no } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in - Credentials.Field.allCases.forEach { Credentials[$0] = nil } + Credentials.apiToken = nil }) - alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak alert] _ in - for (i, (_, field)) in fields.enumerated() { - if let v = alert?.textFields?[i].text, !v.isEmpty { Credentials[field] = v } + alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in + let fields = alert?.textFields ?? [] + guard fields.count == 2, let url = Server.normalise(fields[1].text ?? ""), + url.scheme == "https" else { + self?.askForCredentials("The player address has to be https, such as \(Server.suggestedAPI).") + return } + Server.apiURL = url + if let token = fields[0].text, !token.isEmpty { Credentials.apiToken = token } }) present(alert, animated: true) } diff --git a/ios/Tests/BadgeTests.swift b/ios/Tests/BadgeTests.swift new file mode 100644 index 0000000..982aabb --- /dev/null +++ b/ios/Tests/BadgeTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import iPX + +final class BadgeTests: XCTestCase { + private func count(_ json: String) -> Int? { Badge.unread(inAll: Data(json.utf8)) } + + func testItCountsTheUnreadOfFeedsInAll() { + XCTAssertEqual(count(#"[{"id":"a","unread":3},{"id":"b","unread":4,"hide_from_all":false}]"#), 7) + } + + func testAFeedHiddenFromAllIsLeftOut() { + XCTAssertEqual(count(#"[{"id":"a","unread":3},{"id":"b","unread":4,"hide_from_all":true}]"#), 3) + } + + func testAFeedFromAnOPMLHiddenFromAllIsLeftOut() { + let json = #"[{"id":"o","unread":0,"hide_from_all":true},{"id":"a","unread":5,"group":"o"},{"id":"b","unread":2}]"# + XCTAssertEqual(count(json), 2) + } + + func testAnErrorBodyIsNotAFeedListAndIsNotZero() { + XCTAssertNil(count(#"{"error":"unauthorised"}"#)) + XCTAssertNil(count("sign in")) + } +} diff --git a/ios/Tests/ServerTests.swift b/ios/Tests/ServerTests.swift index e12f5f6..364a1a5 100644 --- a/ios/Tests/ServerTests.swift +++ b/ios/Tests/ServerTests.swift @@ -21,4 +21,22 @@ final class ServerTests: XCTestCase { XCTAssertEqual(Server.resolve("/media/42/deadair.m3u8?traceparent=x")?.absoluteString, "https://ipodderx.sdf1.net/media/42/deadair.m3u8?traceparent=x") } + + func testThePlayerGoesToItsOwnAddressOnlyWithAToken() { + UserDefaults.standard.set("https://ipodderx.sdf1.net", forKey: "ipx.server") + UserDefaults.standard.set("https://ipxapi.sdf1.net", forKey: "ipx.api") + Credentials.apiToken = nil + XCTAssertEqual(Server.resolveForPlayer("/media/1")?.host, "ipodderx.sdf1.net") + Credentials.apiToken = "t" + XCTAssertEqual(Server.resolveForPlayer("/media/1")?.host, "ipxapi.sdf1.net") + XCTAssertEqual(Credentials.headers["Authorization"], "Bearer t") + Credentials.apiToken = nil + UserDefaults.standard.removeObject(forKey: "ipx.api") + } + + func testThePlayerAddressIsNeverPlainHTTP() { + UserDefaults.standard.set("http://192.168.1.130:8099", forKey: "ipx.api") + XCTAssertNil(Server.apiURL) + UserDefaults.standard.removeObject(forKey: "ipx.api") + } } diff --git a/ios/project.yml b/ios/project.yml index 7bfb975..d1d506a 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -25,10 +25,12 @@ targets: properties: CFBundleDisplayName: iPX CFBundleShortVersionString: $(MARKETING_VERSION) - CFBundleVersion: "2" + CFBundleVersion: "4" # Playing on with the screen locked. A plist key, not a signed entitlement, so it works on # a free personal team; CarPlay's own dashboard app is an entitlement Apple has to grant. - UIBackgroundModes: [audio] + UIBackgroundModes: [audio, fetch] + # The unread badge's refresh while the app is closed (#421). + BGTaskSchedulerPermittedIdentifiers: [net.sdf1.ipx.refresh] # HTTPS only, so TestFlight need not ask the export-compliance question on every build. ITSAppUsesNonExemptEncryption: false UILaunchScreen: {} @@ -63,6 +65,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: net.sdf1.ipx.tests + GENERATE_INFOPLIST_FILE: YES schemes: iPX: