Path: blob/main/src/publish/confluence/api/index.ts
6456 views
/*1* index.ts2*3* Copyright (C) 2020 by Posit, PBC4*/56import { encodeBase64 as base64encode } from "encoding/base64";7import { ensureTrailingSlash } from "../../../core/path.ts";89import { AccountToken } from "../../provider-types.ts";10import { ApiError } from "../../types.ts";11import {12AttachmentSummary,13ConfluenceParent,14Content,15ContentArray,16ContentChangeType,17ContentCreate,18ContentDelete,19ContentProperty,20ContentSummary,21ContentUpdate,22LogPrefix,23Space,24User,25WrappedResult,26} from "./types.ts";2728import {29CAN_SET_PERMISSIONS_DISABLED,30CAN_SET_PERMISSIONS_ENABLED_CACHED,31DESCENDANT_PAGE_SIZE,32V2EDITOR_METADATA,33} from "../constants.ts";34import { logError, trace } from "../confluence-logger.ts";35import { buildContentCreate } from "../confluence-helper.ts";3637export class ConfluenceClient {38public constructor(private readonly token_: AccountToken) {}3940public getUser(expand = ["operations"]): Promise<User> {41return this.get<User>(`user/current?expand=${expand}`);42}4344public getSpace(spaceId: string, expand = ["homepage"]): Promise<Space> {45return this.get<Space>(`space/${spaceId}?expand=${expand}`);46}4748public getContent(id: string): Promise<Content> {49return this.get<Content>(`content/${id}`);50}5152public async getContentProperty(id: string): Promise<ContentProperty[]> {53const result: WrappedResult<ContentProperty> = await this.get<54WrappedResult<ContentProperty>55>(`content/${id}/property`);5657return result.results;58}5960public getDescendantsPage(61id: string,62start: number = 0,63expand = ["metadata.properties", "ancestors"],64): Promise<WrappedResult<ContentSummary>> {65const url =66`content/${id}/descendant/page?limit=${DESCENDANT_PAGE_SIZE}&start=${start}&expand=${expand}`;67return this.get<WrappedResult<ContentSummary>>(url);68}6970public async isTitleUniqueInSpace(71title: string,72space: Space,73idToIgnore: string = "",74): Promise<boolean> {75const result = await this.fetchMatchingTitlePages(title, space);7677if (result.length === 1 && result[0].id === idToIgnore) {78return true;79}8081return result.length === 0;82}8384public async fetchMatchingTitlePages(85title: string,86space: Space,87isFuzzy: boolean = false,88): Promise<Content[]> {89const encodedTitle = encodeURIComponent(title);9091let cql = `title="${encodedTitle}"`;9293const CQL_CONTEXT =94"%7B%22contentStatuses%22%3A%5B%22archived%22%2C%20%22current%22%2C%20%22draft%22%5D%7D"; //{"contentStatuses":["archived", "current", "draft"]}9596cql = `${cql}&spaces=${space.key}&cqlcontext=${CQL_CONTEXT}`;9798const result = await this.get<ContentArray>(`content/search?cql=${cql}`);99return result?.results ?? [];100}101102/**103* Perform a test to see if the user can manage permissions. In the space create a simple test page, attempt to set104* permissions on it, then delete it.105*/106public async canSetPermissions(107parent: ConfluenceParent,108space: Space,109user: User,110): Promise<boolean> {111let result = true;112113trace("canSetPermissions check");114115trace(116"localStorage.getItem(CAN_SET_PERMISSIONS_DISABLED)",117localStorage.getItem(CAN_SET_PERMISSIONS_DISABLED),118);119trace(120"localStorage.getItem(CAN_SET_PERMISSIONS_ENABLED_CACHED)",121localStorage.getItem(CAN_SET_PERMISSIONS_ENABLED_CACHED),122);123124const permissionsTestDisabled =125localStorage.getItem(CAN_SET_PERMISSIONS_DISABLED) === "true" ||126localStorage.getItem(CAN_SET_PERMISSIONS_ENABLED_CACHED) === "true";127128trace("permissionsTestDisabled", permissionsTestDisabled);129130if (permissionsTestDisabled) {131return Promise.resolve(true);132}133134const testContent: ContentCreate = buildContentCreate(135`quarto-permission-test-${globalThis.crypto.randomUUID()}`,136space,137{138storage: {139value: "",140representation: "storage",141},142},143"permisson-test",144);145const testContentCreated = await this.createContent(user, testContent);146147const testContentId = testContentCreated.id ?? "";148149try {150await this.put<Content>(151`content/${testContentId}/restriction/byOperation/update/user?accountId=${user.accountId}`,152);153} catch (error) {154if (!(error instanceof ApiError)) {155throw error;156}157trace("lockDownResult Error", error);158// Note, sometimes a successful call throws a159// "SyntaxError: Unexpected end of JSON input"160// check for the 403 status only161if (error?.status === 403) {162result = false;163}164}165166const contentDelete: ContentDelete = {167id: testContentId,168contentChangeType: ContentChangeType.delete,169};170171let attemptArchive = false;172try {173await this.deleteContent(contentDelete);174} catch (error) {175if (!(error instanceof ApiError)) {176throw error;177}178trace("delete canSetPermissions Test Error", error);179if (error?.status === 403) {180//Delete is disabled for this user, attempt an archive181attemptArchive = true;182}183}184185try {186await this.archiveContent(contentDelete);187} catch (error) {188trace("archive canSetPermissions Test Error", error);189}190191if (attemptArchive) {192trace(193"Disabling Permissions Test: confluenceCanSetPermissionsDisabled",194"true",195);196// https://github.com/quarto-dev/quarto-cli/issues/5299197// This account can't delete the test document, we attempted an archive198// Let's prevent this "canSetPermissions" test from being run in the future199localStorage.setItem(CAN_SET_PERMISSIONS_DISABLED, "true");200}201202return result;203}204205public async lockDownPermissions(206contentId: string,207user: User,208): Promise<any> {209try {210return await this.put<Content>(211`content/${contentId}/restriction/byOperation/update/user?accountId=${user.accountId}`,212);213} catch (error) {214trace("lockDownResult Error", error);215}216}217218public async createContent(219user: User,220content: ContentCreate,221metadata: Record<string, any> = V2EDITOR_METADATA,222): Promise<Content> {223const toCreate = {224...content,225...metadata,226};227228trace("to create", toCreate);229trace("createContent body", content.body.storage.value);230const createBody = JSON.stringify(toCreate);231const result: Content = await this.post<Content>("content", createBody);232233await this.lockDownPermissions(result.id ?? "", user);234235return result;236}237238public async updateContent(239user: User,240content: ContentUpdate,241metadata: Record<string, any> = V2EDITOR_METADATA,242): Promise<Content> {243const toUpdate = {244...content,245...metadata,246};247248const result = await this.put<Content>(249`content/${content.id}`,250JSON.stringify(toUpdate),251);252253await this.lockDownPermissions(content.id ?? "", user);254255return result;256}257258public createContentProperty(id: string, content: any): Promise<Content> {259return this.post<Content>(260`content/${id}/property`,261JSON.stringify(content),262);263}264265public deleteContent(content: ContentDelete): Promise<Content> {266trace("deleteContent", content);267return this.delete<Content>(`content/${content.id}`);268}269270public archiveContent(content: ContentDelete): Promise<Content> {271trace("archiveContent", content);272const toArchive = {273pages: [274{275id: content.id,276},277],278};279return this.post<Content>(`content/archive`, JSON.stringify(toArchive));280}281282public async getAttachments(id: string): Promise<AttachmentSummary[]> {283const wrappedResult: WrappedResult<AttachmentSummary> = await this.get<284WrappedResult<AttachmentSummary>285>(`content/${id}/child/attachment`);286287const result = wrappedResult?.results ?? [];288return result;289}290291public async createOrUpdateAttachment(292parentId: string,293file: File,294comment: string = "",295): Promise<AttachmentSummary> {296trace("createOrUpdateAttachment", { file, parentId }, LogPrefix.ATTACHMENT);297298const wrappedResult: WrappedResult<AttachmentSummary> = await this299.putAttachment<WrappedResult<AttachmentSummary>>(300`content/${parentId}/child/attachment`,301file,302comment,303);304305trace("createOrUpdateAttachment", wrappedResult, LogPrefix.ATTACHMENT);306307const result = wrappedResult.results[0] ?? null;308return result;309}310311private get = <T>(path: string): Promise<T> => this.fetch<T>("GET", path);312313private delete = <T>(path: string): Promise<T> =>314this.fetch<T>("DELETE", path);315316private post = <T>(path: string, body?: BodyInit | null): Promise<T> =>317this.fetch<T>("POST", path, body);318319private put = <T>(path: string, body?: BodyInit | null): Promise<T> =>320this.fetch<T>("PUT", path, body);321322private putAttachment = <T>(323path: string,324file: File,325comment: string = "",326): Promise<T> => this.fetchWithAttachment<T>("PUT", path, file, comment);327328private fetch = async <T>(329method: string,330path: string,331body?: BodyInit | null,332): Promise<T> => {333const headers = {334Accept: "application/json",335...(["POST", "PUT"].includes(method)336? { "Content-Type": "application/json" }337: {}),338...this.authorizationHeader(),339};340const request = {341method,342headers,343body,344};345return this.handleResponse<T>(await fetch(this.apiUrl(path), request));346};347348private fetchWithAttachment = async <T>(349method: string,350path: string,351file: File,352comment: string = "",353): Promise<T> => {354// https://blog.hyper.io/uploading-files-with-deno/355const formData = new FormData();356formData.append("file", file);357formData.append("minorEdit", "true");358formData.append("comment", comment);359360const headers = {361["X-Atlassian-Token"]: "nocheck",362...this.authorizationHeader(),363};364365const request = {366method,367headers,368body: formData,369};370return this.handleResponse<T>(await fetch(this.apiUrl(path), request));371};372373private apiUrl = (path: string) =>374`${ensureTrailingSlash(this.token_.server!)}wiki/rest/api/${path}`;375376private authorizationHeader() {377const auth = base64encode(this.token_.name + ":" + this.token_.token);378return {379Authorization: `Basic ${auth}`,380};381}382383private async handleResponse<T>(response: Response) {384if (response.ok) {385if (response.body) {386// Some Confluence API endpoints return successfull calls with no body while using content-type "application/json"387// example: https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content-restrictions/#api-wiki-rest-api-content-id-restriction-byoperation-operationkey-bygroupid-groupid-get388// To prevent JSON parsing errors we have to return null for empty bodies and only parse when there is content389let data = await response.text();390391if (data === "") {392return null as unknown as T;393} else {394return JSON.parse(data) as unknown as T;395}396} else {397return response as unknown as T;398}399} else if (response.status === 403) {400// Let parent handle 403 Forbidden, sometimes they are expected401throw new ApiError(response.status, response.statusText);402} else if (response.status !== 200) {403logError("response.status !== 200", response);404throw new ApiError(response.status, response.statusText);405} else {406logError("handleResponse", response);407throw new Error(`${response.status} - ${response.statusText}`);408}409}410}411412413