mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:25:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ae764f91 |
@@ -255,8 +255,9 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content, use plain formatting to avoid expensive regex and detector work.
|
// For very long content without special tokens, use plain formatting
|
||||||
if content.isOversizedForRichFormatting() {
|
let containsCashu = containsCashuToken(content)
|
||||||
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ enum TransportConfig {
|
|||||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||||
static let uiContentRateBucketCapacity: Double = 3
|
static let uiContentRateBucketCapacity: Double = 3
|
||||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
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
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
|
|||||||
@@ -70,7 +70,12 @@ final class ChatMessageFormatter {
|
|||||||
let content = message.content
|
let content = message.content
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
if content.isOversizedForRichFormatting() {
|
let containsCashuEarly: Bool = {
|
||||||
|
let regex = Patterns.quickCashuPresence
|
||||||
|
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
var plainStyle = AttributeContainer()
|
var plainStyle = AttributeContainer()
|
||||||
plainStyle.foregroundColor = baseColor
|
plainStyle.foregroundColor = baseColor
|
||||||
plainStyle.font = isSelf
|
plainStyle.font = isSelf
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
|
||||||
|
now.timeIntervalSince(lastRefill) >= idleTTL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var senderBuckets: [String: TokenBucket] = [:]
|
private var senderBuckets: [String: TokenBucket] = [:]
|
||||||
@@ -35,43 +39,93 @@ struct MessageRateLimiter {
|
|||||||
private let senderRefill: Double
|
private let senderRefill: Double
|
||||||
private let contentCapacity: Double
|
private let contentCapacity: Double
|
||||||
private let contentRefill: Double
|
private let contentRefill: Double
|
||||||
|
private let maxSenderBuckets: Int
|
||||||
|
private let maxContentBuckets: Int
|
||||||
|
private let bucketIdleTTL: TimeInterval
|
||||||
|
|
||||||
init(
|
init(
|
||||||
senderCapacity: Double,
|
senderCapacity: Double,
|
||||||
senderRefillPerSec: Double,
|
senderRefillPerSec: Double,
|
||||||
contentCapacity: Double,
|
contentCapacity: Double,
|
||||||
contentRefillPerSec: Double
|
contentRefillPerSec: Double,
|
||||||
|
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
|
||||||
|
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
|
||||||
|
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
|
||||||
) {
|
) {
|
||||||
self.senderCapacity = senderCapacity
|
self.senderCapacity = senderCapacity
|
||||||
self.senderRefill = senderRefillPerSec
|
self.senderRefill = senderRefillPerSec
|
||||||
self.contentCapacity = contentCapacity
|
self.contentCapacity = contentCapacity
|
||||||
self.contentRefill = contentRefillPerSec
|
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 {
|
mutating func allow(senderKey: String, contentKey: String, now: Date = Date()) -> Bool {
|
||||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
var senderBucket = bucket(
|
||||||
|
for: senderKey,
|
||||||
|
in: &senderBuckets,
|
||||||
capacity: senderCapacity,
|
capacity: senderCapacity,
|
||||||
tokens: senderCapacity,
|
|
||||||
refillPerSec: senderRefill,
|
refillPerSec: senderRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxSenderBuckets,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
let senderAllowed = senderBucket.allow(now: now)
|
let senderAllowed = senderBucket.allow(now: now)
|
||||||
senderBuckets[senderKey] = senderBucket
|
senderBuckets[senderKey] = senderBucket
|
||||||
|
guard senderAllowed else { return false }
|
||||||
|
|
||||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
var contentBucket = bucket(
|
||||||
|
for: contentKey,
|
||||||
|
in: &contentBuckets,
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
tokens: contentCapacity,
|
|
||||||
refillPerSec: contentRefill,
|
refillPerSec: contentRefill,
|
||||||
lastRefill: now
|
maxBuckets: maxContentBuckets,
|
||||||
|
now: now
|
||||||
)
|
)
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
let contentAllowed = contentBucket.allow(now: now)
|
||||||
contentBuckets[contentKey] = contentBucket
|
contentBuckets[contentKey] = contentBucket
|
||||||
|
|
||||||
return senderAllowed && contentAllowed
|
return contentAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func reset() {
|
mutating func reset() {
|
||||||
senderBuckets.removeAll()
|
senderBuckets.removeAll()
|
||||||
contentBuckets.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ struct TextMessageView: View {
|
|||||||
let cashuLinks = message.content.extractCashuLinks()
|
let cashuLinks = message.content.extractCashuLinks()
|
||||||
let lightningLinks = message.content.extractLightningLinks()
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
let isLong = message.content.isLongForDisplay()
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
|
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -38,7 +38,7 @@ struct TextMessageView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Expand/Collapse for very long messages
|
// Expand/Collapse for very long messages
|
||||||
if message.content.isLongForDisplay() {
|
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
||||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
Button(labelKey) {
|
Button(labelKey) {
|
||||||
|
|||||||
@@ -21,19 +21,6 @@ extension String {
|
|||||||
return current >= threshold
|
return current >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect if message content should be collapsed to avoid expensive unbounded layout.
|
|
||||||
func isLongForDisplay(
|
|
||||||
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
|
|
||||||
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
|
|
||||||
) -> Bool {
|
|
||||||
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect if message content should use plain formatting to avoid expensive regex parsing.
|
|
||||||
func isOversizedForRichFormatting(lengthThreshold: Int = 4000, tokenThreshold: Int = 1024) -> Bool {
|
|
||||||
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||||
func extractCashuLinks(max: Int = 3) -> [String] {
|
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||||
let regex = MessageFormattingEngine.Patterns.cashu
|
let regex = MessageFormattingEngine.Patterns.cashu
|
||||||
|
|||||||
@@ -606,25 +606,6 @@ struct ChatViewModelFormattingTests {
|
|||||||
|
|
||||||
#expect(String(header.characters) == "<@Alice#a1b2> ")
|
#expect(String(header.characters) == "<@Alice#a1b2> ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
|
||||||
func formatMessageAsText_longCashuMessageUsesPlainFastPath() async {
|
|
||||||
let (viewModel, _) = makeTestableViewModel()
|
|
||||||
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
|
||||||
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "fmt-long-cashu",
|
|
||||||
sender: "Alice#a1b2",
|
|
||||||
content: longContent,
|
|
||||||
timestamp: Date(timeIntervalSince1970: 1_700_010_123),
|
|
||||||
isRelay: false,
|
|
||||||
senderPeerID: PeerID(str: "00000000000000b3")
|
|
||||||
)
|
|
||||||
|
|
||||||
let formatted = viewModel.formatMessageAsText(message, colorScheme: .light)
|
|
||||||
|
|
||||||
#expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Verification Tests
|
// MARK: - Verification Tests
|
||||||
|
|||||||
@@ -323,32 +323,6 @@ struct MessageFormattingEngineTests {
|
|||||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
// Exactly at threshold DOES trigger (uses >= comparison)
|
||||||
#expect(content.hasVeryLongToken(threshold: 50))
|
#expect(content.hasVeryLongToken(threshold: 50))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func isLongForDisplay_doesNotIgnoreCashuLinks() {
|
|
||||||
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
|
||||||
let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu
|
|
||||||
|
|
||||||
#expect(content.extractCashuLinks().count == 1)
|
|
||||||
#expect(content.isLongForDisplay())
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
@Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() {
|
|
||||||
let context = MockMessageFormattingContext(nickname: "carol")
|
|
||||||
let cashu = "cashuA" + String(repeating: "a", count: 40)
|
|
||||||
let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100)
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: "long-cashu",
|
|
||||||
sender: "alice",
|
|
||||||
content: longContent,
|
|
||||||
timestamp: Date(timeIntervalSince1970: 1_700_000_999),
|
|
||||||
isRelay: false
|
|
||||||
)
|
|
||||||
|
|
||||||
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
|
|
||||||
|
|
||||||
#expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user