mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
* Refactor BLE transport event handling * Make image output paths unique * Keep queued Nostr read receipts alive * Refine BLE ingress fanout * Rediscover BLE service after invalidation * Extract BLE notification retry buffer * Extract BLE inbound write buffer * Extract BLE fragment assembly buffer * Tidy secure log handling from device run * Extract BLE outbound fragment scheduler * Harden app CI media tests * Redact BLE message content from logs * Extract BLE Noise session queues * Fix BLE read receipt UI updates * Allow self-authored RSR ingress replies * Harden read receipt queue test timing * Extract BLE outbound policy and incoming file storage * Avoid duplicate BLE link snapshots during send * Canonicalize Nostr relay URLs --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
169 lines
7.2 KiB
Swift
169 lines
7.2 KiB
Swift
import BitLogger
|
|
import BitFoundation
|
|
import Foundation
|
|
|
|
struct BLEIncomingFileStore {
|
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
|
|
|
private let fileManager: FileManager
|
|
private let baseDirectory: URL?
|
|
private let dateProvider: () -> Date
|
|
|
|
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
|
self.fileManager = fileManager
|
|
self.baseDirectory = baseDirectory
|
|
self.dateProvider = dateProvider
|
|
}
|
|
|
|
func save(
|
|
data: Data,
|
|
preferredName: String?,
|
|
subdirectory: String,
|
|
fallbackExtension: String?,
|
|
defaultPrefix: String
|
|
) -> URL? {
|
|
do {
|
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
|
let sanitized = sanitizedFileName(
|
|
preferredName,
|
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
|
fallbackExtension: fallbackExtension
|
|
)
|
|
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
|
try data.write(to: destination, options: .atomic)
|
|
return destination
|
|
} catch {
|
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func enforceQuota(reservingBytes: Int) {
|
|
do {
|
|
let base = try filesDirectory()
|
|
let incomingDirs = [
|
|
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
|
base.appendingPathComponent("images/incoming", isDirectory: true),
|
|
base.appendingPathComponent("files/incoming", isDirectory: true)
|
|
]
|
|
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
|
|
|
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
|
guard let contents = try? fileManager.contentsOfDirectory(
|
|
at: dir,
|
|
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else { continue }
|
|
|
|
for fileURL in contents {
|
|
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
|
let size = attrs.fileSize,
|
|
let modified = attrs.contentModificationDate else { continue }
|
|
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
|
}
|
|
}
|
|
|
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
|
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
|
guard currentUsage > targetUsage else { return }
|
|
|
|
let needToFree = currentUsage - targetUsage
|
|
var freedSpace: Int64 = 0
|
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
|
guard freedSpace < needToFree else { break }
|
|
do {
|
|
try fileManager.removeItem(at: file.url)
|
|
freedSpace += file.size
|
|
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
|
} catch {
|
|
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
|
}
|
|
}
|
|
|
|
if freedSpace > 0 {
|
|
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
|
}
|
|
} catch {
|
|
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
|
}
|
|
}
|
|
|
|
private func filesDirectory() throws -> URL {
|
|
let root = try baseDirectory ?? fileManager.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
return filesDir
|
|
}
|
|
|
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
|
var candidate = (name ?? "")
|
|
.replacingOccurrences(of: "\0", with: "")
|
|
.precomposedStringWithCanonicalMapping
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "\\", with: "_")
|
|
|
|
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
|
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
|
if candidate.isEmpty { candidate = defaultName }
|
|
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
|
|
|
if candidate.count > 120 {
|
|
let ext = (candidate as NSString).pathExtension
|
|
let base = (candidate as NSString).deletingPathExtension
|
|
candidate = ext.isEmpty
|
|
? String(candidate.prefix(120))
|
|
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
|
}
|
|
|
|
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
|
candidate += ".\(fallbackExtension)"
|
|
}
|
|
|
|
return candidate.isEmpty ? defaultName : candidate
|
|
}
|
|
|
|
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
|
let directoryPath = directory.standardizedFileURL.path
|
|
func isInsideDirectory(_ url: URL) -> Bool {
|
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
|
}
|
|
|
|
var candidate = directory.appendingPathComponent(fileName)
|
|
guard isInsideDirectory(candidate) else {
|
|
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
|
}
|
|
|
|
if !fileManager.fileExists(atPath: candidate.path) {
|
|
return candidate
|
|
}
|
|
|
|
let baseName = (fileName as NSString).deletingPathExtension
|
|
let ext = (fileName as NSString).pathExtension
|
|
for counter in 1..<100 {
|
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
|
candidate = directory.appendingPathComponent(newName)
|
|
guard isInsideDirectory(candidate) else {
|
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
|
}
|
|
if !fileManager.fileExists(atPath: candidate.path) {
|
|
return candidate
|
|
}
|
|
}
|
|
|
|
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
|
}
|
|
|
|
private static func timestampString(from date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
|
return formatter.string(from: date)
|
|
}
|
|
}
|