From f64e718ba275dd37159cd0ef759d92b555c98c17 Mon Sep 17 00:00:00 2001 From: Sasha Weiss Date: Mon, 1 Jun 2026 12:42:17 -0700 Subject: [PATCH] Never allow My Story to be deleted --- .../Messages/Stories/StoryManager.swift | 17 ++++++++++----- .../Storage/Database/GRDBSchemaMigrator.swift | 21 ++++++++++++++++++- .../StorageServiceProto+Sync.swift | 6 ++++++ .../PrivateStoryThreadDeletionManager.swift | 10 ++++++++- .../Threads/TSPrivateStoryThread.swift | 2 +- .../MyStorySettingsViewController.swift | 4 ++-- 6 files changed, 50 insertions(+), 10 deletions(-) diff --git a/SignalServiceKit/Messages/Stories/StoryManager.swift b/SignalServiceKit/Messages/Stories/StoryManager.swift index 12ae74a2d6..1c409bd7d0 100644 --- a/SignalServiceKit/Messages/Stories/StoryManager.swift +++ b/SignalServiceKit/Messages/Stories/StoryManager.swift @@ -14,13 +14,20 @@ public class StoryManager { cacheAreViewReceiptsEnabled() appReadiness.runNowOrWhenAppDidBecomeReadyAsync { - SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in - // Create My Story thread if necessary - TSPrivateStoryThread.getOrCreateMyStory(transaction: transaction) + let db = DependenciesBridge.shared.db + let storageServiceManager = SSKEnvironment.shared.storageServiceManagerRef + let privateStoryThreadDeletionManager = DependenciesBridge.shared.privateStoryThreadDeletionManager + + db.asyncWrite { tx in + if TSPrivateStoryThread.getMyStory(transaction: tx) == nil { + let myStory = TSPrivateStoryThread.getOrCreateMyStory(transaction: tx) + storageServiceManager.recordPendingUpdates( + updatedStoryDistributionListIds: [myStory.distributionListIdentifier!], + ) + } if CurrentAppContext().isMainApp { - DependenciesBridge.shared.privateStoryThreadDeletionManager - .cleanUpDeletedTimestamps(tx: transaction) + privateStoryThreadDeletionManager.cleanUpDeletedTimestamps(tx: tx) } } } diff --git a/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift b/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift index d9a039785f..226e143f2b 100644 --- a/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift +++ b/SignalServiceKit/Storage/Database/GRDBSchemaMigrator.swift @@ -331,6 +331,7 @@ public class GRDBSchemaMigrator { case migrateSecureValueRecovery case wipeCachedSVRBAuthCredentials case addMimeTypeToMessageAttachmentReference + case purgeMyStoryDeletedAtTimestamp // NOTE: Every time we add a migration id, consider // incrementing grdbSchemaVersionLatest. @@ -454,7 +455,7 @@ public class GRDBSchemaMigrator { } public static let grdbSchemaVersionDefault: UInt = 0 - public static let grdbSchemaVersionLatest: UInt = 143 + public static let grdbSchemaVersionLatest: UInt = 144 private class DatabaseMigratorWrapper { // Run with immediate (or disabled) foreign key checks so that pre-existing @@ -5184,6 +5185,24 @@ public class GRDBSchemaMigrator { return .success(()) } + migrator.registerMigration(.purgeMyStoryDeletedAtTimestamp) { tx in + // "My Story" should never be deleted, but we've seen reports where + // it has been marked as such. Remove it from the deleted-story + // store if necessary; we (at the time of writing) create the My Story + // thread on app launch and have guards against deleting it again in + // the future. + try tx.database.execute( + sql: """ + DELETE FROM keyvalue WHERE collection = ? AND key = ? + """, + arguments: [ + "TSPrivateStoryThread+DeletedAtTimestamp", + "00000000-0000-0000-0000-000000000000", + ], + ) + return .success(()) + } + // MARK: - Schema Migration Insertion Point } diff --git a/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift b/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift index 3ec2799b82..355856fc77 100644 --- a/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift +++ b/SignalServiceKit/StorageService/StorageServiceProto+Sync.swift @@ -2077,6 +2077,12 @@ class StorageServiceStoryDistributionListRecordUpdater: StorageServiceRecordUpda // The story has been deleted on another device, record that // and ensure we don't try and put it back. guard record.deletedAtTimestamp == 0 else { + if uniqueId.uuidString == TSPrivateStoryThread.myStoryUniqueId { + // My Story should never be deleted. If someone put such in + // Storage Service, ignore and overwrite it. + return .merged(needsUpdate: true, identifier) + } + if let existingStory { threadRemover.remove(existingStory, tx: transaction) } diff --git a/SignalServiceKit/Threads/PrivateStoryThreadDeletionManager.swift b/SignalServiceKit/Threads/PrivateStoryThreadDeletionManager.swift index 93c694d608..1afdd8622b 100644 --- a/SignalServiceKit/Threads/PrivateStoryThreadDeletionManager.swift +++ b/SignalServiceKit/Threads/PrivateStoryThreadDeletionManager.swift @@ -84,7 +84,15 @@ final class PrivateStoryThreadDeletionManagerImpl: PrivateStoryThreadDeletionMan return } - guard let uniqueId = identifier.uuidString else { return } + guard let uniqueId = identifier.uuidString else { + return + } + + if uniqueId == TSPrivateStoryThread.myStoryUniqueId { + owsFailDebug("Refusing to record My Story as deleted.", logger: logger) + return + } + deletedAtTimestampStore.setUInt64(timestamp, key: uniqueId, transaction: tx) } diff --git a/SignalServiceKit/Threads/TSPrivateStoryThread.swift b/SignalServiceKit/Threads/TSPrivateStoryThread.swift index c78beec5af..c174dbd527 100644 --- a/SignalServiceKit/Threads/TSPrivateStoryThread.swift +++ b/SignalServiceKit/Threads/TSPrivateStoryThread.swift @@ -145,7 +145,7 @@ public final class TSPrivateStoryThread: TSThread { "00000000-0000-0000-0000-000000000000" } - public class func getMyStory(transaction: DBReadTransaction) -> TSPrivateStoryThread! { + public class func getMyStory(transaction: DBReadTransaction) -> TSPrivateStoryThread? { fetchPrivateStoryThreadViaCache(uniqueId: myStoryUniqueId, transaction: transaction) } diff --git a/SignalUI/Stories/MyStorySettingsViewController.swift b/SignalUI/Stories/MyStorySettingsViewController.swift index f4249bc0f1..6f1690f9a6 100644 --- a/SignalUI/Stories/MyStorySettingsViewController.swift +++ b/SignalUI/Stories/MyStorySettingsViewController.swift @@ -82,7 +82,7 @@ private class MyStorySettingsDataSource: NSObject { myStoryThread, myStoryThreadRecipientIds, ) = databaseStorage.read { transaction -> (Bool, TSPrivateStoryThread, [SignalRecipient.RowId]) in - let myStoryThread: TSPrivateStoryThread = TSPrivateStoryThread.getMyStory(transaction: transaction) + let myStoryThread: TSPrivateStoryThread = TSPrivateStoryThread.getMyStory(transaction: transaction)! return ( StoryManager.hasSetMyStoriesPrivacy(transaction: transaction), myStoryThread, @@ -249,7 +249,7 @@ private class MyStorySettingsDataSource: NSObject { @objc private func didToggleReplies(_ toggle: UISwitch) { - let myStoryThread: TSPrivateStoryThread! = SSKEnvironment.shared.databaseStorageRef.read { TSPrivateStoryThread.getMyStory(transaction: $0) } + let myStoryThread: TSPrivateStoryThread = SSKEnvironment.shared.databaseStorageRef.read { TSPrivateStoryThread.getMyStory(transaction: $0)! } guard myStoryThread.allowsReplies != toggle.isOn else { return } SSKEnvironment.shared.databaseStorageRef.write { transaction in myStoryThread.updateWithAllowsReplies(toggle.isOn, updateStorageService: true, transaction: transaction)