2021-05-13 18:57:37 +02:00
import { Store } from 'redux' ;
import JoplinServerApi from '../../JoplinServerApi' ;
2021-11-03 18:24:40 +02:00
import { _ } from '../../locale' ;
2023-07-27 17:05:56 +02:00
import Logger from '@joplin/utils/Logger' ;
2021-05-13 18:57:37 +02:00
import Folder from '../../models/Folder' ;
2021-11-03 18:24:40 +02:00
import MasterKey from '../../models/MasterKey' ;
2021-05-13 18:57:37 +02:00
import Note from '../../models/Note' ;
import Setting from '../../models/Setting' ;
2021-11-03 18:24:40 +02:00
import { FolderEntity } from '../database/types' ;
import EncryptionService from '../e2ee/EncryptionService' ;
import { PublicPrivateKeyPair , mkReencryptFromPasswordToPublicKey , mkReencryptFromPublicKeyToPassword } from '../e2ee/ppk' ;
import { MasterKeyEntity } from '../e2ee/types' ;
import { getMasterPassword } from '../e2ee/utils' ;
2021-11-28 18:46:10 +02:00
import ResourceService from '../ResourceService' ;
2021-11-03 18:24:40 +02:00
import { addMasterKey , getEncryptionEnabled , localSyncInfo } from '../synchronizer/syncInfoUtils' ;
2023-07-16 18:42:42 +02:00
import { ShareInvitation , SharePermissions , State , stateRootKey , StateShare } from './reducer' ;
2021-05-13 18:57:37 +02:00
2021-06-20 20:29:34 +02:00
const logger = Logger . create ( 'ShareService' ) ;
2021-11-03 18:24:40 +02:00
export interface ApiShare {
id : string ;
master_key_id : string ;
}
function formatShareInvitations ( invitations : any [ ] ) : ShareInvitation [ ] {
return invitations . map ( inv = > {
return {
. . . inv ,
master_key : inv.master_key ? JSON . parse ( inv . master_key ) : null ,
} ;
} ) ;
}
2021-05-13 18:57:37 +02:00
export default class ShareService {
private static instance_ : ShareService ;
private api_ : JoplinServerApi = null ;
private store_ : Store < any > = null ;
2021-11-03 18:24:40 +02:00
private encryptionService_ : EncryptionService = null ;
2021-10-31 20:31:40 +02:00
private initialized_ = false ;
2021-05-13 18:57:37 +02:00
public static instance ( ) : ShareService {
if ( this . instance_ ) return this . instance_ ;
this . instance_ = new ShareService ( ) ;
return this . instance_ ;
}
2021-11-03 18:24:40 +02:00
public initialize ( store : Store < any > , encryptionService : EncryptionService , api : JoplinServerApi = null ) {
2021-10-31 20:31:40 +02:00
this . initialized_ = true ;
2021-05-13 18:57:37 +02:00
this . store_ = store ;
2021-11-03 18:24:40 +02:00
this . encryptionService_ = encryptionService ;
2021-07-19 11:27:43 +02:00
this . api_ = api ;
2021-05-13 18:57:37 +02:00
}
public get enabled ( ) : boolean {
2021-10-31 20:31:40 +02:00
if ( ! this . initialized_ ) return false ;
2021-06-03 17:12:07 +02:00
return [ 9 , 10 ] . includes ( Setting . value ( 'sync.target' ) ) ; // Joplin Server, Joplin Cloud targets
2021-05-13 18:57:37 +02:00
}
private get store ( ) : Store < any > {
return this . store_ ;
}
public get state ( ) : State {
return this . store . getState ( ) [ stateRootKey ] as State ;
}
2021-06-10 19:33:04 +02:00
public get userId ( ) : string {
return this . api ( ) ? this . api ( ) . userId : '' ;
}
2021-05-13 18:57:37 +02:00
private api ( ) : JoplinServerApi {
if ( this . api_ ) return this . api_ ;
2021-06-03 17:12:07 +02:00
const syncTargetId = Setting . value ( 'sync.target' ) ;
2021-05-13 18:57:37 +02:00
this . api_ = new JoplinServerApi ( {
2021-06-03 17:12:07 +02:00
baseUrl : ( ) = > Setting . value ( ` sync. ${ syncTargetId } .path ` ) ,
2021-06-06 19:14:12 +02:00
userContentBaseUrl : ( ) = > Setting . value ( ` sync. ${ syncTargetId } .userContentPath ` ) ,
2021-06-03 17:12:07 +02:00
username : ( ) = > Setting . value ( ` sync. ${ syncTargetId } .username ` ) ,
password : ( ) = > Setting . value ( ` sync. ${ syncTargetId } .password ` ) ,
2021-05-13 18:57:37 +02:00
} ) ;
return this . api_ ;
}
2021-11-03 18:24:40 +02:00
public async shareFolder ( folderId : string ) : Promise < ApiShare > {
2021-05-13 18:57:37 +02:00
const folder = await Folder . load ( folderId ) ;
if ( ! folder ) throw new Error ( ` No such folder: ${ folderId } ` ) ;
2021-11-03 18:24:40 +02:00
let folderMasterKey : MasterKeyEntity = null ;
if ( getEncryptionEnabled ( ) ) {
const syncInfo = localSyncInfo ( ) ;
// Shouldn't happen
if ( ! syncInfo . ppk ) throw new Error ( 'Cannot share notebook because E2EE is enabled and no Public Private Key pair exists.' ) ;
// TODO: handle "undefinedMasterPassword" error - show master password dialog
folderMasterKey = await this . encryptionService_ . generateMasterKey ( getMasterPassword ( ) ) ;
folderMasterKey = await MasterKey . save ( folderMasterKey ) ;
addMasterKey ( syncInfo , folderMasterKey ) ;
}
const newFolderProps : FolderEntity = { } ;
if ( folder . parent_id ) newFolderProps . parent_id = '' ;
if ( folderMasterKey ) newFolderProps . master_key_id = folderMasterKey . id ;
if ( Object . keys ( newFolderProps ) . length ) {
await Folder . save ( {
id : folder.id ,
. . . newFolderProps ,
} ) ;
2021-05-13 18:57:37 +02:00
}
2021-11-03 18:24:40 +02:00
const share = await this . api ( ) . exec ( 'POST' , 'api/shares' , { } , {
folder_id : folderId ,
master_key_id : folderMasterKey ? folderMasterKey . id : '' ,
} ) ;
2021-05-13 18:57:37 +02:00
// Note: race condition if the share is created but the app crashes
// before setting share_id on the folder. See unshareFolder() for info.
await Folder . save ( { id : folder.id , share_id : share.id } ) ;
2021-11-28 18:46:10 +02:00
await Folder . updateAllShareIds ( ResourceService . instance ( ) ) ;
2021-05-13 18:57:37 +02:00
return share ;
}
2021-10-13 19:02:54 +02:00
// This allows the notebook owner to stop sharing it. For a recipient to
// leave the shared notebook, see the leaveSharedFolder command.
2021-05-13 18:57:37 +02:00
public async unshareFolder ( folderId : string ) {
const folder = await Folder . load ( folderId ) ;
if ( ! folder ) throw new Error ( ` No such folder: ${ folderId } ` ) ;
const share = this . shares . find ( s = > s . folder_id === folderId ) ;
if ( ! share ) throw new Error ( ` No share for folder: ${ folderId } ` ) ;
// First, delete the share - which in turns is going to remove the items
// for all users, except the owner.
await this . deleteShare ( share . id ) ;
// Then reset the "share_id" field for the folder and all sub-items.
// This could potentially be done server-side, when deleting the share,
// but since clients are normally responsible for maintaining the
// share_id property, we do it here for consistency. It will also avoid
// conflicts because changes will come only from the clients.
//
// Note that there could be a race condition here if the share is
// deleted, but the app crashes just before setting share_id to "". It's
// very unlikely to happen so we leave like this for now.
//
// We could potentially have a clean up process at some point:
//
// - It would download all share objects
// - Then look for all items where the share_id is not in any of these
// shares objects
// - And set those to ""
//
// Likewise, it could apply the share_id to folders based on
// share.folder_id
//
// Setting the share_id is not critical - what matters is that when the
// share is deleted, other users no longer have access to the item, so
// can't change or read them.
await Folder . save ( { id : folder.id , share_id : '' } ) ;
// It's ok if updateAllShareIds() doesn't run because it's executed on
// each sync too.
2021-11-28 18:46:10 +02:00
await Folder . updateAllShareIds ( ResourceService . instance ( ) ) ;
2021-05-13 18:57:37 +02:00
}
2021-10-15 17:16:02 +02:00
// This is when a share recipient decides to leave the shared folder.
//
2023-07-23 16:57:55 +02:00
// In that case we delete the root folder. Deleting the folder tells the
// server that we want to leave the share.
2021-10-15 17:16:02 +02:00
//
2023-07-23 16:57:55 +02:00
// 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.
2021-12-20 16:47:50 +02:00
//
// 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
// (instead "unshareFolder" should be called).
public async leaveSharedFolder ( folderId : string , folderShareUserId : string = null ) : Promise < void > {
if ( folderShareUserId !== null ) {
const userId = Setting . value ( 'sync.userId' ) ;
if ( folderShareUserId === userId ) throw new Error ( 'Cannot leave own notebook' ) ;
}
2023-07-23 16:57:55 +02:00
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 } ) ;
2021-10-15 17:16:02 +02:00
}
2021-12-20 16:47:50 +02:00
// Finds any folder that is associated with a share, but the user no longer
// has access to the share, and remove these folders. This check is
// necessary otherwise sync will try to update items that are not longer
// accessible and will throw the error "Could not find share with ID: xxxx")
public async checkShareConsistency() {
const rootSharedFolders = await Folder . rootSharedFolders ( ) ;
let hasRefreshedShares = false ;
let shares = this . shares ;
for ( const folder of rootSharedFolders ) {
let share = shares . find ( s = > s . id === folder . share_id ) ;
if ( ! share && ! hasRefreshedShares ) {
shares = await this . refreshShares ( ) ;
share = shares . find ( s = > s . id === folder . share_id ) ;
hasRefreshedShares = true ;
}
if ( ! share ) {
// This folder is a associated with a share, but the user no
// longer has access to this share. It can happen for two
// reasons:
//
// - It no longer exists
// - Or the user rejected that share from a different device,
// and the folder was not deleted as it should have been.
//
// In that case we need to leave the notebook.
logger . warn ( ` Found a folder that was associated with a share, but the user not longer has access to the share - leaving the folder. Folder: ${ folder . title } ( ${ folder . id } ). Share: ${ folder . share_id } ` ) ;
await this . leaveSharedFolder ( folder . id ) ;
}
}
}
2022-04-03 20:19:24 +02:00
public async shareNote ( noteId : string , recursive : boolean ) : Promise < StateShare > {
2021-05-13 18:57:37 +02:00
const note = await Note . load ( noteId ) ;
if ( ! note ) throw new Error ( ` No such note: ${ noteId } ` ) ;
2022-04-03 20:19:24 +02:00
const share = await this . api ( ) . exec ( 'POST' , 'api/shares' , { } , {
note_id : noteId ,
recursive : recursive ? 1 : 0 ,
} ) ;
2021-05-13 18:57:37 +02:00
2021-07-19 11:27:43 +02:00
await Note . save ( {
id : note.id ,
parent_id : note.parent_id ,
is_shared : 1 ,
updated_time : Date.now ( ) ,
} , {
autoTimestamp : false ,
} ) ;
2021-05-13 18:57:37 +02:00
return share ;
}
2021-05-16 17:28:49 +02:00
public async unshareNote ( noteId : string ) {
const note = await Note . load ( noteId ) ;
if ( ! note ) throw new Error ( ` No such note: ${ noteId } ` ) ;
const shares = await this . refreshShares ( ) ;
const noteShares = shares . filter ( s = > s . note_id === noteId ) ;
const promises : Promise < void > [ ] = [ ] ;
for ( const share of noteShares ) {
promises . push ( this . deleteShare ( share . id ) ) ;
}
await Promise . all ( promises ) ;
2021-07-19 11:27:43 +02:00
await Note . save ( {
id : note.id ,
parent_id : note.parent_id ,
is_shared : 0 ,
updated_time : Date.now ( ) ,
} , {
autoTimestamp : false ,
} ) ;
2021-05-16 17:28:49 +02:00
}
2021-06-10 19:33:04 +02:00
public shareUrl ( userId : string , share : StateShare ) : string {
2021-06-15 13:41:15 +02:00
return ` ${ this . api ( ) . personalizedUserContentBaseUrl ( userId ) } /shares/ ${ share . id } ` ;
2021-05-13 18:57:37 +02:00
}
2021-11-03 18:24:40 +02:00
public folderShare ( folderId : string ) : StateShare {
return this . shares . find ( s = > s . folder_id === folderId ) ;
}
public isSharedFolderOwner ( folderId : string , userId : string = null ) : boolean {
if ( userId === null ) userId = this . userId ;
const share = this . folderShare ( folderId ) ;
if ( ! share ) throw new Error ( ` Cannot find share associated with folder: ${ folderId } ` ) ;
return share . user . id === userId ;
}
2021-05-13 18:57:37 +02:00
public get shares() {
return this . state . shares ;
}
public get shareLinkNoteIds ( ) : string [ ] {
return this . shares . filter ( s = > ! ! s . note_id ) . map ( s = > s . note_id ) ;
}
2021-06-20 20:29:34 +02:00
public get shareInvitations() {
return this . state . shareInvitations ;
}
2021-11-03 18:24:40 +02:00
private async userPublicKey ( userEmail : string ) : Promise < PublicPrivateKeyPair > {
return this . api ( ) . exec ( 'GET' , ` api/users/ ${ encodeURIComponent ( userEmail ) } /public_key ` ) ;
}
2023-07-16 18:42:42 +02:00
public async addShareRecipient ( shareId : string , masterKeyId : string , recipientEmail : string , permissions : SharePermissions ) {
2021-11-03 18:24:40 +02:00
let recipientMasterKey : MasterKeyEntity = null ;
if ( getEncryptionEnabled ( ) ) {
const syncInfo = localSyncInfo ( ) ;
const masterKey = syncInfo . masterKeys . find ( m = > m . id === masterKeyId ) ;
if ( ! masterKey ) throw new Error ( ` Cannot find master key with ID " ${ masterKeyId } " ` ) ;
const recipientPublicKey : PublicPrivateKeyPair = await this . userPublicKey ( recipientEmail ) ;
2021-11-08 12:00:11 +02:00
if ( ! recipientPublicKey ) throw new Error ( _ ( 'Cannot share encrypted notebook with recipient %s because they have not enabled end-to-end encryption. They may do so from the screen Configuration > Encryption.' , recipientEmail ) ) ;
2021-11-03 18:24:40 +02:00
logger . info ( 'Reencrypting master key with recipient public key' , recipientPublicKey ) ;
recipientMasterKey = await mkReencryptFromPasswordToPublicKey (
this . encryptionService_ ,
masterKey ,
getMasterPassword ( ) ,
recipientPublicKey
) ;
}
2021-05-13 18:57:37 +02:00
return this . api ( ) . exec ( 'POST' , ` api/shares/ ${ shareId } /users ` , { } , {
email : recipientEmail ,
2021-11-03 18:24:40 +02:00
master_key : JSON.stringify ( recipientMasterKey ) ,
2023-07-16 18:42:42 +02:00
. . . permissions ,
2021-05-13 18:57:37 +02:00
} ) ;
}
public async deleteShareRecipient ( shareUserId : string ) {
await this . api ( ) . exec ( 'DELETE' , ` api/share_users/ ${ shareUserId } ` ) ;
}
public async deleteShare ( shareId : string ) {
await this . api ( ) . exec ( 'DELETE' , ` api/shares/ ${ shareId } ` ) ;
}
private async loadShares() {
return this . api ( ) . exec ( 'GET' , 'api/shares' ) ;
}
private async loadShareUsers ( shareId : string ) {
return this . api ( ) . exec ( 'GET' , ` api/shares/ ${ shareId } /users ` ) ;
}
private async loadShareInvitations() {
return this . api ( ) . exec ( 'GET' , 'api/share_users' ) ;
}
2021-09-25 19:00:43 +02:00
public setProcessingShareInvitationResponse ( v : boolean ) {
this . store . dispatch ( {
type : 'SHARE_INVITATION_RESPONSE_PROCESSING' ,
value : v ,
} ) ;
}
2023-07-16 18:42:42 +02:00
public async setPermissions ( shareId : string , shareUserId : string , permissions : SharePermissions ) {
logger . info ( 'setPermissions: ' , shareUserId , permissions ) ;
await this . api ( ) . exec ( 'PATCH' , ` api/share_users/ ${ shareUserId } ` , null , {
can_read : 1 ,
can_write : permissions.can_write ,
} ) ;
this . store . dispatch ( {
type : 'SHARE_USER_UPDATE_ONE' ,
shareId : shareId ,
shareUser : {
id : shareUserId ,
. . . permissions ,
} ,
} ) ;
}
2021-11-03 18:24:40 +02:00
public async respondInvitation ( shareUserId : string , masterKey : MasterKeyEntity , accept : boolean ) {
logger . info ( 'respondInvitation: ' , shareUserId , accept ) ;
2021-05-13 18:57:37 +02:00
if ( accept ) {
2021-11-03 18:24:40 +02:00
if ( masterKey ) {
const reencryptedMasterKey = await mkReencryptFromPublicKeyToPassword (
this . encryptionService_ ,
masterKey ,
localSyncInfo ( ) . ppk ,
getMasterPassword ( ) ,
getMasterPassword ( )
) ;
logger . info ( 'respondInvitation: Key has been reencrypted using master password' , reencryptedMasterKey ) ;
await MasterKey . save ( reencryptedMasterKey ) ;
}
2021-05-13 18:57:37 +02:00
await this . api ( ) . exec ( 'PATCH' , ` api/share_users/ ${ shareUserId } ` , null , { status : 1 } ) ;
} else {
await this . api ( ) . exec ( 'PATCH' , ` api/share_users/ ${ shareUserId } ` , null , { status : 2 } ) ;
}
}
public async refreshShareInvitations() {
const result = await this . loadShareInvitations ( ) ;
2021-11-03 18:24:40 +02:00
const invitations = formatShareInvitations ( result . items ) ;
logger . info ( 'Refresh share invitations:' , invitations ) ;
2021-05-13 18:57:37 +02:00
this . store . dispatch ( {
type : 'SHARE_INVITATION_SET' ,
2021-11-03 18:24:40 +02:00
shareInvitations : invitations ,
2021-05-13 18:57:37 +02:00
} ) ;
}
2021-11-03 18:24:40 +02:00
public async shareById ( id : string ) {
const stateShare = this . state . shares . find ( s = > s . id === id ) ;
if ( stateShare ) return stateShare ;
const refreshedShares = await this . refreshShares ( ) ;
const refreshedShare = refreshedShares . find ( s = > s . id === id ) ;
if ( ! refreshedShare ) throw new Error ( ` Could not find share with ID: ${ id } ` ) ;
return refreshedShare ;
}
// In most cases the share objects will already be part of the state, so
// this function checks there first. If the required share objects are not
// present, it refreshes them from the API.
public async sharesByIds ( ids : string [ ] ) {
const buildOutput = async ( shares : StateShare [ ] ) = > {
const output : Record < string , StateShare > = { } ;
for ( const share of shares ) {
if ( ids . includes ( share . id ) ) output [ share . id ] = share ;
}
return output ;
} ;
let output = await buildOutput ( this . state . shares ) ;
if ( Object . keys ( output ) . length === ids . length ) return output ;
const refreshedShares = await this . refreshShares ( ) ;
output = await buildOutput ( refreshedShares ) ;
if ( Object . keys ( output ) . length !== ids . length ) {
logger . error ( 'sharesByIds: Need:' , ids ) ;
logger . error ( 'sharesByIds: Got:' , Object . keys ( refreshedShares ) ) ;
throw new Error ( 'Could not retrieve required share objects' ) ;
}
return output ;
}
2021-05-16 17:28:49 +02:00
public async refreshShares ( ) : Promise < StateShare [ ] > {
2021-05-13 18:57:37 +02:00
const result = await this . loadShares ( ) ;
2021-11-03 18:24:40 +02:00
logger . info ( 'Refreshed shares:' , result ) ;
2021-05-13 18:57:37 +02:00
this . store . dispatch ( {
type : 'SHARE_SET' ,
shares : result.items ,
} ) ;
2021-05-16 17:28:49 +02:00
return result . items ;
2021-05-13 18:57:37 +02:00
}
public async refreshShareUsers ( shareId : string ) {
const result = await this . loadShareUsers ( shareId ) ;
2021-11-03 18:24:40 +02:00
logger . info ( 'Refreshed share users:' , result ) ;
2021-05-13 18:57:37 +02:00
this . store . dispatch ( {
type : 'SHARE_USER_SET' ,
shareId : shareId ,
shareUsers : result.items ,
} ) ;
}
2021-06-20 20:29:34 +02:00
private async updateNoLongerSharedItems() {
const shareIds = this . shares . map ( share = > share . id ) . concat ( this . shareInvitations . map ( si = > si . share . id ) ) ;
await Folder . updateNoLongerSharedItems ( shareIds ) ;
}
2021-05-13 18:57:37 +02:00
public async maintenance() {
if ( this . enabled ) {
2021-06-20 20:29:34 +02:00
let hasError = false ;
try {
await this . refreshShareInvitations ( ) ;
await this . refreshShares ( ) ;
Setting . setValue ( 'sync.userId' , this . api ( ) . userId ) ;
} catch ( error ) {
hasError = true ;
logger . error ( 'Failed to run maintenance:' , error ) ;
}
// If there was no errors, it means we have all the share objects,
// so we can run the clean up function.
if ( ! hasError ) await this . updateNoLongerSharedItems ( ) ;
2021-05-13 18:57:37 +02:00
}
}
}