From 9c4142fcd23c655215c2dacb241a68ccd5618cfd Mon Sep 17 00:00:00 2001
From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Date: Mon, 9 Dec 2024 07:52:45 -0800
Subject: [PATCH] Desktop: Fixes #11457: Fix crash on startup if old read-only
 items are in the trash (#11458)

---
 .../trash/permanentlyDeleteOldItems.test.ts   | 40 ++++++++++++++++-
 .../trash/permanentlyDeleteOldItems.ts        | 43 +++++++++++++++++--
 2 files changed, 78 insertions(+), 5 deletions(-)

diff --git a/packages/lib/services/trash/permanentlyDeleteOldItems.test.ts b/packages/lib/services/trash/permanentlyDeleteOldItems.test.ts
index a0643c22b6..5b92cd6fdc 100644
--- a/packages/lib/services/trash/permanentlyDeleteOldItems.test.ts
+++ b/packages/lib/services/trash/permanentlyDeleteOldItems.test.ts
@@ -1,7 +1,7 @@
 import { Day, msleep } from '@joplin/utils/time';
 import Folder from '../../models/Folder';
 import Note from '../../models/Note';
-import { setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
+import { setupDatabaseAndSynchronizer, simulateReadOnlyShareEnv, switchClient } from '../../testing/test-utils';
 import permanentlyDeleteOldItems from './permanentlyDeleteOldItems';
 import Setting from '../../models/Setting';
 
@@ -75,4 +75,42 @@ describe('permanentlyDeleteOldItems', () => {
 		expect(await Folder.count()).toBe(1);
 	});
 
+	it('should not auto-delete read-only items', async () => {
+		const shareId = 'testShare';
+
+		// Simulates a folder having been deleted a long time ago
+		const longTimeAgo = 1000;
+
+		const readOnlyFolder = await Folder.save({
+			title: 'Read-only folder',
+			share_id: shareId,
+			deleted_time: longTimeAgo,
+		});
+		const readOnlyNote1 = await Note.save({
+			title: 'Read-only note',
+			parent_id: readOnlyFolder.id,
+			share_id: shareId,
+			deleted_time: longTimeAgo,
+		});
+		const readOnlyNote2 = await Note.save({
+			title: 'Read-only note 2',
+			share_id: shareId,
+			deleted_time: longTimeAgo,
+		});
+		const writableNote = await Note.save({
+			title: 'Editable note',
+			deleted_time: longTimeAgo,
+		});
+
+		const cleanup = simulateReadOnlyShareEnv(shareId);
+		await permanentlyDeleteOldItems(Day);
+
+		// Should preserve only the read-only items.
+		expect(await Folder.load(readOnlyFolder.id)).toBeTruthy();
+		expect(await Note.load(readOnlyNote1.id)).toBeTruthy();
+		expect(await Note.load(readOnlyNote2.id)).toBeTruthy();
+		expect(await Note.load(writableNote.id)).toBeFalsy();
+
+		cleanup();
+	});
 });
diff --git a/packages/lib/services/trash/permanentlyDeleteOldItems.ts b/packages/lib/services/trash/permanentlyDeleteOldItems.ts
index 8fbccc1069..73ddaa977c 100644
--- a/packages/lib/services/trash/permanentlyDeleteOldItems.ts
+++ b/packages/lib/services/trash/permanentlyDeleteOldItems.ts
@@ -4,9 +4,44 @@ import Setting from '../../models/Setting';
 import Note from '../../models/Note';
 import { Day, Hour } from '@joplin/utils/time';
 import shim from '../../shim';
+import { itemIsReadOnlySync } from '../../models/utils/readOnly';
+import BaseItem from '../../models/BaseItem';
+import { ModelType } from '../../BaseModel';
+import ItemChange from '../../models/ItemChange';
 
 const logger = Logger.create('permanentlyDeleteOldData');
 
+const readOnlyItemsRemoved = async (itemIds: string[], itemType: ModelType) => {
+	const result = [];
+	for (const id of itemIds) {
+		const item = await BaseItem.loadItem(itemType, id);
+
+		// Only do the share-related read-only checks. If other checks are done,
+		// readOnly will always be true because the item is in the trash.
+		const shareChecksOnly = true;
+		const readOnly = itemIsReadOnlySync(
+			itemType,
+			ItemChange.SOURCE_UNSPECIFIED,
+			item,
+			Setting.value('sync.userId'),
+			BaseItem.syncShareCache,
+			shareChecksOnly,
+		);
+		if (!readOnly) {
+			result.push(id);
+		}
+	}
+	return result;
+};
+
+const itemsToDelete = async (ttl: number|null = null) => {
+	const result = await Folder.trashItemsOlderThan(ttl);
+	const folderIds = await readOnlyItemsRemoved(result.folderIds, ModelType.Folder);
+	const noteIds = await readOnlyItemsRemoved(result.noteIds, ModelType.Note);
+
+	return { folderIds, noteIds };
+};
+
 const permanentlyDeleteOldItems = async (ttl: number = null) => {
 	ttl = ttl === null ? Setting.value('trash.ttlDays') * Day : ttl;
 
@@ -17,13 +52,13 @@ const permanentlyDeleteOldItems = async (ttl: number = null) => {
 		return;
 	}
 
-	const result = await Folder.trashItemsOlderThan(ttl);
-	logger.info('Items to permanently delete:', result);
+	const toDelete = await itemsToDelete(ttl);
+	logger.info('Items to permanently delete:', toDelete);
 
-	await Note.batchDelete(result.noteIds, { sourceDescription: 'permanentlyDeleteOldItems' });
+	await Note.batchDelete(toDelete.noteIds, { sourceDescription: 'permanentlyDeleteOldItems' });
 
 	// We only auto-delete folders if they are empty.
-	for (const folderId of result.folderIds) {
+	for (const folderId of toDelete.folderIds) {
 		const noteIds = await Folder.noteIds(folderId, { includeDeleted: true });
 		if (!noteIds.length) {
 			logger.info(`Deleting empty folder: ${folderId}`);