1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-07-16 00:14:34 +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
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/themes/type.js
packages/lib/time.js packages/lib/time.js
packages/lib/utils/credentialFiles.js packages/lib/utils/credentialFiles.js
packages/lib/utils/inboxFetcher.js
packages/lib/utils/joplinCloud.js packages/lib/utils/joplinCloud.js
packages/lib/utils/userFetcher.js
packages/lib/utils/webDAVUtils.js packages/lib/utils/webDAVUtils.js
packages/lib/utils/webDAVUtils.test.js packages/lib/utils/webDAVUtils.test.js
packages/lib/uuid.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/themes/type.js
packages/lib/time.js packages/lib/time.js
packages/lib/utils/credentialFiles.js packages/lib/utils/credentialFiles.js
packages/lib/utils/inboxFetcher.js
packages/lib/utils/joplinCloud.js packages/lib/utils/joplinCloud.js
packages/lib/utils/userFetcher.js
packages/lib/utils/webDAVUtils.js packages/lib/utils/webDAVUtils.js
packages/lib/utils/webDAVUtils.test.js packages/lib/utils/webDAVUtils.test.js
packages/lib/uuid.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 eventManager from '@joplin/lib/eventManager';
import path = require('path'); import path = require('path');
import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils'; import { checkPreInstalledDefaultPlugins, installDefaultPlugins, setSettingsForDefaultPlugins } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils';
// import { runIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils'; import userFetcher, { initializeUserFetcher } from '@joplin/lib/utils/userFetcher';
import { initializeInboxFetcher, inboxFetcher } from '@joplin/lib/utils/inboxFetcher';
const pluginClasses = [ const pluginClasses = [
require('./plugins/GotoAnything').default, require('./plugins/GotoAnything').default,
@ -488,8 +487,8 @@ class Application extends BaseApplication {
shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000); shim.setInterval(() => { runAutoUpdateCheck(); }, 12 * 60 * 60 * 1000);
} }
initializeInboxFetcher(); initializeUserFetcher();
shim.setInterval(() => { void inboxFetcher(); }, 1000 * 60 * 60); shim.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
this.updateTray(); this.updateTray();

View File

@ -25,7 +25,7 @@ const JoplinCloudConfigScreen = (props: JoplinCloudConfigScreenProps) => {
const mapStateToProps = (state: AppState) => { const mapStateToProps = (state: AppState) => {
return { 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}`); 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)); 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.'); 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; onClose(): void;
shares: StateShare[]; shares: StateShare[];
shareUsers: Record<string, StateShareUser[]>; shareUsers: Record<string, StateShareUser[]>;
isJoplinCloud: boolean; canUseSharePermissions: boolean;
} }
interface RecipientDeleteEvent { interface RecipientDeleteEvent {
@ -261,7 +261,7 @@ function ShareFolderDialog(props: Props) {
function renderAddRecipient() { function renderAddRecipient() {
const disabled = shareState !== ShareState.Idle; 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 ( return (
<StyledAddRecipient> <StyledAddRecipient>
@ -306,7 +306,7 @@ function ShareFolderDialog(props: Props) {
const permission = shareUser.can_write ? 'can_read_and_write' : 'can_read'; const permission = shareUser.can_write ? 'can_read_and_write' : 'can_read';
const enabled = !recipientsBeingUpdated[shareUser.id]; 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 ( return (
<StyledRecipient key={shareUser.user.email} index={index}> <StyledRecipient key={shareUser.user.email} index={index}>
@ -407,7 +407,7 @@ const mapStateToProps = (state: State) => {
return { return {
shares: state.shareService.shares, shares: state.shareService.shares,
shareUsers: state.shareService.shareUsers, 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 1 createUsers,createData,reset,sync && ./runForTesting.sh 1a reset,sync && ./runForTesting.sh 1
# ./runForTesting.sh 1a # ./runForTesting.sh 1a
# ----------------------------------------------------------------------------------
# Team accounts:
# ----------------------------------------------------------------------------------
# ./runForTesting.sh 1 createTeams,createData,resetTeam,sync && ./runForTesting.sh 2 resetTeam,sync && ./runForTesting.sh 1
set -e set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
@ -54,6 +60,16 @@ if [ "$USER_NUM" = "1b" ]; then
USER_PROFILE_NUM=1b USER_PROFILE_NUM=1b
fi 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")) COMMANDS=($(echo $2 | tr "," "\n"))
PROFILE_DIR=~/.config/joplindev-desktop-$USER_PROFILE_NUM PROFILE_DIR=~/.config/joplindev-desktop-$USER_PROFILE_NUM
SYNC_TARGET=10 SYNC_TARGET=10
@ -74,6 +90,10 @@ do
curl --data '{"action": "createUserDeletions"}' -H 'Content-Type: application/json' http://api.joplincloud.local:22300/api/debug 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 elif [[ $CMD == "createData" ]]; then
echo 'mkbook "shared"' >> "$CMD_FILE" 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.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
echo "config sync.$SYNC_TARGET.userContentPath http://joplinusercontent.local:22300" >> "$CMD_FILE" echo "config sync.$SYNC_TARGET.userContentPath http://joplinusercontent.local:22300" >> "$CMD_FILE"
fi 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 elif [[ $CMD == "e2ee" ]]; then

View File

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

View File

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

View File

@ -117,7 +117,7 @@ import sensorInfo, { SensorInfo } from './components/biometrics/sensorInfo';
import { getCurrentProfile } from '@joplin/lib/services/profileConfig'; import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles'; import { getDatabaseName, getProfilesRootDir, getResourceDir, setDispatch } from './services/profiles';
import { ReactNode } from 'react'; 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 { parseShareCache } from '@joplin/lib/services/share/reducer';
import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme'; import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTheme';
@ -665,8 +665,8 @@ async function initialize(dispatch: Function) {
reg.setupRecurrentSync(); reg.setupRecurrentSync();
initializeInboxFetcher(); initializeUserFetcher();
PoorManIntervals.setInterval(() => { void inboxFetcher(); }, 1000 * 60 * 60); PoorManIntervals.setInterval(() => { void userFetcher(); }, 1000 * 60 * 60);
PoorManIntervals.setTimeout(() => { PoorManIntervals.setTimeout(() => {
void AlarmService.garbageCollect(); 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 // sync, we don't need to track the deletion, because the operation doesn't
// need to applied again on next sync. // need to applied again on next sync.
trackDeleted?: boolean; trackDeleted?: boolean;
disableReadOnlyCheck?: boolean;
} }
class BaseModel { class BaseModel {

View File

@ -1,5 +1,5 @@
import { ModelType, DeleteOptions } from '../BaseModel'; 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 Setting from './Setting';
import BaseModel from '../BaseModel'; import BaseModel from '../BaseModel';
import time from '../time'; 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'] }); const previousItems = await this.loadItemsByTypeAndIds(this.modelType(), ids, { fields: ['share_id', 'id'] });
checkIfItemsCanBeChanged(this.modelType(), options.changeSource, previousItems, this.syncShareCache); 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 // - 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). // 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. // 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]); 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); 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) { public static async delete(folderId: string, options: DeleteOptions = null) {
options = { options = {
deleteChildren: true, deleteChildren: true,
@ -90,12 +105,16 @@ export default class Folder extends BaseItem {
if (!folder) return; // noop if (!folder) return; // noop
if (options.deleteChildren) { if (options.deleteChildren) {
const childrenDeleteOptions: DeleteOptions = {
disableReadOnlyCheck: options.disableReadOnlyCheck,
};
const noteIds = await Folder.noteIds(folderId); const noteIds = await Folder.noteIds(folderId);
await Note.batchDelete(noteIds); await Note.batchDelete(noteIds, childrenDeleteOptions);
const subFolderIds = await Folder.subFolderIds(folderId); const subFolderIds = await Folder.subFolderIds(folderId);
for (let i = 0; i < subFolderIds.length; i++) { 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); 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({ this.dispatch({
type: 'FOLDER_UPDATE_ONE', type: 'FOLDER_UPDATE_ONE',

View File

@ -21,6 +21,7 @@ export default class ItemChange extends BaseModel {
public static SOURCE_UNSPECIFIED = 1; public static SOURCE_UNSPECIFIED = 1;
public static SOURCE_SYNC = 2; public static SOURCE_SYNC = 2;
public static SOURCE_DECRYPTION = 2; // CAREFUL - SAME ID AS SOURCE_SYNC! public static SOURCE_DECRYPTION = 2; // CAREFUL - SAME ID AS SOURCE_SYNC!
public static SOURCE_SHARE_SERVICE = 4;
public static tableName() { public static tableName() {
return 'item_changes'; 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 BaseItem from './BaseItem';
import ItemChange from './ItemChange'; import ItemChange from './ItemChange';
import Setting from './Setting'; import Setting from './Setting';
@ -744,7 +744,7 @@ export default class Note extends BaseItem {
return note; return note;
} }
public static async batchDelete(ids: string[], options: any = null) { public static async batchDelete(ids: string[], options: DeleteOptions = null) {
ids = ids.slice(); ids = ids.slice();
while (ids.length) { while (ids.length) {

View File

@ -717,6 +717,12 @@ class Setting extends BaseModel {
secure: true, 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.5.syncTargets': { value: {}, type: SettingItemType.Object, public: false },
'sync.resourceDownloadMode': { 'sync.resourceDownloadMode': {
@ -1714,10 +1720,6 @@ class Setting extends BaseModel {
label: () => _('Voice typing language files (URL)'), label: () => _('Voice typing language files (URL)'),
section: 'note', section: 'note',
}, },
'emailToNote.inboxEmail': { value: '', type: SettingItemType.String, public: false },
'emailToNote.inboxJopId': { value: '', type: SettingItemType.String, public: false },
}; };
this.metadata_ = { ...this.buildInMetadata_ }; 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 // 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 // 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. // 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 (Setting.value('sync.target') !== 10) return false;
if (changeSource === ItemChange.SOURCE_SYNC) return false; if (changeSource === ItemChange.SOURCE_SYNC) return false;
if (!Setting.value('sync.userId')) return false; if (!Setting.value('sync.userId')) return false;

View File

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

View File

@ -1,5 +1,5 @@
import Note from '../../models/Note'; 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 ShareService from './ShareService';
import reducer, { defaultState } from '../../reducer'; import reducer, { defaultState } from '../../reducer';
import { createStore } from 'redux'; import { createStore } from 'redux';
@ -15,6 +15,9 @@ import shim from '../../shim';
import Resource from '../../models/Resource'; import Resource from '../../models/Resource';
import { readFile } from 'fs-extra'; import { readFile } from 'fs-extra';
import BaseItem from '../../models/BaseItem'; import BaseItem from '../../models/BaseItem';
import ResourceService from '../ResourceService';
import Setting from '../../models/Setting';
import { ModelType } from '../../BaseModel';
interface TestShareFolderServiceOptions { interface TestShareFolderServiceOptions {
master_key_id?: string; master_key_id?: string;
@ -239,5 +242,40 @@ describe('ShareService', () => {
Logger.globalLogger.setLevel(previousLogLevel); 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. // 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. // In that case we delete the root folder. Deleting the folder tells the
// Deleting the folder tells the server that we want to leave the share. The // server that we want to leave the share.
// server will then proceed to delete all associated user_items. So
// eventually all the notebook content will also be deleted for the current
// user.
// //
// We don't delete the children here because that would delete them for the // We also immediately delete the children, but we do not sync the changes
// other share participants too. // 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 // 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 // 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'); 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 // 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 { 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; 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;