// // 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: 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(_ 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( _ 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( _ 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( _ 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( _ 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) } }