/
ShareService.ts
264 lines (209 loc) · 7.43 KB
/
ShareService.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
import { Store } from 'redux';
import JoplinServerApi from '../../JoplinServerApi';
import Logger from '../../Logger';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Setting from '../../models/Setting';
import { State, stateRootKey, StateShare } from './reducer';
const logger = Logger.create('ShareService');
export default class ShareService {
private static instance_: ShareService;
private api_: JoplinServerApi = null;
private store_: Store<any> = null;
public static instance(): ShareService {
if (this.instance_) return this.instance_;
this.instance_ = new ShareService();
return this.instance_;
}
public initialize(store: Store<any>, api: JoplinServerApi = null) {
this.store_ = store;
this.api_ = api;
}
public get enabled(): boolean {
return [9, 10].includes(Setting.value('sync.target')); // Joplin Server, Joplin Cloud targets
}
private get store(): Store<any> {
return this.store_;
}
public get state(): State {
return this.store.getState()[stateRootKey] as State;
}
public get userId(): string {
return this.api() ? this.api().userId : '';
}
private api(): JoplinServerApi {
if (this.api_) return this.api_;
const syncTargetId = Setting.value('sync.target');
this.api_ = new JoplinServerApi({
baseUrl: () => Setting.value(`sync.${syncTargetId}.path`),
userContentBaseUrl: () => Setting.value(`sync.${syncTargetId}.userContentPath`),
username: () => Setting.value(`sync.${syncTargetId}.username`),
password: () => Setting.value(`sync.${syncTargetId}.password`),
});
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;
}
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();
}
public async shareNote(noteId: string): Promise<StateShare> {
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 });
await Note.save({
id: note.id,
parent_id: note.parent_id,
is_shared: 1,
updated_time: Date.now(),
}, {
autoTimestamp: false,
});
return share;
}
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);
await Note.save({
id: note.id,
parent_id: note.parent_id,
is_shared: 0,
updated_time: Date.now(),
}, {
autoTimestamp: false,
});
}
public shareUrl(userId: string, share: StateShare): string {
return `${this.api().personalizedUserContentBaseUrl(userId)}/shares/${share.id}`;
}
public get shares() {
return this.state.shares;
}
public get shareLinkNoteIds(): string[] {
return this.shares.filter(s => !!s.note_id).map(s => s.note_id);
}
public get shareInvitations() {
return this.state.shareInvitations;
}
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');
}
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,
});
}
public async refreshShares(): Promise<StateShare[]> {
const result = await this.loadShares();
this.store.dispatch({
type: 'SHARE_SET',
shares: result.items,
});
return result.items;
}
public async refreshShareUsers(shareId: string) {
const result = await this.loadShareUsers(shareId);
this.store.dispatch({
type: 'SHARE_USER_SET',
shareId: shareId,
shareUsers: result.items,
});
}
private async updateNoLongerSharedItems() {
const shareIds = this.shares.map(share => share.id).concat(this.shareInvitations.map(si => si.share.id));
await Folder.updateNoLongerSharedItems(shareIds);
}
public async maintenance() {
if (this.enabled) {
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();
}
}
}