mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:25:20 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b7f5171e |
@@ -255,9 +255,8 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content without special tokens, use plain formatting
|
// For very long content, use plain formatting to avoid expensive regex and detector work.
|
||||||
let containsCashu = containsCashuToken(content)
|
if content.isOversizedForRichFormatting() {
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,9 +56,6 @@ 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,12 +70,7 @@ 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
|
||||||
let containsCashuEarly: Bool = {
|
if content.isOversizedForRichFormatting() {
|
||||||
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,10 +26,6 @@ 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] = [:]
|
||||||
@@ -39,93 +35,43 @@ 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 = bucket(
|
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||||
for: senderKey,
|
|
||||||
in: &senderBuckets,
|
|
||||||
capacity: senderCapacity,
|
capacity: senderCapacity,
|
||||||
|
tokens: senderCapacity,
|
||||||
refillPerSec: senderRefill,
|
refillPerSec: senderRefill,
|
||||||
maxBuckets: maxSenderBuckets,
|
lastRefill: now
|
||||||
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 = bucket(
|
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||||
for: contentKey,
|
|
||||||
in: &contentBuckets,
|
|
||||||
capacity: contentCapacity,
|
capacity: contentCapacity,
|
||||||
|
tokens: contentCapacity,
|
||||||
refillPerSec: contentRefill,
|
refillPerSec: contentRefill,
|
||||||
maxBuckets: maxContentBuckets,
|
lastRefill: now
|
||||||
now: now
|
|
||||||
)
|
)
|
||||||
let contentAllowed = contentBucket.allow(now: now)
|
let contentAllowed = contentBucket.allow(now: now)
|
||||||
contentBuckets[contentKey] = contentBucket
|
contentBuckets[contentKey] = contentBucket
|
||||||
|
|
||||||
return contentAllowed
|
return senderAllowed && 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.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
let isLong = message.content.isLongForDisplay()
|
||||||
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.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
if message.content.isLongForDisplay() {
|
||||||
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,6 +21,19 @@ 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,6 +606,25 @@ 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,6 +323,32 @@ 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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user