diff --git a/bitchat/Features/media/ImageUtils.swift b/bitchat/Features/media/ImageUtils.swift index 3a50e5fb..ed68c858 100644 --- a/bitchat/Features/media/ImageUtils.swift +++ b/bitchat/Features/media/ImageUtils.swift @@ -1,10 +1,10 @@ import Foundation +import ImageIO +import UniformTypeIdentifiers #if os(iOS) import UIKit #else import AppKit -import ImageIO -import UniformTypeIdentifiers #endif enum ImageUtilsError: Error { @@ -40,19 +40,30 @@ enum ImageUtils { #if os(iOS) static func processImage(_ image: UIImage, maxDimension: CGFloat = 512) throws -> URL { return try autoreleasepool { + // Scale the image first let scaled = scaledImage(image, maxDimension: maxDimension) - var quality = compressionQuality - guard var jpegData = scaled.jpegData(compressionQuality: quality) else { + + // Get CGImage from UIImage - this is the key to stripping metadata + guard let cgImage = scaled.cgImage else { throw ImageUtilsError.encodingFailed } + + // Use CGImageDestination to encode without metadata (same as macOS) + var quality = compressionQuality + guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else { + throw ImageUtilsError.encodingFailed + } + + // Compress to target size while jpegData.count > targetImageBytes && quality > 0.3 { quality -= 0.1 autoreleasepool { - if let next = scaled.jpegData(compressionQuality: quality) { + if let next = encodeJPEG(from: cgImage, quality: quality) { jpegData = next } } } + let outputURL = try makeOutputURL() try jpegData.write(to: outputURL, options: .atomic) return outputURL @@ -65,12 +76,35 @@ enum ImageUtils { guard maxSide > maxDimension else { return image } let scale = maxDimension / maxSide let newSize = CGSize(width: size.width * scale, height: size.height * scale) + + // Draw into a new context to get a clean CGImage without metadata UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0) image.draw(in: CGRect(origin: .zero, size: newSize)) let rendered = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return rendered ?? image } + + // Shared EXIF-stripping JPEG encoder for both iOS and macOS + private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? { + guard let data = CFDataCreateMutable(nil, 0) else { + return nil + } + guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { + return nil + } + // Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP) + // By only specifying compression quality and no metadata keys, + // we ensure a clean JPEG with no privacy-leaking information + let options: [CFString: Any] = [ + kCGImageDestinationLossyCompressionQuality: quality + ] + CGImageDestinationAddImage(destination, cgImage, options as CFDictionary) + guard CGImageDestinationFinalize(destination) else { + return nil + } + return data as Data + } #else static func processImage(_ image: NSImage, maxDimension: CGFloat = 512) throws -> URL { return try autoreleasepool { @@ -130,6 +164,7 @@ enum ImageUtils { return scaledImage } + // Shared EXIF-stripping JPEG encoder for both iOS and macOS private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? { guard let data = CFDataCreateMutable(nil, 0) else { return nil @@ -137,8 +172,9 @@ enum ImageUtils { guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil } - // Security H2: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP) - // Don't add any metadata dictionary keys - fresh CGContext ensures clean image + // Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP) + // By only specifying compression quality and no metadata keys, + // we ensure a clean JPEG with no privacy-leaking information let options: [CFString: Any] = [ kCGImageDestinationLossyCompressionQuality: quality ] diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 7e3acdda..f94ffd1a 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -2503,65 +2503,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } - @MainActor - func sendFileAttachment(from sourceURL: URL) { - let targetPeer = selectedPrivateChatPeer - - Task.detached(priority: .userInitiated) { [weak self] in - guard let self = self else { return } - defer { try? FileManager.default.removeItem(at: sourceURL) } - var destinationURL: URL? - do { - // Security H1: Check file size BEFORE reading into memory - let attrs = try FileManager.default.attributesOfItem(atPath: sourceURL.path) - guard let fileSize = attrs[.size] as? Int else { - throw MediaSendError.encodingFailed - } - guard FileTransferLimits.isValidPayload(fileSize) else { - throw MediaSendError.tooLarge - } - - let data = try Data(contentsOf: sourceURL) - - let destination = try self.prepareOutgoingFileCopy(from: sourceURL, data: data) - destinationURL = destination - let packet = BitchatFilePacket( - fileName: destination.lastPathComponent, - fileSize: UInt64(data.count), - mimeType: self.mimeType(for: destination), - content: data - ) - guard packet.encode() != nil else { - try? FileManager.default.removeItem(at: destination) - throw MediaSendError.encodingFailed - } - - await MainActor.run { - let message = self.enqueueMediaMessage(content: "[file] \(destination.lastPathComponent)", targetPeer: targetPeer?.id) - let messageID = message.id - let transferId = self.makeTransferID(messageID: messageID) - self.registerTransfer(transferId: transferId, messageID: messageID) - if let peerID = targetPeer { - self.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId) - } else { - self.meshService.sendFileBroadcast(packet, transferId: transferId) - } - } - } catch MediaSendError.tooLarge { - await MainActor.run { - self.addSystemMessage("File is too large to send.") - } - } catch { - if let destination = destinationURL { - try? FileManager.default.removeItem(at: destination) - } - SecureLogger.error("File attachment send failed: \(error)", category: .session) - await MainActor.run { - self.addSystemMessage("Failed to send file.") - } - } - } - } @MainActor func cancelMediaSend(messageID: String) { @@ -2748,55 +2689,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { } } - private func prepareOutgoingFileCopy(from sourceURL: URL, data: Data) throws -> URL { - let base = try applicationFilesDirectory().appendingPathComponent("files/outgoing", isDirectory: true) - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil) - - let rawName = sourceURL.lastPathComponent - let sanitized = sanitizeOutgoingFileName(rawName.isEmpty ? "file" : rawName) - var destination = base.appendingPathComponent(sanitized) - var counter = 1 - while FileManager.default.fileExists(atPath: destination.path) { - let baseName = (sanitized as NSString).deletingPathExtension - let ext = (sanitized as NSString).pathExtension - let newName: String - if ext.isEmpty { - newName = "\(baseName) (\(counter))" - } else { - newName = "\(baseName) (\(counter)).\(ext)" - } - destination = base.appendingPathComponent(newName) - counter += 1 - } - - try data.write(to: destination, options: .atomic) - return destination - } - - private func sanitizeOutgoingFileName(_ name: String) -> String { - var candidate = name - candidate = candidate.replacingOccurrences(of: "\\", with: "/") - candidate = candidate.components(separatedBy: "/").last ?? name - candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - if candidate.isEmpty { candidate = "file" } - let invalid = CharacterSet(charactersIn: "<>:\"|?*") - candidate = candidate.components(separatedBy: invalid).joined(separator: "_") - if candidate.isEmpty { candidate = "file" } - if (candidate as NSString).pathExtension.isEmpty { - candidate += ".bin" - } - return candidate - } - - private func mimeType(for url: URL) -> String { - let ext = url.pathExtension.lowercased() - if !ext.isEmpty, - let type = UTType(filenameExtension: ext), - let mime = type.preferredMIMEType { - return mime - } - return "application/octet-stream" - } private func applicationFilesDirectory() throws -> URL { let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 22c5adc1..ecc88ba9 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -71,7 +71,6 @@ struct ContentView: View { @State private var recordingDuration: TimeInterval = 0 @State private var recordingTimer: Timer? @State private var recordingStartDate: Date? - @State private var showFileImporter = false #if os(iOS) @State private var showPhotoPicker = false @State private var selectedPhotoPickerItem: PhotosPickerItem? @@ -216,9 +215,6 @@ struct ContentView: View { Task { await handlePhotoSelection(item) } } #endif - .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data], allowsMultipleSelection: false) { result in - handleImportResult(result, handler: handleImportedFile) - } .sheet(isPresented: Binding( get: { imagePreviewURL != nil }, set: { presenting in if !presenting { imagePreviewURL = nil } } @@ -234,10 +230,6 @@ struct ContentView: View { DispatchQueue.main.async { showPhotoPicker = true } } #endif - Button("File") { - showAttachmentActions = false - DispatchQueue.main.async { showFileImporter = true } - } Button("Cancel", role: .cancel) {} } .alert("Recording Error", isPresented: $showRecordingAlert, actions: { @@ -1547,11 +1539,10 @@ struct ContentView: View { private enum MessageMedia { case voice(URL) case image(URL) - case file(URL) var url: URL { switch self { - case .voice(let url), .image(let url), .file(let url): + case .voice(let url), .image(let url): return url } } @@ -1589,13 +1580,6 @@ private extension ContentView { let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename) return .image(url) } - if message.content.hasPrefix("[file] ") { - let filename = String(message.content.dropFirst("[file] ".count)).trimmingCharacters(in: .whitespacesAndNewlines) - guard !filename.isEmpty else { return nil } - let subdir = message.sender == viewModel.nickname ? "files/outgoing" : "files/incoming" - let url = baseDirectory.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(filename) - return .file(url) - } return nil } @@ -1687,13 +1671,6 @@ private extension ContentView { } : nil ) .frame(maxWidth: 280) - case .file(let url): - FileAttachmentView( - url: url, - isSending: state.isSending, - progress: state.progress, - onCancel: cancelAction - ) } } } @@ -2024,24 +2001,6 @@ private extension ContentView { } #endif - func handleImportedFile(url: URL) async { - do { - let fileManager = FileManager.default - let tempDir = fileManager.temporaryDirectory - let fileName = url.lastPathComponent.isEmpty ? "attachment" : url.lastPathComponent - let destination = tempDir.appendingPathComponent(UUID().uuidString + "_" + fileName) - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - try fileManager.copyItem(at: url, to: destination) - await MainActor.run { - viewModel.sendFileAttachment(from: destination) - } - } catch { - SecureLogger.error("File copy failed before send: \(error)", category: .session) - } - } - func applicationFilesDirectory() -> URL? { // Cache the directory lookup to avoid repeated FileManager calls during view rendering struct Cache { diff --git a/bitchat/Views/Media/FileAttachmentView.swift b/bitchat/Views/Media/FileAttachmentView.swift deleted file mode 100644 index b57b54ef..00000000 --- a/bitchat/Views/Media/FileAttachmentView.swift +++ /dev/null @@ -1,118 +0,0 @@ -import SwiftUI - -struct FileAttachmentView: View { - private let url: URL - private let isSending: Bool - private let progress: Double? - private let onCancel: (() -> Void)? - - @Environment(\.colorScheme) private var colorScheme - #if os(iOS) - @State private var showExporter = false - #endif - - init(url: URL, isSending: Bool, progress: Double?, onCancel: (() -> Void)?) { - self.url = url - self.isSending = isSending - self.progress = progress - self.onCancel = onCancel - } - - private var fileName: String { - url.lastPathComponent - } - - private var normalizedProgress: Double? { - guard let progress = progress else { return nil } - return max(0, min(1, progress)) - } - - var body: some View { - HStack(alignment: .center, spacing: 12) { - Image(systemName: "doc.fill") - .foregroundColor(Color.blue) - .font(.bitchatSystem(size: 24)) - - VStack(alignment: .leading, spacing: 4) { - Text(fileName) - .font(.bitchatSystem(size: 14, weight: .medium)) - .foregroundColor(.primary) - .lineLimit(2) - Text(url.lastPathComponent) - .font(.bitchatSystem(size: 11, design: .monospaced)) - .foregroundColor(.secondary) - .lineLimit(1) - if let progress = normalizedProgress { - ProgressView(value: progress) - .progressViewStyle(.linear) - .tint(Color.blue) - } - } - - Spacer() - - Button(action: openFile) { - Text("open", comment: "Button to open attached file") - .font(.bitchatSystem(size: 13, weight: .semibold)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - Capsule().fill(Color.blue.opacity(0.15)) - ) - } - .buttonStyle(.plain) - - if let onCancel = onCancel, isSending { - Button(action: onCancel) { - Image(systemName: "xmark") - .font(.bitchatSystem(size: 11, weight: .bold)) - .frame(width: 26, height: 26) - .background(Circle().fill(Color.red.opacity(0.9))) - .foregroundColor(.white) - } - .buttonStyle(.plain) - } - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(colorScheme == .dark ? Color.black.opacity(0.6) : Color.white) - ) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(Color.gray.opacity(0.2), lineWidth: 1) - ) - #if os(iOS) - .sheet(isPresented: $showExporter) { - FileExportController(url: url) - } - #endif - } - - private func openFile() { - #if os(iOS) - showExporter = true - #else - NSWorkspace.shared.open(url) - #endif - } -} - -#if os(iOS) -import UniformTypeIdentifiers -import UIKit - -private struct FileExportController: UIViewControllerRepresentable { - let url: URL - - func makeUIViewController(context: Context) -> UIDocumentPickerViewController { - let controller = UIDocumentPickerViewController(forExporting: [url]) - controller.shouldShowFileExtensions = true - return controller - } - - func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} -} -#else -import AppKit -#endif