You've already forked joplin
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:
@ -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
2
.gitignore
vendored
@ -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
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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'],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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'],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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();
|
||||||
|
@ -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 {
|
||||||
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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',
|
||||||
|
@ -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';
|
||||||
|
@ -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) {
|
||||||
|
@ -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_ };
|
||||||
|
@ -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;
|
||||||
|
@ -33,4 +33,5 @@ export interface SaveOptions {
|
|||||||
ignoreProvisionalFlag?: boolean;
|
ignoreProvisionalFlag?: boolean;
|
||||||
dispatchUpdateAction?: boolean;
|
dispatchUpdateAction?: boolean;
|
||||||
changeSource?: number;
|
changeSource?: number;
|
||||||
|
disableReadOnlyCheck?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
|
||||||
};
|
|
41
packages/lib/utils/userFetcher.ts
Normal file
41
packages/lib/utils/userFetcher.ts
Normal 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;
|
Reference in New Issue
Block a user