mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:25:20 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca18843bb0 | ||
|
|
593fd7d737 |
Generated
+1
@@ -337,6 +337,7 @@
|
||||
es,
|
||||
ar,
|
||||
de,
|
||||
fa,
|
||||
fr,
|
||||
he,
|
||||
id,
|
||||
|
||||
@@ -7,45 +7,146 @@ 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] = []
|
||||
private let geoNicknameCapacity: Int
|
||||
private var geoNicknameOrder: [String] = []
|
||||
|
||||
init(
|
||||
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
|
||||
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
|
||||
) {
|
||||
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
|
||||
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
|
||||
}
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
currentGeohash = geohash?.lowercased()
|
||||
let normalized = geohash?.lowercased()
|
||||
if currentGeohash != normalized {
|
||||
// Presence markers are scoped to the active geohash channel.
|
||||
clearTeleportedGeo()
|
||||
clearGeoNicknames()
|
||||
}
|
||||
currentGeohash = normalized
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||
guard geoNicknameCapacity > 0 else {
|
||||
clearGeoNicknames()
|
||||
return
|
||||
}
|
||||
|
||||
let key = pubkeyHex.lowercased()
|
||||
if geoNicknames[key] != nil {
|
||||
geoNicknames[key] = nickname
|
||||
return
|
||||
}
|
||||
|
||||
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
|
||||
geoNicknameOrder.removeFirst()
|
||||
geoNicknames.removeValue(forKey: oldest)
|
||||
}
|
||||
|
||||
geoNicknames[key] = nickname
|
||||
geoNicknameOrder.append(key)
|
||||
}
|
||||
|
||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||
geoNicknames = Dictionary(
|
||||
uniqueKeysWithValues: nicknames.map { key, value in
|
||||
(key.lowercased(), value)
|
||||
}
|
||||
)
|
||||
guard geoNicknameCapacity > 0 else {
|
||||
clearGeoNicknames()
|
||||
return
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
var normalized: [String: String] = [:]
|
||||
for (key, value) in nicknames {
|
||||
let lower = key.lowercased()
|
||||
guard seen.insert(lower).inserted else { continue }
|
||||
ordered.append(lower)
|
||||
normalized[lower] = value
|
||||
}
|
||||
if ordered.count > geoNicknameCapacity {
|
||||
let kept = Array(ordered.suffix(geoNicknameCapacity))
|
||||
ordered = kept
|
||||
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
|
||||
normalized[key].map { (key, $0) }
|
||||
})
|
||||
}
|
||||
geoNicknameOrder = ordered
|
||||
geoNicknames = normalized
|
||||
}
|
||||
|
||||
func clearGeoNicknames() {
|
||||
geoNicknames.removeAll()
|
||||
geoNicknameOrder.removeAll()
|
||||
}
|
||||
|
||||
func retainGeoNicknames(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
|
||||
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
|
||||
}
|
||||
|
||||
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()
|
||||
geoNicknameOrder.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
+3099
-1
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,10 @@ class NoiseSession {
|
||||
|
||||
// Only initiator writes the first message
|
||||
if role == .initiator {
|
||||
let message = try handshakeState!.writeMessage()
|
||||
guard let handshake = handshakeState else {
|
||||
throw NoiseSessionError.invalidState
|
||||
}
|
||||
let message = try handshake.writeMessage()
|
||||
sentHandshakeMessages.append(message)
|
||||
return message
|
||||
} else {
|
||||
|
||||
@@ -700,6 +700,10 @@ struct NostrEvent: Codable {
|
||||
let content = dict["content"] as? String else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
guard Self.isWithinInboundTagLimits(tags) else {
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
self.id = dict["id"] as? String ?? ""
|
||||
self.pubkey = pubkey
|
||||
@@ -709,6 +713,21 @@ struct NostrEvent: Codable {
|
||||
self.content = content
|
||||
self.sig = dict["sig"] as? String
|
||||
}
|
||||
|
||||
/// Bounds untrusted relay tag arrays so attackers cannot force large
|
||||
/// allocations or expensive joins on the inbound hot path.
|
||||
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
|
||||
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
|
||||
|
||||
for tag in tags {
|
||||
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
|
||||
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
|
||||
let (eventId, eventIdHash) = try calculateEventId()
|
||||
|
||||
@@ -1480,7 +1480,7 @@ private enum ParsedInbound {
|
||||
case notice(String)
|
||||
|
||||
init?(_ message: URLSessionWebSocketTask.Message) {
|
||||
guard let data = message.data,
|
||||
guard let data = message.dataWithinInboundLimit,
|
||||
let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
|
||||
array.count >= 2,
|
||||
let type = array[0] as? String else {
|
||||
@@ -1525,11 +1525,19 @@ private enum ParsedInbound {
|
||||
}
|
||||
|
||||
private extension URLSessionWebSocketTask.Message {
|
||||
var data: Data? {
|
||||
/// Prefer rejecting oversized frames before UTF-8/Data materialization
|
||||
/// where we can (string length), and always before JSON parse.
|
||||
var dataWithinInboundLimit: Data? {
|
||||
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
|
||||
switch self {
|
||||
case .string(let text): text.data(using: .utf8)
|
||||
case .data(let data): data
|
||||
@unknown default: nil
|
||||
case .string(let text):
|
||||
guard text.utf8.count <= maxBytes else { return nil }
|
||||
return text.data(using: .utf8)
|
||||
case .data(let data):
|
||||
guard data.count <= maxBytes else { return nil }
|
||||
return data
|
||||
@unknown default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// Thread-safe announce admission state.
|
||||
///
|
||||
/// Announce requests originate from the Bluetooth delegate queue, the
|
||||
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
|
||||
/// behind a lock makes admission and maintenance snapshots atomic when those
|
||||
/// request sources race.
|
||||
final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
struct BLEAnnounceThrottle {
|
||||
private var lastSent: Date
|
||||
private let normalMinimumInterval: TimeInterval
|
||||
private let forcedMinimumInterval: TimeInterval
|
||||
@@ -23,18 +16,16 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func elapsed(since now: Date) -> TimeInterval {
|
||||
lock.withLock { now.timeIntervalSince(lastSent) }
|
||||
now.timeIntervalSince(lastSent)
|
||||
}
|
||||
|
||||
func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
lock.withLock {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard elapsed(since: now) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||
let peerID: PeerID
|
||||
let peerIDData: Data
|
||||
let nickname: String
|
||||
}
|
||||
|
||||
/// Lock-backed local identity state shared by the transport's message,
|
||||
/// Bluetooth, maintenance, and main-actor entry points.
|
||||
///
|
||||
/// `peerID` and its binary wire representation must change as one unit during
|
||||
/// panic rotation. A snapshot also gives announce construction one consistent
|
||||
/// view of the nickname and identity instead of reading three independently
|
||||
/// mutable properties across queues.
|
||||
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var state: BLELocalIdentitySnapshot
|
||||
|
||||
init(
|
||||
peerID: PeerID = PeerID(str: ""),
|
||||
nickname: String = "anon"
|
||||
) {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
|
||||
func snapshot() -> BLELocalIdentitySnapshot {
|
||||
lock.withLock { state }
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: state.peerID,
|
||||
peerIDData: state.peerIDData,
|
||||
nickname: nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func replacePeerIdentity(with peerID: PeerID) {
|
||||
lock.withLock {
|
||||
state = BLELocalIdentitySnapshot(
|
||||
peerID: peerID,
|
||||
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||
nickname: state.nickname
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +134,7 @@ final class BLEService: NSObject {
|
||||
private let incomingFileStore = BLEIncomingFileStore()
|
||||
|
||||
// Simple announce throttling
|
||||
private let announceThrottle = BLEAnnounceThrottle()
|
||||
private var announceThrottle = BLEAnnounceThrottle()
|
||||
|
||||
// Application state tracking (thread-safe)
|
||||
#if os(iOS)
|
||||
@@ -162,7 +162,9 @@ final class BLEService: NSObject {
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let localIdentityState = BLELocalIdentityStateStore()
|
||||
/// Binary form of `myPeerID`; same contract — mutated only inside a
|
||||
/// `messageQueue` barrier via `refreshPeerIdentity()`.
|
||||
private var myPeerIDData: Data = Data()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
// No Local Name by default for maximum privacy. No rotating alias.
|
||||
@@ -415,7 +417,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func resetIdentityForPanic(currentNickname: String) {
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
pendingNoiseSessionQueues.removeAll()
|
||||
}
|
||||
|
||||
@@ -535,17 +537,20 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: Identity
|
||||
|
||||
/// Derived from the Noise identity fingerprint. Reads can originate from
|
||||
/// the main actor, message queue, Bluetooth queue, and maintenance timer,
|
||||
/// so all three local identity fields live in one lock-backed snapshot.
|
||||
var myPeerID: PeerID { localIdentityState.snapshot().peerID }
|
||||
var myNickname: String { localIdentityState.snapshot().nickname }
|
||||
private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData }
|
||||
/// Derived from the Noise identity fingerprint; rotated only via
|
||||
/// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
|
||||
/// inside a `messageQueue` barrier so concurrent queue work never sees a
|
||||
/// half-updated identity. Externally read-only — no out-of-band mutation
|
||||
/// may bypass that derivation.
|
||||
private(set) var myPeerID = PeerID(str: "")
|
||||
/// Externally read-only; mutate via `setNickname(_:)`, which also
|
||||
/// broadcasts the change to peers.
|
||||
private(set) var myNickname: String = "anon"
|
||||
|
||||
/// Sole mutator for `myNickname`: updates the stored value and force-sends
|
||||
/// an announce so peers learn the new name.
|
||||
func setNickname(_ nickname: String) {
|
||||
localIdentityState.setNickname(nickname)
|
||||
self.myNickname = nickname
|
||||
// Send announce to notify peers of nickname change (force send)
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
@@ -591,11 +596,10 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func stopServices() {
|
||||
let localIdentity = localIdentityState.snapshot()
|
||||
// Send leave message synchronously to ensure delivery
|
||||
var leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: localIdentity.peerIDData,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(),
|
||||
@@ -1633,16 +1637,6 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
private func sendAnnounce(forceSend: Bool = false) {
|
||||
// Announce construction reads the replaceable Noise service and several
|
||||
// related state snapshots. Serialize the whole operation with identity
|
||||
// rotation instead of letting CoreBluetooth and maintenance callbacks
|
||||
// execute it directly on their own queues.
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.sendAnnounceNow(forceSend: forceSend)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAnnounceNow(forceSend: Bool) {
|
||||
// Throttle announces to prevent flooding
|
||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||
return
|
||||
@@ -1662,9 +1656,8 @@ final class BLEService: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
let localIdentity = localIdentityState.snapshot()
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: localIdentity.nickname,
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub,
|
||||
directNeighbors: connectedPeerIDs,
|
||||
@@ -1680,7 +1673,7 @@ final class BLEService: NSObject {
|
||||
// Create packet with signature using the noise private key
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: localIdentity.peerIDData,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -1694,7 +1687,14 @@ final class BLEService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
broadcastPacket(signedPacket)
|
||||
// Call directly if on messageQueue, otherwise dispatch
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
broadcastPacket(signedPacket)
|
||||
} else {
|
||||
messageQueue.async { [weak self] in
|
||||
self?.broadcastPacket(signedPacket)
|
||||
}
|
||||
}
|
||||
// Ensure our own announce is included in sync state
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
|
||||
@@ -3358,9 +3358,8 @@ extension BLEService {
|
||||
private func refreshPeerIdentity() {
|
||||
let swap = {
|
||||
let fingerprint = self.noiseService.getIdentityFingerprint()
|
||||
self.localIdentityState.replacePeerIdentity(
|
||||
with: PeerID(str: fingerprint.prefix(16))
|
||||
)
|
||||
self.myPeerID = PeerID(str: fingerprint.prefix(16))
|
||||
self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
|
||||
self.meshTopology.reset()
|
||||
}
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
|
||||
@@ -251,9 +251,9 @@ final class MessageFormattingEngine {
|
||||
isSelf: Bool,
|
||||
isMentioned: Bool
|
||||
) -> AttributedString {
|
||||
// For very long content without special tokens, use plain formatting
|
||||
let containsCashu = containsCashuToken(content)
|
||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
||||
// For very long content, use plain formatting to avoid expensive
|
||||
// regex/detector work. Cashu presence must not disable this guard.
|
||||
if content.isOversizedForRichFormatting() {
|
||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ enum TransportConfig {
|
||||
static let privateChatCap: Int = 1337
|
||||
static let meshTimelineCap: Int = 1337
|
||||
static let geoTimelineCap: Int = 1337
|
||||
static let geoNicknameParticipantsCap: Int = 1337
|
||||
static let contentLRUCap: Int = 2000
|
||||
static let geoSamplingEventLRUCap: Int = 2000
|
||||
|
||||
@@ -81,6 +82,11 @@ enum TransportConfig {
|
||||
static let nostrDuplicateEventLogInterval: Int = 50
|
||||
// Sample interval for per-event debug logs on the inbound hot path.
|
||||
static let nostrInboundEventLogInterval: Int = 100
|
||||
// Reject oversized/untrusted relay frames before JSON parse / store.
|
||||
static let nostrMaxInboundMessageBytes: Int = 256 * 1024
|
||||
static let nostrMaxEventTags: Int = 64
|
||||
static let nostrMaxEventTagValues: Int = 16
|
||||
static let nostrMaxEventTagValueBytes: Int = 1024
|
||||
|
||||
// Conversation store diagnostics (field observability)
|
||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||
@@ -98,6 +104,12 @@ enum TransportConfig {
|
||||
static let uiSenderRateBucketRefillPerSec: Double = 1.0
|
||||
static let uiContentRateBucketCapacity: Double = 3
|
||||
static let uiContentRateBucketRefillPerSec: Double = 0.5
|
||||
// Bound attacker-keyed bucket maps (sender IDs / content digests).
|
||||
static let uiSenderRateBucketMaxEntries: Int = 2000
|
||||
static let uiContentRateBucketMaxEntries: Int = 2000
|
||||
static let uiRateBucketIdleTTL: TimeInterval = 10 * 60
|
||||
// Cap teleported-participant markers so remote events cannot grow the set.
|
||||
static let geoTeleportedParticipantsCap: Int = 1337
|
||||
|
||||
// UI sleeps/delays
|
||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// In-app override for the UI language, on top of the system per-app
|
||||
/// language. Apple resolves localization from the AppleLanguages default at
|
||||
/// process start, so a new choice takes effect on the next launch — callers
|
||||
/// surface a "restart to apply" note after changing it.
|
||||
enum AppLanguageSettings {
|
||||
/// "" means no override: follow the device (or per-app system) language.
|
||||
static let overrideKey = "app.languageOverride"
|
||||
private static let appleLanguagesKey = "AppleLanguages"
|
||||
|
||||
/// Language codes the app ships translations for, straight from the
|
||||
/// built bundle so this never drifts from the string catalog.
|
||||
static var availableLanguages: [String] {
|
||||
Bundle.main.localizations
|
||||
.filter { $0 != "Base" }
|
||||
.sorted { endonym(for: $0).localizedCaseInsensitiveCompare(endonym(for: $1)) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// The language's name in that language ("فارسی", "한국어") so every user
|
||||
/// can find their own entry regardless of the current UI language.
|
||||
static func endonym(for code: String) -> String {
|
||||
let locale = Locale(identifier: code)
|
||||
let name = locale.localizedString(forIdentifier: code) ?? code
|
||||
return name.lowercased(with: locale)
|
||||
}
|
||||
|
||||
/// Persists the override (nil clears it). AppleLanguages drives the
|
||||
/// actual localization lookup on next launch.
|
||||
static func setOverride(_ code: String?) {
|
||||
let defaults = UserDefaults.standard
|
||||
if let code, !code.isEmpty {
|
||||
defaults.set(code, forKey: overrideKey)
|
||||
defaults.set([code], forKey: appleLanguagesKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: overrideKey)
|
||||
defaults.removeObject(forKey: appleLanguagesKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,12 +71,8 @@ final class ChatMessageFormatter {
|
||||
let content = message.content
|
||||
let nsContent = content as NSString
|
||||
let nsLen = nsContent.length
|
||||
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 {
|
||||
if content.isOversizedForRichFormatting() {
|
||||
var plainStyle = AttributeContainer()
|
||||
plainStyle.foregroundColor = baseColor
|
||||
plainStyle.font = isSelf
|
||||
|
||||
@@ -1183,6 +1183,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
identityManager.clearAllIdentityData()
|
||||
peerIdentityStore.clearAll()
|
||||
locationPresenceStore.reset()
|
||||
publicRateLimiter.reset()
|
||||
|
||||
// Clear persistent favorites from keychain
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
|
||||
@@ -156,6 +156,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
|
||||
let visible = Set(people.map { $0.id })
|
||||
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
|
||||
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
}
|
||||
|
||||
func loadPersistedViewState() {
|
||||
|
||||
@@ -26,6 +26,10 @@ struct MessageRateLimiter {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isIdle(since now: Date, idleTTL: TimeInterval) -> Bool {
|
||||
now.timeIntervalSince(lastRefill) >= idleTTL
|
||||
}
|
||||
}
|
||||
|
||||
private var senderBuckets: [String: TokenBucket] = [:]
|
||||
@@ -35,17 +39,26 @@ 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
|
||||
contentRefillPerSec: Double,
|
||||
maxSenderBuckets: Int = TransportConfig.uiSenderRateBucketMaxEntries,
|
||||
maxContentBuckets: Int = TransportConfig.uiContentRateBucketMaxEntries,
|
||||
bucketIdleTTL: TimeInterval = TransportConfig.uiRateBucketIdleTTL
|
||||
) {
|
||||
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
|
||||
}
|
||||
|
||||
/// - Parameter powBits: validated NIP-13 difficulty of the event
|
||||
@@ -58,25 +71,83 @@ struct MessageRateLimiter {
|
||||
if powBits >= NostrPoW.rateLimitBypassBits {
|
||||
senderAllowed = true
|
||||
} else {
|
||||
var senderBucket = senderBuckets[senderKey] ?? TokenBucket(
|
||||
var senderBucket = Self.bucket(
|
||||
for: senderKey,
|
||||
in: &senderBuckets,
|
||||
capacity: senderCapacity,
|
||||
tokens: senderCapacity,
|
||||
refillPerSec: senderRefill,
|
||||
lastRefill: now
|
||||
maxBuckets: maxSenderBuckets,
|
||||
idleTTL: bucketIdleTTL,
|
||||
now: now
|
||||
)
|
||||
senderAllowed = senderBucket.allow(now: now)
|
||||
senderBuckets[senderKey] = senderBucket
|
||||
}
|
||||
|
||||
var contentBucket = contentBuckets[contentKey] ?? TokenBucket(
|
||||
// Rejected senders must not mint attacker-keyed content entries.
|
||||
guard senderAllowed else { return false }
|
||||
|
||||
var contentBucket = Self.bucket(
|
||||
for: contentKey,
|
||||
in: &contentBuckets,
|
||||
capacity: contentCapacity,
|
||||
tokens: contentCapacity,
|
||||
refillPerSec: contentRefill,
|
||||
lastRefill: now
|
||||
maxBuckets: maxContentBuckets,
|
||||
idleTTL: bucketIdleTTL,
|
||||
now: now
|
||||
)
|
||||
let contentAllowed = contentBucket.allow(now: now)
|
||||
contentBuckets[contentKey] = contentBucket
|
||||
|
||||
return senderAllowed && contentAllowed
|
||||
return contentAllowed
|
||||
}
|
||||
|
||||
mutating func reset() {
|
||||
senderBuckets.removeAll()
|
||||
contentBuckets.removeAll()
|
||||
}
|
||||
|
||||
var bucketCountsForTesting: (sender: Int, content: Int) {
|
||||
(senderBuckets.count, contentBuckets.count)
|
||||
}
|
||||
|
||||
// Static so we can take `inout` on a stored dictionary without overlapping
|
||||
// exclusive access through a mutating method on `self`.
|
||||
private static func bucket(
|
||||
for key: String,
|
||||
in buckets: inout [String: TokenBucket],
|
||||
capacity: Double,
|
||||
refillPerSec: Double,
|
||||
maxBuckets: Int,
|
||||
idleTTL: TimeInterval,
|
||||
now: Date
|
||||
) -> TokenBucket {
|
||||
if let existing = buckets[key] {
|
||||
return existing
|
||||
}
|
||||
|
||||
evictIfNeeded(from: &buckets, maxBuckets: maxBuckets, idleTTL: idleTTL, now: now)
|
||||
return TokenBucket(
|
||||
capacity: capacity,
|
||||
tokens: capacity,
|
||||
refillPerSec: refillPerSec,
|
||||
lastRefill: now
|
||||
)
|
||||
}
|
||||
|
||||
private static func evictIfNeeded(
|
||||
from buckets: inout [String: TokenBucket],
|
||||
maxBuckets: Int,
|
||||
idleTTL: TimeInterval,
|
||||
now: Date
|
||||
) {
|
||||
guard buckets.count >= maxBuckets else { return }
|
||||
|
||||
buckets = buckets.filter { !$0.value.isIdle(since: now, idleTTL: idleTTL) }
|
||||
guard buckets.count >= maxBuckets else { return }
|
||||
|
||||
if let oldestKey = buckets.min(by: { $0.value.lastRefill < $1.value.lastRefill })?.key {
|
||||
buckets.removeValue(forKey: oldestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,10 @@ final class NostrInboundPipeline {
|
||||
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
||||
geoEventLogCount += 1
|
||||
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
||||
SecureLogger.debug(
|
||||
"GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… pow=\(powBits) tagCount=\(event.tags.count)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
|
||||
@@ -26,6 +26,10 @@ struct AppInfoView: View {
|
||||
/// introduction), and afterwards the sheet reopens wherever it was left.
|
||||
@AppStorage("appInfo.selectedPane") private var selectedPane: Pane = .info
|
||||
@State private var showPanicConfirmation = false
|
||||
@AppStorage(AppLanguageSettings.overrideKey) private var languageOverride = ""
|
||||
/// The override changed this session; localization resolves at process
|
||||
/// start, so surface the restart hint.
|
||||
@State private var showLanguageRestartNote = false
|
||||
|
||||
private enum Pane: String {
|
||||
case settings
|
||||
@@ -55,6 +59,11 @@ struct AppInfoView: View {
|
||||
|
||||
static let connectivityTitle = String(localized: "app_info.settings.connectivity.title", defaultValue: "CONNECTIVITY", comment: "Section header (uppercase) for the connectivity toggles: mesh bridge, internet gateway, tor routing")
|
||||
|
||||
static let languageTitle = String(localized: "app_info.settings.language.title", defaultValue: "LANGUAGE", comment: "Section header (uppercase) for the app language picker in settings")
|
||||
static let languagePickerLabel = String(localized: "app_info.settings.language.picker_label", defaultValue: "app language", comment: "Label of the app language picker row in settings")
|
||||
static let languageSystem = String(localized: "app_info.settings.language.system", defaultValue: "system default", comment: "Menu option that clears the in-app language override so the app follows the device language")
|
||||
static let languageRestartNote = String(localized: "app_info.settings.language.restart_note", defaultValue: "restart bitchat to apply the new language", comment: "Caption shown after the user picks a different app language; the change takes effect on next launch")
|
||||
|
||||
static let bridgeTitle = String(localized: "app_info.settings.bridge.title", defaultValue: "mesh bridge", comment: "Title of the mesh bridge toggle in settings")
|
||||
static let bridgeSubtitle = String(localized: "app_info.settings.bridge.subtitle", defaultValue: "joins nearby mesh islands over the internet: what you say in the mesh channel also reaches people in your area beyond radio range, and their messages appear here marked with the network glyph. while you have internet, your device also carries bridge and location-channel traffic for phones around you that have none.", comment: "Subtitle explaining what the mesh bridge toggle does")
|
||||
static func bridgeCell(_ cell: String) -> String {
|
||||
@@ -313,6 +322,52 @@ struct AppInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Language — an in-app override so the UI language can differ
|
||||
// from the device language (takes effect on next launch).
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(verbatim: Strings.Settings.languageTitle)
|
||||
|
||||
settingsCard {
|
||||
Menu {
|
||||
Button {
|
||||
selectLanguage(nil)
|
||||
} label: {
|
||||
menuItemLabel(Strings.Settings.languageSystem, isSelected: languageOverride.isEmpty)
|
||||
}
|
||||
Divider()
|
||||
ForEach(AppLanguageSettings.availableLanguages, id: \.self) { code in
|
||||
Button {
|
||||
selectLanguage(code)
|
||||
} label: {
|
||||
menuItemLabel(AppLanguageSettings.endonym(for: code), isSelected: languageOverride == code)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(Strings.Settings.languagePickerLabel)
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
Text(languageOverride.isEmpty ? Strings.Settings.languageSystem : AppLanguageSettings.endonym(for: languageOverride))
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.accent)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if showLanguageRestartNote {
|
||||
Text(Strings.Settings.languageRestartNote)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Voice — same card + IRC pill as every other toggle setting.
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(Strings.Voice.title)
|
||||
@@ -458,6 +513,24 @@ struct AppInfoView: View {
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func selectLanguage(_ code: String?) {
|
||||
let previous = languageOverride
|
||||
AppLanguageSettings.setOverride(code)
|
||||
languageOverride = code ?? ""
|
||||
if languageOverride != previous {
|
||||
showLanguageRestartNote = true
|
||||
}
|
||||
}
|
||||
|
||||
private func menuItemLabel(_ title: String, isSelected: Bool) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bridgeToggleBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { bridgeService.isEnabled },
|
||||
|
||||
@@ -41,7 +41,7 @@ struct TextMessageView: View {
|
||||
// first text line; a fixed top padding left the lock's solid body
|
||||
// hanging below the line's visual center.
|
||||
HStack(alignment: .firstTextBaseline, 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)
|
||||
if message.isPrivate {
|
||||
Image(systemName: "lock.fill")
|
||||
@@ -103,7 +103,7 @@ struct TextMessageView: View {
|
||||
}
|
||||
|
||||
// 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 labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||
Button(labelKey) {
|
||||
|
||||
@@ -21,6 +21,26 @@ extension String {
|
||||
return current >= threshold
|
||||
}
|
||||
|
||||
/// True when the message should collapse behind Show more in the UI.
|
||||
/// Length alone decides this — embedding a Cashu-looking token must not
|
||||
/// disable the guard (remote DoS via unbounded layout).
|
||||
func isLongForDisplay(
|
||||
lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold,
|
||||
tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold
|
||||
) -> Bool {
|
||||
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||
}
|
||||
|
||||
/// True when rich formatting (regex / link detectors) should be skipped.
|
||||
/// Cashu presence used to exempt oversized content from the plain path;
|
||||
/// that let untrusted input force expensive formatting work.
|
||||
func isOversizedForRichFormatting(
|
||||
lengthThreshold: Int = 4000,
|
||||
tokenThreshold: Int = 1024
|
||||
) -> Bool {
|
||||
count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold)
|
||||
}
|
||||
|
||||
// Extract up to `max` distinct Cashu tokens (cashuA/cashuB), as the bare
|
||||
// bearer strings. Allow dot '.' and shorter lengths. The `cashu:` URI
|
||||
// form matches too — the token embedded after the scheme is the match.
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
"comment" : "Fallback title when saving a shared link"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "پیوند اشتراکگذاریشده"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
@@ -233,6 +239,12 @@
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "کدگذاری پیوند ناموفق بود"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
@@ -428,6 +440,12 @@
|
||||
"comment" : "Shown when provided content cannot be shared"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "محتوای قابل اشتراکگذاری وجود ندارد"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
@@ -623,6 +641,12 @@
|
||||
"comment" : "Shown when the share extension receives no content"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "چیزی برای اشتراکگذاری نیست"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
@@ -818,6 +842,12 @@
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
@@ -1013,6 +1043,12 @@
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
|
||||
@@ -147,6 +147,44 @@ 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("LocationPresenceStore bounds geohash nicknames and clears on channel switch")
|
||||
@MainActor
|
||||
func locationPresenceStoreBoundsGeoNicknames() {
|
||||
let store = LocationPresenceStore(geoNicknameCapacity: 2)
|
||||
|
||||
store.setCurrentGeohash("u4pruy")
|
||||
store.setNickname("alice", for: "AAAAAA")
|
||||
store.setNickname("bob", for: "BBBBBB")
|
||||
store.setNickname("carol", for: "CCCCCC")
|
||||
|
||||
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
|
||||
|
||||
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
|
||||
#expect(store.geoNicknames == ["cccccc": "carol"])
|
||||
|
||||
store.setCurrentGeohash("u4pruz")
|
||||
#expect(store.geoNicknames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("PeerHandle equality and hashing use the canonical identity only")
|
||||
func peerHandleEqualityUsesCanonicalIdentity() {
|
||||
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
|
||||
|
||||
@@ -646,6 +646,25 @@ struct ChatViewModelFormattingTests {
|
||||
#expect(String(formatted.characters) == "<@Alice#a1b2> hello #mesh [\(message.formattedTimestamp)]")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func formatMessageAsText_longCashuFallsBackToPlain() 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)]")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func formatMessageHeader_formatsSenderHeader() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
@@ -323,6 +323,32 @@ struct MessageFormattingEngineTests {
|
||||
// Exactly at threshold DOES trigger (uses >= comparison)
|
||||
#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
|
||||
|
||||
@@ -116,4 +116,87 @@ struct MessageRateLimiterTests {
|
||||
#expect(plain)
|
||||
#expect(!plainExhausted)
|
||||
}
|
||||
|
||||
@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()
|
||||
|
||||
let first = limiter.allow(senderKey: "sender", contentKey: "content-0", now: now)
|
||||
var rejected = true
|
||||
for index in 1...100 {
|
||||
if limiter.allow(senderKey: "sender", contentKey: "content-\(index)", now: now) {
|
||||
rejected = false
|
||||
}
|
||||
}
|
||||
|
||||
#expect(first)
|
||||
#expect(rejected)
|
||||
#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 {
|
||||
_ = limiter.allow(
|
||||
senderKey: "sender-\(index)",
|
||||
contentKey: "content-\(index)",
|
||||
now: now.addingTimeInterval(TimeInterval(index))
|
||||
)
|
||||
}
|
||||
|
||||
#expect(limiter.bucketCountsForTesting.sender == maxEntries)
|
||||
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||
}
|
||||
|
||||
@Test("PoW bypass still creates content buckets under the cap")
|
||||
func powBypassCreatesBoundedContentBuckets() {
|
||||
let maxEntries = 3
|
||||
var limiter = MessageRateLimiter(
|
||||
senderCapacity: 1,
|
||||
senderRefillPerSec: 0,
|
||||
contentCapacity: 100,
|
||||
contentRefillPerSec: 0,
|
||||
maxSenderBuckets: maxEntries,
|
||||
maxContentBuckets: maxEntries,
|
||||
bucketIdleTTL: 60
|
||||
)
|
||||
let now = Date()
|
||||
|
||||
var allAllowed = true
|
||||
for index in 0..<10 {
|
||||
let allowed = limiter.allow(
|
||||
senderKey: "sender",
|
||||
contentKey: "content-\(index)",
|
||||
powBits: NostrPoW.rateLimitBypassBits,
|
||||
now: now.addingTimeInterval(TimeInterval(index))
|
||||
)
|
||||
if !allowed { allAllowed = false }
|
||||
}
|
||||
|
||||
#expect(allAllowed)
|
||||
#expect(limiter.bucketCountsForTesting.sender == 0)
|
||||
#expect(limiter.bucketCountsForTesting.content == maxEntries)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +290,65 @@ struct NostrProtocolTests {
|
||||
#expect(object["limit"] as? Int == 42)
|
||||
}
|
||||
|
||||
|
||||
@Test func inboundNostrEventRejectsTooManyTags() throws {
|
||||
var eventDict = Self.validInboundEventDict()
|
||||
eventDict["tags"] = Array(
|
||||
repeating: ["g", "u4pruyd"],
|
||||
count: TransportConfig.nostrMaxEventTags + 1
|
||||
)
|
||||
|
||||
#expect(throws: NostrError.invalidEvent) {
|
||||
_ = try NostrEvent(from: eventDict)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func inboundNostrEventRejectsTooManyTagValues() throws {
|
||||
var eventDict = Self.validInboundEventDict()
|
||||
eventDict["tags"] = [Array(
|
||||
repeating: "value",
|
||||
count: TransportConfig.nostrMaxEventTagValues + 1
|
||||
)]
|
||||
|
||||
#expect(throws: NostrError.invalidEvent) {
|
||||
_ = try NostrEvent(from: eventDict)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func inboundNostrEventRejectsOversizedTagValues() throws {
|
||||
var eventDict = Self.validInboundEventDict()
|
||||
eventDict["tags"] = [[
|
||||
"g",
|
||||
String(repeating: "a", count: TransportConfig.nostrMaxEventTagValueBytes + 1)
|
||||
]]
|
||||
|
||||
#expect(throws: NostrError.invalidEvent) {
|
||||
_ = try NostrEvent(from: eventDict)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func inboundNostrEventAcceptsTagsWithinLimits() throws {
|
||||
var eventDict = Self.validInboundEventDict()
|
||||
eventDict["tags"] = [["g", "u4pruyd"], ["t", "teleport"]]
|
||||
|
||||
let event = try NostrEvent(from: eventDict)
|
||||
|
||||
#expect(event.tags.count == 2)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func validInboundEventDict() -> [String: Any] {
|
||||
[
|
||||
"id": String(repeating: "0", count: 64),
|
||||
"pubkey": String(repeating: "1", count: 64),
|
||||
"created_at": 1_234_567,
|
||||
"kind": NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
"tags": [["g", "u4pruyd"]],
|
||||
"content": "hello",
|
||||
"sig": String(repeating: "2", count: 128)
|
||||
]
|
||||
}
|
||||
|
||||
private static func base64URLDecode(_ s: String) -> Data? {
|
||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||
let rem = str.count % 4
|
||||
|
||||
@@ -5,7 +5,7 @@ import Testing
|
||||
struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func firstAnnounceIsAllowed() {
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
||||
|
||||
@@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func regularAnnounceUsesNormalMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
||||
@@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func forcedAnnounceUsesShorterMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
||||
@@ -43,40 +43,10 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func elapsedReportsTimeSinceAcceptedSend() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
_ = throttle.shouldSend(force: false, now: now)
|
||||
|
||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||
}
|
||||
|
||||
@Test
|
||||
func concurrentRequestsAdmitOnlyOneAnnounce() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(
|
||||
normalMinimumInterval: 10,
|
||||
forcedMinimumInterval: 2
|
||||
)
|
||||
let accepted = LockedCounter()
|
||||
|
||||
DispatchQueue.concurrentPerform(iterations: 1_000) { _ in
|
||||
if throttle.shouldSend(force: false, now: now) {
|
||||
accepted.increment()
|
||||
}
|
||||
}
|
||||
|
||||
#expect(accepted.value == 1)
|
||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedCounter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var count = 0
|
||||
|
||||
var value: Int { lock.withLock { count } }
|
||||
|
||||
func increment() {
|
||||
lock.withLock { count += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLELocalIdentityStateStoreTests {
|
||||
@Test
|
||||
func identityReplacementUpdatesWireBytesAtomically() throws {
|
||||
let initial = PeerID(str: "0011223344556677")
|
||||
let replacement = PeerID(str: "8899aabbccddeeff")
|
||||
let store = BLELocalIdentityStateStore(peerID: initial, nickname: "alice")
|
||||
|
||||
store.replacePeerIdentity(with: replacement)
|
||||
|
||||
let snapshot = store.snapshot()
|
||||
#expect(snapshot.peerID == replacement)
|
||||
#expect(snapshot.peerIDData == Data(hexString: replacement.id))
|
||||
#expect(snapshot.nickname == "alice")
|
||||
}
|
||||
|
||||
@Test
|
||||
func concurrentReadsNeverObserveSplitIdentityState() {
|
||||
let peerIDs = [
|
||||
PeerID(str: "0011223344556677"),
|
||||
PeerID(str: "8899aabbccddeeff")
|
||||
]
|
||||
let store = BLELocalIdentityStateStore(peerID: peerIDs[0], nickname: "alice")
|
||||
let failures = LockedFailureRecorder()
|
||||
|
||||
DispatchQueue.concurrentPerform(iterations: 2_000) { index in
|
||||
if index.isMultiple(of: 2) {
|
||||
store.replacePeerIdentity(with: peerIDs[index % peerIDs.count])
|
||||
} else {
|
||||
store.setNickname(index.isMultiple(of: 3) ? "alice" : "bob")
|
||||
}
|
||||
|
||||
let snapshot = store.snapshot()
|
||||
let expectedWireID = Data(hexString: snapshot.peerID.id) ?? Data()
|
||||
if snapshot.peerIDData != expectedWireID {
|
||||
failures.record()
|
||||
}
|
||||
}
|
||||
|
||||
#expect(!failures.hasFailure)
|
||||
}
|
||||
}
|
||||
|
||||
private final class LockedFailureRecorder: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var failed = false
|
||||
|
||||
var hasFailure: Bool { lock.withLock { failed } }
|
||||
|
||||
func record() {
|
||||
lock.withLock { failed = true }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user