Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/publish/confluence/api/index.ts
6456 views
1
/*
2
* index.ts
3
*
4
* Copyright (C) 2020 by Posit, PBC
5
*/
6
7
import { encodeBase64 as base64encode } from "encoding/base64";
8
import { ensureTrailingSlash } from "../../../core/path.ts";
9
10
import { AccountToken } from "../../provider-types.ts";
11
import { ApiError } from "../../types.ts";
12
import {
13
AttachmentSummary,
14
ConfluenceParent,
15
Content,
16
ContentArray,
17
ContentChangeType,
18
ContentCreate,
19
ContentDelete,
20
ContentProperty,
21
ContentSummary,
22
ContentUpdate,
23
LogPrefix,
24
Space,
25
User,
26
WrappedResult,
27
} from "./types.ts";
28
29
import {
30
CAN_SET_PERMISSIONS_DISABLED,
31
CAN_SET_PERMISSIONS_ENABLED_CACHED,
32
DESCENDANT_PAGE_SIZE,
33
V2EDITOR_METADATA,
34
} from "../constants.ts";
35
import { logError, trace } from "../confluence-logger.ts";
36
import { buildContentCreate } from "../confluence-helper.ts";
37
38
export class ConfluenceClient {
39
public constructor(private readonly token_: AccountToken) {}
40
41
public getUser(expand = ["operations"]): Promise<User> {
42
return this.get<User>(`user/current?expand=${expand}`);
43
}
44
45
public getSpace(spaceId: string, expand = ["homepage"]): Promise<Space> {
46
return this.get<Space>(`space/${spaceId}?expand=${expand}`);
47
}
48
49
public getContent(id: string): Promise<Content> {
50
return this.get<Content>(`content/${id}`);
51
}
52
53
public async getContentProperty(id: string): Promise<ContentProperty[]> {
54
const result: WrappedResult<ContentProperty> = await this.get<
55
WrappedResult<ContentProperty>
56
>(`content/${id}/property`);
57
58
return result.results;
59
}
60
61
public getDescendantsPage(
62
id: string,
63
start: number = 0,
64
expand = ["metadata.properties", "ancestors"],
65
): Promise<WrappedResult<ContentSummary>> {
66
const url =
67
`content/${id}/descendant/page?limit=${DESCENDANT_PAGE_SIZE}&start=${start}&expand=${expand}`;
68
return this.get<WrappedResult<ContentSummary>>(url);
69
}
70
71
public async isTitleUniqueInSpace(
72
title: string,
73
space: Space,
74
idToIgnore: string = "",
75
): Promise<boolean> {
76
const result = await this.fetchMatchingTitlePages(title, space);
77
78
if (result.length === 1 && result[0].id === idToIgnore) {
79
return true;
80
}
81
82
return result.length === 0;
83
}
84
85
public async fetchMatchingTitlePages(
86
title: string,
87
space: Space,
88
isFuzzy: boolean = false,
89
): Promise<Content[]> {
90
const encodedTitle = encodeURIComponent(title);
91
92
let cql = `title="${encodedTitle}"`;
93
94
const CQL_CONTEXT =
95
"%7B%22contentStatuses%22%3A%5B%22archived%22%2C%20%22current%22%2C%20%22draft%22%5D%7D"; //{"contentStatuses":["archived", "current", "draft"]}
96
97
cql = `${cql}&spaces=${space.key}&cqlcontext=${CQL_CONTEXT}`;
98
99
const result = await this.get<ContentArray>(`content/search?cql=${cql}`);
100
return result?.results ?? [];
101
}
102
103
/**
104
* Perform a test to see if the user can manage permissions. In the space create a simple test page, attempt to set
105
* permissions on it, then delete it.
106
*/
107
public async canSetPermissions(
108
parent: ConfluenceParent,
109
space: Space,
110
user: User,
111
): Promise<boolean> {
112
let result = true;
113
114
trace("canSetPermissions check");
115
116
trace(
117
"localStorage.getItem(CAN_SET_PERMISSIONS_DISABLED)",
118
localStorage.getItem(CAN_SET_PERMISSIONS_DISABLED),
119
);
120
trace(
121
"localStorage.getItem(CAN_SET_PERMISSIONS_ENABLED_CACHED)",
122
localStorage.getItem(CAN_SET_PERMISSIONS_ENABLED_CACHED),
123
);
124
125
const permissionsTestDisabled =
126
localStorage.getItem(CAN_SET_PERMISSIONS_DISABLED) === "true" ||
127
localStorage.getItem(CAN_SET_PERMISSIONS_ENABLED_CACHED) === "true";
128
129
trace("permissionsTestDisabled", permissionsTestDisabled);
130
131
if (permissionsTestDisabled) {
132
return Promise.resolve(true);
133
}
134
135
const testContent: ContentCreate = buildContentCreate(
136
`quarto-permission-test-${globalThis.crypto.randomUUID()}`,
137
space,
138
{
139
storage: {
140
value: "",
141
representation: "storage",
142
},
143
},
144
"permisson-test",
145
);
146
const testContentCreated = await this.createContent(user, testContent);
147
148
const testContentId = testContentCreated.id ?? "";
149
150
try {
151
await this.put<Content>(
152
`content/${testContentId}/restriction/byOperation/update/user?accountId=${user.accountId}`,
153
);
154
} catch (error) {
155
if (!(error instanceof ApiError)) {
156
throw error;
157
}
158
trace("lockDownResult Error", error);
159
// Note, sometimes a successful call throws a
160
// "SyntaxError: Unexpected end of JSON input"
161
// check for the 403 status only
162
if (error?.status === 403) {
163
result = false;
164
}
165
}
166
167
const contentDelete: ContentDelete = {
168
id: testContentId,
169
contentChangeType: ContentChangeType.delete,
170
};
171
172
let attemptArchive = false;
173
try {
174
await this.deleteContent(contentDelete);
175
} catch (error) {
176
if (!(error instanceof ApiError)) {
177
throw error;
178
}
179
trace("delete canSetPermissions Test Error", error);
180
if (error?.status === 403) {
181
//Delete is disabled for this user, attempt an archive
182
attemptArchive = true;
183
}
184
}
185
186
try {
187
await this.archiveContent(contentDelete);
188
} catch (error) {
189
trace("archive canSetPermissions Test Error", error);
190
}
191
192
if (attemptArchive) {
193
trace(
194
"Disabling Permissions Test: confluenceCanSetPermissionsDisabled",
195
"true",
196
);
197
// https://github.com/quarto-dev/quarto-cli/issues/5299
198
// This account can't delete the test document, we attempted an archive
199
// Let's prevent this "canSetPermissions" test from being run in the future
200
localStorage.setItem(CAN_SET_PERMISSIONS_DISABLED, "true");
201
}
202
203
return result;
204
}
205
206
public async lockDownPermissions(
207
contentId: string,
208
user: User,
209
): Promise<any> {
210
try {
211
return await this.put<Content>(
212
`content/${contentId}/restriction/byOperation/update/user?accountId=${user.accountId}`,
213
);
214
} catch (error) {
215
trace("lockDownResult Error", error);
216
}
217
}
218
219
public async createContent(
220
user: User,
221
content: ContentCreate,
222
metadata: Record<string, any> = V2EDITOR_METADATA,
223
): Promise<Content> {
224
const toCreate = {
225
...content,
226
...metadata,
227
};
228
229
trace("to create", toCreate);
230
trace("createContent body", content.body.storage.value);
231
const createBody = JSON.stringify(toCreate);
232
const result: Content = await this.post<Content>("content", createBody);
233
234
await this.lockDownPermissions(result.id ?? "", user);
235
236
return result;
237
}
238
239
public async updateContent(
240
user: User,
241
content: ContentUpdate,
242
metadata: Record<string, any> = V2EDITOR_METADATA,
243
): Promise<Content> {
244
const toUpdate = {
245
...content,
246
...metadata,
247
};
248
249
const result = await this.put<Content>(
250
`content/${content.id}`,
251
JSON.stringify(toUpdate),
252
);
253
254
await this.lockDownPermissions(content.id ?? "", user);
255
256
return result;
257
}
258
259
public createContentProperty(id: string, content: any): Promise<Content> {
260
return this.post<Content>(
261
`content/${id}/property`,
262
JSON.stringify(content),
263
);
264
}
265
266
public deleteContent(content: ContentDelete): Promise<Content> {
267
trace("deleteContent", content);
268
return this.delete<Content>(`content/${content.id}`);
269
}
270
271
public archiveContent(content: ContentDelete): Promise<Content> {
272
trace("archiveContent", content);
273
const toArchive = {
274
pages: [
275
{
276
id: content.id,
277
},
278
],
279
};
280
return this.post<Content>(`content/archive`, JSON.stringify(toArchive));
281
}
282
283
public async getAttachments(id: string): Promise<AttachmentSummary[]> {
284
const wrappedResult: WrappedResult<AttachmentSummary> = await this.get<
285
WrappedResult<AttachmentSummary>
286
>(`content/${id}/child/attachment`);
287
288
const result = wrappedResult?.results ?? [];
289
return result;
290
}
291
292
public async createOrUpdateAttachment(
293
parentId: string,
294
file: File,
295
comment: string = "",
296
): Promise<AttachmentSummary> {
297
trace("createOrUpdateAttachment", { file, parentId }, LogPrefix.ATTACHMENT);
298
299
const wrappedResult: WrappedResult<AttachmentSummary> = await this
300
.putAttachment<WrappedResult<AttachmentSummary>>(
301
`content/${parentId}/child/attachment`,
302
file,
303
comment,
304
);
305
306
trace("createOrUpdateAttachment", wrappedResult, LogPrefix.ATTACHMENT);
307
308
const result = wrappedResult.results[0] ?? null;
309
return result;
310
}
311
312
private get = <T>(path: string): Promise<T> => this.fetch<T>("GET", path);
313
314
private delete = <T>(path: string): Promise<T> =>
315
this.fetch<T>("DELETE", path);
316
317
private post = <T>(path: string, body?: BodyInit | null): Promise<T> =>
318
this.fetch<T>("POST", path, body);
319
320
private put = <T>(path: string, body?: BodyInit | null): Promise<T> =>
321
this.fetch<T>("PUT", path, body);
322
323
private putAttachment = <T>(
324
path: string,
325
file: File,
326
comment: string = "",
327
): Promise<T> => this.fetchWithAttachment<T>("PUT", path, file, comment);
328
329
private fetch = async <T>(
330
method: string,
331
path: string,
332
body?: BodyInit | null,
333
): Promise<T> => {
334
const headers = {
335
Accept: "application/json",
336
...(["POST", "PUT"].includes(method)
337
? { "Content-Type": "application/json" }
338
: {}),
339
...this.authorizationHeader(),
340
};
341
const request = {
342
method,
343
headers,
344
body,
345
};
346
return this.handleResponse<T>(await fetch(this.apiUrl(path), request));
347
};
348
349
private fetchWithAttachment = async <T>(
350
method: string,
351
path: string,
352
file: File,
353
comment: string = "",
354
): Promise<T> => {
355
// https://blog.hyper.io/uploading-files-with-deno/
356
const formData = new FormData();
357
formData.append("file", file);
358
formData.append("minorEdit", "true");
359
formData.append("comment", comment);
360
361
const headers = {
362
["X-Atlassian-Token"]: "nocheck",
363
...this.authorizationHeader(),
364
};
365
366
const request = {
367
method,
368
headers,
369
body: formData,
370
};
371
return this.handleResponse<T>(await fetch(this.apiUrl(path), request));
372
};
373
374
private apiUrl = (path: string) =>
375
`${ensureTrailingSlash(this.token_.server!)}wiki/rest/api/${path}`;
376
377
private authorizationHeader() {
378
const auth = base64encode(this.token_.name + ":" + this.token_.token);
379
return {
380
Authorization: `Basic ${auth}`,
381
};
382
}
383
384
private async handleResponse<T>(response: Response) {
385
if (response.ok) {
386
if (response.body) {
387
// Some Confluence API endpoints return successfull calls with no body while using content-type "application/json"
388
// example: https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/#api-wiki-rest-api-content-id-restriction-byoperation-operationkey-bygroupid-groupid-get
389
// To prevent JSON parsing errors we have to return null for empty bodies and only parse when there is content
390
let data = await response.text();
391
392
if (data === "") {
393
return null as unknown as T;
394
} else {
395
return JSON.parse(data) as unknown as T;
396
}
397
} else {
398
return response as unknown as T;
399
}
400
} else if (response.status === 403) {
401
// Let parent handle 403 Forbidden, sometimes they are expected
402
throw new ApiError(response.status, response.statusText);
403
} else if (response.status !== 200) {
404
logError("response.status !== 200", response);
405
throw new ApiError(response.status, response.statusText);
406
} else {
407
logError("handleResponse", response);
408
throw new Error(`${response.status} - ${response.statusText}`);
409
}
410
}
411
}
412
413