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.
436 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|