Files
Leeksov 4647310322 GLEGram 12.5 — Initial public release
Based on Swiftgram 12.5 (Telegram iOS 12.5).
All GLEGram features ported and organized in GLEGram/ folder.

Features: Ghost Mode, Saved Deleted Messages, Content Protection Bypass,
Font Replacement, Fake Profile, Chat Export, Plugin System, and more.

See CHANGELOG_12.5.md for full details.
2026-04-06 09:48:12 +03:00

934 lines
39 KiB
Swift
Executable File

import Foundation
import UIKit
import SwiftSignalKit
import SGConfig
import SGLogging
import SGRequests
import SGSimpleSettings
private func supportersRequest(
_ request: URLRequest,
baseURL: String
) -> Signal<(Data, URLResponse?), Error?> {
let pins = SG_CONFIG.supportersPinnedCertHashes
guard !pins.isEmpty, let host = URL(string: baseURL)?.host else {
return requestsCustom(request: request)
}
return requestsCustomWithPinning(request: request, host: host, pinnedHashes: pins)
}
public enum SupportersAPIError: Error {
case notConfigured
case network
case invalidResponse
/// HTTP 429 rate limit. Response body is plain JSON, not encrypted.
case tooManyRequests
}
/// Single badge from server: id, display name, hex color, display mode, image URL.
public struct SupportersBadge: Equatable {
public let id: String
public let name: String
public let colorHex: String
public let displayMode: String
public let imageURL: String?
public init(id: String, name: String, colorHex: String, displayMode: String = "text", imageURL: String? = nil) {
self.id = id
self.name = name
self.colorHex = colorHex
self.displayMode = displayMode
self.imageURL = imageURL
}
}
/// Checks whether the given user ID is in the supporters list (encrypted request/response).
public func checkIsSupporter(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<Bool, SupportersAPIError> {
return Signal { subscriber in
let urlString = baseURL.hasSuffix("/") ? "\(baseURL)api/encrypted" : "\(baseURL)/api/encrypted"
guard let url = URL(string: urlString) else {
subscriber.putError(.notConfigured)
return EmptyDisposable
}
let payload: [String: Any] = [
"action": "check",
"payload": ["userId": String(userId)]
]
let body: String
do {
body = try SupportersCrypto.encrypt(payload, key: aesKey, hmacKey: hmacKey)
} catch {
subscriber.putError(.invalidResponse)
return EmptyDisposable
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(body.utf8)
let completed = Atomic<Bool>(value: false)
let disposable = supportersRequest(request, baseURL: baseURL).start(
next: { data, response in
guard completed.swap(true) == false else { return }
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
if code == 429 {
subscriber.putError(.tooManyRequests)
return
}
guard let text = String(data: data, encoding: .utf8) else {
subscriber.putError(.invalidResponse)
return
}
do {
let decrypted = try SupportersCrypto.decrypt(text, key: aesKey, hmacKey: hmacKey)
let supported = (decrypted["supported"] as? Bool) ?? false
subscriber.putNext(supported)
subscriber.putCompletion()
} catch {
subscriber.putError(.invalidResponse)
}
},
error: { _ in
guard completed.swap(true) == false else { return }
subscriber.putError(.network)
}
)
return ActionDisposable {
if !completed.with({ $0 }) {
disposable.dispose()
}
}
}
}
/// Convenience: check using SG_CONFIG supporters URL and key if configured.
public func checkIsSupporterIfConfigured(userId: Int64) -> Signal<Bool, SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return checkIsSupporter(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
// MARK: - Badges (list from server, cache in Keychain encrypted, not in files)
private let kSupportersCacheAccount = "sg_supporters_cache"
private func loadCacheFile() -> [String: Any] {
supportersSecureLoadJSON(account: kSupportersCacheAccount) ?? [:]
}
private func saveCacheFile(_ dict: [String: Any]) {
_ = supportersSecureSaveJSON(dict, account: kSupportersCacheAccount)
}
/// Fetches badges and assignments (encrypted). Used to populate cache.
public func fetchBadges(baseURL: String, aesKey: String, hmacKey: String? = nil) -> Signal<(badges: [SupportersBadge], assignments: [String: [String]]), SupportersAPIError> {
return Signal { subscriber in
let urlString = baseURL.hasSuffix("/") ? "\(baseURL)api/encrypted" : "\(baseURL)/api/encrypted"
guard let url = URL(string: urlString) else {
subscriber.putError(.notConfigured)
return EmptyDisposable
}
let payload: [String: Any] = ["action": "list_badges", "payload": [:] as [String: Any]]
let body: String
do {
body = try SupportersCrypto.encrypt(payload, key: aesKey, hmacKey: hmacKey)
} catch {
subscriber.putError(.invalidResponse)
return EmptyDisposable
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(body.utf8)
SGLogger.shared.log("SGSupporters", "fetchBadges: POST \(urlString)")
let completed = Atomic<Bool>(value: false)
let disposable = supportersRequest(request, baseURL: baseURL).start(
next: { data, response in
guard completed.swap(true) == false else { return }
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
SGLogger.shared.log("SGSupporters", "fetchBadges: response status=\(code), bodyLen=\(data.count)")
if code == 429 {
subscriber.putError(.tooManyRequests)
return
}
guard let text = String(data: data, encoding: .utf8) else {
SGLogger.shared.log("SGSupporters", "fetchBadges: body not UTF-8")
subscriber.putError(.invalidResponse)
return
}
do {
let decrypted = try SupportersCrypto.decrypt(text, key: aesKey, hmacKey: hmacKey)
let badgesRaw = decrypted["badges"] as? [[String: Any]] ?? []
let assignmentsRaw = decrypted["assignments"] as? [String: [String]] ?? [:]
let badges: [SupportersBadge] = badgesRaw.compactMap { b in
guard let id = b["id"] as? String else { return nil }
let name = b["name"] as? String ?? id
let colorHex = b["color"] as? String ?? "#34C759"
let displayMode = b["displayMode"] as? String ?? "text"
let imageURL = b["image"] as? String
return SupportersBadge(id: id, name: name, colorHex: colorHex, displayMode: displayMode, imageURL: imageURL)
}
SGLogger.shared.log("SGSupporters", "fetchBadges: decrypted ok — badges=\(badges.count)")
subscriber.putNext((badges: badges, assignments: assignmentsRaw))
subscriber.putCompletion()
} catch {
SGLogger.shared.log("SGSupporters", "fetchBadges: decrypt failed — \(String(describing: error))")
subscriber.putError(.invalidResponse)
}
},
error: { err in
guard completed.swap(true) == false else { return }
SGLogger.shared.log("SGSupporters", "fetchBadges: network error — \(String(describing: err))")
subscriber.putError(.network)
}
)
return ActionDisposable {
if !completed.with({ $0 }) {
disposable.dispose()
}
}
}
}
func setCachedBadges(_ badges: [SupportersBadge]) {
var cache = loadCacheFile()
cache["badges"] = badges.map { b -> [String: Any] in
var d: [String: Any] = ["id": b.id, "name": b.name, "color": b.colorHex, "displayMode": b.displayMode]
if let img = b.imageURL { d["image"] = img }
return d
}
saveCacheFile(cache)
}
func setCachedAssignments(_ assignments: [String: [String]]) {
var cache = loadCacheFile()
cache["assignments"] = assignments
saveCacheFile(cache)
}
private func loadCachedBadges() -> [SupportersBadge] {
let cache = loadCacheFile()
guard let raw = cache["badges"] as? [[String: Any]] else { return [] }
return raw.compactMap { b in
guard let id = b["id"] as? String else { return nil }
let name = b["name"] as? String ?? id
let colorHex = b["color"] as? String ?? "#34C759"
let displayMode = b["displayMode"] as? String ?? "text"
let imageURL = b["image"] as? String
return SupportersBadge(id: id, name: name, colorHex: colorHex, displayMode: displayMode, imageURL: imageURL)
}
}
private func loadCachedAssignments() -> [String: [String]] {
let cache = loadCacheFile()
guard let raw = cache["assignments"] as? [String: [String]] else { return [:] }
return raw
}
private let refreshStarted = Atomic<Bool>(value: false)
/// Call when app becomes active or at launch to refresh badges cache. Uses SG_CONFIG if set.
public func refreshSupportersCacheIfConfigured() {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: skip — URL or key not set (url=\(SG_CONFIG.supportersApiUrl ?? "nil"), key=\(SG_CONFIG.supportersAesKey != nil ? "***" : "nil"))")
return
}
if refreshStarted.swap(true) {
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: refresh already in progress, skip")
return
}
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: start fetch \(baseURL)")
_ = fetchBadges(baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey).start(next: { data in
setCachedBadges(data.badges)
setCachedAssignments(data.assignments)
let uniqueUsers = Set(data.assignments.values.flatMap { $0 })
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: ok — badges=\(data.badges.count), unique users=\(uniqueUsers.count)")
// Prefetch image badges from list_badges result
let imageURLs = data.badges.compactMap { badge -> String? in
guard badge.displayMode == "image", let img = badge.imageURL else { return nil }
if img.hasPrefix("http") {
return isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
}
guard img.hasPrefix("/") else { return nil }
let baseNorm = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let full = baseNorm + img
return isUrlSafeForBadgeImage(full, allowedBaseURL: baseURL) ? full : nil
}
if !imageURLs.isEmpty {
prefetchBadgeImages(urls: imageURLs, allowedBaseURL: baseURL)
}
}, error: { err in
_ = refreshStarted.swap(false)
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: error — \(String(describing: err))")
}, completed: {
_ = refreshStarted.swap(false)
SGLogger.shared.log("SGSupporters", "refreshSupportersCacheIfConfigured: completed")
})
}
/// Full badge info for a user: name, color, display mode, image URL. Merges list_badges cache with check_user cache.
public func badges(forUserId userId: Int64) -> [(name: String, color: UIColor, displayMode: String, imageURL: String?)] {
let userIdStr = String(userId)
let baseURL = SG_CONFIG.supportersApiUrl
if let status = loadCachedUserStatus(userId: userIdStr) {
return status.badges.map { badge in
let color = UIColor(hex: badge.color) ?? UIColor(red: 52/255, green: 199/255, blue: 89/255, alpha: 1)
var fullImageURL: String? = nil
if let img = badge.image {
if img.hasPrefix("http") {
fullImageURL = isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
} else if let base = baseURL, img.hasPrefix("/") {
let baseNorm = base.hasSuffix("/") ? String(base.dropLast()) : base
let full = baseNorm + img
fullImageURL = isUrlSafeForBadgeImage(full, allowedBaseURL: base) ? full : nil
}
}
return (name: badge.name, color: color, displayMode: badge.displayMode, imageURL: fullImageURL)
}
}
let badgesList = loadCachedBadges()
let assignments = loadCachedAssignments()
var result: [(name: String, color: UIColor, displayMode: String, imageURL: String?)] = []
for badge in badgesList {
let userIds = assignments[badge.id] ?? []
if userIds.contains(userIdStr) {
let color = UIColor(hex: badge.colorHex) ?? UIColor(red: 52/255, green: 199/255, blue: 89/255, alpha: 1)
var fullImageURL: String? = nil
if let img = badge.imageURL {
if img.hasPrefix("http") {
fullImageURL = isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
} else if let base = baseURL, img.hasPrefix("/") {
let baseNorm = base.hasSuffix("/") ? String(base.dropLast()) : base
let full = baseNorm + img
fullImageURL = isUrlSafeForBadgeImage(full, allowedBaseURL: base) ? full : nil
}
}
result.append((name: badge.name, color: color, displayMode: badge.displayMode, imageURL: fullImageURL))
}
}
if !result.isEmpty {
SGLogger.shared.log("SGSupporters", "badges(userId=\(userIdStr)): \(result.map { $0.name }.joined(separator: ", "))")
}
return result
}
/// Legacy: true if user has at least one badge.
/// Integrity-gated: also verifies text segment checksum.
public func isSupporter(userId: Int64) -> Bool {
guard SupportersIntegrity.textOK() else { return false }
return !badges(forUserId: userId).isEmpty
}
// MARK: - GLEGram check_user (full user status, Keychain encrypted, not in files)
private let kUserStatusCacheAccount = "sg_glegram_user_status"
private let userStatusCacheLock = NSLock()
private let kVerifiedUserIds = "_verified"
private func loadAllCachedUserStatuses() -> [String: [String: Any]] {
guard let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) else {
return [:]
}
var result: [String: [String: Any]] = [:]
for (key, value) in j {
if key == "_multi" || key == kVerifiedUserIds { continue }
if let statusDict = value as? [String: Any] {
result[key] = statusDict
}
}
return result
}
private func loadVerifiedUserIds() -> Set<String> {
guard let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) else {
return []
}
if let arr = j[kVerifiedUserIds] as? [String], !arr.isEmpty {
return Set(arr)
}
// Backward compat: no _verified yet treat existing entries as verified
var keys: Set<String> = []
for (key, value) in j {
if key != "_multi" && key != kVerifiedUserIds, value is [String: Any] {
keys.insert(key)
}
}
return keys
}
private func loadCachedUserStatus(userId: String? = nil) -> GLEGramUserStatus? {
let all = loadAllCachedUserStatuses()
if let userId = userId {
guard let json = all[userId] else { return nil }
return GLEGramUserStatus(json: json)
}
// Return first available status (backward compat)
guard let first = all.values.first else { return nil }
return GLEGramUserStatus(json: first)
}
private func saveCachedUserStatus(_ status: GLEGramUserStatus) {
userStatusCacheLock.lock()
defer { userStatusCacheLock.unlock() }
var all: [String: [String: Any]] = [:]
var verified: Set<String> = []
if let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) {
verified = Set((j[kVerifiedUserIds] as? [String]) ?? [])
for (key, value) in j {
if key == "_multi" || key == kVerifiedUserIds { continue }
if let statusDict = value as? [String: Any] {
all[key] = statusDict
}
}
}
all[status.userId] = status.toJSON()
verified.insert(status.userId)
var dict: [String: Any] = ["_multi": true, kVerifiedUserIds: Array(verified)]
for (key, value) in all {
dict[key] = value
}
_ = supportersSecureSaveJSON(dict, account: kUserStatusCacheAccount)
}
/// Cached GLEGram user status for a specific user ID (or first available).
public func cachedGLEGramUserStatus(userId: String? = nil) -> GLEGramUserStatus? {
return loadCachedUserStatus(userId: userId)
}
/// Aggregate access across cached accounts. If validUserIds is set, only those accounts are considered.
/// Only entries from our check_user API (_verified) are trusted prevents injection of fake IDs.
/// Access is verified through multiple independent integrity layers (token + text checksum + accumulator).
public func cachedAggregateAccess(validUserIds: Set<String>? = nil) -> GLEGramAccess {
var all = loadAllCachedUserStatuses()
let verified = loadVerifiedUserIds()
all = all.filter { verified.contains($0.key) }
if let ids = validUserIds {
all = all.filter { ids.contains($0.key) }
}
var glegramTab = false
var betaBuilds = false
var tokenVerified = false
for (userId, json) in all {
let status = GLEGramUserStatus(json: json)
if status.access.glegramTab { glegramTab = true }
if status.access.betaBuilds { betaBuilds = true }
// Integrity layer: verify per-user access token
if let tokenB64 = status.access.accessToken,
let tokenData = Data(base64Encoded: tokenB64),
let hmacB64 = SG_CONFIG.supportersHmacKey ?? SG_CONFIG.supportersAesKey {
let keyData = SupportersCrypto.normalizeKeyData(hmacB64)
if SupportersIntegrity.verifyAccessToken(
tokenData,
userId: userId,
glegramTab: status.access.glegramTab,
betaBuilds: status.access.betaBuilds,
hmacKeyData: keyData
) {
tokenVerified = true
} else {
// Token mismatch flags were tampered in cache
SGLogger.shared.log("SGIntegrity", "access token mismatch for userId=\(userId)")
glegramTab = false
betaBuilds = false
}
}
}
// Integrity layer: text segment checksum
if !SupportersIntegrity.textOK() {
SGLogger.shared.log("SGIntegrity", "text segment modified — revoking access")
glegramTab = false
betaBuilds = false
}
// Re-validate accumulator from cached state
if tokenVerified {
SupportersIntegrity.validate(
cryptoSucceeded: tokenVerified,
cacheDecrypted: !all.isEmpty,
glegramTab: glegramTab,
betaBuilds: betaBuilds
)
}
return GLEGramAccess(json: ["glegramTab": glegramTab, "betaBuilds": betaBuilds])
}
/// Returns true if ANY cached account has an active subscription.
/// Integrity-gated: also verifies text segment checksum.
public func hasAnyCachedSubscription() -> Bool {
guard SupportersIntegrity.textOK() else { return false }
let all = loadAllCachedUserStatuses()
return all.values.contains { json in
GLEGramUserStatus(json: json).hasActiveSubscription
}
}
/// Returns true if ANY cached account has an active trial.
/// Integrity-gated: also verifies text segment checksum.
public func hasAnyCachedTrial() -> Bool {
guard SupportersIntegrity.textOK() else { return false }
let all = loadAllCachedUserStatuses()
return all.values.contains { json in
GLEGramUserStatus(json: json).hasActiveTrial
}
}
/// Returns the first betaConfig found across cached accounts that has betaBuilds access.
/// Integrity-gated: returns nil if text segment has been modified.
public func cachedAggregateBetaConfig(validUserIds: Set<String>? = nil) -> GLEGramBetaConfig? {
guard SupportersIntegrity.textOK() else { return nil }
var all = loadAllCachedUserStatuses()
let verified = loadVerifiedUserIds()
all = all.filter { verified.contains($0.key) }
if let ids = validUserIds {
all = all.filter { ids.contains($0.key) }
}
for (_, json) in all {
let status = GLEGramUserStatus(json: json)
if status.access.betaBuilds, let config = status.betaConfig {
return config
}
}
return nil
}
/// Returns the first promo found across cached accounts (for paywall display).
public func cachedAggregatePromo() -> (promo: GLEGramPromo, trialAvailable: Bool)? {
let all = loadAllCachedUserStatuses()
for (_, json) in all {
let status = GLEGramUserStatus(json: json)
if let promo = status.glegramPromo {
return (promo: promo, trialAvailable: status.trialAvailable)
}
}
return nil
}
/// Full user status: badges, subscription, trial, access, promo, beta config.
public func checkUser(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<GLEGramUserStatus, SupportersAPIError> {
return encryptedAPICall(
action: "check_user",
payload: ["userId": String(userId)],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
// Generate integrity access token and inject into JSON before parsing
var enrichedJSON = json
let userId = json["userId"] as? String ?? String(userId)
let accessJSON = json["access"] as? [String: Any] ?? [:]
let tab = accessJSON["glegramTab"] as? Bool ?? false
let beta = accessJSON["betaBuilds"] as? Bool ?? false
if let hmacB64 = SG_CONFIG.supportersHmacKey ?? SG_CONFIG.supportersAesKey {
let keyData = SupportersCrypto.normalizeKeyData(hmacB64)
let token = SupportersIntegrity.computeAccessToken(
userId: userId, glegramTab: tab, betaBuilds: beta, hmacKeyData: keyData
)
var accessWithToken = accessJSON
accessWithToken["_accessToken"] = token.base64EncodedString()
enrichedJSON["access"] = accessWithToken
}
let status = GLEGramUserStatus(json: enrichedJSON)
saveCachedUserStatus(status)
// Integrity: validate all layers after successful check_user
SupportersIntegrity.validate(
cryptoSucceeded: true,
cacheDecrypted: true,
glegramTab: tab,
betaBuilds: beta
)
// Parse gated features from check_user response (if server embeds them).
// Write to SGSimpleSettings on main queue to avoid threading issues.
if let gatedArray = json["gatedFeatures"] as? [[String: Any]] {
let features = gatedArray.compactMap { f -> (key: String, deeplinkPath: String)? in
guard let key = f["key"] as? String,
let path = f["deeplinkPath"] as? String else { return nil }
return (key: key, deeplinkPath: path)
}
DispatchQueue.main.async {
SGSimpleSettings.shared.updateGatedFeatures(features)
}
SGLogger.shared.log("SGSupporters", "check_user: parsed \(features.count) gatedFeatures")
}
if let unlockedArray = json["unlockedFeatures"] as? [String] {
DispatchQueue.main.async {
var current = SGSimpleSettings.shared.unlockedFeatureKeys
for k in unlockedArray {
if !current.contains(k) { current.append(k) }
}
SGSimpleSettings.shared.unlockedFeatureKeys = current
}
SGLogger.shared.log("SGSupporters", "check_user: parsed \(unlockedArray.count) unlockedFeatures")
}
return status
}
}
/// Convenience: check_user using SG_CONFIG.
public func checkUserIfConfigured(userId: Int64) -> Signal<GLEGramUserStatus, SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return checkUser(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Start 7-day trial (one-time per Telegram ID).
public func startTrial(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<GLEGramTrial?, SupportersAPIError> {
return encryptedAPICall(
action: "start_trial",
payload: ["userId": String(userId)],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
(json["trial"] as? [String: Any]).flatMap { GLEGramTrial(json: $0) }
}
}
/// Convenience: start_trial using SG_CONFIG.
public func startTrialIfConfigured(userId: Int64) -> Signal<GLEGramTrial?, SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return startTrial(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// On app launch: call check_user and refresh badges cache.
public func refreshGLEGramStatusIfConfigured(userId: Int64) {
guard let signal = checkUserIfConfigured(userId: userId) else { return }
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatus: starting check_user for \(userId)")
_ = signal.start(next: { status in
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatus: ok — access.glegramTab=\(status.access.glegramTab), badges=\(status.badges.count), sub=\(status.hasActiveSubscription), trial=\(status.hasActiveTrial)")
let baseURL = SG_CONFIG.supportersApiUrl
let imageURLs = status.badges.compactMap { badge -> String? in
guard badge.displayMode == "image", let img = badge.image else { return nil }
if img.hasPrefix("http") {
return isUrlSafeForBadgeImage(img, allowedBaseURL: baseURL) ? img : nil
}
guard let base = baseURL, img.hasPrefix("/") else { return nil }
let baseNorm = base.hasSuffix("/") ? String(base.dropLast()) : base
let full = baseNorm + img
return isUrlSafeForBadgeImage(full, allowedBaseURL: base) ? full : nil
}
if !imageURLs.isEmpty {
prefetchBadgeImages(urls: imageURLs, allowedBaseURL: baseURL)
}
}, error: { err in
let msg: String
if case .tooManyRequests = err { msg = "429 Too Many Requests" }
else { msg = String(describing: err) }
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatus: error — \(msg)")
})
}
/// Prune cache to only keep statuses for accounts that exist in the app.
public func pruneCachedUserStatuses(keepingUserIds: Set<String>) {
userStatusCacheLock.lock()
defer { userStatusCacheLock.unlock() }
guard let j = supportersSecureLoadJSON(account: kUserStatusCacheAccount) else { return }
var all: [String: [String: Any]] = [:]
var verified = Set<String>((j[kVerifiedUserIds] as? [String]) ?? [])
for (key, value) in j {
if key == "_multi" || key == kVerifiedUserIds { continue }
if let statusDict = value as? [String: Any] {
all[key] = statusDict
}
}
let before = all.count
all = all.filter { keepingUserIds.contains($0.key) }
verified = verified.intersection(keepingUserIds)
if all.count != before {
var dict: [String: Any] = ["_multi": true, kVerifiedUserIds: Array(verified)]
for (key, value) in all {
dict[key] = value
}
_ = supportersSecureSaveJSON(dict, account: kUserStatusCacheAccount)
SGLogger.shared.log("SGSupporters", "pruneCachedUserStatuses: removed \(before - all.count) stale entries")
}
}
/// Fetches check_user for all userIds in parallel. Completes when all finish (success or error).
/// Use before checking cachedAggregateAccess when current account has no beta.
public func fetchAllUserStatusesIfConfigured(userIds: [Int64]) -> Signal<Never, NoError> {
return Signal { subscriber in
let total = userIds.count
if total == 0 {
subscriber.putCompletion()
return EmptyDisposable
}
let completed = Atomic<Int>(value: 0)
let disposable = DisposableSet()
for userId in userIds {
guard let s = checkUserIfConfigured(userId: userId) else {
if completed.modify({ $0 + 1 }) == total { subscriber.putCompletion() }
continue
}
let d = s.start(error: { _ in
if completed.modify({ $0 + 1 }) == total { subscriber.putCompletion() }
}, completed: {
if completed.modify({ $0 + 1 }) == total { subscriber.putCompletion() }
})
disposable.add(d)
}
return disposable
}
}
/// Check all accounts at once (multi-account access support). Prunes cache to match app accounts.
public func refreshGLEGramStatusForAllAccounts(userIds: [Int64]) {
SGLogger.shared.log("SGSupporters", "refreshGLEGramStatusForAllAccounts: \(userIds.count) accounts")
let validUserIds = Set(userIds.map { String($0) })
pruneCachedUserStatuses(keepingUserIds: validUserIds)
for userId in userIds {
refreshGLEGramStatusIfConfigured(userId: userId)
}
}
// MARK: - Generic encrypted API call helper
private func encryptedAPICall(
action: String,
payload: [String: Any],
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[String: Any], SupportersAPIError> {
return Signal { subscriber in
let urlString = baseURL.hasSuffix("/") ? "\(baseURL)api/encrypted" : "\(baseURL)/api/encrypted"
guard let url = URL(string: urlString) else {
subscriber.putError(.notConfigured)
return EmptyDisposable
}
let requestPayload: [String: Any] = ["action": action, "payload": payload]
let body: String
do {
body = try SupportersCrypto.encrypt(requestPayload, key: aesKey, hmacKey: hmacKey)
} catch {
subscriber.putError(.invalidResponse)
return EmptyDisposable
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("text/plain", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(body.utf8)
SGLogger.shared.log("SGSupporters", "\(action): POST \(urlString)")
let completed = Atomic<Bool>(value: false)
let disposable = supportersRequest(request, baseURL: baseURL).start(
next: { data, response in
guard completed.swap(true) == false else { return }
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
SGLogger.shared.log("SGSupporters", "\(action): status=\(code)")
if code == 429 {
subscriber.putError(.tooManyRequests)
return
}
guard let text = String(data: data, encoding: .utf8) else {
subscriber.putError(.invalidResponse)
return
}
do {
let decrypted = try SupportersCrypto.decrypt(text, key: aesKey, hmacKey: hmacKey)
if let ok = decrypted["ok"] as? Bool, !ok {
let errMsg = decrypted["error"] as? String ?? "Unknown error"
SGLogger.shared.log("SGSupporters", "\(action): server error — \(errMsg)")
subscriber.putError(.invalidResponse)
return
}
// Integrity: seal text checksum on first successful crypto verification
SupportersIntegrity.seal()
subscriber.putNext(decrypted)
subscriber.putCompletion()
} catch {
SGLogger.shared.log("SGSupporters", "\(action): decrypt failed — \(String(describing: error))")
subscriber.putError(.invalidResponse)
}
},
error: { err in
guard completed.swap(true) == false else { return }
SGLogger.shared.log("SGSupporters", "\(action): network error — \(String(describing: err))")
subscriber.putError(.network)
}
)
return ActionDisposable {
if !completed.with({ $0 }) {
disposable.dispose()
}
}
}
}
// MARK: - Gated Features (encrypted, same as Supporters DRM)
/// Fetch list of gated features from server (encrypted).
public func fetchGatedFeatures(
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[(key: String, deeplinkPath: String)], SupportersAPIError> {
return encryptedAPICall(
action: "gated_features",
payload: [:],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
guard let features = json["gatedFeatures"] as? [[String: Any]] else { return [] }
return features.compactMap { f in
guard let key = f["key"] as? String,
let path = f["deeplinkPath"] as? String else { return nil }
return (key: key, deeplinkPath: path)
}
}
}
/// Fetch unlocked features for a specific user (encrypted).
public func fetchUnlockedFeatures(
userId: Int64,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[String], SupportersAPIError> {
return encryptedAPICall(
action: "unlocked_features",
payload: ["userId": String(userId)],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
return json["unlockedKeys"] as? [String] ?? []
}
}
/// Unlock a feature via deeplink path (encrypted).
/// Returns array of unlocked keys (single key for individual paths, multiple for group paths like "unlock-all", "ghost-mode").
public func unlockFeature(
userId: Int64,
deeplinkPath: String,
baseURL: String,
aesKey: String,
hmacKey: String? = nil
) -> Signal<[String], SupportersAPIError> {
return encryptedAPICall(
action: "unlock_feature",
payload: ["userId": String(userId), "deeplinkPath": deeplinkPath],
baseURL: baseURL,
aesKey: aesKey,
hmacKey: hmacKey
) |> map { json in
// Group unlock: server returns "unlockedKeys": ["key1", "key2", ...]
if let keys = json["unlockedKeys"] as? [String] {
return keys
}
// Single unlock: server returns "key": "singleKey"
if let key = json["key"] as? String {
return [key]
}
return []
}
}
/// Convenience: fetch gated features using SG_CONFIG.
public func fetchGatedFeaturesIfConfigured() -> Signal<[(key: String, deeplinkPath: String)], SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return fetchGatedFeatures(baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Convenience: fetch unlocked features using SG_CONFIG.
public func fetchUnlockedFeaturesIfConfigured(userId: Int64) -> Signal<[String], SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return fetchUnlockedFeatures(userId: userId, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Convenience: unlock feature using SG_CONFIG.
public func unlockFeatureIfConfigured(userId: Int64, deeplinkPath: String) -> Signal<[String], SupportersAPIError>? {
guard let baseURL = SG_CONFIG.supportersApiUrl, !baseURL.isEmpty,
let key = SG_CONFIG.supportersAesKey, !key.isEmpty else {
return nil
}
return unlockFeature(userId: userId, deeplinkPath: deeplinkPath, baseURL: baseURL, aesKey: key, hmacKey: SG_CONFIG.supportersHmacKey)
}
/// Refresh gated features cache: fetch gated list + unlocked list, save to SimpleSettings.
public func refreshGatedFeaturesCache(userId: Int64) {
guard let gatedSignal = fetchGatedFeaturesIfConfigured() else { return }
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: starting")
_ = gatedSignal.start(next: { features in
DispatchQueue.main.async {
SGSimpleSettings.shared.updateGatedFeatures(features)
}
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: \(features.count) gated features cached")
// Now fetch unlocked for this user
guard let unlockedSignal = fetchUnlockedFeaturesIfConfigured(userId: userId) else { return }
_ = unlockedSignal.start(next: { keys in
DispatchQueue.main.async {
var current = SGSimpleSettings.shared.unlockedFeatureKeys
for k in keys {
if !current.contains(k) { current.append(k) }
}
SGSimpleSettings.shared.unlockedFeatureKeys = current
}
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: \(keys.count) unlocked features synced")
}, error: { err in
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: unlocked fetch error — \(err)")
})
}, error: { err in
SGLogger.shared.log("SGSupporters", "refreshGatedFeatures: gated fetch error — \(err)")
})
}
// MARK: - UIColor from hex
private extension UIColor {
convenience init?(hex: String) {
var hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
if hex.hasPrefix("#") { hex = String(hex.dropFirst()) }
if hex.count == 6 {
hex += "FF"
} else if hex.count != 8 {
return nil
}
guard let value = UInt32(hex, radix: 16) else { return nil }
let r = CGFloat((value >> 24) & 0xFF) / 255
let g = CGFloat((value >> 16) & 0xFF) / 255
let b = CGFloat((value >> 8) & 0xFF) / 255
let a = CGFloat(value & 0xFF) / 255
self.init(red: r, green: g, blue: b, alpha: a)
}
}