Files
Felitendo 3e62b98577 refactor(admin): FTStoreAdmin/Console is FTStoreAdmin/App
The last place still calling the operator app a console. The Xcode project uses
a synchronized root group pointed at FTStoreAdmin itself, so it never named the
subfolder and the rename is a git mv and nothing else.

App/ rather than Screens/, because the folder holds the transport, the session
and the wire types as well as the views, and because the distinction that
actually matters here is against Support/: this app's own code, versus helpers
inlined from elsewhere with a note saying where they came from.

Both AGENTS.md files follow, including the note recording that the history runs
back through manager/FTStore/Admin/ — it now says the middle stretch was
Console/, since that is the part a git log --follow has to cross.
2026-08-10 21:16:57 +02:00

436 lines
14 KiB
Swift

//
// AdminAPI.swift
// FTStore Admin
//
// Transport for the FTStore Admin dashboard API (`web/src/app/api/*`).
//
// Everything under `/api/` except login/logout/health/branding is gated by the
// server: `src/middleware.ts` rejects a request without the `ftstore_session`
// cookie with 401, and each handler re-checks it against the database through
// `withAuth()` / `withAdmin()`. That is the whole security model of this
// FTStore Admin — the app ships no key, no token and no privileged URL, so a
// decrypted IPA is worth exactly nothing without the operator's credentials.
//
// The session token is attached by hand from the Keychain rather than left to
// `HTTPCookieStorage`, which would write it to the app container in the clear.
//
import Foundation
/// Name of the FTStore Admin session cookie (`web/src/lib/auth.ts`).
private let _sessionCookieName = "ftstore_session"
// MARK: - Errors
enum AdminAPIError: LocalizedError, Equatable {
case notConfigured
/// The server refused the session — the token is gone or expired.
case unauthorized
/// Authenticated, but not allowed (unapproved account, or admin-only route).
case forbidden(String)
case http(Int, String?)
case decoding
case network(String)
/// Ours is translated; the server's is not. A `message` that arrived in a
/// response is passed through as it came, because translating it would mean
/// keeping a copy of every sentence the API can produce.
var errorDescription: String? {
switch self {
case .notConfigured:
return .localized("No FTStore Admin address is configured.")
case .unauthorized:
return .localized("Your session expired. Sign in again.")
case .forbidden(let message):
return message.isEmpty ? .localized("You don't have access to that.") : message
case .http(let code, let message):
if let message, !message.isEmpty { return message }
return .localized("FTStore Admin returned an error (HTTP %lld).", code)
case .decoding:
return .localized("FTStore Admin sent a response the app couldn't read.")
case .network(let message):
return .localized("Couldn't reach FTStore Admin: %@", message)
}
}
}
// MARK: - Coding
enum AdminJSON {
/// Prisma serialises with `Date.toISOString()`, i.e. always fractional
/// seconds — but a couple of hand-built payloads don't, so accept both.
static let decoder: JSONDecoder = {
let plain = ISO8601DateFormatter()
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let raw = try decoder.singleValueContainer().decode(String.self)
guard let date = fractional.date(from: raw) ?? plain.date(from: raw) else {
throw DecodingError.dataCorruptedError(
in: try decoder.singleValueContainer(),
debugDescription: "Unrecognised date: \(raw)"
)
}
return date
}
return decoder
}()
static let encoder = JSONEncoder()
}
/// `{ success, data, message, error }` — the shape most handlers answer with.
struct AdminEnvelope<Payload: Decodable>: Decodable {
let success: Bool?
let data: Payload?
let message: String?
let error: String?
}
/// The same envelope where only the outcome matters (the action endpoints).
struct AdminAck: Decodable {
let success: Bool?
let message: String?
let error: String?
/// 202-style responses from the Telegram-worker actions.
let accepted: Bool?
var summary: String {
message ?? error ?? "Done"
}
}
// MARK: - Client
struct AdminAPI {
static let shared = AdminAPI()
enum Method: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
/// Ephemeral on purpose: no cookie jar and no URL cache, so neither the
/// session token nor any dashboard payload is written to disk.
private let _session: URLSession = {
let config = URLSessionConfiguration.ephemeral
config.httpShouldSetCookies = false
config.httpCookieAcceptPolicy = .never
config.httpCookieStorage = nil
config.urlCache = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 120
return URLSession(configuration: config)
}()
// MARK: Requests
private func _request(
_ method: Method,
_ path: String,
query: [String: String],
bodyData: Data?,
contentType: String? = nil,
timeout: TimeInterval? = nil,
token: String?
) async throws -> Data {
guard AdminConfig.isConfigured, let base = AdminConfig.endpointURL(path: path) else {
throw AdminAPIError.notConfigured
}
var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
if !query.isEmpty {
components?.queryItems = query
.sorted { $0.key < $1.key }
.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = components?.url else { throw AdminAPIError.notConfigured }
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue(AdminConfig.userAgent, forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let timeout { request.timeoutInterval = timeout }
if let bodyData {
request.httpBody = bodyData
request.setValue(contentType ?? "application/json", forHTTPHeaderField: "Content-Type")
}
if let token, !token.isEmpty {
request.setValue("\(_sessionCookieName)=\(token)", forHTTPHeaderField: "Cookie")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await _session.data(for: request)
} catch {
if Task.isCancelled || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
throw AdminAPIError.network(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw AdminAPIError.network("invalid response")
}
guard (200..<300).contains(http.statusCode) else {
let message = Self._errorMessage(in: data)
switch http.statusCode {
case 401: throw AdminAPIError.unauthorized
case 403: throw AdminAPIError.forbidden(message ?? "")
default: throw AdminAPIError.http(http.statusCode, message)
}
}
return data
}
/// Pull `error` (or `message`) out of a failed response so the UI can show
/// what the server actually said instead of a bare status code.
private static func _errorMessage(in data: Data) -> String? {
guard
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
return nil
}
if let error = object["error"] as? String, !error.isEmpty { return error }
if let message = object["message"] as? String, !message.isEmpty { return message }
return nil
}
private func _decode<Response: Decodable>(_ data: Data, as type: Response.Type) throws -> Response {
do {
return try AdminJSON.decoder.decode(type, from: data)
} catch {
throw AdminAPIError.decoding
}
}
// MARK: Typed helpers
func get<Response: Decodable>(
_ path: String,
query: [String: String] = [:],
as type: Response.Type,
token: String?
) async throws -> Response {
let data = try await _request(.get, path, query: query, bodyData: nil, token: token)
return try _decode(data, as: type)
}
func send<Response: Decodable>(
_ method: Method,
_ path: String,
query: [String: String] = [:],
as type: Response.Type,
token: String?
) async throws -> Response {
let data = try await _request(method, path, query: query, bodyData: nil, token: token)
return try _decode(data, as: type)
}
func send<Body: Encodable, Response: Decodable>(
_ method: Method,
_ path: String,
query: [String: String] = [:],
body: Body,
as type: Response.Type,
token: String?
) async throws -> Response {
let encoded: Data
do {
encoded = try AdminJSON.encoder.encode(body)
} catch {
throw AdminAPIError.decoding
}
let data = try await _request(method, path, query: query, bodyData: encoded, token: token)
return try _decode(data, as: type)
}
// MARK: Raw payloads
//
// The settings backup is a file, not a model: the export streams a JSON
// document the server built for download, and the import posts one back
// verbatim. Decoding and re-encoding it here would silently rewrite a
// document whose whole point is to round-trip unchanged.
func rawData(
_ method: Method,
_ path: String,
query: [String: String] = [:],
body: Data? = nil,
contentType: String? = nil,
token: String?
) async throws -> Data {
try await _request(
method,
path,
query: query,
bodyData: body,
contentType: contentType,
token: token
)
}
// MARK: Multipart
/// One `multipart/form-data` POST: any number of text fields plus a single
/// file part. Used by the chunked IPA upload and the tweak-icon upload,
/// which are the only two endpoints that don't take JSON.
func upload<Response: Decodable>(
_ path: String,
fields: [String: String],
fileField: String,
fileName: String,
mimeType: String,
fileData: Data,
as type: Response.Type,
token: String?
) async throws -> Response {
// A random boundary can't collide with binary content that happens to
// contain the literal marker.
let boundary = "----FTStoreAdmin\(UUID().uuidString)"
var body = Data()
func append(_ string: String) {
body.append(Data(string.utf8))
}
for key in fields.keys.sorted() {
append("--\(boundary)\r\n")
append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n")
append("\(fields[key] ?? "")\r\n")
}
append("--\(boundary)\r\n")
append("Content-Disposition: form-data; name=\"\(fileField)\"; filename=\"\(fileName)\"\r\n")
append("Content-Type: \(mimeType)\r\n\r\n")
body.append(fileData)
append("\r\n--\(boundary)--\r\n")
let data = try await _request(
.post,
path,
query: [:],
bodyData: body,
contentType: "multipart/form-data; boundary=\(boundary)",
// An 8 MiB chunk over a phone connection needs more headroom than a
// JSON call does.
timeout: 180,
token: token
)
return try _decode(data, as: type)
}
// MARK: Authentication
private struct _Credentials: Encodable {
let username: String
let password: String
}
private struct _AuthResponse: Decodable {
let success: Bool?
let user: AdminUser?
let error: String?
}
/// Exchange a username/password for a session token.
///
/// The password is used for exactly this one request and never persisted;
/// what comes back — and what the Keychain keeps — is the server-issued
/// session cookie, which the operator can revoke from the dashboard.
func signIn(username: String, password: String) async throws -> (user: AdminUser, token: String) {
guard AdminConfig.isConfigured, let url = AdminConfig.endpointURL(path: "/api/auth/login") else {
throw AdminAPIError.notConfigured
}
var request = URLRequest(url: url)
request.httpMethod = Method.post.rawValue
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue(AdminConfig.userAgent, forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? AdminJSON.encoder.encode(
_Credentials(username: username, password: password)
)
let data: Data
let response: URLResponse
do {
(data, response) = try await _session.data(for: request)
} catch {
if Task.isCancelled || (error as? URLError)?.code == .cancelled {
throw CancellationError()
}
throw AdminAPIError.network(error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw AdminAPIError.network("invalid response")
}
guard (200..<300).contains(http.statusCode) else {
let message = Self._errorMessage(in: data)
switch http.statusCode {
// The login route answers 401 for bad credentials and 403 for an
// account still awaiting admin approval — both are worth showing
// verbatim, so neither maps onto the session-expired path.
case 401: throw AdminAPIError.http(401, message ?? .localized("Invalid username or password."))
case 403: throw AdminAPIError.forbidden(message ?? "")
default: throw AdminAPIError.http(http.statusCode, message)
}
}
let decoded = try _decode(data, as: _AuthResponse.self)
guard let user = decoded.user else { throw AdminAPIError.decoding }
guard let token = Self._sessionToken(from: http, url: url) else {
throw AdminAPIError.decoding
}
return (user, token)
}
func signOut(token: String?) async {
_ = try? await send(.post, "/api/auth/logout", as: AdminAck.self, token: token)
}
func me(token: String) async throws -> AdminUser {
let response = try await get("/api/auth/me", as: _AuthResponse.self, token: token)
guard let user = response.user else { throw AdminAPIError.unauthorized }
return user
}
/// Read the session cookie out of the login response. Parsed through
/// `HTTPCookie` rather than by hand because iOS folds multiple `Set-Cookie`
/// headers into one comma-joined string that a naive split would mangle.
private static func _sessionToken(from response: HTTPURLResponse, url: URL) -> String? {
guard let headers = response.allHeaderFields as? [String: String] else { return nil }
return HTTPCookie
.cookies(withResponseHeaderFields: headers, for: url)
.first { $0.name == _sessionCookieName }
.map { $0.value }
.flatMap { $0.isEmpty ? nil : $0 }
}
// MARK: Media
/// Resolve an icon/screenshot reference from the dashboard. Server-hosted
/// assets come back as `/media/icons/…` paths; App Store artwork is already
/// absolute.
static func mediaURL(_ raw: String?) -> URL? {
guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
if raw.hasPrefix("http://") || raw.hasPrefix("https://") {
return URL(string: raw)
}
return AdminConfig.endpointURL(path: raw)
}
}