String trimming in a centralized manner (#1079)

* String trimming in a centralized manner

* Fix empty-nickname case
This commit is contained in:
Islam
2026-04-04 15:56:39 -05:00
committed by GitHub
parent b5834cfc76
commit 473f5c7cce
18 changed files with 81 additions and 79 deletions
+5 -3
View File
@@ -42,9 +42,11 @@ extension BitchatMessage {
guard let baseDirectory = Cache.shared.filesDir else { return nil }
func url(for category: MimeType.Category) -> URL? {
guard content.hasPrefix(category.messagePrefix) else { return nil }
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !filename.isEmpty else { return nil }
guard content.hasPrefix(category.messagePrefix),
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
else {
return nil
}
// Check outgoing first for sent messages, incoming for received
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
+1 -1
View File
@@ -338,7 +338,7 @@ extension BitchatMessage {
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
let arr = filter { $0.content.trimmed.isEmpty == false }
guard arr.count > 1 else {
return arr
}
+2 -3
View File
@@ -377,10 +377,9 @@ final class GeoRelayDirectory {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
guard let line = raw.trimmedOrNilIfEmpty else { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
let parts = line.split(separator: ",").map { $0.trimmed }
guard parts.count >= 3 else { continue }
var host = parts[0]
host = host.replacingOccurrences(of: "https://", with: "")
+2 -2
View File
@@ -109,7 +109,7 @@ struct NostrProtocol {
teleported: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if teleported {
@@ -152,7 +152,7 @@ struct NostrProtocol {
nickname: String? = nil
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
let event = NostrEvent(
+1 -1
View File
@@ -28,7 +28,7 @@ extension Data {
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) {
var hex = hexString.trimmingCharacters(in: .whitespaces)
var hex = hexString.trimmed
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
+1 -1
View File
@@ -1354,7 +1354,7 @@ final class BLEService: NSObject {
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
candidate = candidate.trimmed
if candidate.isEmpty { candidate = defaultName }
// Security: Reject dotfiles (hidden file attacks)
+4 -4
View File
@@ -166,7 +166,7 @@ final class CommandProcessor {
}
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /\(command) <nickname>")
}
@@ -209,7 +209,7 @@ final class CommandProcessor {
}
private func handleBlock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
if targetName.isEmpty {
// List blocked users (mesh) and geohash (Nostr) blocks
@@ -284,7 +284,7 @@ final class CommandProcessor {
}
private func handleUnblock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /unblock <nickname>")
}
@@ -311,7 +311,7 @@ final class CommandProcessor {
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces)
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
}
+2 -3
View File
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
var displayName: String {
let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let nick = nickname?.trimmedOrNilIfEmpty {
return "\(nick)#\(suffix)"
}
return "anon#\(suffix)"
@@ -199,8 +199,7 @@ final class LocationNotesManager: ObservableObject {
/// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else {
state = .noRelays
+1 -1
View File
@@ -597,7 +597,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmed
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
@@ -145,7 +145,7 @@ enum ContentNormalizer {
}
// Trim and collapse whitespace
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmed = simplified.trimmed
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
// Take prefix and hash
+1 -3
View File
@@ -21,9 +21,7 @@ struct InputValidator {
/// Rejects strings containing control characters to prevent potential security issues
/// and UI rendering problems. This strict approach ensures data integrity at input time.
static func validateUserString(_ string: String, maxLength: Int) -> String? {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed.count <= maxLength else { return nil }
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
// Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering.
+22
View File
@@ -0,0 +1,22 @@
//
// String+Ext.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
extension StringProtocol {
var trimmed: String {
trimmingCharacters(in: .whitespacesAndNewlines)
}
var trimmedOrNilIfEmpty: String? {
let trimmed = self.trimmed
return trimmed.isEmpty ? nil : trimmed
}
var nilIfEmpty: Self? {
isEmpty ? nil : self
}
}
+11 -24
View File
@@ -144,10 +144,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" {
didSet {
// Trim whitespace whenever nickname is set
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
if trimmed != nickname {
nickname = trimmed
return
}
// Update mesh service nickname if it's initialized
if !meshService.myPeerID.isEmpty {
@@ -747,8 +748,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private func loadNickname() {
if let savedNickname = userDefaults.string(forKey: nicknameKey) {
// Trim whitespace when loading
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
nickname = savedNickname.trimmed
} else {
nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname()
@@ -764,20 +764,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
}
func validateAndSaveNickname() {
// Trim whitespace from nickname
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
// Check if nickname is empty after trimming
if trimmed.isEmpty {
nickname = "anon\(Int.random(in: 1000...9999))"
} else {
nickname = trimmed
}
nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))"
saveNickname()
}
// MARK: - Favorites Management
// MARK: - Blocked Users Management (Delegated to PeerStateManager)
@@ -998,8 +988,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
@MainActor
func sendMessage(_ content: String) {
// Ignore messages that are empty or whitespace-only to prevent blank lines
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
// Check for commands
if content.hasPrefix("/") {
@@ -3002,7 +2991,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
Task { @MainActor in
// Early validation
guard !isMessageBlocked(message) else { return }
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
// Route to appropriate handler
if message.isPrivate {
@@ -3175,7 +3164,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = content.trimmed
let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage(
id: messageID,
@@ -3783,10 +3772,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
// Removed background nudge notification for generic "new chats!"
// Append via batching buffer (skip empty content) with simple dedup by ID
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if !messages.contains(where: { $0.id == finalMessage.id }) {
publicMessagePipeline.enqueue(finalMessage)
}
if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) {
publicMessagePipeline.enqueue(finalMessage)
}
}
@@ -77,7 +77,7 @@ extension ChatViewModel {
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
let nick = nickTag[1].trimmed
geoNicknames[event.pubkey.lowercased()] = nick
}
@@ -115,8 +115,8 @@ extension ChatViewModel {
}
let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
let content = event.content.trimmed
// Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
@@ -192,7 +192,7 @@ extension ChatViewModel {
case .mesh:
refreshVisibleMessages(from: .mesh)
// Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
@@ -308,8 +308,7 @@ extension ChatViewModel {
// Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
geoNicknames[event.pubkey.lowercased()] = nick
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
}
// Store mapping for geohash DM initiation
@@ -325,8 +324,10 @@ extension ChatViewModel {
let content = event.content
// If this is a teleport presence event (no content), don't add to timeline
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
@@ -496,9 +497,8 @@ extension ChatViewModel {
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
guard let content = event.content.trimmedOrNilIfEmpty else { return }
// Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
@@ -548,13 +548,15 @@ extension ChatViewModel {
func cleanupLocalFile(forMessage message: BitchatMessage) {
// Check both outgoing and incoming directories for thorough cleanup
let categories: [MimeType.Category] = [.audio, .image, .file]
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }) else { return }
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
// Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
let base = try? applicationFilesDirectory(),
// Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
safeFilename != "." && safeFilename != ".."
else {
return
}
// Try all possible locations (outgoing and incoming)
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
+3 -9
View File
@@ -361,8 +361,7 @@ struct ContentView: View {
}()
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
let trimmed = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let trimmed = message.content.trimmedOrNilIfEmpty else { return nil }
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
}
@@ -766,8 +765,7 @@ struct ContentView: View {
// MARK: - Actions
private func sendMessage() {
let trimmed = trimmedMessageText
guard !trimmed.isEmpty else { return }
guard let trimmed = messageText.trimmedOrNilIfEmpty else { return }
// Clear input immediately for instant feedback
messageText = ""
@@ -1537,10 +1535,6 @@ private extension ContentView {
)
}
private var trimmedMessageText: String {
messageText.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var shouldShowMediaControls: Bool {
if let peer = viewModel.selectedPrivateChatPeer, !(peer.isGeoDM || peer.isGeoChat) {
return true
@@ -1601,7 +1595,7 @@ private extension ContentView {
@ViewBuilder
var sendOrMicButton: some View {
let hasText = !trimmedMessageText.isEmpty
let hasText = !messageText.trimmed.isEmpty
if shouldShowVoiceControl {
ZStack {
micButtonView
+2 -2
View File
@@ -302,7 +302,7 @@ struct LocationChannelsSheet: View {
}
}
let normalized = customGeohash
.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmed
.lowercased()
.replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized)
@@ -449,7 +449,7 @@ struct LocationChannelsSheet: View {
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
let prefix = String(s[..<idx]).trimmed
let suffix = String(s[idx...])
return (prefix, suffix)
}
+2 -3
View File
@@ -264,14 +264,13 @@ struct LocationNotesView: View {
}
private func send() {
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
guard let content = draft.trimmedOrNilIfEmpty else { return }
manager.send(content: content, nickname: viewModel.nickname)
draft = ""
}
private var sendButtonEnabled: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
!draft.trimmed.isEmpty && manager.state != .noRelays
}
// MARK: - Timestamp Formatting