You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	All: Fixes #5796: Handle duplicate attachments when the parent notebook is shared
This commit is contained in:
		| @@ -429,7 +429,7 @@ export default class Synchronizer { | ||||
|  | ||||
| 		// Before synchronising make sure all share_id properties are set | ||||
| 		// correctly so as to share/unshare the right items. | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(this.resourceService()); | ||||
|  | ||||
| 		const itemUploader = new ItemUploader(this.api(), this.apiCall); | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { setupDatabaseAndSynchronizer, switchClient, createFolderTree, supportDir, msleep } from '../testing/test-utils'; | ||||
| import { setupDatabaseAndSynchronizer, switchClient, createFolderTree, supportDir, msleep, resourceService } from '../testing/test-utils'; | ||||
| import Folder from '../models/Folder'; | ||||
| import { allNotesFolders } from '../testing/test-utils-synchronizer'; | ||||
| import Note from '../models/Note'; | ||||
| @@ -41,7 +41,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		]); | ||||
|  | ||||
| 		await Folder.save({ id: folder.id, share_id: 'abcd1234' }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		const allItems = await allNotesFolders(); | ||||
| 		for (const item of allItems) { | ||||
| @@ -87,7 +87,7 @@ describe('models/Folder.sharing', function() { | ||||
|  | ||||
| 		await Folder.save({ id: folder1.id, share_id: 'abcd1234' }); | ||||
|  | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		folder1 = await Folder.loadByTitle('folder 1'); | ||||
| 		const folder2 = await Folder.loadByTitle('folder 2'); | ||||
| @@ -121,7 +121,7 @@ describe('models/Folder.sharing', function() { | ||||
|  | ||||
| 		await Folder.save({ id: folder1.id, share_id: 'abcd1234' }); | ||||
|  | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		folder1 = await Folder.loadByTitle('folder 1'); | ||||
| 		let folder2 = await Folder.loadByTitle('folder 2'); | ||||
| @@ -133,7 +133,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		// Move the folder outside the shared folder | ||||
|  | ||||
| 		await Folder.save({ id: folder2.id, parent_id: folder3.id }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
| 		folder2 = await Folder.loadByTitle('folder 2'); | ||||
| 		expect(folder2.share_id).toBe(''); | ||||
|  | ||||
| @@ -141,7 +141,7 @@ describe('models/Folder.sharing', function() { | ||||
|  | ||||
| 		{ | ||||
| 			await Folder.save({ id: folder2.id, parent_id: folder1.id }); | ||||
| 			await Folder.updateAllShareIds(); | ||||
| 			await Folder.updateAllShareIds(resourceService()); | ||||
| 			folder2 = await Folder.loadByTitle('folder 2'); | ||||
| 			expect(folder2.share_id).toBe('abcd1234'); | ||||
| 		} | ||||
| @@ -180,7 +180,7 @@ describe('models/Folder.sharing', function() { | ||||
|  | ||||
| 		await Folder.save({ id: folder1.id, share_id: 'abcd1234' }); | ||||
|  | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		const note1: NoteEntity = await Note.loadByTitle('note 1'); | ||||
| 		const note2: NoteEntity = await Note.loadByTitle('note 2'); | ||||
| @@ -210,7 +210,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		]); | ||||
|  | ||||
| 		await Folder.save({ id: folder1.id, share_id: 'abcd1234' }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
| 		const note1: NoteEntity = await Note.loadByTitle('note 1'); | ||||
| 		const folder2: FolderEntity = await Folder.loadByTitle('folder 2'); | ||||
| 		expect(note1.share_id).toBe('abcd1234'); | ||||
| @@ -218,7 +218,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		// Move the note outside of the shared folder | ||||
|  | ||||
| 		await Note.save({ id: note1.id, parent_id: folder2.id }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		{ | ||||
| 			const note1: NoteEntity = await Note.loadByTitle('note 1'); | ||||
| @@ -228,7 +228,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		// Move the note back inside the shared folder | ||||
|  | ||||
| 		await Note.save({ id: note1.id, parent_id: folder1.id }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		{ | ||||
| 			const note1: NoteEntity = await Note.loadByTitle('note 1'); | ||||
| @@ -256,7 +256,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		]); | ||||
|  | ||||
| 		await Folder.save({ id: folder1.id, share_id: 'abcd1234' }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		let note1: NoteEntity = await Note.loadByTitle('note 1'); | ||||
| 		let note2: NoteEntity = await Note.loadByTitle('note 2'); | ||||
| @@ -266,7 +266,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		expect(note2.share_id).toBe('abcd1234'); | ||||
|  | ||||
| 		await Note.save({ id: note1.id, parent_id: folder2.id }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService()); | ||||
|  | ||||
| 		note1 = await Note.loadByTitle('note 1'); | ||||
| 		note2 = await Note.loadByTitle('note 2'); | ||||
| @@ -296,7 +296,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		]); | ||||
|  | ||||
| 		await Folder.save({ id: folder.id, share_id: 'abcd1234' }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService); | ||||
|  | ||||
| 		const folder2: FolderEntity = await Folder.loadByTitle('folder 2'); | ||||
| 		const note1: NoteEntity = await Note.loadByTitle('note 1'); | ||||
| @@ -312,9 +312,7 @@ describe('models/Folder.sharing', function() { | ||||
| 			expect(resource.share_id).toBe(''); | ||||
| 		} | ||||
|  | ||||
| 		await Folder.updateAllShareIds(); | ||||
|  | ||||
| 		// await NoteResource.updateResourceShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService); | ||||
|  | ||||
| 		{ | ||||
| 			const resource: ResourceEntity = await Resource.load(resourceId); | ||||
| @@ -324,7 +322,7 @@ describe('models/Folder.sharing', function() { | ||||
| 		await Note.save({ id: note1.id, parent_id: folder2.id }); | ||||
| 		await resourceService.indexNoteResources(); | ||||
|  | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService); | ||||
|  | ||||
| 		{ | ||||
| 			const resource: ResourceEntity = await Resource.load(resourceId); | ||||
| @@ -332,6 +330,108 @@ describe('models/Folder.sharing', function() { | ||||
| 		} | ||||
| 	}); | ||||
|  | ||||
| 	it('should automatically duplicate resources when they are shared', async () => { | ||||
| 		const resourceService = new ResourceService(); | ||||
|  | ||||
| 		const folder1 = await createFolderTree('', [ | ||||
| 			{ | ||||
| 				title: 'folder 1', // SHARE 1 | ||||
| 				children: [ | ||||
| 					{ | ||||
| 						title: 'note 1', | ||||
| 					}, | ||||
| 					{ | ||||
| 						title: 'note 2', | ||||
| 					}, | ||||
| 				], | ||||
| 			}, | ||||
| 			{ | ||||
| 				title: 'folder 2', // SHARE 2 | ||||
| 				children: [ | ||||
| 					{ | ||||
| 						title: 'note 3', | ||||
| 					}, | ||||
| 				], | ||||
| 			}, | ||||
| 			{ | ||||
| 				title: 'folder 3', // (not shared) | ||||
| 				children: [ | ||||
| 					{ | ||||
| 						title: 'note 4', | ||||
| 					}, | ||||
| 				], | ||||
| 			}, | ||||
| 		]); | ||||
|  | ||||
| 		const folder2: FolderEntity = await Folder.loadByTitle('folder 2'); | ||||
| 		// await Folder.loadByTitle('folder 3'); | ||||
| 		let note1: NoteEntity = await Note.loadByTitle('note 1'); | ||||
| 		let note2: NoteEntity = await Note.loadByTitle('note 2'); | ||||
| 		let note3: NoteEntity = await Note.loadByTitle('note 3'); | ||||
| 		let note4: NoteEntity = await Note.loadByTitle('note 4'); | ||||
|  | ||||
| 		await Folder.save({ id: folder1.id, share_id: 'share1' }); | ||||
| 		await Folder.save({ id: folder2.id, share_id: 'share2' }); | ||||
|  | ||||
| 		note1 = await shim.attachFileToNote(note1, testImagePath); | ||||
| 		note2 = await shim.attachFileToNote(note2, testImagePath); | ||||
| 		note3 = await Note.save({ id: note3.id, body: note1.body }); | ||||
| 		note4 = await Note.save({ id: note4.id, body: note1.body }); | ||||
|  | ||||
| 		const userUpdatedTimes: Record<string, number> = { | ||||
| 			[note1.id]: note1.user_updated_time, | ||||
| 			[note2.id]: note2.user_updated_time, | ||||
| 			[note3.id]: note3.user_updated_time, | ||||
| 			[note4.id]: note4.user_updated_time, | ||||
| 		}; | ||||
|  | ||||
| 		await msleep(1); | ||||
|  | ||||
| 		// We need to index the resources to populate the note_resources table | ||||
|  | ||||
| 		await resourceService.indexNoteResources(); | ||||
| 		await Folder.updateAllShareIds(resourceService); | ||||
|  | ||||
| 		// BEFORE: | ||||
| 		// | ||||
| 		// - Note 1 has resource 1 (share1) | ||||
| 		// - Note 2 has resource 2 (share1) | ||||
| 		// - Note 3 has resource 1 (share2) | ||||
| 		// - Note 4 has resource 1 (not shared) | ||||
|  | ||||
| 		// AFTER: | ||||
| 		// | ||||
| 		// - Note 1 has resource 1 (share1) | ||||
| 		// - Note 2 has resource 2 (share1) | ||||
| 		// - Note 3 has resource 3 (share2) | ||||
| 		// - Note 4 has resource 4 (not shared) | ||||
|  | ||||
| 		const resources = await Resource.all(); | ||||
| 		expect(resources.length).toBe(4); | ||||
|  | ||||
| 		note1 = await Note.load(note1.id); | ||||
| 		note2 = await Note.load(note2.id); | ||||
| 		note3 = await Note.load(note3.id); | ||||
| 		note4 = await Note.load(note4.id); | ||||
|  | ||||
| 		console.info(note1.body); | ||||
| 		console.info(note2.body); | ||||
| 		console.info(note3.body); | ||||
| 		console.info(note4.body); | ||||
|  | ||||
| 		expect(note1.body).not.toBe(note2.body); | ||||
| 		expect(note1.body).not.toBe(note3.body); | ||||
| 		expect(note1.body).not.toBe(note4.body); | ||||
| 		expect(note2.body).not.toBe(note3.body); | ||||
| 		expect(note2.body).not.toBe(note4.body); | ||||
| 		expect(note3.body).not.toBe(note4.body); | ||||
|  | ||||
| 		expect(note1.user_updated_time).toBe(userUpdatedTimes[note1.id]); | ||||
| 		expect(note2.user_updated_time).toBe(userUpdatedTimes[note2.id]); | ||||
| 		expect(note3.user_updated_time).toBe(userUpdatedTimes[note3.id]); | ||||
| 		expect(note4.user_updated_time).toBe(userUpdatedTimes[note4.id]); | ||||
| 	}); | ||||
|  | ||||
| 	it('should unshare items that are no longer part of an existing share', async () => { | ||||
| 		await createFolderTree('', [ | ||||
| 			{ | ||||
| @@ -367,7 +467,7 @@ describe('models/Folder.sharing', function() { | ||||
|  | ||||
| 		await resourceService.indexNoteResources(); | ||||
|  | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(resourceService); | ||||
|  | ||||
| 		await Folder.updateNoLongerSharedItems(['1']); | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { FolderEntity, FolderIcon } from '../services/database/types'; | ||||
| import { FolderEntity, FolderIcon, NoteEntity } from '../services/database/types'; | ||||
| import BaseModel, { DeleteOptions } from '../BaseModel'; | ||||
| import time from '../time'; | ||||
| import { _ } from '../locale'; | ||||
| @@ -9,6 +9,7 @@ import Resource from './Resource'; | ||||
| import { isRootSharedFolder } from '../services/share/reducer'; | ||||
| import Logger from '../Logger'; | ||||
| import syncDebugLog from '../services/synchronizer/syncDebugLog'; | ||||
| import ResourceService from '../services/ResourceService'; | ||||
| const { substrWithEllipsis } = require('../string-utils.js'); | ||||
|  | ||||
| const logger = Logger.create('models/Folder'); | ||||
| @@ -368,36 +369,130 @@ export default class Folder extends BaseItem { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public static async updateResourceShareIds() { | ||||
| 		// Find all resources where share_id is different from parent note | ||||
| 		// share_id. Then update share_id on all these resources. Essentially it | ||||
| 		// makes it match the resource share_id to the note share_id. At the | ||||
| 		// same time we also process the is_shared property. | ||||
| 		const rows = await this.db().selectAll(` | ||||
| 			SELECT r.id, n.share_id, n.is_shared | ||||
| 			FROM note_resources nr | ||||
| 			LEFT JOIN resources r ON nr.resource_id = r.id | ||||
| 			LEFT JOIN notes n ON nr.note_id = n.id | ||||
| 			WHERE n.share_id != r.share_id | ||||
| 			OR n.is_shared != r.is_shared | ||||
| 		`); | ||||
| 	public static async updateResourceShareIds(resourceService: ResourceService) { | ||||
| 		// Updating the share_id property of the resources is complex because: | ||||
| 		// | ||||
| 		// The resource association to the note is done indirectly via the | ||||
| 		// ResourceService | ||||
| 		// | ||||
| 		// And a given resource can appear inside multiple notes. However, for | ||||
| 		// sharing we make the assumption that a resource can be part of only | ||||
| 		// one share (one-to-one relationship because "share_id" is part of the | ||||
| 		// "resources" table), which is usually the case. By copying and pasting | ||||
| 		// note content from one note to another it's however possible to have | ||||
| 		// the same resource in multiple shares (or in a non-shared and a shared | ||||
| 		// folder). | ||||
| 		// | ||||
| 		// So in this function we take this into account - if a shared resource | ||||
| 		// is part of multiple notes, we duplicate that resource so that each | ||||
| 		// note has its own instance. When such duplication happens, we need to | ||||
| 		// resume the process from the start (thus the loop) so that we deal | ||||
| 		// with the right note/resource associations. | ||||
|  | ||||
| 		logger.debug('updateResourceShareIds: resources to update:', rows.length); | ||||
| 		for (let i = 0; i < 5; i++) { | ||||
| 			// Find all resources where share_id is different from parent note | ||||
| 			// share_id. Then update share_id on all these resources. Essentially it | ||||
| 			// makes it match the resource share_id to the note share_id. At the | ||||
| 			// same time we also process the is_shared property. | ||||
|  | ||||
| 		for (const row of rows) { | ||||
| 			await Resource.save({ | ||||
| 				id: row.id, | ||||
| 				share_id: row.share_id || '', | ||||
| 				is_shared: row.is_shared, | ||||
| 				updated_time: Date.now(), | ||||
| 			}, { autoTimestamp: false }); | ||||
| 			const rows = await this.db().selectAll(` | ||||
| 				SELECT r.id, n.share_id, n.is_shared | ||||
| 				FROM note_resources nr | ||||
| 				LEFT JOIN resources r ON nr.resource_id = r.id | ||||
| 				LEFT JOIN notes n ON nr.note_id = n.id | ||||
| 				WHERE ( | ||||
| 					n.share_id != r.share_id | ||||
| 					OR n.is_shared != r.is_shared | ||||
| 				) AND nr.is_associated = 1 | ||||
| 			`); | ||||
|  | ||||
| 			if (!rows.length) return; | ||||
|  | ||||
| 			logger.debug('updateResourceShareIds: resources to update:', rows.length); | ||||
|  | ||||
| 			const resourceIds = rows.map(r => r.id); | ||||
|  | ||||
| 			interface Row { | ||||
| 				resource_id: string; | ||||
| 				note_id: string; | ||||
| 				share_id: string; | ||||
| 			} | ||||
|  | ||||
| 			// Now we check, for each resource, that it is associated with only | ||||
| 			// one note. If it is not, we create duplicate resources so that | ||||
| 			// each note has its own separate resource. | ||||
|  | ||||
| 			const noteResourceAssociations = await this.db().selectAll(` | ||||
| 				SELECT resource_id, note_id, notes.share_id | ||||
| 				FROM note_resources | ||||
| 				LEFT JOIN notes ON notes.id = note_resources.note_id | ||||
| 				WHERE resource_id IN ('${resourceIds.join('\',\'')}') | ||||
| 				AND is_associated = 1 | ||||
| 			`) as Row[]; | ||||
|  | ||||
| 			const resourceIdToNotes: Record<string, Row[]> = {}; | ||||
|  | ||||
| 			for (const r of noteResourceAssociations) { | ||||
| 				if (!resourceIdToNotes[r.resource_id]) resourceIdToNotes[r.resource_id] = []; | ||||
| 				resourceIdToNotes[r.resource_id].push(r); | ||||
| 			} | ||||
|  | ||||
| 			let hasCreatedResources = false; | ||||
|  | ||||
| 			for (const [resourceId, rows] of Object.entries(resourceIdToNotes)) { | ||||
| 				if (rows.length <= 1) continue; | ||||
|  | ||||
| 				for (let i = 0; i < rows.length - 1; i++) { | ||||
| 					const row = rows[i]; | ||||
| 					const note: NoteEntity = await Note.load(row.note_id); | ||||
| 					if (!note) continue; // probably got deleted in the meantime? | ||||
| 					const newResource = await Resource.duplicateResource(resourceId); | ||||
| 					logger.info(`updateResourceShareIds: Automatically created resource "${newResource.id}" to replace resource "${resourceId}" because it is shared and duplicate across notes:`, row); | ||||
| 					const regex = new RegExp(resourceId, 'gi'); | ||||
| 					const newBody = note.body.replace(regex, newResource.id); | ||||
| 					await Note.save({ | ||||
| 						id: note.id, | ||||
| 						body: newBody, | ||||
| 						parent_id: note.parent_id, | ||||
| 						updated_time: Date.now(), | ||||
| 					}, { | ||||
| 						autoTimestamp: false, | ||||
| 					}); | ||||
| 					hasCreatedResources = true; | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// If we have created resources, we refresh the note/resource | ||||
| 			// associations using ResourceService and we resume the process. | ||||
| 			// Normally, if the user didn't create any new notes or resources in | ||||
| 			// the meantime, the second loop should find that each shared | ||||
| 			// resource is associated with only one note. | ||||
|  | ||||
| 			if (hasCreatedResources) { | ||||
| 				await resourceService.indexNoteResources(); | ||||
| 				continue; | ||||
| 			} else { | ||||
| 				// If all is good, we can set the share_id and is_shared | ||||
| 				// property of the resource. | ||||
| 				for (const row of rows) { | ||||
| 					await Resource.save({ | ||||
| 						id: row.id, | ||||
| 						share_id: row.share_id || '', | ||||
| 						is_shared: row.is_shared, | ||||
| 						updated_time: Date.now(), | ||||
| 					}, { autoTimestamp: false }); | ||||
| 				} | ||||
| 				return; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		throw new Error('Failed to update resource share IDs'); | ||||
| 	} | ||||
|  | ||||
| 	public static async updateAllShareIds() { | ||||
| 	public static async updateAllShareIds(resourceService: ResourceService) { | ||||
| 		await this.updateFolderShareIds(); | ||||
| 		await this.updateNoteShareIds(); | ||||
| 		await this.updateResourceShareIds(); | ||||
| 		await this.updateResourceShareIds(resourceService); | ||||
| 	} | ||||
|  | ||||
| 	// Clear the "share_id" property for the items that are associated with a | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import EncryptionService from '../e2ee/EncryptionService'; | ||||
| import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword } from '../e2ee/ppk'; | ||||
| import { MasterKeyEntity } from '../e2ee/types'; | ||||
| import { getMasterPassword } from '../e2ee/utils'; | ||||
| import ResourceService from '../ResourceService'; | ||||
| import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils'; | ||||
| import { ShareInvitation, State, stateRootKey, StateShare } from './reducer'; | ||||
|  | ||||
| @@ -122,7 +123,7 @@ export default class ShareService { | ||||
| 		// Note: race condition if the share is created but the app crashes | ||||
| 		// before setting share_id on the folder. See unshareFolder() for info. | ||||
| 		await Folder.save({ id: folder.id, share_id: share.id }); | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(ResourceService.instance()); | ||||
|  | ||||
| 		return share; | ||||
| 	} | ||||
| @@ -167,7 +168,7 @@ export default class ShareService { | ||||
|  | ||||
| 		// It's ok if updateAllShareIds() doesn't run because it's executed on | ||||
| 		// each sync too. | ||||
| 		await Folder.updateAllShareIds(); | ||||
| 		await Folder.updateAllShareIds(ResourceService.instance()); | ||||
| 	} | ||||
|  | ||||
| 	// This is when a share recipient decides to leave the shared folder. | ||||
|   | ||||
		Reference in New Issue
	
	Block a user