diff --git a/.eslintignore b/.eslintignore index b9f5eec37..24134f65f 100644 --- a/.eslintignore +++ b/.eslintignore @@ -261,6 +261,9 @@ packages/app-desktop/gui/MainScreen/commands/hideModalMessage.js.map packages/app-desktop/gui/MainScreen/commands/index.d.ts packages/app-desktop/gui/MainScreen/commands/index.js packages/app-desktop/gui/MainScreen/commands/index.js.map +packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.d.ts +packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js +packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js.map packages/app-desktop/gui/MainScreen/commands/moveToFolder.d.ts packages/app-desktop/gui/MainScreen/commands/moveToFolder.js packages/app-desktop/gui/MainScreen/commands/moveToFolder.js.map diff --git a/.gitignore b/.gitignore index f7947d357..09a76e105 100644 --- a/.gitignore +++ b/.gitignore @@ -244,6 +244,9 @@ packages/app-desktop/gui/MainScreen/commands/hideModalMessage.js.map packages/app-desktop/gui/MainScreen/commands/index.d.ts packages/app-desktop/gui/MainScreen/commands/index.js packages/app-desktop/gui/MainScreen/commands/index.js.map +packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.d.ts +packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js +packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.js.map packages/app-desktop/gui/MainScreen/commands/moveToFolder.d.ts packages/app-desktop/gui/MainScreen/commands/moveToFolder.js packages/app-desktop/gui/MainScreen/commands/moveToFolder.js.map diff --git a/packages/app-desktop/gui/MainScreen/commands/index.ts b/packages/app-desktop/gui/MainScreen/commands/index.ts index e184aec29..19d9abd9e 100644 --- a/packages/app-desktop/gui/MainScreen/commands/index.ts +++ b/packages/app-desktop/gui/MainScreen/commands/index.ts @@ -4,6 +4,7 @@ import * as editAlarm from './editAlarm'; import * as exportPdf from './exportPdf'; import * as gotoAnything from './gotoAnything'; import * as hideModalMessage from './hideModalMessage'; +import * as leaveSharedFolder from './leaveSharedFolder'; import * as moveToFolder from './moveToFolder'; import * as newFolder from './newFolder'; import * as newNote from './newNote'; @@ -36,6 +37,7 @@ const index:any[] = [ exportPdf, gotoAnything, hideModalMessage, + leaveSharedFolder, moveToFolder, newFolder, newNote, diff --git a/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts b/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts new file mode 100644 index 000000000..b06054a52 --- /dev/null +++ b/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts @@ -0,0 +1,28 @@ +import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; +import { _ } from '@joplin/lib/locale'; +import Folder from '@joplin/lib/models/Folder'; + +export const declaration: CommandDeclaration = { + name: 'leaveSharedFolder', + label: () => _('Leave notebook...'), +}; + +export const runtime = (): CommandRuntime => { + return { + execute: async (_context: CommandContext, folderId: string = null) => { + const answer = confirm(_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?')); + if (!answer) return; + + // 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. + // + // We don't delete the children here because that would delete them + // for the other share participants too. + await Folder.delete(folderId, { deleteChildren: false }); + }, + enabledCondition: 'joplinServerConnected && folderIsShareRootAndNotOwnedByUser', + }; +}; diff --git a/packages/app-desktop/gui/Sidebar/Sidebar.tsx b/packages/app-desktop/gui/Sidebar/Sidebar.tsx index 6eb03a982..dc24d3e5b 100644 --- a/packages/app-desktop/gui/Sidebar/Sidebar.tsx +++ b/packages/app-desktop/gui/Sidebar/Sidebar.tsx @@ -314,10 +314,16 @@ class SidebarComponent extends React.Component { // that are within a shared notebook. If user wants to do this, // they'd have to move the notebook out of the shared notebook // first. - if (CommandService.instance().isEnabled('showShareFolderDialog', stateToWhenClauseContext(state, { commandFolderId: itemId }))) { + const whenClause = stateToWhenClauseContext(state, { commandFolderId: itemId }); + + if (CommandService.instance().isEnabled('showShareFolderDialog', whenClause)) { menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('showShareFolderDialog', itemId))); } + if (CommandService.instance().isEnabled('leaveSharedFolder', whenClause)) { + menu.append(new MenuItem(menuUtils.commandToStatefulMenuItem('leaveSharedFolder', itemId))); + } + menu.append( new MenuItem({ label: _('Export'), diff --git a/packages/app-desktop/gui/menuCommandNames.ts b/packages/app-desktop/gui/menuCommandNames.ts index 98f20247b..5e6d3d4ad 100644 --- a/packages/app-desktop/gui/menuCommandNames.ts +++ b/packages/app-desktop/gui/menuCommandNames.ts @@ -47,6 +47,7 @@ export default function() { 'toggleSafeMode', 'showShareNoteDialog', 'showShareFolderDialog', + 'leaveSharedFolder', 'gotoAnything', 'commandPalette', 'openMasterPasswordDialog', diff --git a/packages/app-desktop/runForTesting.sh b/packages/app-desktop/runForTesting.sh index f2e87b157..cb1218437 100755 --- a/packages/app-desktop/runForTesting.sh +++ b/packages/app-desktop/runForTesting.sh @@ -8,6 +8,10 @@ # ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,e2ee,sync && ./runForTesting.sh 1 +# Without E2EE: + +# ./runForTesting.sh 1 createUsers,createData,reset,sync && ./runForTesting.sh 2 reset,sync && ./runForTesting.sh 1 + set -e SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" @@ -63,7 +67,7 @@ do elif [[ $CMD == "sync" ]]; then - echo "sync" >> "$CMD_FILE" + echo "sync --use-lock 0" >> "$CMD_FILE" # elif [[ $CMD == "generatePpk" ]]; then diff --git a/packages/lib/models/Folder.ts b/packages/lib/models/Folder.ts index a0e6d8819..f56157784 100644 --- a/packages/lib/models/Folder.ts +++ b/packages/lib/models/Folder.ts @@ -16,6 +16,16 @@ interface FolderEntityWithChildren extends FolderEntity { children?: FolderEntity[]; } +export interface DeleteOptions { + deleteChildren?: boolean; +} + +const defaultDeleteOptions = (): DeleteOptions => { + return { + deleteChildren: true, + }; +}; + export default class Folder extends BaseItem { static tableName() { return 'folders'; @@ -78,9 +88,9 @@ export default class Folder extends BaseItem { return this.db().exec(query); } - static async delete(folderId: string, options: any = null) { + public static async delete(folderId: string, options: DeleteOptions = null) { options = { - deleteChildren: true, + ...defaultDeleteOptions(), ...options, }; diff --git a/packages/lib/services/commands/stateToWhenClauseContext.ts b/packages/lib/services/commands/stateToWhenClauseContext.ts index 2b3b6b7f2..86fddf5de 100644 --- a/packages/lib/services/commands/stateToWhenClauseContext.ts +++ b/packages/lib/services/commands/stateToWhenClauseContext.ts @@ -25,8 +25,10 @@ export interface WhenClauseContext { noteTodoCompleted: boolean; noteIsMarkdown: boolean; noteIsHtml: boolean; + folderIsShareRootAndNotOwnedByUser: boolean; folderIsShareRootAndOwnedByUser: boolean; folderIsShared: boolean; + folderIsShareRoot: boolean; joplinServerConnected: boolean; } @@ -74,6 +76,8 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau noteIsHtml: selectedNote ? selectedNote.markup_language === MarkupToHtml.MARKUP_LANGUAGE_HTML : false, // Current context folder + folderIsShareRoot: commandFolder ? isRootSharedFolder(commandFolder) : false, + folderIsShareRootAndNotOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && !isSharedFolderOwner(state, commandFolder.id) : false, folderIsShareRootAndOwnedByUser: commandFolder ? isRootSharedFolder(commandFolder) && isSharedFolderOwner(state, commandFolder.id) : false, folderIsShared: commandFolder ? !!commandFolder.share_id : false, diff --git a/packages/lib/services/share/ShareService.ts b/packages/lib/services/share/ShareService.ts index e9618d7ef..97aa530b4 100644 --- a/packages/lib/services/share/ShareService.ts +++ b/packages/lib/services/share/ShareService.ts @@ -74,6 +74,8 @@ export default class ShareService { 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}`); diff --git a/readme/spec/server_sharing.md b/readme/spec/server_sharing.md index 2f65ba76b..9f20862c7 100644 --- a/readme/spec/server_sharing.md +++ b/readme/spec/server_sharing.md @@ -1,16 +1,33 @@ # Joplin Server sharing feature -## Sharing a file via a public URL +## Sharing a notebook with a user -Joplin Server is essentially a file hosting service and it allows sharing files via public URLs. To do so, an API call is made to `/api/shares` with the ID or path of the file that needs to be shared. This call returns a SHAREID that is then used to access the file via URL. When viewing the file, it will display it according to its mime type. Thus by default a Markdown file will be displayed as plain text. +Sharing a notebook is done via synchronisation using the following API objects: -## Sharing a note via a public URL +- `item`: any Joplin item such as a note or notebook. +- `user_item`: owned by a user and points to an item. Multiple user_items can point to the same item, which is important to enable sharing. +- `share`: associated with a notebook ID, it specifies which notebook should be shared and by whom +- `share_user`: associated with share and a user. This is essentially an invitation that the sharer sent to recipients. There can be multiple such objects, and they can be accepted or rejected by the recipient. -It is built on top of the file sharing feature. The file corresponding to the note is shared via the above API. Then a separate application, specific to Joplin, read and parse the Markdown file, and display it as note. +The process to share is then: -That application works as a viewer - instead of displaying the Markdown file as plain text (by default), it renders it and displays it as HTML. +- First, the sharer calls `POST /api/shares` with the notebook ID that needs to be shared. +- Then invitations can be sent by calling `POST /api/share_users` and providing the share ID and recipient email. +- The recipient accept or reject the application by setting the status onn the `share_users` object (which corresponds to an invitation). -The rendering engine is the same as the main applications, which allows us to use the same plugins and settings. +Once share is setup, the client recursively goes through all notes, sub-notebooks and resources within the shared notebook, and set their `share_id` property. Basically any item within the notebook should have this property set. Then all these items are synchronized. + +On the server, a service is running at regular interval to check the `share_id` property, and generate `user_item` objects for each recipient. Once these objects have been created, the recipient will start receiving the shared notebooks and notes. + +### Why is the share_id set on the client and not the server? + +Technically, the server would only need to know the root shared folder, and from that can be find out its children. This approach was tried but it makes the system much more complex because some information is lost after sync - in particular when notes or notebooks are moved out of folders, when resources are attached or removed, etc. Keeping track of all this is possible but complex and innefficient. + +On the other hand, all that information is present on the client. Whenever a notes is moved out a shared folder, or whenever a resources is attached, the changes are tracked, and that can be used to easily assign a `share_id` property. Once this is set, it makes the whole system more simple and reliable. + +## Publishing a note via a public URL + +This is done by posting a note ID to `/api/shares`. ### Attached resources @@ -29,7 +46,3 @@ Any linked note will **not** be shared, due to the following reasons: It should be possible to have multiple share links for a given note. For example: I share a note with one person, then the same note with a different person. I revoke the share for one person, but I sill want the other person to access the note. So when a share link is created for a note, the API always return a new link. - -## Sharing a note with a user - -TBD