1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-02-19 20:00:20 +02:00

Chore: Desktop: Update for share permissions (#8528)

This commit is contained in:
Laurent Cozic 2023-07-23 15:57:55 +01:00 committed by GitHub
parent 880304c2fb
commit 1c1d20f82c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 201 additions and 71 deletions

View File

@ -802,8 +802,8 @@ packages/lib/themes/solarizedLight.js
packages/lib/themes/type.js
packages/lib/time.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/inboxFetcher.js
packages/lib/utils/joplinCloud.js
packages/lib/utils/userFetcher.js
packages/lib/utils/webDAVUtils.js
packages/lib/utils/webDAVUtils.test.js
packages/lib/uuid.js

2
.gitignore vendored
View File

@ -787,8 +787,8 @@ packages/lib/themes/solarizedLight.js
packages/lib/themes/type.js
packages/lib/time.js
packages/lib/utils/credentialFiles.js
packages/lib/utils/inboxFetcher.js
packages/lib/utils/joplinCloud.js
packages/lib/utils/userFetcher.js
packages/lib/utils/webDAVUtils.js
packages/lib/utils/webDAVUtils.test.js
packages/lib/uuid.js

View File

@ -66,8 +66,7 @@ import syncDebugLog from '@joplin/lib/services/synchronizer/syncDebugLog';
import eventManager from '@joplin/lib/eventManager';
import path = require('path');
import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
// import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
import { initializeInboxFetcher, inboxFetcher } from '@joplin/lib/utils/inboxFetcher';
import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetcher';
const pluginClasses = [
require('./plugins/GotoAnything').default,
@ -488,8 +487,8 @@ class Application extends BaseApplication {
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
}
initializeInboxFetcher();
shim.setInterval(() => { void inboxFetcher(); }, 1000 * 60 * 60);
initializeUserFetcher();
shim.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
this.updateTray();

View File

@ -25,7 +25,7 @@ const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
const mapStateToProps = (state: AppState) => {
return {
inboxEmail: state.settings['emailToNote.inboxEmail'],
inboxEmail: state.settings['sync.10.inboxEmail'],
};
};

View File

@ -18,7 +18,7 @@ export const runtime = (): CommandRuntime => {
if (!folder) throw new Error(`No such folder: ${folderId}`);
let deleteMessage = _('Delete notebook "%s"?\n\nAll notes and sub-notebooks within this notebook will also be deleted.', substrWithEllipsis(folder.title, 0, 32));
if (folderId === context.state.settings['emailToNote.inboxJopId']) {
if (folderId === context.state.settings['sync.10.inboxId']) {
deleteMessage = _('Delete the Inbox notebook?\n\nIf you delete the inbox notebook, any email that\'s recently been sent to it may be lost.');
}

View File

@ -96,7 +96,7 @@ interface Props {
onClose(): void;
shares: StateShare[];
shareUsers: Record<string, StateShareUser[]>;
isJoplinCloud: boolean;
canUseSharePermissions: boolean;
}
interface RecipientDeleteEvent {
@ -261,7 +261,7 @@ function ShareFolderDialog(props: Props) {
function renderAddRecipient() {
const disabled = shareState !== ShareState.Idle;
const dropdown = !props.isJoplinCloud ? null : <Dropdown className="permission-dropdown" options={permissionOptions} value={recipientPermissions} onChange={recipientPermissions_change}/>;
const dropdown = !props.canUseSharePermissions ? null : <Dropdown className="permission-dropdown" options={permissionOptions} value={recipientPermissions} onChange={recipientPermissions_change}/>;
return (
<StyledAddRecipient>
@ -306,7 +306,7 @@ function ShareFolderDialog(props: Props) {
const permission = shareUser.can_write ? 'can_read_and_write' : 'can_read';
const enabled = !recipientsBeingUpdated[shareUser.id];
const dropdown = !props.isJoplinCloud ? null : <Dropdown disabled={!enabled} className="permission-dropdown" value={permission} options={permissionOptions} variant={DropdownVariant.NoBorder} onChange={event => recipient_permissionChange(shareUser.id, event.value)}/>;
const dropdown = !props.canUseSharePermissions ? null : <Dropdown disabled={!enabled} className="permission-dropdown" value={permission} options={permissionOptions} variant={DropdownVariant.NoBorder} onChange={event => recipient_permissionChange(shareUser.id, event.value)}/>;
return (
<StyledRecipient key={shareUser.user.email} index={index}>
@ -407,7 +407,7 @@ const mapStateToProps = (state: State) => {
return {
shares: state.shareService.shares,
shareUsers: state.shareService.shareUsers,
isJoplinCloud: state.settings['sync.target'] === 10,
canUseSharePermissions: state.settings['sync.target'] === 10 && state.settings['sync.10.canUseSharePermissions'],
};
};

View File

@ -31,6 +31,12 @@
# ./runForTesting.sh 1 createUsers,createData,reset,sync && ./runForTesting.sh 1a reset,sync && ./runForTesting.sh 1
# ./runForTesting.sh 1a
# ----------------------------------------------------------------------------------
# Team accounts:
# ----------------------------------------------------------------------------------
# ./runForTesting.sh 1 createTeams,createData,resetTeam,sync && ./runForTesting.sh 2 resetTeam,sync && ./runForTesting.sh 1
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
@ -54,6 +60,16 @@ if [ "$USER_NUM" = "1b" ]; then
USER_PROFILE_NUM=1b
fi
if [ "$USER_NUM" = "2a" ]; then
USER_NUM=2
USER_PROFILE_NUM=2a
fi
if [ "$USER_NUM" = "2b" ]; then
USER_NUM=2
USER_PROFILE_NUM=2b
fi
COMMANDS=($(echo $2 | tr "," "\n"))
PROFILE_DIR=~/.config/joplindev-desktop-$USER_PROFILE_NUM
SYNC_TARGET=10
@ -74,6 +90,10 @@ do
curl --data '{"action": "createUserDeletions"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
elif [[ $CMD == "createTeams" ]]; then
curl --data '{"action": "createTeams"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug
elif [[ $CMD == "createData" ]]; then
echo 'mkbook "shared"' >> "$CMD_FILE"
@ -96,6 +116,16 @@ do
echo "config sync.$SYNC_TARGET.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
echo "config sync.$SYNC_TARGET.userContentPath http://joplinusercontent.local:22300" >> "$CMD_FILE"
fi
elif [[ $CMD == "resetTeam" ]]; then
USER_EMAIL="teamuser1-$USER_NUM@example.com"
rm -rf "$PROFILE_DIR"
echo "config keychain.supported 0" >> "$CMD_FILE"
echo "config sync.target $SYNC_TARGET" >> "$CMD_FILE"
echo "config sync.$SYNC_TARGET.username $USER_EMAIL" >> "$CMD_FILE"
echo "config sync.$SYNC_TARGET.password 111111" >> "$CMD_FILE"
elif [[ $CMD == "e2ee" ]]; then

View File

@ -362,13 +362,13 @@ class ConfigScreenComponent extends BaseScreenComponent {
<View key="joplinCloud">
<View style={this.styles().settingContainerNoBottomBorder}>
<Text style={this.styles().settingText}>{_('Email to note')}</Text>
<Text style={{ fontWeight: 'bold' }}>{this.props.settings['emailToNote.inboxEmail']}</Text>
<Text style={{ fontWeight: 'bold' }}>{this.props.settings['sync.10.inboxEmail']}</Text>
</View>
{
this.renderButton(
'emailToNote.inboxEmail',
'sync.10.inboxEmail',
_('Copy to clipboard'),
() => Clipboard.setString(this.props.settings['emailToNote.inboxEmail']),
() => Clipboard.setString(this.props.settings['sync.10.inboxEmail']),
{ description }
)
}

View File

@ -528,6 +528,6 @@ export default connect((state: AppState) => {
isOnMobileData: state.isOnMobileData,
syncOnlyOverWifi: state.settings['sync.mobileWifiOnly'],
profileConfig: state.profileConfig,
inboxJopId: state.settings['emailToNote.inboxJopId'],
inboxJopId: state.settings['sync.10.inboxId'],
};
})(SideMenuContentComponent);

View File

@ -117,7 +117,7 @@ import sensorInfo, { SensorInfo } from './components/biometrics/sensorInfo';
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles';
import { ReactNode } from 'react';
import { initializeInboxFetcher, inboxFetcher } from '@joplin/lib/utils/inboxFetcher';
import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetcher';
import { parseShareCache } from '@joplin/lib/services/share/reducer';
import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme';
@ -665,8 +665,8 @@ async function initialize(dispatch: Function) {
reg.setupRecurrentSync();
initializeInboxFetcher();
PoorManIntervals.setInterval(() => { void inboxFetcher(); }, 1000 * 60 * 60);
initializeUserFetcher();
PoorManIntervals.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
PoorManIntervals.setTimeout(() => {
void AlarmService.garbageCollect();

View File

@ -38,6 +38,8 @@ export interface DeleteOptions {
// sync, we don't need to track the deletion, because the operation doesn't
// need to applied again on next sync.
trackDeleted?: boolean;
disableReadOnlyCheck?: boolean;
}
class BaseModel {

View File

@ -1,5 +1,5 @@
import { ModelType, DeleteOptions } from '../BaseModel';
import { BaseItemEntity, NoteEntity } from '../services/database/types';
import { BaseItemEntity, DeletedItemEntity, NoteEntity } from '../services/database/types';
import Setting from './Setting';
import BaseModel from '../BaseModel';
import time from '../time';
@ -285,7 +285,7 @@ export default class BaseItem extends BaseModel {
});
}
if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache)) {
if (needsReadOnlyChecks(this.modelType(), options.changeSource, this.syncShareCache, options.disableReadOnlyCheck)) {
const previousItems = await this.loadItemsByTypeAndIds(this.modelType(), ids, { fields: ['share_id', 'id'] });
checkIfItemsCanBeChanged(this.modelType(), options.changeSource, previousItems, this.syncShareCache);
}
@ -321,7 +321,7 @@ export default class BaseItem extends BaseModel {
// - Client 1 syncs with target 2 only => the note is *not* deleted from target 2 because no information
// that it was previously deleted exist (deleted_items entry has been deleted).
// The solution would be to permanently store the list of deleted items on each client.
public static deletedItems(syncTarget: number) {
public static deletedItems(syncTarget: number): Promise<DeletedItemEntity[]> {
return this.db().selectAll('SELECT * FROM deleted_items WHERE sync_target = ?', [syncTarget]);
}

View File

@ -80,6 +80,21 @@ export default class Folder extends BaseItem {
return this.db().exec(query);
}
public static async deleteAllByShareId(shareId: string, deleteOptions: DeleteOptions = null) {
const tableNameToClasses: Record<string, any> = {
'folders': Folder,
'notes': Note,
'resources': Resource,
};
for (const tableName of ['folders', 'notes', 'resources']) {
const ItemClass = tableNameToClasses[tableName];
const rows = await this.db().selectAll(`SELECT id FROM ${tableName} WHERE share_id = ?`, [shareId]);
const ids: string[] = rows.map(r => r.id);
await ItemClass.batchDelete(ids, deleteOptions);
}
}
public static async delete(folderId: string, options: DeleteOptions = null) {
options = {
deleteChildren: true,
@ -90,12 +105,16 @@ export default class Folder extends BaseItem {
if (!folder) return; // noop
if (options.deleteChildren) {
const childrenDeleteOptions: DeleteOptions = {
disableReadOnlyCheck: options.disableReadOnlyCheck,
};
const noteIds = await Folder.noteIds(folderId);
await Note.batchDelete(noteIds);
await Note.batchDelete(noteIds, childrenDeleteOptions);
const subFolderIds = await Folder.subFolderIds(folderId);
for (let i = 0; i < subFolderIds.length; i++) {
await Folder.delete(subFolderIds[i]);
await Folder.delete(subFolderIds[i], childrenDeleteOptions);
}
}
@ -762,7 +781,14 @@ export default class Folder extends BaseItem {
syncDebugLog.info('Folder Save:', o);
const savedFolder: FolderEntity = await super.save(o, options);
let savedFolder: FolderEntity = await super.save(o, options);
// Ensures that any folder added to the state has all the required
// properties, in particular "share_id" and "parent_id', which are
// required in various parts of the code.
if (!('share_id' in savedFolder) || !('parent_id' in savedFolder)) {
savedFolder = await this.load(savedFolder.id);
}
this.dispatch({
type: 'FOLDER_UPDATE_ONE',

View File

@ -21,6 +21,7 @@ export default class ItemChange extends BaseModel {
public static SOURCE_UNSPECIFIED = 1;
public static SOURCE_SYNC = 2;
public static SOURCE_DECRYPTION = 2; // CAREFUL - SAME ID AS SOURCE_SYNC!
public static SOURCE_SHARE_SERVICE = 4;
public static tableName() {
return 'item_changes';

View File

@ -1,4 +1,4 @@
import BaseModel, { ModelType } from '../BaseModel';
import BaseModel, { DeleteOptions, ModelType } from '../BaseModel';
import BaseItem from './BaseItem';
import ItemChange from './ItemChange';
import Setting from './Setting';
@ -744,7 +744,7 @@ export default class Note extends BaseItem {
return note;
}
public static async batchDelete(ids: string[], options: any = null) {
public static async batchDelete(ids: string[], options: DeleteOptions = null) {
ids = ids.slice();
while (ids.length) {

View File

@ -717,6 +717,12 @@ class Setting extends BaseModel {
secure: true,
},
'sync.10.inboxEmail': { value: '', type: SettingItemType.String, public: false },
'sync.10.inboxId': { value: '', type: SettingItemType.String, public: false },
'sync.10.canUseSharePermissions': { value: false, type: SettingItemType.Bool, public: false },
'sync.5.syncTargets': { value: {}, type: SettingItemType.Object, public: false },
'sync.resourceDownloadMode': {
@ -1714,10 +1720,6 @@ class Setting extends BaseModel {
label: () => _('Voice typing language files (URL)'),
section: 'note',
},
'emailToNote.inboxEmail': { value: '', type: SettingItemType.String, public: false },
'emailToNote.inboxJopId': { value: '', type: SettingItemType.String, public: false },
};
this.metadata_ = { ...this.buildInMetadata_ };

View File

@ -13,7 +13,8 @@ export interface ItemSlice {
// This function can be called to wrap any read-only-related code. It should be
// fast and allows an early exit for cases that don't apply, for example if not
// synchronising with Joplin Cloud or if not sharing any notebook.
export const needsReadOnlyChecks = (itemType: ModelType, changeSource: number, shareState: ShareState) => {
export const needsReadOnlyChecks = (itemType: ModelType, changeSource: number, shareState: ShareState, disableReadOnlyCheck = false) => {
if (disableReadOnlyCheck) return false;
if (Setting.value('sync.target') !== 10) return false;
if (changeSource === ItemChange.SOURCE_SYNC) return false;
if (!Setting.value('sync.userId')) return false;

View File

@ -33,4 +33,5 @@ export interface SaveOptions {
ignoreProvisionalFlag?: boolean;
dispatchUpdateAction?: boolean;
changeSource?: number;
disableReadOnlyCheck?: boolean;
}

View File

@ -1,5 +1,5 @@
import Note from '../../models/Note';
import { encryptionService, loadEncryptionMasterKey, msleep, resourceService, setupDatabaseAndSynchronizer, supportDir, switchClient } from '../../testing/test-utils';
import { createFolderTree, encryptionService, loadEncryptionMasterKey, msleep, resourceService, setupDatabaseAndSynchronizer, simulateReadOnlyShareEnv, supportDir, switchClient } from '../../testing/test-utils';
import ShareService from './ShareService';
import reducer, { defaultState } from '../../reducer';
import { createStore } from 'redux';
@ -15,6 +15,9 @@ 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';
interface TestShareFolderServiceOptions {
master_key_id?: string;
@ -239,5 +242,40 @@ describe('ShareService', () => {
Logger.globalLogger.setLevel(previousLogLevel);
});
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();
});
});

View File

@ -173,14 +173,21 @@ export default class ShareService {
// This is when a share recipient decides to leave the shared folder.
//
// In that case, we should only delete the folder but none of its children.
// Deleting the folder tells the server that we want to leave the share. The
// server will then proceed to delete all associated user_items. So
// eventually all the notebook content will also be deleted for the current
// user.
// In that case we delete the root folder. Deleting the folder tells the
// server that we want to leave the share.
//
// We don't delete the children here because that would delete them for the
// other share participants too.
// 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
@ -191,7 +198,14 @@ export default class ShareService {
if (folderShareUserId === userId) throw new Error('Cannot leave own notebook');
}
await Folder.delete(folderId, { deleteChildren: false });
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());
await Folder.delete(folderId, { deleteChildren: false, disableReadOnlyCheck: true });
await Folder.deleteAllByShareId(folder.share_id, { disableReadOnlyCheck: true, trackDeleted: false });
}
// Finds any folder that is associated with a share, but the user no longer

View File

@ -94,6 +94,11 @@ export function isSharedFolderOwner(state: RootState, folderId: string): boolean
}
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;
}

View File

@ -1,30 +0,0 @@
import SyncTargetRegistry from '../SyncTargetRegistry';
import eventManager from '../eventManager';
import Setting from '../models/Setting';
import { reg } from '../registry';
export const inboxFetcher = async () => {
if (Setting.value('sync.target') !== SyncTargetRegistry.nameToId('joplinCloud')) {
return;
}
const syncTarget = reg.syncTarget();
const fileApi = await syncTarget.fileApi();
const api = fileApi.driver().api();
const owner = await api.exec('GET', `api/users/${api.userId}`);
if (owner.inbox) {
Setting.setValue('emailToNote.inboxJopId', owner.inbox.jop_id);
}
if (owner.inbox_email) {
Setting.setValue('emailToNote.inboxEmail', owner.inbox_email);
}
};
// Listen to the event only once
export const initializeInboxFetcher = () => {
eventManager.once('sessionEstablished', inboxFetcher);
};

View File

@ -0,0 +1,41 @@
import SyncTargetRegistry from '../SyncTargetRegistry';
import eventManager from '../eventManager';
import Setting from '../models/Setting';
import { reg } from '../registry';
import Logger from '../Logger';
const logger = Logger.create('userFetcher');
interface UserApiResponse {
inbox?: {
jop_id: string;
};
inbox_email?: string;
can_use_share_permissions?: number;
}
const userFetcher = async () => {
if (Setting.value('sync.target') !== SyncTargetRegistry.nameToId('joplinCloud')) {
return;
}
const syncTarget = reg.syncTarget();
const fileApi = await syncTarget.fileApi();
const api = fileApi.driver().api();
const owner: UserApiResponse = await api.exec('GET', `api/users/${api.userId}`);
logger.info('Got user:', owner);
Setting.setValue('sync.10.inboxId', owner.inbox ? owner.inbox.jop_id : '');
Setting.setValue('sync.10.inboxEmail', owner.inbox_email ? owner.inbox_email : '');
Setting.setValue('sync.10.canUseSharePermissions', !!owner.can_use_share_permissions);
};
// Listen to the event only once
export const initializeUserFetcher = () => {
eventManager.once('sessionEstablished', userFetcher);
};
export default userFetcher;