Simplify PR: Focus on audio+image, fix EXIF stripping, remove file transfers

- Fix critical EXIF privacy issue in iOS image processing
  - Both iOS and macOS now use CGImageDestination for metadata stripping
  - Shared encodeJPEG function ensures no GPS, camera, or metadata leaks

- Remove file transfer functionality to simplify PR scope
  - Deleted FileAttachmentView
  - Removed sendFileAttachment from ChatViewModel
  - Removed file picker UI from ContentView
  - Simplified attachment dialog to image only (iOS) or voice only

- Keep focused media features:
  - Voice recording and playback
  - Image sending with progressive reveal
  - Binary protocol for media transfer
This commit is contained in:
jack
2025-10-18 13:35:14 +02:00
parent 9879cb2580
commit 9d738cff45
4 changed files with 44 additions and 275 deletions
+43 -7
View File
@@ -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
]
-108
View File
@@ -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)
+1 -42
View File
@@ -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 {
@@ -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