feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,607 @@
|
||||
import Note from '../../models/Note';
|
||||
import { createFolderTree, encryptionService, loadEncryptionMasterKey, msleep, resourceService, setupDatabaseAndSynchronizer, simulateReadOnlyShareEnv, supportDir, switchClient, synchronizerStart } from '../../testing/test-utils';
|
||||
import ShareService, { ApiShare } from './ShareService';
|
||||
import { ShareType, StateShare } from './reducer';
|
||||
import { NoteEntity, ResourceEntity } from '../database/types';
|
||||
import Folder from '../../models/Folder';
|
||||
import { localSyncInfo, setEncryptionEnabled, setPpk } from '../synchronizer/syncInfoUtils';
|
||||
import { generateKeyPair } from '../e2ee/ppk/ppk';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { generateMasterKeyAndEnableEncryption, loadMasterKeysFromSettings, setupAndEnableEncryption, updateMasterPassword } from '../e2ee/utils';
|
||||
import Logger, { LogLevel } from '@joplin/utils/Logger';
|
||||
import shim from '../../shim';
|
||||
import Resource from '../../models/Resource';
|
||||
import { readFile } from 'fs-extra';
|
||||
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';
|
||||
import mockShareService from '../../testing/share/mockShareService';
|
||||
|
||||
const testImagePath = `${supportDir}/photo.jpg`;
|
||||
|
||||
|
||||
const mockServiceForNoteSharing = () => {
|
||||
return mockShareService({
|
||||
getShares: async () => {
|
||||
return { items: [] };
|
||||
},
|
||||
postShares: async () => null,
|
||||
getShareInvitations: async () => null,
|
||||
});
|
||||
};
|
||||
|
||||
describe('ShareService', () => {
|
||||
|
||||
jest.retryTimes(2);
|
||||
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
});
|
||||
|
||||
it('should not change the note user timestamps when sharing or unsharing', async () => {
|
||||
let note = await Note.save({});
|
||||
const service = mockServiceForNoteSharing();
|
||||
await msleep(1);
|
||||
await service.shareNote(note.id, false);
|
||||
|
||||
function checkTimestamps(previousNote: NoteEntity, newNote: NoteEntity) {
|
||||
// After sharing or unsharing, only the updated_time property should
|
||||
// be updated, for sync purposes. All other timestamps shouldn't
|
||||
// change.
|
||||
expect(previousNote.user_created_time).toBe(newNote.user_created_time);
|
||||
expect(previousNote.user_updated_time).toBe(newNote.user_updated_time);
|
||||
expect(previousNote.updated_time < newNote.updated_time).toBe(true);
|
||||
expect(previousNote.created_time).toBe(newNote.created_time);
|
||||
}
|
||||
|
||||
{
|
||||
const noteReloaded = await Note.load(note.id);
|
||||
checkTimestamps(note, noteReloaded);
|
||||
note = noteReloaded;
|
||||
}
|
||||
|
||||
await msleep(1);
|
||||
await service.unshareNote(note.id);
|
||||
|
||||
{
|
||||
const noteReloaded = await Note.load(note.id);
|
||||
checkTimestamps(note, noteReloaded);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
function testShareFolderService(extraExecHandlers: Record<string, (query: unknown, body: unknown)=> unknown> = {}) {
|
||||
let nextShareId = 1;
|
||||
let shares: ApiShare[] = [];
|
||||
const shareByFolderId = (folderId: string) => {
|
||||
return shares.find(share => share.folder_id === folderId);
|
||||
};
|
||||
|
||||
return mockShareService({
|
||||
onExec: async (method: string, path: string, query: Record<string, unknown>, body: unknown) => {
|
||||
if (extraExecHandlers[`${method} ${path}`]) return extraExecHandlers[`${method} ${path}`](query, body);
|
||||
|
||||
if (method === 'GET' && path === 'api/shares') {
|
||||
return {
|
||||
items: [...shares],
|
||||
};
|
||||
}
|
||||
|
||||
if (method === 'POST' && path === 'api/shares') {
|
||||
const b = body as { folder_id: string; master_key_id: string };
|
||||
// Return the existing share, if it exists. This is to match the behavior
|
||||
// of Joplin Server.
|
||||
const existingShare = shareByFolderId(b.folder_id);
|
||||
if (existingShare) {
|
||||
return existingShare;
|
||||
}
|
||||
|
||||
// Use a predictable ID:
|
||||
const id = `share_${nextShareId++}`;
|
||||
|
||||
const share = {
|
||||
id,
|
||||
master_key_id: b.master_key_id,
|
||||
folder_id: b.folder_id,
|
||||
};
|
||||
shares.push(share);
|
||||
return share;
|
||||
}
|
||||
|
||||
if (method === 'DELETE' && path.startsWith('api/shares/')) {
|
||||
const id = path.replace(/^api\/shares\//, '');
|
||||
shares = shares.filter(share => share.id !== id);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled: ${method} ${path}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const prepareNoteFolderResource = async () => {
|
||||
const folder = await Folder.save({});
|
||||
let note = await Note.save({ parent_id: folder.id });
|
||||
note = await shim.attachFileToNote(note, testImagePath);
|
||||
const resourceId = (await Note.linkedResourceIds(note.body))[0];
|
||||
const resource = await Resource.load(resourceId);
|
||||
|
||||
await resourceService().indexNoteResources();
|
||||
|
||||
return { folder, note, resource };
|
||||
};
|
||||
|
||||
async function testShareFolder(service: ShareService) {
|
||||
const { folder, note, resource } = await prepareNoteFolderResource();
|
||||
const share = await service.shareFolder(folder.id);
|
||||
expect(share.id).toBe('share_1');
|
||||
expect((await Folder.load(folder.id)).share_id).toBe('share_1');
|
||||
expect((await Note.load(note.id)).share_id).toBe('share_1');
|
||||
expect((await Resource.load(resource.id)).share_id).toBe('share_1');
|
||||
|
||||
return { share, folder, note, resource };
|
||||
}
|
||||
|
||||
it('should share a folder', async () => {
|
||||
await testShareFolder(testShareFolderService());
|
||||
});
|
||||
|
||||
it('should share a folder - E2EE', async () => {
|
||||
const masterKey = await loadEncryptionMasterKey();
|
||||
await setupAndEnableEncryption(encryptionService(), masterKey, '111111');
|
||||
const ppk = await generateKeyPair(encryptionService(), '111111');
|
||||
setPpk(ppk);
|
||||
|
||||
const shareService = testShareFolderService();
|
||||
|
||||
expect(await MasterKey.count()).toBe(1);
|
||||
|
||||
let { folder, note, resource } = await prepareNoteFolderResource();
|
||||
|
||||
BaseItem.shareService_ = shareService;
|
||||
Resource.shareService_ = shareService;
|
||||
|
||||
await shareService.shareFolder(folder.id);
|
||||
|
||||
await Folder.updateAllShareIds(resourceService(), []);
|
||||
|
||||
// The share service should automatically create a new encryption key
|
||||
// specifically for that shared folder
|
||||
expect(await MasterKey.count()).toBe(2);
|
||||
|
||||
folder = await Folder.load(folder.id);
|
||||
note = await Note.load(note.id);
|
||||
resource = await Resource.load(resource.id);
|
||||
|
||||
// The key that is not the master key is the folder key
|
||||
const folderKey = (await MasterKey.all()).find(mk => mk.id !== masterKey.id);
|
||||
|
||||
// Double-check that it's going to encrypt the folder using the shared
|
||||
// key (and not the user's own master key)
|
||||
expect(folderKey.id).not.toBe(masterKey.id);
|
||||
expect(folder.master_key_id).toBe(folderKey.id);
|
||||
|
||||
await loadMasterKeysFromSettings(encryptionService());
|
||||
|
||||
try {
|
||||
const serializedNote = await Note.serializeForSync(note);
|
||||
expect(serializedNote).toContain(folderKey.id);
|
||||
|
||||
// The resource should be encrypted using the above key (if it is,
|
||||
// the key ID will be in the header).
|
||||
const result = await Resource.fullPathForSyncUpload(resource);
|
||||
const content = await readFile(result.path, 'utf8');
|
||||
expect(content).toContain(folderKey.id);
|
||||
|
||||
{
|
||||
await synchronizerStart();
|
||||
const remoteItems = await remoteNotesFoldersResources();
|
||||
expect(remoteItems.map(it => it.encryption_applied)).toEqual([1, 1, 1]);
|
||||
}
|
||||
} finally {
|
||||
BaseItem.shareService_ = shareService;
|
||||
Resource.shareService_ = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('should add a recipient', async () => {
|
||||
setEncryptionEnabled(true);
|
||||
await updateMasterPassword('', '111111');
|
||||
const ppk = await generateKeyPair(encryptionService(), '111111');
|
||||
setPpk(ppk);
|
||||
const recipientPpk = await generateKeyPair(encryptionService(), '222222');
|
||||
expect(ppk.id).not.toBe(recipientPpk.id);
|
||||
|
||||
let uploadedEmail = '';
|
||||
let uploadedMasterKey: MasterKeyEntity = null;
|
||||
|
||||
const service = testShareFolderService({
|
||||
'GET api/users/toto%40example.com/public_key': async (_query, _body) => {
|
||||
return recipientPpk;
|
||||
},
|
||||
'POST api/shares/share_1/users': async (_query, body) => {
|
||||
const b = body as { email: string; master_key: string };
|
||||
uploadedEmail = b.email;
|
||||
uploadedMasterKey = JSON.parse(b.master_key);
|
||||
},
|
||||
});
|
||||
|
||||
const { share } = await testShareFolder(service);
|
||||
|
||||
await service.addShareRecipient(share.id, share.master_key_id, 'toto@example.com', { can_read: 1, can_write: 1 });
|
||||
|
||||
expect(uploadedEmail).toBe('toto@example.com');
|
||||
|
||||
const content = JSON.parse(uploadedMasterKey.content);
|
||||
expect(recipientPpk.id).toBe(content.ppkId);
|
||||
});
|
||||
|
||||
it('should not update the folder\'s master_key_id when shareFolder is called multiple times', async () => {
|
||||
await generateMasterKeyAndEnableEncryption(encryptionService(), 'testing!');
|
||||
const ppk = await generateKeyPair(encryptionService(), '111111');
|
||||
setPpk(ppk);
|
||||
|
||||
expect(localSyncInfo().e2ee).toBe(true);
|
||||
expect(localSyncInfo().masterKeys).toHaveLength(1);
|
||||
|
||||
const service = testShareFolderService();
|
||||
const { share, folder } = await testShareFolder(service);
|
||||
// Should have created a new master key for the share
|
||||
expect(localSyncInfo().masterKeys).toHaveLength(2);
|
||||
expect(
|
||||
localSyncInfo().masterKeys.some(key => key.id === share.master_key_id),
|
||||
).toBe(true);
|
||||
|
||||
// Sharing an already-shared folder should keep the existing key:
|
||||
const share2 = await service.shareFolder(folder.id);
|
||||
expect(share2.master_key_id).toBe(share.master_key_id);
|
||||
expect(await Folder.load(folder.id)).toHaveProperty('master_key_id', share.master_key_id);
|
||||
// Should not have added a new master key
|
||||
expect(localSyncInfo().masterKeys).toHaveLength(2);
|
||||
});
|
||||
|
||||
// On CI this test can randomly throw "Exceeded timeout of 90000 ms for a
|
||||
// test." because of the multiple RSA key generation and master key
|
||||
// encryption steps. Increase the timeout as a backstop in addition to the
|
||||
// suite-level `jest.retryTimes(2)`.
|
||||
it('should use a different master key when folders are unshared, then shared again', async () => {
|
||||
await generateMasterKeyAndEnableEncryption(encryptionService(), 'testing!');
|
||||
const ppk = await generateKeyPair(encryptionService(), '111111');
|
||||
setPpk(ppk);
|
||||
|
||||
const service = testShareFolderService();
|
||||
const { share, folder } = await testShareFolder(service);
|
||||
await service.refreshShares();
|
||||
await service.unshareFolder(folder.id);
|
||||
const share2 = await service.shareFolder(folder.id);
|
||||
|
||||
expect(share2.master_key_id).toBeTruthy();
|
||||
expect(share2.folder_id).toBe(folder.id);
|
||||
expect(share.master_key_id).not.toBe(share2.master_key_id);
|
||||
}, 60000 * 5);
|
||||
|
||||
it('should leave folders that are no longer with the user', async () => {
|
||||
// `checkShareConsistency` will emit a warning so we need to silent it
|
||||
// in tests.
|
||||
const previousLogLevel = Logger.globalLogger.setLevel(LogLevel.Error);
|
||||
|
||||
// checkShareConsistency only runs when the sync target supports
|
||||
// sharing (Joplin Server/Cloud).
|
||||
Setting.setValue('sync.target', 9);
|
||||
|
||||
const service = testShareFolderService({
|
||||
'GET api/shares': async (_query, _body) => {
|
||||
return {
|
||||
items: [],
|
||||
has_more: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const folder = await Folder.save({ share_id: 'nolongershared' });
|
||||
await service.checkShareConsistency();
|
||||
expect(await Folder.load(folder.id)).toBeFalsy();
|
||||
|
||||
Logger.globalLogger.setLevel(previousLogLevel);
|
||||
});
|
||||
|
||||
it('should skip share consistency check when not using a share-compatible sync target', async () => {
|
||||
// Simulate a non-Joplin Server sync target (e.g. WebDAV = 6).
|
||||
// This reproduces the scenario where a user previously used Joplin
|
||||
// Server (which set share_id on folders), then switched to WebDAV.
|
||||
// checkShareConsistency() should not attempt to call the Joplin
|
||||
// Server API since we are no longer on a share-capable sync target.
|
||||
Setting.setValue('sync.target', 6);
|
||||
|
||||
const service = mockShareService({
|
||||
onExec: async () => {
|
||||
throw new Error('Should not call the API when share is not enabled');
|
||||
},
|
||||
});
|
||||
|
||||
// Create a folder with a stale share_id leftover from Joplin Server
|
||||
const folder = await Folder.save({ share_id: 'stale_share_id' });
|
||||
|
||||
// This should not throw or attempt any API calls
|
||||
await service.checkShareConsistency();
|
||||
|
||||
// The folder should still exist since we cannot verify shares
|
||||
// without a Joplin Server API connection
|
||||
expect(await Folder.load(folder.id)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should leave a shared folder', async () => {
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder 1',
|
||||
children: [
|
||||
{
|
||||
title: 'note 1',
|
||||
},
|
||||
{
|
||||
title: 'note 2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const resourceService = new ResourceService();
|
||||
await Folder.save({ id: folder1.id, share_id: '123456789' });
|
||||
await Folder.updateAllShareIds(resourceService, []);
|
||||
|
||||
const cleanup = simulateReadOnlyShareEnv('123456789');
|
||||
|
||||
const shareService = testShareFolderService();
|
||||
await shareService.leaveSharedFolder(folder1.id, 'somethingrandom');
|
||||
|
||||
expect(await Folder.count()).toBe(0);
|
||||
expect(await Note.count()).toBe(0);
|
||||
|
||||
const deletedItems = await BaseItem.deletedItems(Setting.value('sync.target'));
|
||||
|
||||
expect(deletedItems.length).toBe(1);
|
||||
expect(deletedItems[0].item_type).toBe(ModelType.Folder);
|
||||
expect(deletedItems[0].item_id).toBe(folder1.id);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('should not return a published folder share when looking up a regular folder share', async () => {
|
||||
const service = mockShareService({
|
||||
getShares: async () => ({
|
||||
items: [{
|
||||
id: 'share1',
|
||||
type: ShareType.PublishedFolder,
|
||||
folder_id: 'folder1',
|
||||
note_id: '',
|
||||
master_key_id: '',
|
||||
}],
|
||||
}),
|
||||
postShares: async () => null,
|
||||
getShareInvitations: async () => ({ items: [] }),
|
||||
});
|
||||
|
||||
await service.refreshShares();
|
||||
|
||||
expect(service.folderShare('folder1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should unpublish a folder and all its children', async () => {
|
||||
const folderA = await Folder.save({ title: 'folder A' });
|
||||
const folderB = await Folder.save({ title: 'folder B', parent_id: folderA.id });
|
||||
const noteA = await Note.save({ title: 'note A', parent_id: folderA.id });
|
||||
const noteB = await Note.save({ title: 'note B', parent_id: folderB.id });
|
||||
|
||||
let serverShares: StateShare[] = [];
|
||||
const deletedShareIds: string[] = [];
|
||||
const service = mockShareService({
|
||||
onExec: async (method, path) => {
|
||||
if (method === 'GET' && path === 'api/shares') {
|
||||
return { items: serverShares };
|
||||
}
|
||||
if (method === 'POST' && path === 'api/shares') {
|
||||
const share: StateShare = {
|
||||
id: 'published-folder-a',
|
||||
type: ShareType.PublishedFolder,
|
||||
folder_id: folderA.id,
|
||||
note_id: '',
|
||||
master_key_id: '',
|
||||
};
|
||||
serverShares.push(share);
|
||||
return share;
|
||||
}
|
||||
if (method === 'DELETE' && path.startsWith('api/shares/')) {
|
||||
const shareId = path.substring('api/shares/'.length);
|
||||
deletedShareIds.push(shareId);
|
||||
serverShares = serverShares.filter(share => share.id !== shareId);
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Unhandled: ${method} ${path}`);
|
||||
},
|
||||
});
|
||||
|
||||
await service.publishFolder(folderA.id);
|
||||
await Folder.save({ id: folderA.id, is_shared: 1 });
|
||||
await Folder.save({ id: folderB.id, is_shared: 1 });
|
||||
await Note.save({ id: noteA.id, parent_id: folderA.id, is_shared: 1 });
|
||||
await Note.save({ id: noteB.id, parent_id: folderB.id, is_shared: 1 });
|
||||
|
||||
expect((await Folder.load(folderA.id)).is_shared).toBe(1);
|
||||
expect((await Folder.load(folderB.id)).is_shared).toBe(1);
|
||||
expect((await Note.load(noteA.id)).is_shared).toBe(1);
|
||||
expect((await Note.load(noteB.id)).is_shared).toBe(1);
|
||||
|
||||
await service.unpublishFolder(folderA.id);
|
||||
|
||||
expect(deletedShareIds).toEqual(['published-folder-a']);
|
||||
expect((await Folder.load(folderA.id)).is_shared).toBe(0);
|
||||
expect((await Folder.load(folderB.id)).is_shared).toBe(0);
|
||||
expect((await Note.load(noteA.id)).is_shared).toBe(0);
|
||||
expect((await Note.load(noteB.id)).is_shared).toBe(0);
|
||||
});
|
||||
|
||||
it('should recover if app closes while unpublishing a folder', async () => {
|
||||
const folder = await Folder.save({ title: 'folder' });
|
||||
const note = await Note.save({ title: 'note', parent_id: folder.id });
|
||||
const share = {
|
||||
id: 'published-folder',
|
||||
type: ShareType.PublishedFolder,
|
||||
folder_id: folder.id,
|
||||
};
|
||||
let serverShares = [share];
|
||||
const service = mockShareService({
|
||||
onExec: async (method, path) => {
|
||||
if (method === 'GET' && path === 'api/shares') return { items: serverShares };
|
||||
if (method === 'DELETE' && path === `api/shares/${share.id}`) {
|
||||
serverShares = [];
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Unhandled: ${method} ${path}`);
|
||||
},
|
||||
});
|
||||
|
||||
await service.refreshShares();
|
||||
await Folder.save({ id: folder.id, is_shared: 1 });
|
||||
await Note.save({ id: note.id, parent_id: folder.id, is_shared: 1 });
|
||||
|
||||
const updateShareStatusSpy = jest.spyOn(Folder, 'updateShareStatus').mockImplementationOnce(async (item, isShared) => {
|
||||
await BaseItem.updateShareStatus(item, isShared);
|
||||
throw new Error('App closed');
|
||||
});
|
||||
try {
|
||||
await expect(service.unpublishFolder(folder.id)).rejects.toThrow('App closed');
|
||||
} finally {
|
||||
updateShareStatusSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(serverShares).toEqual([share]);
|
||||
|
||||
const restartedService = mockShareService({
|
||||
getShares: async () => ({ items: serverShares }),
|
||||
postShares: async () => null,
|
||||
getShareInvitations: async () => ({ items: [] }),
|
||||
});
|
||||
await restartedService.refreshShares();
|
||||
await Folder.updateAllShareIds(resourceService(), restartedService.shares);
|
||||
|
||||
expect((await Folder.load(folder.id)).is_shared).toBe(1);
|
||||
expect((await Note.load(note.id)).is_shared).toBe(1);
|
||||
});
|
||||
|
||||
it('should keep a separately published child folder and child note published', async () => {
|
||||
const folderA = await Folder.save({ title: 'folder A' });
|
||||
const folderB = await Folder.save({ title: 'folder B', parent_id: folderA.id });
|
||||
const noteA = await Note.save({ title: 'note A', parent_id: folderA.id });
|
||||
const publishedFolderAShare = {
|
||||
id: 'published-folder-a',
|
||||
type: ShareType.PublishedFolder,
|
||||
folder_id: folderA.id,
|
||||
};
|
||||
const publishedFolderBShare = {
|
||||
id: 'published-folder-b',
|
||||
type: ShareType.PublishedFolder,
|
||||
folder_id: folderB.id,
|
||||
};
|
||||
const publishedNoteAShare = {
|
||||
id: 'published-note-a',
|
||||
type: ShareType.Note,
|
||||
note_id: noteA.id,
|
||||
};
|
||||
let sharesReturnedByServer = [publishedFolderAShare, publishedFolderBShare, publishedNoteAShare];
|
||||
const service = mockShareService({
|
||||
getShares: async () => ({ items: sharesReturnedByServer }),
|
||||
postShares: async () => null,
|
||||
getShareInvitations: async () => ({ items: [] }),
|
||||
});
|
||||
|
||||
await service.refreshShares();
|
||||
await Folder.save({ id: folderA.id, is_shared: 1 });
|
||||
await Folder.save({ id: folderB.id, is_shared: 1 });
|
||||
await Note.save({ id: noteA.id, parent_id: folderA.id, is_shared: 1 });
|
||||
sharesReturnedByServer = [publishedFolderBShare, publishedNoteAShare];
|
||||
|
||||
await service.unpublishFolder(folderA.id);
|
||||
|
||||
expect(service.shares).toEqual([publishedFolderBShare, publishedNoteAShare]);
|
||||
expect((await Folder.load(folderA.id)).is_shared).toBe(0);
|
||||
expect((await Folder.load(folderB.id)).is_shared).toBe(1);
|
||||
expect((await Note.load(noteA.id)).is_shared).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw when the folder or its published share does not exist', async () => {
|
||||
const folderA = await Folder.save({ title: 'folder A' });
|
||||
let deleteCallCount = 0;
|
||||
const service = mockShareService({
|
||||
onExec: async (method, path) => {
|
||||
if (method === 'GET' && path === 'api/shares') {
|
||||
return { items: [] };
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
deleteCallCount++;
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Unhandled: ${method} ${path}`);
|
||||
},
|
||||
});
|
||||
|
||||
await service.refreshShares();
|
||||
|
||||
await expect(service.unpublishFolder('000000000000000000000000000000F9')).rejects.toThrow('No such folder: 000000000000000000000000000000F9');
|
||||
await expect(service.unpublishFolder(folderA.id)).rejects.toThrow(`No published share for folder: ${folderA.id}`);
|
||||
expect(deleteCallCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should throw when publishing a folder that does not exist', async () => {
|
||||
const service = mockShareService({
|
||||
getShares: async () => ({ items: [] }),
|
||||
postShares: async () => null,
|
||||
getShareInvitations: async () => ({ items: [] }),
|
||||
});
|
||||
|
||||
await expect(service.publishFolder('000000000000000000000000000000F9')).rejects.toThrow('No such folder: 000000000000000000000000000000F9');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,689 @@
|
||||
import { Store } from 'redux';
|
||||
import { ModelType } from '../../BaseModel';
|
||||
import JoplinServerApi from '../../JoplinServerApi';
|
||||
import { _ } from '../../locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import Folder from '../../models/Folder';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import Note from '../../models/Note';
|
||||
import Setting from '../../models/Setting';
|
||||
import { FolderEntity, NoteEntity } from '../database/types';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword, supportsPpkAlgorithm } from '../e2ee/ppk/ppk';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { getMasterPassword } from '../e2ee/utils';
|
||||
import ResourceService from '../ResourceService';
|
||||
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
import { ShareInvitation, SharePermissions, ShareType, State, stateRootKey, StateShare } from './reducer';
|
||||
import PerformanceLogger from '../../PerformanceLogger';
|
||||
|
||||
const logger = Logger.create('ShareService');
|
||||
const perfLogger = PerformanceLogger.create();
|
||||
|
||||
export interface ApiShare {
|
||||
id: string;
|
||||
master_key_id: string;
|
||||
folder_id?: string;
|
||||
note_id?: string;
|
||||
type?: number;
|
||||
}
|
||||
|
||||
export interface ApiTeamUsers {
|
||||
items: { email: string }[];
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
function formatShareInvitations(invitations: (Omit<ShareInvitation, 'master_key'> & { master_key: string | MasterKeyEntity | null })[]): ShareInvitation[] {
|
||||
return invitations.map(inv => {
|
||||
return {
|
||||
...inv,
|
||||
master_key: inv.master_key && typeof inv.master_key === 'string' ? JSON.parse(inv.master_key) : inv.master_key,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default class ShareService {
|
||||
|
||||
private static instance_: ShareService;
|
||||
private api_: JoplinServerApi = null;
|
||||
private store_: Store<unknown> = null;
|
||||
private encryptionService_: EncryptionService = null;
|
||||
private initialized_ = false;
|
||||
|
||||
public static instance(): ShareService {
|
||||
if (this.instance_) return this.instance_;
|
||||
this.instance_ = new ShareService();
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public initialize(store: Store<unknown>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
|
||||
this.initialized_ = true;
|
||||
this.store_ = store;
|
||||
this.encryptionService_ = encryptionService;
|
||||
this.api_ = api;
|
||||
}
|
||||
|
||||
public get enabled(): boolean {
|
||||
if (!this.initialized_) return false;
|
||||
return [9, 10, 11].includes(Setting.value('sync.target')); // Joplin Server, Joplin Cloud targets
|
||||
}
|
||||
|
||||
private get store(): Store<unknown> {
|
||||
return this.store_;
|
||||
}
|
||||
|
||||
public get state(): State {
|
||||
return (this.store.getState() as Record<string, unknown>)[stateRootKey] as State;
|
||||
}
|
||||
|
||||
public get userId(): string {
|
||||
return this.api() ? this.api().userId : '';
|
||||
}
|
||||
|
||||
private api(): JoplinServerApi {
|
||||
if (this.api_) return this.api_;
|
||||
|
||||
const syncTargetId = Setting.value('sync.target');
|
||||
|
||||
this.api_ = new JoplinServerApi({
|
||||
baseUrl: () => Setting.value(`sync.${syncTargetId}.path`),
|
||||
userContentBaseUrl: () => Setting.value(`sync.${syncTargetId}.userContentPath`),
|
||||
username: () => Setting.value(`sync.${syncTargetId}.username`),
|
||||
password: () => Setting.value(`sync.${syncTargetId}.password`),
|
||||
apiKey: () => Setting.value(`sync.${syncTargetId}.apiKey`),
|
||||
session: () => {
|
||||
if (syncTargetId === 11) {
|
||||
return {
|
||||
id: Setting.value('sync.11.id'),
|
||||
user_id: Setting.value('sync.11.userId'),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public async shareFolder(folderId: string): Promise<ApiShare> {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
let folderMasterKey: MasterKeyEntity = null;
|
||||
|
||||
if (getEncryptionEnabled()) {
|
||||
const syncInfo = localSyncInfo();
|
||||
|
||||
// Shouldn't happen
|
||||
if (!syncInfo.ppk) throw new Error('Cannot share notebook because E2EE is enabled and no Public Private Key pair exists.');
|
||||
|
||||
// This can happen if using an outdated Joplin client, where the PPK
|
||||
// was generated on a different device with a newer PPK implementation.
|
||||
if (!supportsPpkAlgorithm(syncInfo.ppk)) {
|
||||
throw new Error('The local public private key pair uses an unsupported algorithm. It may be necessary to upgrade Joplin or share from a different device.');
|
||||
}
|
||||
|
||||
if (folder.master_key_id) {
|
||||
logger.info(`Folder ${folderId}'s already has a master key. Not creating a new one.`);
|
||||
} else {
|
||||
// TODO: handle "undefinedMasterPassword" error - show master password dialog
|
||||
folderMasterKey = await this.encryptionService_.generateMasterKey(getMasterPassword());
|
||||
folderMasterKey = await MasterKey.save(folderMasterKey);
|
||||
|
||||
addMasterKey(syncInfo, folderMasterKey);
|
||||
}
|
||||
}
|
||||
|
||||
const newFolderProps: FolderEntity = {};
|
||||
|
||||
if (folder.parent_id) newFolderProps.parent_id = '';
|
||||
if (folderMasterKey) newFolderProps.master_key_id = folderMasterKey.id;
|
||||
|
||||
if (Object.keys(newFolderProps).length) {
|
||||
await Folder.save({
|
||||
id: folder.id,
|
||||
...newFolderProps,
|
||||
});
|
||||
}
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, {
|
||||
folder_id: folderId,
|
||||
master_key_id: folderMasterKey ? folderMasterKey.id : '',
|
||||
});
|
||||
|
||||
// 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(ResourceService.instance(), this.shares);
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
// This allows the notebook owner to stop sharing it. For a recipient to
|
||||
// leave the shared notebook, see the leaveSharedFolder command.
|
||||
public async unshareFolder(folderId: string) {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
const share = this.shares.find(s => s.folder_id === folderId);
|
||||
if (!share) throw new Error(`No share for folder: ${folderId}`);
|
||||
|
||||
// First, delete the share - which in turns is going to remove the items
|
||||
// for all users, except the owner.
|
||||
await this.deleteShare(share.id);
|
||||
|
||||
// Clear the master_key_id so that the folder uses a new master key if
|
||||
// shared again.
|
||||
// TODO: Remove the now-unused master key from local sync info.
|
||||
await Folder.save({ id: folderId, master_key_id: '' });
|
||||
|
||||
// Then reset the "share_id" field for the folder and all sub-items.
|
||||
// This could potentially be done server-side, when deleting the share,
|
||||
// but since clients are normally responsible for maintaining the
|
||||
// share_id property, we do it here for consistency. It will also avoid
|
||||
// conflicts because changes will come only from the clients.
|
||||
//
|
||||
// Note that there could be a race condition here if the share is
|
||||
// deleted, but the app crashes just before setting share_id to "". It's
|
||||
// very unlikely to happen so we leave like this for now.
|
||||
//
|
||||
// We could potentially have a clean up process at some point:
|
||||
//
|
||||
// - It would download all share objects
|
||||
// - Then look for all items where the share_id is not in any of these
|
||||
// shares objects
|
||||
// - And set those to ""
|
||||
//
|
||||
// Likewise, it could apply the share_id to folders based on
|
||||
// share.folder_id
|
||||
//
|
||||
// Setting the share_id is not critical - what matters is that when the
|
||||
// share is deleted, other users no longer have access to the item, so
|
||||
// can't change or read them.
|
||||
await Folder.save({ id: folder.id, share_id: '' });
|
||||
|
||||
// It's ok if updateAllShareIds() doesn't run because it's executed on
|
||||
// each sync too.
|
||||
await Folder.updateAllShareIds(ResourceService.instance(), this.shares);
|
||||
}
|
||||
|
||||
// This is when a share recipient decides to leave the shared folder.
|
||||
//
|
||||
// In that case we delete the root folder. Deleting the folder tells the
|
||||
// server that we want to leave the share.
|
||||
//
|
||||
// We also immediately delete the children, but we do not sync the changes
|
||||
// otherwise it would delete the items for other users too.
|
||||
//
|
||||
// If we do not delete them now it would also cause all kind of issues with
|
||||
// read-only shares, because the read-only status will be lost after the
|
||||
// deletion of the root folder, which means various services may modify the
|
||||
// data. The changes will then be rejected by the sync target and cause
|
||||
// conflicts.
|
||||
//
|
||||
// We do not need to sync the children deletion, because the server will
|
||||
// take care of deleting all associated user_items. So eventually all the
|
||||
// notebook content will also be deleted for the current user.
|
||||
//
|
||||
// If `folderShareUserId` is provided, the function will check that the user
|
||||
// does not own the share. It would be an error to leave such a folder
|
||||
// (instead "unshareFolder" should be called).
|
||||
public async leaveSharedFolder(folderId: string, folderShareUserId: string = null): Promise<void> {
|
||||
if (folderShareUserId !== null) {
|
||||
const userId = Setting.value('sync.userId');
|
||||
if (folderShareUserId === userId) throw new Error('Cannot leave own notebook');
|
||||
}
|
||||
|
||||
const folder = await Folder.load(folderId);
|
||||
|
||||
// We call this to make sure all items are correctly linked before we
|
||||
// call deleteAllByShareId()
|
||||
await Folder.updateAllShareIds(ResourceService.instance(), this.shares);
|
||||
|
||||
const source = 'ShareService.leaveSharedFolder';
|
||||
await Folder.delete(folderId, { deleteChildren: false, disableReadOnlyCheck: true, sourceDescription: source });
|
||||
await Folder.deleteAllByShareId(folder.share_id, { disableReadOnlyCheck: true, trackDeleted: false, sourceDescription: source });
|
||||
}
|
||||
|
||||
// Finds any folder that is associated with a share, but the user no longer
|
||||
// has access to the share, and remove these folders. This check is
|
||||
// necessary otherwise sync will try to update items that are not longer
|
||||
// accessible and will throw the error "Could not find share with ID: xxxx")
|
||||
public async checkShareConsistency() {
|
||||
// Sharing is only supported on Joplin Server/Cloud. If the user is
|
||||
// using a different sync target, there is no share API to query and
|
||||
// any share_id values on folders are stale leftovers from a previous
|
||||
// sync target configuration.
|
||||
if (!this.enabled) return;
|
||||
|
||||
const rootSharedFolders = await Folder.rootSharedFolders(this.shares);
|
||||
let hasRefreshedShares = false;
|
||||
let shares = this.shares;
|
||||
|
||||
for (const folder of rootSharedFolders) {
|
||||
let share = shares.find(s => s.id === folder.share_id);
|
||||
|
||||
if (!share && !hasRefreshedShares) {
|
||||
shares = await this.refreshShares();
|
||||
share = shares.find(s => s.id === folder.share_id);
|
||||
hasRefreshedShares = true;
|
||||
}
|
||||
|
||||
if (!share) {
|
||||
// This folder is a associated with a share, but the user no
|
||||
// longer has access to this share. It can happen for two
|
||||
// reasons:
|
||||
//
|
||||
// - It no longer exists
|
||||
// - Or the user rejected that share from a different device,
|
||||
// and the folder was not deleted as it should have been.
|
||||
//
|
||||
// In that case we need to leave the notebook.
|
||||
logger.warn(`Found a folder that was associated with a share, but the user not longer has access to the share - leaving the folder. Folder: ${folder.title} (${folder.id}). Share: ${folder.share_id}`);
|
||||
await this.leaveSharedFolder(folder.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async shareNote(noteId: string, recursive: boolean): Promise<StateShare> {
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) throw new Error(`No such note: ${noteId}`);
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, {
|
||||
note_id: noteId,
|
||||
recursive: recursive ? 1 : 0,
|
||||
});
|
||||
|
||||
await Note.save({
|
||||
id: note.id,
|
||||
parent_id: note.parent_id,
|
||||
is_shared: 1,
|
||||
updated_time: Date.now(),
|
||||
}, {
|
||||
autoTimestamp: false,
|
||||
});
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
public async publishFolder(folderId: string): Promise<StateShare> {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, {
|
||||
folder_id: folderId,
|
||||
type: ShareType.PublishedFolder,
|
||||
});
|
||||
|
||||
await this.refreshShares();
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
public async unpublishFolder(folderId: string): Promise<void> {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
const share = this.shares.find(s => s.type === ShareType.PublishedFolder && s.folder_id === folderId);
|
||||
if (!share) throw new Error(`No published share for folder: ${folderId}`);
|
||||
|
||||
const remainingShares = this.shares.filter(s => s.id !== share.id);
|
||||
const folderIds = [folderId, ...(await Folder.allChildrenFolders(folderId)).map(f => f.id)];
|
||||
const folders = await Folder.loadItemsByIds(folderIds) as FolderEntity[];
|
||||
const noteIds = (await Promise.all(folderIds.map(id => Folder.noteIds(id, { includeConflicts: true, includeDeleted: true })))).flat();
|
||||
const notes = await Note.loadItemsByIds(noteIds) as NoteEntity[];
|
||||
const directlyPublishedNoteIds = new Set(remainingShares
|
||||
.filter(s => s.type === ShareType.Note && !!s.note_id)
|
||||
.map(s => s.note_id));
|
||||
|
||||
for (const folderItem of folders) {
|
||||
await Folder.updateShareStatus({ ...folderItem, type_: ModelType.Folder }, false);
|
||||
}
|
||||
|
||||
for (const note of notes) {
|
||||
await Note.updateShareStatus({ ...note, type_: ModelType.Note }, directlyPublishedNoteIds.has(note.id));
|
||||
}
|
||||
|
||||
await Folder.updateAllShareIds(ResourceService.instance(), remainingShares);
|
||||
|
||||
// Clean local state first so next sync can recover if deletion stops midway
|
||||
await this.deleteShare(share.id);
|
||||
await this.refreshShares();
|
||||
}
|
||||
|
||||
public async unshareNote(noteId: string) {
|
||||
const note = await Note.load(noteId);
|
||||
if (!note) throw new Error(`No such note: ${noteId}`);
|
||||
|
||||
const noteShares = (await this.loadSharesByItem(noteId))
|
||||
.filter(s => s.type === ShareType.Note);
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const share of noteShares) {
|
||||
promises.push(this.deleteShare(share.id));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
await Note.save({
|
||||
id: note.id,
|
||||
parent_id: note.parent_id,
|
||||
is_shared: 0,
|
||||
updated_time: Date.now(),
|
||||
}, {
|
||||
autoTimestamp: false,
|
||||
});
|
||||
}
|
||||
|
||||
public shareUrl(userId: string, share: StateShare): string {
|
||||
return `${this.api().personalizedUserContentBaseUrl(userId)}/shares/${share.id}`;
|
||||
}
|
||||
|
||||
public folderShare(folderId: string): StateShare {
|
||||
return this.shares.find(s => s.type === ShareType.Folder && s.folder_id === folderId);
|
||||
}
|
||||
|
||||
public isSharedFolderOwner(folderId: string, userId: string = null): boolean {
|
||||
if (userId === null) userId = this.userId;
|
||||
|
||||
const share = this.folderShare(folderId);
|
||||
if (!share) throw new Error(`Cannot find share associated with folder: ${folderId}`);
|
||||
return share.user.id === userId;
|
||||
}
|
||||
|
||||
public get shares() {
|
||||
return this.state.shares;
|
||||
}
|
||||
|
||||
public get shareLinkNoteIds(): string[] {
|
||||
return this.shares.filter(s => !!s.note_id).map(s => s.note_id);
|
||||
}
|
||||
|
||||
public get shareInvitations() {
|
||||
return this.state.shareInvitations;
|
||||
}
|
||||
|
||||
private async userPublicKey(userEmail: string): Promise<PublicPrivateKeyPair|''> {
|
||||
return await this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
|
||||
}
|
||||
|
||||
public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string, permissions: SharePermissions) {
|
||||
let recipientMasterKey: MasterKeyEntity = null;
|
||||
|
||||
if (getEncryptionEnabled()) {
|
||||
if (!recipientEmail) throw new Error(_('Please provide the recipient email'));
|
||||
const syncInfo = localSyncInfo();
|
||||
const masterKey = syncInfo.masterKeys.find(m => m.id === masterKeyId);
|
||||
if (!masterKey) throw new Error(`Cannot find master key with ID "${masterKeyId}"`);
|
||||
|
||||
const recipientPublicKey = await this.userPublicKey(recipientEmail);
|
||||
if (!recipientPublicKey) throw new Error(_('Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end encryption. They may do so from the screen Configuration > Encryption.', recipientEmail));
|
||||
|
||||
logger.info('Reencrypting master key with recipient public key', recipientPublicKey);
|
||||
|
||||
recipientMasterKey = await mkReencryptFromPasswordToPublicKey(
|
||||
this.encryptionService_,
|
||||
masterKey,
|
||||
getMasterPassword(),
|
||||
recipientPublicKey,
|
||||
);
|
||||
}
|
||||
|
||||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||
email: recipientEmail,
|
||||
master_key: JSON.stringify(recipientMasterKey),
|
||||
...permissions,
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteShareRecipient(shareUserId: string) {
|
||||
await this.api().exec('DELETE', `api/share_users/${shareUserId}`);
|
||||
}
|
||||
|
||||
public async deleteShare(shareId: string) {
|
||||
await this.api().exec('DELETE', `api/shares/${shareId}`);
|
||||
}
|
||||
|
||||
public async isPublished(item: NoteEntity|FolderEntity, shares: StateShare[]) {
|
||||
// Try to determine whether the item is published without doing a network request:
|
||||
const isPublishedItemShare = (s: StateShare) => s.type === ShareType.Note || s.type === ShareType.PublishedFolder;
|
||||
if (shares.some(s => (
|
||||
isPublishedItemShare(s) &&
|
||||
(s.folder_id === item.id || s.note_id === item.id)
|
||||
))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// In some cases, an item can have is_shared = 1, but no share in `shares`. In this case,
|
||||
// either the item has been unpublished remotely and not yet synced, or the item was published
|
||||
// by another user. Send a network request to determine whether the item is actually published:
|
||||
if (item.is_shared) {
|
||||
const shares = (await this.loadSharesByItem(item.id))
|
||||
.filter(isPublishedItemShare);
|
||||
return shares.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async loadSharesByItem(itemId: string) {
|
||||
const shares = await this.api().exec('GET', 'api/shares', { item: itemId });
|
||||
if (!Array.isArray(shares?.items)) throw new Error(`Invalid response: Shares list is not an array. Was ${typeof shares?.items}.`);
|
||||
|
||||
const items: StateShare[] = shares.items.filter(
|
||||
// For compatibility with older server versions that don't support search
|
||||
(i: StateShare) => (
|
||||
(i.type === ShareType.Note && i.note_id === itemId)
|
||||
|| (i.type === ShareType.PublishedFolder && i.folder_id === itemId)
|
||||
|| (i.type === ShareType.Folder && i.folder_id === itemId)
|
||||
),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
private async loadShares() {
|
||||
return this.api().exec('GET', 'api/shares');
|
||||
}
|
||||
|
||||
private async loadShareUsers(shareId: string) {
|
||||
return this.api().exec('GET', `api/shares/${shareId}/users`);
|
||||
}
|
||||
|
||||
// Team member email autocomplete when sharing a notebook (Joplin Cloud / Teams).
|
||||
public async loadTeamUsers(teamId: string, userEmailSearch: string): Promise<ApiTeamUsers> {
|
||||
return this.api().exec('GET', `api/teams/${encodeURIComponent(teamId)}/users`, {
|
||||
user_email_search: userEmailSearch,
|
||||
});
|
||||
}
|
||||
|
||||
private async loadShareInvitations() {
|
||||
return this.api().exec('GET', 'api/share_users');
|
||||
}
|
||||
|
||||
public setProcessingShareInvitationResponse(v: boolean) {
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_INVITATION_RESPONSE_PROCESSING',
|
||||
value: v,
|
||||
});
|
||||
}
|
||||
|
||||
public async setPermissions(shareId: string, shareUserId: string, permissions: SharePermissions) {
|
||||
logger.info('setPermissions: ', shareUserId, permissions);
|
||||
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, {
|
||||
can_read: 1,
|
||||
can_write: permissions.can_write,
|
||||
});
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_USER_UPDATE_ONE',
|
||||
shareId: shareId,
|
||||
shareUser: {
|
||||
id: shareUserId,
|
||||
...permissions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
|
||||
logger.info('respondInvitation: ', shareUserId, accept);
|
||||
|
||||
if (accept) {
|
||||
if (masterKey) {
|
||||
const getReEncryptedKey = async (ppkCandidates: PublicPrivateKeyPair[]) => {
|
||||
let lastError: Error = null;
|
||||
for (const ppk of ppkCandidates) {
|
||||
lastError = null;
|
||||
try {
|
||||
return await mkReencryptFromPublicKeyToPassword(
|
||||
this.encryptionService_,
|
||||
masterKey,
|
||||
ppk,
|
||||
getMasterPassword(),
|
||||
getMasterPassword(),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to decrypt master key. Has the public key been migrated since the item was shared? Error:', error);
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
// The invitation's masterKey may be encrypted with either the current PPK or a PPK from
|
||||
// before a recent migration. Check the old PPK to prevent the sharer from having to
|
||||
// create a new invitation just after the recipient runs a migration.
|
||||
const ppkCandidates = [localSyncInfo().ppk];
|
||||
|
||||
const cachedPpk = Setting.value('encryption.cachedPpk');
|
||||
if ('ppk' in cachedPpk) {
|
||||
ppkCandidates.push(cachedPpk.ppk);
|
||||
}
|
||||
|
||||
const reencryptedMasterKey = await getReEncryptedKey(ppkCandidates);
|
||||
logger.info('respondInvitation: Key has been reencrypted using master password', reencryptedMasterKey);
|
||||
|
||||
await MasterKey.save(reencryptedMasterKey);
|
||||
}
|
||||
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
|
||||
} else {
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
public async refreshShareInvitations() {
|
||||
const result = await this.loadShareInvitations();
|
||||
|
||||
const invitations = formatShareInvitations(result.items);
|
||||
logger.info('Refresh share invitations:', invitations);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_INVITATION_SET',
|
||||
shareInvitations: invitations,
|
||||
});
|
||||
}
|
||||
|
||||
public async shareById(id: string) {
|
||||
const stateShare = this.state.shares.find(s => s.id === id);
|
||||
if (stateShare) return stateShare;
|
||||
|
||||
const refreshedShares = await this.refreshShares();
|
||||
const refreshedShare = refreshedShares.find(s => s.id === id);
|
||||
if (!refreshedShare) throw new Error(`Could not find share with ID: ${id}`);
|
||||
return refreshedShare;
|
||||
}
|
||||
|
||||
// In most cases the share objects will already be part of the state, so
|
||||
// this function checks there first. If the required share objects are not
|
||||
// present, it refreshes them from the API.
|
||||
public async sharesByIds(ids: string[]) {
|
||||
const buildOutput = async (shares: StateShare[]) => {
|
||||
const output: Record<string, StateShare> = {};
|
||||
for (const share of shares) {
|
||||
if (ids.includes(share.id)) output[share.id] = share;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
let output = await buildOutput(this.state.shares);
|
||||
if (Object.keys(output).length === ids.length) return output;
|
||||
|
||||
const refreshedShares = await this.refreshShares();
|
||||
output = await buildOutput(refreshedShares);
|
||||
|
||||
if (Object.keys(output).length !== ids.length) {
|
||||
logger.error('sharesByIds: Need:', ids);
|
||||
logger.error('sharesByIds: Got:', Object.keys(refreshedShares));
|
||||
throw new Error('Could not retrieve required share objects');
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async refreshShares(): Promise<StateShare[]> {
|
||||
const result = await this.loadShares();
|
||||
|
||||
logger.info('Refreshed shares:', result);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_SET',
|
||||
shares: result.items,
|
||||
});
|
||||
|
||||
return result.items;
|
||||
}
|
||||
|
||||
public async refreshShareUsers(shareId: string) {
|
||||
const result = await this.loadShareUsers(shareId);
|
||||
|
||||
logger.info('Refreshed share users:', result);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_USER_SET',
|
||||
shareId: shareId,
|
||||
shareUsers: result.items,
|
||||
});
|
||||
}
|
||||
|
||||
private async updateNoLongerSharedItems() {
|
||||
const shareIds = this.shares.map(share => share.id).concat(this.shareInvitations.map(si => si.share.id));
|
||||
await Folder.updateNoLongerSharedItems(shareIds);
|
||||
await Folder.updateNoLongerPublishedFolders(this.shares);
|
||||
await Note.updateNoLongerPublishedNotes(this.shares);
|
||||
}
|
||||
|
||||
public async maintenance() {
|
||||
const task = perfLogger.taskStart('ShareService/maintenance');
|
||||
if (this.enabled) {
|
||||
let hasError = false;
|
||||
|
||||
try {
|
||||
await this.refreshShareInvitations();
|
||||
} catch (error) {
|
||||
hasError = true;
|
||||
logger.error('Maintenance: Failed to update share invitations:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.refreshShares();
|
||||
} catch (error) {
|
||||
hasError = true;
|
||||
logger.error('Maintenance: Failed to refresh shares:', error);
|
||||
}
|
||||
|
||||
Setting.setValue('sync.userId', this.api().userId);
|
||||
|
||||
// If there was no errors, it means we have all the share objects,
|
||||
// so we can run the clean up function.
|
||||
if (!hasError) await this.updateNoLongerSharedItems();
|
||||
}
|
||||
task.onEnd();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import ShareService from './ShareService';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import Folder from '../../models/Folder';
|
||||
import { reg } from '../../registry';
|
||||
import { _ } from '../../locale';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import shim from '../../shim';
|
||||
|
||||
const logger = Logger.create('invitationRespond');
|
||||
|
||||
export default async function(shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) {
|
||||
// The below functions can take a bit of time to complete so in the
|
||||
// meantime we hide the notification so that the user doesn't click
|
||||
// multiple times on the Accept link.
|
||||
ShareService.instance().setProcessingShareInvitationResponse(true);
|
||||
|
||||
try {
|
||||
await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (shim.mobilePlatform() === 'web' && masterKey) {
|
||||
alert(_('The web client does not support accepting encrypted shared notebooks. Please switch to the desktop or mobile app before accepting the share.\n\nError: "%s"', error.message));
|
||||
} else {
|
||||
alert(_('Could not respond to the invitation. Please try again, or check with the notebook owner if they are still sharing it.\n\nThe error was: "%s"', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
// This is to handle an edge case that can happen if:
|
||||
//
|
||||
// - The user is a recipient of a share.
|
||||
// - The sender removes the recipient from the share, then add him again.
|
||||
// - The recipient gets the invitation, but reply "Reject" to it.
|
||||
//
|
||||
// If we don't handle this case, it would kind of work but would create
|
||||
// conflicts because the shared notes would be converted to local ones, then
|
||||
// during sync the synchronizer would try to delete them. Since they've been
|
||||
// changed, they'll all be marked as conflicts.
|
||||
//
|
||||
// So the simplest thing to do is to leave the folder, which is most likely
|
||||
// what the user wants. And if not, it's always possible to ask the sender
|
||||
// to share again.
|
||||
//
|
||||
// NOTE: DOESN'T WORK. Because Folder.updateAllShareIds() would still run
|
||||
// and change the notes share_id property, thus creating conflicts again.
|
||||
// Leaving it as it is for now, as it's an unlikely scenario and it won't
|
||||
// cause any data loss.
|
||||
|
||||
if (!accept) {
|
||||
const existingFolder = await Folder.load(folderId);
|
||||
if (existingFolder) {
|
||||
logger.warn('Rejected an invitation, but the folder was already there. Conflicts are likely to happen. ShareUserId:', shareUserId, 'Folder ID:', folderId);
|
||||
// await ShareService.instance().leaveSharedFolder(folderId);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ShareService.instance().refreshShareInvitations();
|
||||
} finally {
|
||||
ShareService.instance().setProcessingShareInvitationResponse(false);
|
||||
}
|
||||
|
||||
void reg.scheduleSync(1000);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { State as RootState } from '../../reducer';
|
||||
import { Draft } from 'immer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
const logger = Logger.create('share/reducer');
|
||||
|
||||
export enum ShareType {
|
||||
Note = 1,
|
||||
Folder = 3,
|
||||
PublishedFolder = 4,
|
||||
}
|
||||
|
||||
interface StateShareUserUser {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
export enum ShareUserStatus {
|
||||
Waiting = 0,
|
||||
Accepted = 1,
|
||||
Rejected = 2,
|
||||
}
|
||||
|
||||
export interface SharePermissions {
|
||||
can_read: number;
|
||||
can_write: number;
|
||||
}
|
||||
|
||||
export interface StateShareUser {
|
||||
id: string;
|
||||
status: ShareUserStatus;
|
||||
user: StateShareUserUser;
|
||||
can_read: number;
|
||||
can_write: number;
|
||||
}
|
||||
|
||||
export interface StateShare {
|
||||
id: string;
|
||||
type: number;
|
||||
folder_id: string;
|
||||
note_id: string;
|
||||
master_key_id: string;
|
||||
user?: StateShareUserUser;
|
||||
}
|
||||
|
||||
export interface ShareInvitation {
|
||||
id: string;
|
||||
master_key: MasterKeyEntity;
|
||||
share: StateShare;
|
||||
status: ShareUserStatus;
|
||||
can_read: number;
|
||||
can_write: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
shares: StateShare[];
|
||||
shareUsers: Record<string, StateShareUser[]>;
|
||||
shareInvitations: ShareInvitation[];
|
||||
processingShareInvitationResponse: boolean;
|
||||
}
|
||||
|
||||
export const stateRootKey = 'shareService';
|
||||
|
||||
export const defaultState: State = {
|
||||
shares: [],
|
||||
shareUsers: {},
|
||||
shareInvitations: [],
|
||||
processingShareInvitationResponse: false,
|
||||
};
|
||||
|
||||
export const parseShareCache = (serialized: string): State => {
|
||||
let raw: Partial<State> = {};
|
||||
try {
|
||||
raw = JSON.parse(serialized) || {};
|
||||
} catch (error) {
|
||||
logger.info('Could not load share cache from settings - will return a default value. Error was:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
shares: raw.shares || [],
|
||||
shareUsers: raw.shareUsers || {},
|
||||
shareInvitations: raw.shareInvitations || [],
|
||||
processingShareInvitationResponse: false,
|
||||
};
|
||||
};
|
||||
|
||||
export function isSharedFolderOwner(state: RootState, folderId: string): boolean {
|
||||
const userId = state.settings['sync.userId'];
|
||||
const share = state[stateRootKey].shares.find(s => s.folder_id === folderId);
|
||||
if (!share) return false;
|
||||
return share.user.id === userId;
|
||||
}
|
||||
|
||||
export function isRootSharedFolder(folder: FolderEntity): boolean {
|
||||
if (!('share_id' in folder) || !('parent_id' in folder)) {
|
||||
logger.warn('Calling isRootSharedFolder without specifying share_id and parent_id:', folder);
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!folder.share_id && !folder.parent_id;
|
||||
}
|
||||
|
||||
// Returns true if folderId itself, or any of its ancestors, is directly published.
|
||||
export function isFolderPublished(state: RootState, folderId: string): boolean {
|
||||
const publishedIds = new Set(
|
||||
state[stateRootKey].shares
|
||||
.filter(s => s.type === ShareType.PublishedFolder && !!s.folder_id)
|
||||
.map(s => s.folder_id),
|
||||
);
|
||||
const visitedFolderIds = new Set<string>();
|
||||
let currentId = folderId;
|
||||
while (currentId) {
|
||||
if (publishedIds.has(currentId)) return true;
|
||||
if (visitedFolderIds.has(currentId)) break;
|
||||
visitedFolderIds.add(currentId);
|
||||
const folder = state.folders.find(f => f.id === currentId);
|
||||
if (!folder) break;
|
||||
currentId = folder.parent_id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Heterogeneous redux action shape across SHARE_* types; typed action union would require touching every dispatch site
|
||||
const reducer = (draftRoot: Draft<RootState>, action: any) => {
|
||||
if (action.type.indexOf('SHARE_') !== 0) return;
|
||||
|
||||
const draft = draftRoot.shareService;
|
||||
|
||||
try {
|
||||
switch (action.type) {
|
||||
|
||||
case 'SHARE_SET':
|
||||
|
||||
draft.shares = action.shares;
|
||||
break;
|
||||
|
||||
case 'SHARE_USER_SET':
|
||||
|
||||
draft.shareUsers[action.shareId] = action.shareUsers;
|
||||
break;
|
||||
|
||||
case 'SHARE_USER_UPDATE_ONE':
|
||||
|
||||
{
|
||||
const shareUser = draft.shareUsers[action.shareId].find((su: StateShareUser) => su.id === action.shareUser.id);
|
||||
if (!shareUser) throw new Error(`No such user: ${JSON.stringify(action)}`);
|
||||
|
||||
for (const [name, value] of Object.entries(action.shareUser)) {
|
||||
(shareUser as Record<string, unknown>)[name] = value;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SHARE_INVITATION_SET':
|
||||
|
||||
draft.shareInvitations = action.shareInvitations;
|
||||
break;
|
||||
|
||||
case 'SHARE_CACHE_RESTORE':
|
||||
|
||||
draft.shares = action.shares;
|
||||
draft.shareUsers = action.shareUsers;
|
||||
draft.shareInvitations = action.shareInvitations;
|
||||
break;
|
||||
|
||||
case 'SHARE_INVITATION_RESPONSE_PROCESSING':
|
||||
|
||||
draft.processingShareInvitationResponse = action.value;
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In share reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default reducer;
|
||||
在新工单中引用
屏蔽一个用户