import { _ } from '@joplin/lib/locale'; import BaseCommand from './base-command'; import app from './app'; import { reg } from '@joplin/lib/registry'; import Logger from '@joplin/utils/Logger'; import ShareService from '@joplin/lib/services/share/ShareService'; import { ModelType } from '@joplin/lib/BaseModel'; import { FolderEntity } from '@joplin/lib/services/database/types'; import { ShareUserStatus } from '@joplin/lib/services/share/reducer'; import Folder from '@joplin/lib/models/Folder'; import invitationRespond from '@joplin/lib/services/share/invitationRespond'; import CommandService from '@joplin/lib/services/CommandService'; import { substrWithEllipsis } from '@joplin/lib/string-utils'; const logger = Logger.create('command-share'); type Args = { command: string; // eslint-disable-next-line id-denylist -- The "notebook" identifier comes from the UI. notebook?: string; user?: string; options: { 'read-only'?: boolean; json?: boolean; force?: boolean; }; }; const folderTitle = (folder: FolderEntity|null) => { return folder ? substrWithEllipsis(folder.title, 0, 32) : _('[None]'); }; const getShareState = () => app().store().getState().shareService; const getShareFromFolderId = (folderId: string) => { const shareState = getShareState(); const allShares = shareState.shares; const share = allShares.find(share => share.folder_id === folderId); return share; }; const getShareUsers = (folderId: string) => { const share = getShareFromFolderId(folderId); if (!share) { throw new Error(`No share found for folder ${folderId}`); } return getShareState().shareUsers[share.id]; }; class Command extends BaseCommand { public usage() { return 'share [notebook] [user]'; } public description() { return [ _('Shares or unshares the specified [notebook] with [user]. Requires Joplin Cloud or Joplin Server.'), _('Commands: `add`, `remove`, `list`, `delete`, `accept`, `leave`, and `reject`.'), ].join('\n'); } public options() { return [ ['--read-only', _('Don\'t allow the share recipient to write to the shared notebook. Valid only for the `add` subcommand.')], ['-f, --force', _('Do not ask for user confirmation.')], ['--json', _('Prefer JSON output.')], ]; } public async action(args: Args) { const commandShareAdd = async (folder: FolderEntity, email: string) => { await reg.waitForSyncFinishedThenSync(); const share = await ShareService.instance().shareFolder(folder.id); const permissions = { can_read: 1, can_write: args.options['read-only'] ? 0 : 1, }; logger.debug('Sharing folder', folder.id, 'with', email, 'permissions=', permissions); await ShareService.instance().addShareRecipient(share.id, share.master_key_id, email, permissions); await ShareService.instance().refreshShares(); await ShareService.instance().refreshShareUsers(share.id); await reg.waitForSyncFinishedThenSync(); }; const commandShareRemove = async (folder: FolderEntity, email: string) => { await ShareService.instance().refreshShares(); const share = getShareFromFolderId(folder.id); if (!share) { throw new Error(`No share found for folder ${folder.id}`); } await ShareService.instance().refreshShareUsers(share.id); const shareUsers = getShareUsers(folder.id); if (!shareUsers) { throw new Error(`No share found for folder ${folder.id}`); } const targetUser = shareUsers.find(user => user.user?.email === email); if (!targetUser) { throw new Error(`No recipient found with email ${email}`); } await ShareService.instance().deleteShareRecipient(targetUser.id); this.stdout(_('Removed %s from share.', targetUser.user.email)); }; const commandShareList = async () => { let folder = null; if (args.notebook) { folder = await app().loadItemOrFail(ModelType.Folder, args.notebook); } await ShareService.instance().maintenance(); if (folder) { const share = getShareFromFolderId(folder.id); await ShareService.instance().refreshShareUsers(share.id); const shareUsers = getShareUsers(folder.id); const output = { folderTitle: folderTitle(folder), sharedWith: (shareUsers ?? []).map(user => ({ email: user.user.email, readOnly: user.can_read && !user.can_write, })), }; if (args.options.json) { this.stdout(JSON.stringify(output)); } else { this.stdout(_('Folder "%s" is shared with:', output.folderTitle)); for (const user of output.sharedWith) { this.stdout(`\t${user.email}\t${user.readOnly ? _('(Read-only)') : ''}`); } } } else { const shareState = getShareState(); const output = { invitations: shareState.shareInvitations.map(invitation => ({ accepted: invitation.status === ShareUserStatus.Accepted, waiting: invitation.status === ShareUserStatus.Waiting, rejected: invitation.status === ShareUserStatus.Rejected, folderId: invitation.share.folder_id, canWrite: !!invitation.can_write, fromUser: { email: invitation.share.user?.email, }, })), shares: shareState.shares.map(share => ({ isFolder: !!share.folder_id, isNote: !!share.note_id, itemId: share.folder_id ?? share.note_id, fromUser: { email: share.user?.email, }, })), }; if (args.options.json) { this.stdout(JSON.stringify(output)); } else { this.stdout(_('Incoming shares:')); let loggedInvitation = false; for (const invitation of output.invitations) { let message; if (invitation.waiting) { message = _('Waiting: Notebook %s from %s', invitation.folderId, invitation.fromUser.email); } if (invitation.accepted) { const folder = await Folder.load(invitation.folderId); message = _('Accepted: Notebook %s from %s', folderTitle(folder), invitation.fromUser.email); } if (message) { this.stdout(`\t${message}`); loggedInvitation = true; } } if (!loggedInvitation) { this.stdout(`\t${_('None')}`); } this.stdout(_('All shared folders:')); if (output.shares.length) { for (const share of output.shares) { let title; if (share.isFolder) { title = folderTitle(await Folder.load(share.itemId)); } else { title = share.itemId; } if (share.fromUser?.email) { this.stdout(`\t${_('%s from %s', title, share.fromUser?.email)}`); } else { this.stdout(`\t${title} - ${share.itemId}`); } } } else { this.stdout(`\t${_('None')}`); } } } }; const commandShareAcceptOrReject = async (folderId: string, accept: boolean) => { await ShareService.instance().maintenance(); const shareState = getShareState(); const invitations = shareState.shareInvitations.filter(invitation => { return invitation.share.folder_id === folderId && invitation.status === ShareUserStatus.Waiting; }); if (invitations.length === 0) throw new Error('No such invitation found'); // If there are multiple invitations for the same folder, stop early to avoid // accepting the wrong invitation. if (invitations.length > 1) throw new Error('Multiple invitations found with the same ID'); const invitation = invitations[0]; this.stdout(accept ? _('Accepting share...') : _('Rejecting share...')); await invitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, accept); }; const commandShareAccept = (folderId: string) => ( commandShareAcceptOrReject(folderId, true) ); const commandShareReject = (folderId: string) => ( commandShareAcceptOrReject(folderId, false) ); const commandShareDelete = async (folder: FolderEntity) => { const force = args.options.force; const ok = force ? true : await this.prompt( _('Unshare notebook "%s"? This may cause other users to lose access to the notebook.', folderTitle(folder)), { booleanAnswerDefault: 'n' }, ); if (!ok) return; logger.info('Unsharing folder', folder.id); await ShareService.instance().unshareFolder(folder.id); await reg.scheduleSync(); }; if (args.command === 'add' || args.command === 'remove' || args.command === 'delete') { if (!args.notebook) throw new Error('[notebook] is required'); const folder = await app().loadItemOrFail(ModelType.Folder, args.notebook); if (args.command === 'delete') { return commandShareDelete(folder); } else { if (!args.user) throw new Error('[user] is required'); const email = args.user; if (args.command === 'add') { return commandShareAdd(folder, email); } else if (args.command === 'remove') { return commandShareRemove(folder, email); } } } if (args.command === 'leave') { const folder = args.notebook ? await app().loadItemOrFail(ModelType.Folder, args.notebook) : null; await ShareService.instance().maintenance(); return CommandService.instance().execute( 'leaveSharedFolder', folder?.id, { force: args.options.force }, ); } if (args.command === 'list') { return commandShareList(); } if (args.command === 'accept') { return commandShareAccept(args.notebook); } if (args.command === 'reject') { return commandShareReject(args.notebook); } throw new Error(`Unknown subcommand: ${args.command}`); } } module.exports = Command;