diff --git a/packages/app-desktop/package.json b/packages/app-desktop/package.json index 4caf958c0..f9e3ae623 100644 --- a/packages/app-desktop/package.json +++ b/packages/app-desktop/package.json @@ -1,6 +1,6 @@ { "name": "@joplin/app-desktop", - "version": "2.13.7", + "version": "2.13.8", "description": "Joplin for Desktop", "main": "main.js", "private": true, diff --git a/packages/lib/models/Folder.ts b/packages/lib/models/Folder.ts index 5b78c66b2..25d687bf6 100644 --- a/packages/lib/models/Folder.ts +++ b/packages/lib/models/Folder.ts @@ -1,4 +1,4 @@ -import { defaultFolderIcon, FolderEntity, FolderIcon, NoteEntity } from '../services/database/types'; +import { defaultFolderIcon, FolderEntity, FolderIcon, NoteEntity, ResourceEntity } from '../services/database/types'; import BaseModel, { DeleteOptions } from '../BaseModel'; import time from '../time'; import { _ } from '../locale'; @@ -411,14 +411,21 @@ export default class Folder extends BaseItem { // resume the process from the start (thus the loop) so that we deal // with the right note/resource associations. + interface Row { + id: string; + share_id: string; + is_shared: number; + resource_is_shared: number; + } + 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. - const rows = await this.db().selectAll(` - SELECT r.id, n.share_id, n.is_shared + const rows = (await this.db().selectAll(` + SELECT r.id, n.share_id, n.is_shared, r.is_shared as resource_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 @@ -426,7 +433,7 @@ export default class Folder extends BaseItem { n.share_id != r.share_id OR n.is_shared != r.is_shared ) AND nr.is_associated = 1 - `); + `)) as Row[]; if (!rows.length) return; @@ -434,7 +441,7 @@ export default class Folder extends BaseItem { const resourceIds = rows.map(r => r.id); - interface Row { + interface NoteResourceRow { resource_id: string; note_id: string; share_id: string; @@ -450,9 +457,9 @@ export default class Folder extends BaseItem { LEFT JOIN notes ON notes.id = note_resources.note_id WHERE resource_id IN ('${resourceIds.join('\',\'')}') AND is_associated = 1 - `) as Row[]; + `) as NoteResourceRow[]; - const resourceIdToNotes: Record = {}; + const resourceIdToNotes: Record = {}; for (const r of noteResourceAssociations) { if (!resourceIdToNotes[r.resource_id]) resourceIdToNotes[r.resource_id] = []; @@ -496,13 +503,20 @@ export default class Folder extends BaseItem { } else { // If all is good, we can set the share_id and is_shared // property of the resource. + const now = Date.now(); for (const row of rows) { - await Resource.save({ + const resource: ResourceEntity = { id: row.id, share_id: row.share_id || '', is_shared: row.is_shared, - updated_time: Date.now(), - }, { autoTimestamp: false }); + updated_time: now, + }; + + if (row.is_shared !== row.resource_is_shared) { + resource.blob_updated_time = now; + } + + await Resource.save(resource, { autoTimestamp: false }); } return; } diff --git a/packages/lib/services/share/ShareService.test.ts b/packages/lib/services/share/ShareService.test.ts index 6ecfba458..2c2d37139 100644 --- a/packages/lib/services/share/ShareService.test.ts +++ b/packages/lib/services/share/ShareService.test.ts @@ -1,9 +1,9 @@ import Note from '../../models/Note'; -import { createFolderTree, encryptionService, loadEncryptionMasterKey, msleep, resourceService, setupDatabaseAndSynchronizer, simulateReadOnlyShareEnv, supportDir, switchClient } from '../../testing/test-utils'; +import { createFolderTree, encryptionService, loadEncryptionMasterKey, msleep, resourceService, setupDatabaseAndSynchronizer, simulateReadOnlyShareEnv, supportDir, switchClient, synchronizerStart } from '../../testing/test-utils'; import ShareService from './ShareService'; import reducer, { defaultState } from '../../reducer'; import { createStore } from 'redux'; -import { NoteEntity } from '../database/types'; +import { NoteEntity, ResourceEntity } from '../database/types'; import Folder from '../../models/Folder'; import { setEncryptionEnabled, setPpk } from '../synchronizer/syncInfoUtils'; import { generateKeyPair } from '../e2ee/ppk'; @@ -18,6 +18,7 @@ import BaseItem from '../../models/BaseItem'; import ResourceService from '../ResourceService'; import Setting from '../../models/Setting'; import { ModelType } from '../../BaseModel'; +import { remoteNotesFoldersResources } from '../../testing/test-utils-synchronizer'; interface TestShareFolderServiceOptions { master_key_id?: string; @@ -36,6 +37,18 @@ function mockService(api: any) { return service; } +const mockServiceForNoteSharing = () => { + return mockService({ + exec: (method: string, path = '', _query: Record = null, _body: any = null, _headers: any = null, _options: any = null): Promise => { + if (method === 'GET' && path === 'api/shares') return { items: [] } as any; + return null; + }, + personalizedUserContentBaseUrl(_userId: string) { + + }, + }); +}; + describe('ShareService', () => { beforeEach(async () => { @@ -45,15 +58,7 @@ describe('ShareService', () => { it('should not change the note user timestamps when sharing or unsharing', async () => { let note = await Note.save({}); - const service = mockService({ - exec: (method: string, path = '', _query: Record = null, _body: any = null, _headers: any = null, _options: any = null): Promise => { - if (method === 'GET' && path === 'api/shares') return { items: [] } as any; - return null; - }, - personalizedUserContentBaseUrl(_userId: string) { - - }, - }); + const service = mockServiceForNoteSharing(); await msleep(1); await service.shareNote(note.id, false); @@ -82,6 +87,46 @@ describe('ShareService', () => { } }); + it('should not encrypt items that are shared', async () => { + const folder = await Folder.save({}); + const note = await Note.save({ parent_id: folder.id }); + await shim.attachFileToNote(note, testImagePath); + + const service = mockServiceForNoteSharing(); + + setEncryptionEnabled(true); + await loadEncryptionMasterKey(); + + await synchronizerStart(); + + let previousBlobUpdatedTime = Infinity; + { + const allItems = await remoteNotesFoldersResources(); + expect(allItems.map(it => it.encryption_applied)).toEqual([1, 1, 1]); + previousBlobUpdatedTime = allItems.find(it => it.type_ === ModelType.Resource).blob_updated_time; + } + + await service.shareNote(note.id, false); + await msleep(1); + await Folder.updateAllShareIds(resourceService()); + + await synchronizerStart(); + + { + const allItems = await remoteNotesFoldersResources(); + expect(allItems.find(it => it.type_ === ModelType.Note).encryption_applied).toBe(0); + expect(allItems.find(it => it.type_ === ModelType.Folder).encryption_applied).toBe(1); + + const resource: ResourceEntity = allItems.find(it => it.type_ === ModelType.Resource); + expect(resource.encryption_applied).toBe(0); + + // Indicates that both the metadata and blob have been decrypted on + // the sync target. + expect(resource.blob_updated_time).toBe(resource.updated_time); + expect(resource.blob_updated_time).toBeGreaterThan(previousBlobUpdatedTime); + } + }); + // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied function testShareFolderService(extraExecHandlers: Record = {}, options: TestShareFolderServiceOptions = {}) { return mockService({