2021-05-13 18:57:37 +02:00
|
|
|
import { Store } from 'redux';
|
|
|
|
import JoplinServerApi from '../../JoplinServerApi';
|
2021-06-20 20:29:34 +02:00
|
|
|
import Logger from '../../Logger';
|
2021-05-13 18:57:37 +02:00
|
|
|
import Folder from '../../models/Folder';
|
|
|
|
import Note from '../../models/Note';
|
|
|
|
import Setting from '../../models/Setting';
|
|
|
|
import { State, stateRootKey, StateShare } from './reducer';
|
|
|
|
|
2021-06-20 20:29:34 +02:00
|
|
|
const logger = Logger.create('ShareService');
|
|
|
|
|
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-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-07-19 11:27:43 +02:00
|
|
|
public initialize(store: Store<any>, 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-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_;
|
|
|
|
}
|
|
|
|
|
|
|
|
public async shareFolder(folderId: string) {
|
|
|
|
const folder = await Folder.load(folderId);
|
|
|
|
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
|
|
|
|
|
|
|
if (folder.parent_id) {
|
|
|
|
await Folder.save({ id: folder.id, parent_id: '' });
|
|
|
|
}
|
|
|
|
|
|
|
|
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
|
|
|
|
|
|
|
|
// 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 });
|
|
|
|
await Folder.updateAllShareIds();
|
|
|
|
|
|
|
|
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.
|
|
|
|
await Folder.updateAllShareIds();
|
|
|
|
}
|
|
|
|
|
2021-10-15 17:16:02 +02:00
|
|
|
// 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.
|
|
|
|
// 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.
|
|
|
|
public async leaveSharedFolder(folderId: string): Promise<void> {
|
|
|
|
await Folder.delete(folderId, { deleteChildren: false });
|
|
|
|
}
|
|
|
|
|
2021-05-16 17:28:49 +02:00
|
|
|
public async shareNote(noteId: string): 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}`);
|
|
|
|
|
|
|
|
const share = await this.api().exec('POST', 'api/shares', {}, { note_id: noteId });
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
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-05-13 18:57:37 +02:00
|
|
|
public async addShareRecipient(shareId: string, recipientEmail: string) {
|
|
|
|
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
|
|
|
email: recipientEmail,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
public async respondInvitation(shareUserId: string, accept: boolean) {
|
|
|
|
if (accept) {
|
|
|
|
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();
|
|
|
|
|
|
|
|
this.store.dispatch({
|
|
|
|
type: 'SHARE_INVITATION_SET',
|
|
|
|
shareInvitations: result.items,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|