Files
FTStore/admin/FTStoreAdmin/App/AdminAlertWatcher.swift
Felitendo c1c478bf25 feat: make private mode an account rather than a code
Private mode asked for a six-digit code, minted per person and traded for a pass
that a browser kept in a cookie and the app in its keychain. Accounts arrived
later, for the boards and the setup backup, and from that point the code was a
second credential doing a worse version of the same job. It said you may look and
nothing about who was looking, so it could not carry what somebody wrote, it
could not be signed out of from another device, and six digits is a space small
enough that it had to be defended by a throttle doing most of the work.

So the account is the credential now. The landing page asks for a name and a
password instead of drawing a keypad, the feed and the token downloads want the
same session, and the store's own screen for being shut out is the sign-in it
already had. One credential, on both sides of the wire, and the same one
somebody needs before they can post or keep a backup.

What changes on the way in is who may register. Public: anybody makes an account
from the app, as before. Private: an operator mints an invitation and sends the
link, and opening it is the only way a new account happens. One link, one
account, spent the moment it works, so a forwarded link is not a second way in.
The redeem is filtered on being unspent, so two people racing one link end with
one account and one refusal. It is shown once, it can be withdrawn while nobody
has used it, and it expires if nobody does.

The token is 128 bits and stored as a digest, which is the opposite of the choice
the codes had to make: a code was a candidate list somebody could work through
offline, so it needed a slow hash and could not be looked up, where a token is no
list at all.

Withdrawing a link is deliberately not the same action as removing somebody. Once
it has been redeemed there is an account, and taking that away is muting or
deleting it on the board's own screen, where the things they wrote are in view.

This locks out every device a code had let in, and there is no way around it: a
pass records that a code was answered on some device and nothing about who held
it, which is exactly the gap accounts close. The two tables are dropped in
pre-push.sql, where every destructive change here is written out. A public
deployment is untouched.

The throttle stays and gains a second half. The ladder against the address is
what it was, minus a ceiling that came down when the codes did; the ladder
against the name being signed in to already existed for accounts and now bounds
somebody working through one person's password from anywhere they like. Only a
wrong answer climbs either, so being told a name is taken is not a step towards
a lockout.

Both consoles move together, as they have to: the invitations, the accounts and
the devices are the same five sections in the same order in the dashboard and on
the phone. The alert for a new device compares a moment rather than an id, since
a session's key is the digest of a token and there is no id to compare.
2026-09-01 09:27:34 +02:00

287 lines
11 KiB
Swift

//
// AdminAlertWatcher.swift
// FTStore Admin
//
// The four things worth interrupting an operator for: storage filling up,
// somebody posting feedback, somebody asking for a tweak, and a device getting
// into a private server.
//
// Local notifications, not push. This app ships no credential and talks to one
// server the operator configured; a push pipeline would mean a third party
// holding a device token for an app whose whole point is that a decrypted IPA
// is a login form and nothing else.
//
// The cost of that choice is honest and worth stating: a local notification can
// only be posted while this app is running, which is when it is open and when
// iOS grants it a background refresh. Background refresh is opportunistic — the
// system decides, and on a device that rarely opens FTStore Admin it may be
// hours. This is a nudge, not a pager.
//
// Nothing is announced twice. What has already been told is stamped in
// `UserDefaults` by id for the two boards and by threshold for storage, so
// crossing 80% announces once rather than on every check until somebody frees
// space.
//
import Foundation
import UserNotifications
@MainActor
final class AdminAlertWatcher: ObservableObject {
static let shared = AdminAlertWatcher()
private init() {}
// MARK: Preferences
/// Each alert is its own switch. An operator who wants to know about storage
/// does not necessarily want to be told about every feature request, and a
/// single "notifications" toggle would make them take both or neither.
static let storageKey = "FTStoreAdmin.alerts.storage"
static let feedbackKey = "FTStoreAdmin.alerts.feedback"
static let requestsKey = "FTStoreAdmin.alerts.requests"
/// A device got in, or one got in from a country no device had come from
/// before. Only meaningful while private mode is on, and the server says so
/// rather than this app guessing from the numbers.
static let accessKey = "FTStoreAdmin.alerts.access"
/// The fraction of the configured cap that counts as "getting full".
///
/// 80% rather than the cap itself because the cap is where downloads stop:
/// being told then is being told too late. The server already logs the
/// crossing of the cap, so this is the warning that comes first.
static let storageThreshold = 0.8
/// A moment rather than an id, and therefore a new key. Reading the old one
/// back as a `Double` would give a small number that every timestamp is
/// greater than, so the first run after this shipped would have announced a
/// device that had been signed in for months. A fresh key records instead.
private static let _seenAccessKey = "FTStoreAdmin.alerts.seenAccessAt"
/// Which countries have already been seen holding a pass, as a stored list.
/// A set rather than a high-water mark, because a country is not a number
/// and "somewhere new" is a membership test.
private static let _seenCountriesKey = "FTStoreAdmin.alerts.seenCountries"
private static let _seenFeedbackKey = "FTStoreAdmin.alerts.seenFeedbackId"
private static let _seenRequestKey = "FTStoreAdmin.alerts.seenRequestId"
private static let _warnedStorageKey = "FTStoreAdmin.alerts.warnedStorage"
nonisolated static func isOn(_ key: String) -> Bool {
// Off until asked for. An FTStore Admin build that started notifying on its own would
// be one that asked for permission on its own.
UserDefaults.standard.bool(forKey: key)
}
nonisolated var anyEnabled: Bool {
Self.isOn(Self.storageKey)
|| Self.isOn(Self.feedbackKey)
|| Self.isOn(Self.requestsKey)
|| Self.isOn(Self.accessKey)
}
/// Ask iOS, once the operator has turned something on rather than at launch.
static func requestAuthorization() {
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
}
// MARK: Checking
/// Fetch the numbers and announce whatever is new.
///
/// Returns whether anything was posted, which is what the background task
/// reports back to iOS: a refresh that never finds anything is one the
/// system should schedule less often.
@discardableResult
func check(session: AdminSession = .shared) async -> Bool {
guard anyEnabled else { return false }
// `hasStoredSession` and not `isSignedIn`: the biometric gate is a lock on
// the screen, not on the credential, and an FTStore Admin build that re-locked itself
// on the way to the background is exactly the one that should still be
// able to tell somebody the disk is filling up.
guard session.hasStoredSession else { return false }
let alerts: AdminAlerts
do {
alerts = try await session.get("/api/dashboard/alerts", as: AdminAlerts.self)
} catch {
// Silent on purpose. A failed background poll is not something to
// wake somebody up about, and FTStore Admin shows the real error the
// next time a screen loads.
return false
}
guard let data = alerts.data else { return false }
var posted = false
posted = _checkStorage(data.storage) || posted
posted = _checkFeedback(data.feedback) || posted
posted = _checkRequests(data.tweakRequests) || posted
if let access = data.access { posted = _checkAccess(access) || posted }
return posted
}
// MARK: The three
private func _checkStorage(_ storage: AdminAlerts.Storage) -> Bool {
guard Self.isOn(Self.storageKey), storage.hasLimit, let ratio = storage.ratio
else { return false }
let defaults = UserDefaults.standard
let warned = defaults.bool(forKey: Self._warnedStorageKey)
// Below the line again: re-arm, so the next crossing is announced. This
// is what stops "80% reached" repeating every check for a week.
if ratio < Self.storageThreshold {
if warned { defaults.set(false, forKey: Self._warnedStorageKey) }
return false
}
if warned { return false }
defaults.set(true, forKey: Self._warnedStorageKey)
_post(
id: "storage",
title: storage.overLimit
? .localized("Storage is full")
: .localized("Storage is filling up"),
body: String.localized(
"%@ of %@ used (%lld%%).",
AdminFormat.bytes(storage.usedBytes),
AdminFormat.bytes(storage.limitBytes),
Int((ratio * 100).rounded())
)
)
return true
}
private func _checkFeedback(_ feedback: AdminAlerts.Feedback) -> Bool {
guard Self.isOn(Self.feedbackKey), let newest = feedback.newestId else {
return false
}
let defaults = UserDefaults.standard
let seen = defaults.integer(forKey: Self._seenFeedbackKey)
defaults.set(newest, forKey: Self._seenFeedbackKey)
// A first run records where things stand and says nothing. Announcing
// the newest of whatever already existed would be a notification about
// something the operator has probably already read.
guard seen > 0, newest > seen else { return false }
_post(
id: "feedback",
title: newest - seen == 1
? .localized("New feedback")
: String.localized("%lld new reports", newest - seen),
body: feedback.newestTitle ?? .localized("Open the board to read it.")
)
return true
}
private func _checkRequests(_ requests: AdminAlerts.TweakRequests) -> Bool {
guard Self.isOn(Self.requestsKey), let newest = requests.newestId else {
return false
}
let defaults = UserDefaults.standard
let seen = defaults.integer(forKey: Self._seenRequestKey)
defaults.set(newest, forKey: Self._seenRequestKey)
guard seen > 0, newest > seen else { return false }
_post(
id: "requests",
title: newest - seen == 1
? .localized("New tweak request")
: String.localized("%lld new tweak requests", newest - seen),
body: [requests.newestName, requests.newestSource]
.compactMap { $0 }
.joined(separator: " · ")
)
return true
}
/// A device got in, or one got in from somewhere new.
///
/// Two announcements from one payload rather than two switches, because they
/// are the same question asked at two scales: somebody let themselves in,
/// and somebody let themselves in from a place nobody has before. An
/// operator who wants one wants the other.
///
/// Silent when private mode is off. Passes only exist while it is on, so a
/// server that has never used it has nothing to say, and one that has just
/// been opened up should not start announcing its own history.
private func _checkAccess(_ access: AdminAlerts.Access) -> Bool {
guard Self.isOn(Self.accessKey), access.isPrivate else { return false }
let defaults = UserDefaults.standard
var posted = false
// A country nobody had come in from before. Checked first, because it is
// the more alarming of the two and the new device is usually the same
// event seen from closer up.
let known = Set(defaults.stringArray(forKey: Self._seenCountriesKey) ?? [])
let current = Set(access.countries)
defaults.set(Array(current).sorted(), forKey: Self._seenCountriesKey)
// A first run records where things stand and says nothing, the same rule
// the two boards follow: announcing what already existed is a
// notification about something the operator set up themselves.
if !known.isEmpty {
let arrived = current.subtracting(known).sorted()
if !arrived.isEmpty {
_post(
id: "access-country",
title: .localized("Access from a new country"),
body: arrived
.map { "\(AdminCountry.flag($0)) \(AdminCountry.name($0))" }
.joined(separator: ", ")
)
posted = true
}
}
// The two boards above compare ids, which only ever go up. A session has
// no id — its key is the digest of a token — so this compares the moment
// of the newest sign-in instead. Same question, same rule about the first
// run, and it cannot say *how many* are new, which is why the title does
// not try to.
guard let newest = access.newestAt else { return posted }
let seen = defaults.double(forKey: Self._seenAccessKey)
defaults.set(newest.timeIntervalSince1970, forKey: Self._seenAccessKey)
guard seen > 0, newest.timeIntervalSince1970 > seen else { return posted }
_post(
id: "access-device",
title: .localized("A new device signed in"),
body: [
access.newestLabel,
access.newestDevice,
access.newestCountry.map { AdminCountry.name($0) },
String.localized("%lld in total", access.active),
]
.compactMap { $0 }
.joined(separator: " · ")
)
return true
}
/// One notification per kind, replaced rather than stacked.
///
/// A fixed identifier per kind is deliberate: three unread "new feedback"
/// banners say nothing the newest one does not, and a lock screen with
/// twenty of them is one the operator stops reading.
private func _post(id: String, title: String, body: String) {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
UNUserNotificationCenter.current().add(
UNNotificationRequest(
identifier: "FTStoreAdmin.alert.\(id)",
content: content,
trigger: nil
)
)
}
}