Compare commits

..
Author SHA1 Message Date
jack 9f47121b84 Limit geohash teleport participant cache 2026-06-02 22:18:17 +02:00
7 changed files with 102 additions and 134 deletions
+54 -4
View File
@@ -7,8 +7,19 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
init(teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
}
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
let normalized = geohash?.lowercased()
if currentGeohash != normalized {
clearTeleportedGeo()
}
currentGeohash = normalized
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
@@ -28,24 +39,63 @@ final class LocationPresenceStore: ObservableObject {
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
let key = pubkeyHex.lowercased()
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
guard teleportedGeoCapacity > 0 else {
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
}
}
+1 -3
View File
@@ -17,6 +17,7 @@ enum TransportConfig {
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let geoTeleportedParticipantsCap: Int = 1337
static let contentLRUCap: Int = 2000
// Timers
@@ -56,9 +57,6 @@ enum TransportConfig {
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
static let uiSenderRateBucketMaxEntries: Int = 2000
static let uiContentRateBucketMaxEntries: Int = 2000
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
@@ -93,6 +93,10 @@ final class ChatNostrCoordinator {
let hasTeleportTag = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
let content = event.content.trimmed
if hasTeleportTag && content.isEmpty {
return
}
if hasTeleportTag {
let key = event.pubkey.lowercased()
@@ -111,7 +115,6 @@ final class ChatNostrCoordinator {
}
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = viewModel.parseMentions(from: content)
@@ -272,6 +275,10 @@ final class ChatNostrCoordinator {
let hasTeleportTag = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
let content = event.content
if hasTeleportTag && content.trimmed.isEmpty {
return
}
let isSelf: Bool = {
if let gh = viewModel.currentGeohash,
@@ -314,14 +321,6 @@ final class ChatNostrCoordinator {
}
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
let content = event.content
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = viewModel.parseMentions(from: content)
@@ -121,6 +121,17 @@ private extension ChatViewModelBootstrapper {
viewModel?.objectWillChange.send()
}
.store(in: &viewModel.cancellables)
viewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] people in
Task { @MainActor [weak viewModel] in
viewModel?.locationPresenceStore.retainTeleportedGeo(
keeping: Set(people.map { $0.id })
)
}
}
.store(in: &viewModel.cancellables)
}
func loadPersistedViewState() {
+8 -62
View File
@@ -26,10 +26,6 @@ struct MessageRateLimiter {
}
return false
}
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
now.timeIntervalSince(lastRefill) >= idleTTL
}
}
private var senderBuckets: [String: TokenBucket] = [:]
@@ -39,93 +35,43 @@ struct MessageRateLimiter {
private let senderRefill: Double
private let contentCapacity: Double
private let contentRefill: Double
private let maxSenderBuckets: Int
private let maxContentBuckets: Int
private let bucketIdleTTL: TimeInterval
init(
senderCapacity: Double,
senderRefillPerSec: Double,
contentCapacity: Double,
contentRefillPerSec: Double,
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
contentRefillPerSec: Double
) {
self.senderCapacity = senderCapacity
self.senderRefill = senderRefillPerSec
self.contentCapacity = contentCapacity
self.contentRefill = contentRefillPerSec
self.maxSenderBuckets = max(1, maxSenderBuckets)
self.maxContentBuckets = max(1, maxContentBuckets)
self.bucketIdleTTL = bucketIdleTTL
}
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
var senderBucket = bucket(
for: senderKey,
in: &senderBuckets,
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
capacity: senderCapacity,
tokens: senderCapacity,
refillPerSec: senderRefill,
maxBuckets: maxSenderBuckets,
now: now
lastRefill: now
)
let senderAllowed = senderBucket.allow(now: now)
senderBuckets[senderKey] = senderBucket
guard senderAllowed else { return false }
var contentBucket = bucket(
for: contentKey,
in: &contentBuckets,
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
capacity: contentCapacity,
tokens: contentCapacity,
refillPerSec: contentRefill,
maxBuckets: maxContentBuckets,
now: now
lastRefill: now
)
let contentAllowed = contentBucket.allow(now: now)
contentBuckets[contentKey] = contentBucket
return contentAllowed
return senderAllowed && contentAllowed
}
mutating func reset() {
senderBuckets.removeAll()
contentBuckets.removeAll()
}
var bucketCountsForTesting: (sender: Int, content: Int) {
(senderBuckets.count, contentBuckets.count)
}
private mutating func bucket(
for key: String,
in buckets: inout [String: TokenBucket],
capacity: Double,
refillPerSec: Double,
maxBuckets: Int,
now: Date
) -> TokenBucket {
if let bucket = buckets[key] {
return bucket
}
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, now: now)
return TokenBucket(
capacity: capacity,
tokens: capacity,
refillPerSec: refillPerSec,
lastRefill: now
)
}
private func evictIfNeeded(from buckets: inout [String: TokenBucket], maxBuckets: Int, now: Date) {
guard buckets.count >= maxBuckets else { return }
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: bucketIdleTTL) }
guard buckets.count >= maxBuckets else { return }
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
buckets.removeValue(forKey: oldestKey)
}
}
}
+20
View File
@@ -125,6 +125,26 @@ struct AppArchitectureTests {
#expect(store.teleportedGeo.isEmpty)
}
@Test("LocationPresenceStore bounds and prunes teleported geohash participants")
@MainActor
func locationPresenceStoreBoundsTeleportedParticipants() {
let store = LocationPresenceStore(teleportedGeoCapacity: 2)
store.setCurrentGeohash("u4pruy")
store.markTeleported("AAAAAA")
store.markTeleported("BBBBBB")
store.markTeleported("CCCCCC")
#expect(store.teleportedGeo == Set(["bbbbbb", "cccccc"]))
store.retainTeleportedGeo(keeping: Set(["CCCCCC"]))
#expect(store.teleportedGeo == Set(["cccccc"]))
store.setCurrentGeohash("u4pruz")
#expect(store.teleportedGeo.isEmpty)
}
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(
@@ -1,56 +0,0 @@
//
// MessageRateLimiterTests.swift
// bitchatTests
//
// Ensures public-message rate limiter state remains bounded for attacker-derived keys.
//
import Foundation
import Testing
@testable import bitchat
struct MessageRateLimiterTests {
@Test("Content buckets do not grow when sender is rate limited")
func contentBucketsDoNotGrowAfterSenderLimit() {
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: 10,
maxContentBuckets: 10,
bucketIdleTTL: 60
)
let now = Date()
#expect(limiter.allow(senderKey: "sender", contentKey: "content-0", now: now))
for index in 1...100 {
#expect(!limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now))
}
#expect(limiter.bucketCountsForTesting.sender == 1)
#expect(limiter.bucketCountsForTesting.content == 1)
}
@Test("Bucket maps evict entries at configured caps")
func bucketMapsEvictAtConfiguredCaps() {
let maxEntries = 3
var limiter = MessageRateLimiter(
senderCapacity: 1,
senderRefillPerSec: 0,
contentCapacity: 1,
contentRefillPerSec: 0,
maxSenderBuckets: maxEntries,
maxContentBuckets: maxEntries,
bucketIdleTTL: 60
)
let now = Date()
for index in 0..<25 {
#expect(limiter.allow(senderKey: "sender-\(index)", contentKey: "content-\(index)", now: now.addingTimeInterval(TimeInterval(index))))
}
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
#expect(limiter.bucketCountsForTesting.content == maxEntries)
}
}