mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 09:05:19 +00:00
Make private media deletion transactional
This commit is contained in:
@@ -86,6 +86,10 @@ protocol ChatMediaTransferContext: AnyObject {
|
||||
@discardableResult
|
||||
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||
/// Removes a media bubble with direction-scoped cleanup instead of the
|
||||
/// broad compatibility cleanup path.
|
||||
func removeUntombstonedMediaMessage(withID messageID: String)
|
||||
func removeOutgoingMediaMessage(withID messageID: String)
|
||||
func addSystemMessage(_ content: String)
|
||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||
func notifyUIChanged()
|
||||
@@ -122,6 +126,15 @@ protocol ChatMediaTransferContext: AnyObject {
|
||||
)
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
/// Receiver-side stable-ID deletion commit. Implementations must invoke
|
||||
/// completion only after the entire batch is durably tombstoned.
|
||||
func persistDeletedPrivateMedia(
|
||||
messageIDs: [String],
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
)
|
||||
/// Whether any current private-chat copy of this stable ID came from a
|
||||
/// remote peer and therefore requires a receiver tombstone.
|
||||
func requiresPrivateMediaTombstone(messageID: String) -> Bool
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatMediaTransferContext {
|
||||
@@ -208,6 +221,130 @@ extension ChatViewModel: ChatMediaTransferContext {
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
meshService.cancelTransfer(transferId)
|
||||
}
|
||||
|
||||
func removeUntombstonedMediaMessage(withID messageID: String) {
|
||||
let message = conversations.conversationsByID.values.lazy
|
||||
.flatMap(\.messages)
|
||||
.first { $0.id == messageID }
|
||||
if let message {
|
||||
if !isIncomingPrivateMessage(message) {
|
||||
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
||||
forMessage: message
|
||||
)
|
||||
}
|
||||
// Legacy/raw incoming media has no durable ID-to-file ownership.
|
||||
// Its basename may already belong to a pending newer arrival, so
|
||||
// leave the payload for bounded quota cleanup rather than unlink
|
||||
// an ambiguous path.
|
||||
}
|
||||
removeMessage(withID: messageID, cleanupFile: false)
|
||||
}
|
||||
|
||||
func removeOutgoingMediaMessage(withID messageID: String) {
|
||||
let message = conversations.conversationsByID.values.lazy
|
||||
.flatMap(\.messages)
|
||||
.first { $0.id == messageID }
|
||||
if let message {
|
||||
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
||||
forMessage: message
|
||||
)
|
||||
}
|
||||
removeMessage(withID: messageID, cleanupFile: false)
|
||||
}
|
||||
|
||||
func persistDeletedPrivateMedia(
|
||||
messageIDs: [String],
|
||||
completion: @escaping @MainActor (Bool) -> Void
|
||||
) {
|
||||
guard !messageIDs.isEmpty else {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
guard let persistence =
|
||||
meshService as? any PrivateMediaDeletionPersisting else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
let requestedIDs = Set(messageIDs)
|
||||
let incomingPathReferences = Array(
|
||||
conversations.conversationsByID.values
|
||||
.lazy
|
||||
.flatMap(\.messages)
|
||||
.compactMap { message -> (
|
||||
messageID: String,
|
||||
path: String
|
||||
)? in
|
||||
guard self.isIncomingPrivateMessage(message),
|
||||
let path = self.incomingMediaRelativePath(
|
||||
for: message
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
return (message.id, path)
|
||||
}
|
||||
)
|
||||
let ownerIDsByPath = Dictionary(
|
||||
grouping: incomingPathReferences,
|
||||
by: { $0.path }
|
||||
).mapValues { Set($0.map(\.messageID)) }
|
||||
let protectedPayloadRelativePaths = Set(
|
||||
ownerIDsByPath.compactMap { path, ownerIDs in
|
||||
ownerIDs.isSubset(of: requestedIDs) ? nil : path
|
||||
}
|
||||
)
|
||||
var payloadRelativePaths: [String: String] = [:]
|
||||
for reference in incomingPathReferences
|
||||
where requestedIDs.contains(reference.messageID)
|
||||
&& ownerIDsByPath[reference.path, default: []]
|
||||
.isSubset(of: requestedIDs) {
|
||||
payloadRelativePaths[reference.messageID] = reference.path
|
||||
}
|
||||
persistence.persistDeletedPrivateMedia(
|
||||
messageIDs: messageIDs,
|
||||
payloadRelativePaths: payloadRelativePaths,
|
||||
protectedPayloadRelativePaths:
|
||||
protectedPayloadRelativePaths,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
return false
|
||||
}
|
||||
return privateChats.values.lazy.flatMap { $0 }.contains { message in
|
||||
message.id == messageID && isIncomingPrivateMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func isIncomingPrivateMessage(
|
||||
_ message: BitchatMessage
|
||||
) -> Bool {
|
||||
if let senderPeerID = message.senderPeerID {
|
||||
return senderPeerID.toShort() != meshService.myPeerID.toShort()
|
||||
}
|
||||
return message.sender != nickname
|
||||
&& !message.sender.hasPrefix(nickname + "#")
|
||||
}
|
||||
|
||||
private func incomingMediaRelativePath(
|
||||
for message: BitchatMessage
|
||||
) -> String? {
|
||||
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||
guard let category = categories.first(where: {
|
||||
message.content.hasPrefix($0.messagePrefix)
|
||||
}),
|
||||
let rawFilename = String(
|
||||
message.content.dropFirst(category.messagePrefix.count)
|
||||
).trimmedOrNilIfEmpty,
|
||||
let safeFilename =
|
||||
(rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
||||
safeFilename != ".",
|
||||
safeFilename != ".." else {
|
||||
return nil
|
||||
}
|
||||
return "\(category.mediaDir)/incoming/\(safeFilename)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronous boundary between detached image writers and panic deletion.
|
||||
@@ -871,8 +1008,7 @@ final class ChatMediaTransferCoordinator {
|
||||
cancelActiveTransfer: false
|
||||
)
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
|
||||
context.removeOutgoingMediaMessage(withID: messageID)
|
||||
case .rejected(let id, let reason):
|
||||
guard let messageID = currentMessageID(forTransferID: id) else {
|
||||
return
|
||||
@@ -891,6 +1027,40 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||
cleanupLocalFile(
|
||||
forMessage: message,
|
||||
directions: ["outgoing", "incoming"],
|
||||
searchAllCategories: true
|
||||
)
|
||||
}
|
||||
|
||||
/// `/clear` may cancel an outgoing message before receiver tombstones are
|
||||
/// committed. Restrict cleanup to that message's outgoing directory so a
|
||||
/// same-name incoming payload cannot be removed prematurely.
|
||||
func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) {
|
||||
cleanupLocalFile(
|
||||
forMessage: message,
|
||||
directions: ["outgoing"],
|
||||
searchAllCategories: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Receiver cleanup runs only after any required tombstone commit. Keep it
|
||||
/// scoped to the parsed media category and incoming directory so unrelated
|
||||
/// outgoing or cross-category payloads with the same basename survive.
|
||||
func cleanupIncomingLocalFile(forMessage message: BitchatMessage) {
|
||||
cleanupLocalFile(
|
||||
forMessage: message,
|
||||
directions: ["incoming"],
|
||||
searchAllCategories: false
|
||||
)
|
||||
}
|
||||
|
||||
private func cleanupLocalFile(
|
||||
forMessage message: BitchatMessage,
|
||||
directions: [String],
|
||||
searchAllCategories: Bool
|
||||
) {
|
||||
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
||||
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
||||
@@ -901,11 +1071,27 @@ final class ChatMediaTransferCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
||||
let targetCategories = searchAllCategories ? categories : [category]
|
||||
let subdirs = targetCategories.flatMap { category in
|
||||
directions.map { "\(category.mediaDir)/\($0)" }
|
||||
}
|
||||
for subdir in subdirs {
|
||||
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||
guard target.path.hasPrefix(base.path) else { continue }
|
||||
|
||||
guard FileManager.default.fileExists(atPath: target.path) else {
|
||||
continue
|
||||
}
|
||||
guard let values = try? target.resourceValues(
|
||||
forKeys: [.isRegularFileKey]
|
||||
),
|
||||
values.isRegularFile == true else {
|
||||
SecureLogger.warning(
|
||||
"Refusing to cleanup non-file media target \(safeFilename)",
|
||||
category: .session
|
||||
)
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try FileManager.default.removeItem(at: target)
|
||||
} catch CocoaError.fileNoSuchFile {
|
||||
@@ -917,33 +1103,79 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
func cancelMediaSend(messageID: String) {
|
||||
discardReconnectRetry(
|
||||
messageID: messageID,
|
||||
cancelActiveTransfer: false
|
||||
)
|
||||
if let transferId = messageIDToTransferId[messageID],
|
||||
let active = transferIdToMessageIDs[transferId]?.first,
|
||||
active == messageID {
|
||||
context.cancelTransfer(transferId)
|
||||
}
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
cancelAllMediaSendOwners(messageID: messageID)
|
||||
context.removeOutgoingMediaMessage(withID: messageID)
|
||||
}
|
||||
|
||||
/// Lets `/clear` cancel send ownership without implicitly deciding which
|
||||
/// bubbles/files its deletion transaction may remove.
|
||||
func cancelMediaTransferForConversationClear(messageID: String) {
|
||||
cancelAllMediaSendOwners(messageID: messageID)
|
||||
}
|
||||
|
||||
func deleteMediaMessage(messageID: String) {
|
||||
// Stop every exact sender owner before the durable receiver commit.
|
||||
// Otherwise a retained retry or admitted legacy send could transmit
|
||||
// after the bubble and payload have been deleted.
|
||||
cancelAllMediaSendOwners(messageID: messageID)
|
||||
|
||||
guard context.requiresPrivateMediaTombstone(
|
||||
messageID: messageID
|
||||
) else {
|
||||
finishMediaDeletion(
|
||||
messageID: messageID,
|
||||
receiverJournalOwnsPayload: false
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
context.persistDeletedPrivateMedia(
|
||||
messageIDs: [messageID]
|
||||
) { [weak self] persisted in
|
||||
guard let self else { return }
|
||||
guard persisted else {
|
||||
SecureLogger.error(
|
||||
"Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
self.finishMediaDeletion(
|
||||
messageID: messageID,
|
||||
receiverJournalOwnsPayload: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishMediaDeletion(
|
||||
messageID: String,
|
||||
receiverJournalOwnsPayload: Bool
|
||||
) {
|
||||
if receiverJournalOwnsPayload {
|
||||
// The journal already owns the exact path. A basename cleanup here
|
||||
// could delete a different arrival that reused it after unlink.
|
||||
context.removeMessage(withID: messageID, cleanupFile: false)
|
||||
} else {
|
||||
context.removeUntombstonedMediaMessage(withID: messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelAllMediaSendOwners(messageID: String) {
|
||||
// This releases the retained packet and expiry/retry owner. When its
|
||||
// exact active transfer still owns the mapping, it cancels that owner
|
||||
// before any deletion persistence or UI mutation can proceed.
|
||||
discardReconnectRetry(
|
||||
messageID: messageID,
|
||||
cancelActiveTransfer: false
|
||||
cancelActiveTransfer: true
|
||||
)
|
||||
// Delete is also a send cancellation. In particular, an approved
|
||||
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
||||
// removing only the UI mapping would let that deferred work transmit.
|
||||
// In particular, an approved legacy send may still be waiting on
|
||||
// BLEService.messageQueue. Its admission must be canceled before the
|
||||
// mapping/consent owner is released.
|
||||
if let transferId = messageIDToTransferId[messageID],
|
||||
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||
context.cancelTransfer(transferId)
|
||||
}
|
||||
clearTransferMapping(for: messageID)
|
||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||
}
|
||||
|
||||
/// A raw link callback can arrive before the replacement Noise session
|
||||
|
||||
Reference in New Issue
Block a user