diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/README.md b/plugins/scaffolder-backend-module-confluence-to-markdown/README.md index 0fe9b43ed7..108898d1a0 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/README.md +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/README.md @@ -60,38 +60,58 @@ export default async function createPlugin( ### Configuration -You will also need an access token for authorization with `Read` permissions. You can create a Personal Access Token (PAT) in confluence and add the PAT to your `app-config.yaml` +There is some configuration that needs to be setup to use this action, here are all the settings: ```yaml confluence: - baseUrl: ${CONFLUENCE_BASE_URL} - token: ${CONFLUENCE_TOKEN} + baseUrl: 'https://confluence.example.com' + auth: 'basic' + token: '${CONFLUENCE_TOKEN}' + email: 'example@company.org' + username: 'your-username' + password: 'your-password' ``` -#### Confluence Cloud - -For those using Confluence Cloud you will need to have the following configuration: - -```yaml -confluence: - baseUrl: ${CONFLUENCE_BASE_URL} - token: ${CONFLUENCE_TOKEN} - isCloud: true -``` - -##### Base URL +#### Base URL The `baseUrl` for Confluence Cloud should include the product name which is `wiki` by default but can be something else if your Org has changed it. An example `baseUrl` for Confluence Cloud would look like this: `https://example.atlassian.net/wiki` -##### Token +If you are using a self-hosted Confluence instance this does not apply to you. Your `baseUrl` would look something like this: `https://confluence.example.com` -The `token` for Confluence Cloud needs to be base-64 encoded with your Atlassian account email address. Here's how to do that: +#### Auth Methods -1. First get your token from: `https://.atlassian.com/manage-profile/security/api-tokens` -2. Next we need to setup a string in this format: `:` -3. For this example we'll use this: `confluence@backstage.io:wDzAzoXWRGLtvbgHvT0W` -4. Now we can run `echo -n "confluence@backstage.io:wDzAzoXWRGLtvbgHvT0W" | base64` -5. This gives us: `Y29uZmx1ZW5jZUBiYWNrc3RhZ2UuaW86d0R6QXpvWFdSR0x0dmJnSHZUMFc=` which we can now use as the value for the `token` in the configuration +The default authorization method is `basic` but `bearer` and `userpass` are also supported. Here's how you would configure each of these: + +For `basic`: + +```yaml +confluence: + baseUrl: 'https://confluence.example.com' + auth: 'basic' + token: '${CONFLUENCE_TOKEN}' +``` + +For `bearer`: + +```yaml +confluence: + baseUrl: 'https://confluence.example.com' + auth: 'bearer' + token: '${CONFLUENCE_TOKEN}' + email: 'example@company.org' +``` + +For `userpass` + +```yaml +confluence: + baseUrl: 'https://confluence.example.com' + auth: 'userpass' + username: 'your-username' + password: 'your-password' +``` + +**Note:** For `basic` and `bearer` authorization methods you will need an access token for authorization with `Read` permissions. You can create a Personal Access Token (PAT) in Confluence. The value used should be the raw token as it will be encoded for you by the action. ### Template Usage @@ -146,4 +166,4 @@ spec: Replace `` with your GitHub URL without `https://`. -You can find a list of all registered actions including their parameters at the /create/actions route in your Backstage application. +You can find a list of all registered actions including their parameters at the `/create/actions` route in your Backstage application. diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/config.d.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/config.d.ts index c566a629a4..9e3ba5df28 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/config.d.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/config.d.ts @@ -22,13 +22,28 @@ export interface Config { */ baseUrl: string; /** - * The authentication token for accessing the Confluence API - * * @visibility secret + * Authentication method - basic, bearer, username/password */ - token: string; + auth: 'basic' | 'bearer' | 'userpass'; /** - * Flag to determine if you are on Confluence Cloud, optional + * Token used for the basic and bearer auth methods + * @visibility secret */ - isCloud?: string; + token?: string; + /** + * Email encoded with the token for the bearer auth method + * @visibility secret + */ + email?: string; + /** + * Username used with the Username/Password auth method + * @visibility secret + */ + username?: string; + /** + * Password used with the Username/Password auth method + * @visibility secret + */ + password?: string; }; } diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts index 5e8553e426..67db15453e 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/confluenceToMarkdown.ts @@ -28,6 +28,7 @@ import { fetchConfluence, getAndWriteAttachments, createConfluenceVariables, + getConfluenceConfig, } from './helpers'; /** @@ -57,7 +58,7 @@ export const createConfluenceToMarkdownAction = (options: { type: 'array', title: 'Confluence URL', description: - 'Paste your confluence url. Ensure it follows this format: https://{confluence+base+url}/display/{spacekey}/{page+title} or https://{confluence+base+url}/spaces/{spacekey}/pages/1234567/{page+title} for Confluence Cloud', + 'Paste your confluence url. Ensure it follows this format: https://{confluence+base+url}/display/{spacekey}/{page+title} or https://{confluence+base+url}/spaces/{spacekey}/pages/1234567/{page+title} for Confluence Cloud', items: { type: 'string', default: 'Confluence URL', @@ -73,6 +74,7 @@ export const createConfluenceToMarkdownAction = (options: { }, }, async handler(ctx) { + const confluenceConfig = getConfluenceConfig(config); const { confluenceUrls, repoUrl } = ctx.input; const parsedRepoUrl = parseGitUrl(repoUrl); const filePathToMkdocs = parsedRepoUrl.filepath.substring( @@ -96,12 +98,12 @@ export const createConfluenceToMarkdownAction = (options: { for (const url of confluenceUrls) { const { spacekey, title, titleWithSpaces } = - await createConfluenceVariables(url); + createConfluenceVariables(url); // This calls confluence to get the page html and page id ctx.logger.info(`Fetching the Confluence content for ${url}`); const getConfluenceDoc = await fetchConfluence( `/rest/api/content?title=${title}&spaceKey=${spacekey}&expand=body.export_view`, - config, + confluenceConfig, ); if (getConfluenceDoc.results.length === 0) { throw new InputError( @@ -111,7 +113,7 @@ export const createConfluenceToMarkdownAction = (options: { // This gets attachments for the confluence page if they exist const getDocAttachments = await fetchConfluence( `/rest/api/content/${getConfluenceDoc.results[0].id}/child/attachment`, - config, + confluenceConfig, ); if (getDocAttachments.results.length) { @@ -121,7 +123,7 @@ export const createConfluenceToMarkdownAction = (options: { productArray = await getAndWriteAttachments( getDocAttachments, dirPath, - config, + confluenceConfig, filePathToMkdocs, ); } diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.test.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.test.ts index 97a69935c8..4b737998ec 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.test.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.test.ts @@ -13,28 +13,142 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createConfluenceVariables } from './helpers'; +import { ConfigReader } from '@backstage/config'; +import { + createConfluenceVariables, + getAuthorizationHeaderValue, + getConfluenceConfig, +} from './helpers'; describe('createConfluenceVariables', () => { - it('should return values for Confluence Url', async () => { + it('should return values for Confluence Url', () => { const url = 'https://confluence.example.com/display/SPACEKEY/Page+Title'; - const { spacekey, title, titleWithSpaces } = - await createConfluenceVariables(url); + const { spacekey, title, titleWithSpaces } = createConfluenceVariables(url); expect(spacekey).toEqual('SPACEKEY'); expect(title).toEqual('Page+Title'); expect(titleWithSpaces).toEqual('Page Title'); }); - it('should return values for Confluence Cloud Url', async () => { + it('should return values for Confluence Cloud Url', () => { const url = 'https://example.atlassian.net/wiki/spaces/CLOUDSPACEKEY/pages/1234567/Cloud+Page+Title'; - const { spacekey, title, titleWithSpaces } = - await createConfluenceVariables(url); + const { spacekey, title, titleWithSpaces } = createConfluenceVariables(url); expect(spacekey).toEqual('CLOUDSPACEKEY'); expect(title).toEqual('Cloud+Page+Title'); expect(titleWithSpaces).toEqual('Cloud Page Title'); }); }); + +describe('getConfluenceConfig', () => { + it('should return validate basic Confluence config', async () => { + const config = new ConfigReader({ + confluence: { + baseUrl: 'https://example.atlassian.net', + auth: 'basic', + token: 'fake_token', + }, + }); + + const validated = getConfluenceConfig(config); + + expect(validated).toEqual({ + baseUrl: 'https://example.atlassian.net', + auth: 'basic', + token: 'fake_token', + email: undefined, + username: undefined, + password: undefined, + }); + }); + + it('should return validate bearer Confluence config', async () => { + const config = new ConfigReader({ + confluence: { + baseUrl: 'https://example.atlassian.net', + auth: 'bearer', + token: 'fake_token', + email: 'example@example.atlassian.net', + }, + }); + + const validated = getConfluenceConfig(config); + + expect(validated).toEqual({ + baseUrl: 'https://example.atlassian.net', + auth: 'bearer', + token: 'fake_token', + email: 'example@example.atlassian.net', + username: undefined, + password: undefined, + }); + }); + + it('should return validate userpass Confluence config', async () => { + const config = new ConfigReader({ + confluence: { + baseUrl: 'https://example.atlassian.net', + auth: 'userpass', + username: 'fake_user', + password: 'fake_password', + }, + }); + + const validated = getConfluenceConfig(config); + + expect(validated).toEqual({ + baseUrl: 'https://example.atlassian.net', + auth: 'userpass', + token: undefined, + email: undefined, + username: 'fake_user', + password: 'fake_password', + }); + }); +}); + +describe('getAuthorizationHeaderValue', () => { + it('should return basic auth header value', async () => { + const config = { + baseUrl: 'https://example.atlassian.net', + auth: 'basic', + token: 'fake_token', + }; + + const authHeaderValue = getAuthorizationHeaderValue(config); + + expect(authHeaderValue).toEqual('Basic fake_token'); + }); + + it('should return bearer auth header value', async () => { + const config = { + baseUrl: 'https://example.atlassian.net', + auth: 'bearer', + token: 'fake_token', + email: 'example@example.atlassian.net', + }; + + const authHeaderValue = getAuthorizationHeaderValue(config); + + // Note: this is fake and just the encoded result + expect(authHeaderValue).toEqual( + 'Bearer ZXhhbXBsZUBleGFtcGxlLmF0bGFzc2lhbi5uZXQ6ZmFrZV90b2tlbg==', + ); + }); + + it('should return userpass auth header value', async () => { + const config = { + baseUrl: 'https://example.atlassian.net', + auth: 'userpass', + username: 'fake_user', + password: 'fake_password', + }; + + const authHeaderValue = getAuthorizationHeaderValue(config); + + // Note: this is fake and just the encoded result + expect(authHeaderValue).toEqual('Basic ZmFrZV91c2VyOmZha2VfcGFzc3dvcmQ='); + }); +}); diff --git a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.ts b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.ts index 0fb7118676..be34b3ca94 100644 --- a/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.ts +++ b/plugins/scaffolder-backend-module-confluence-to-markdown/src/actions/confluence/helpers.ts @@ -43,21 +43,93 @@ export interface Results { results: Result[]; } +export type ConfluenceConfig = { + baseUrl: string; + auth: string; + token?: string; + email?: string; + username?: string; + password?: string; +}; + +export const getConfluenceConfig = (config: Config) => { + const confluenceConfig: ConfluenceConfig = { + baseUrl: config.getString('confluence.baseUrl'), + auth: config.getOptionalString('confluence.auth') ?? 'basic', + token: config.getOptionalString('confluence.token'), + email: config.getOptionalString('confluence.email'), + username: config.getOptionalString('confluence.username'), + password: config.getOptionalString('confluence.password'), + }; + + if ( + (confluenceConfig.auth === 'basic' || confluenceConfig.auth === 'bearer') && + !confluenceConfig.token + ) { + throw new Error( + `No token provided for the configured '${confluenceConfig.auth}' auth method`, + ); + } + + if (confluenceConfig.auth === 'bearer' && !confluenceConfig.email) { + throw new Error( + `No email provided for the configured '${confluenceConfig.auth}' auth method`, + ); + } + + if ( + confluenceConfig.auth === 'userpass' && + (!confluenceConfig.username || !confluenceConfig.password) + ) { + throw new Error( + `No username/password provided for the configured '${confluenceConfig.auth}' auth method`, + ); + } + + return confluenceConfig; +}; + +export const getAuthorizationHeaderValue = (config: ConfluenceConfig) => { + let authHeaderValue: string = ''; + switch (config.auth) { + case 'basic': + authHeaderValue = `Basic ${config.token}`; + break; + case 'bearer': { + const buffer = Buffer.from(`${config.email}:${config.token}`, 'utf8'); + authHeaderValue = `Bearer ${buffer.toString('base64')}`; + break; + } + case 'userpass': { + const buffer = Buffer.from( + `${config.username}:${config.password}`, + 'utf8', + ); + authHeaderValue = `Basic ${buffer.toString('base64')}`; + break; + } + default: + throw new Error(`Unknown auth method '${config.auth}' provided`); + } + return authHeaderValue; +}; + export const readFileAsString = async (fileDir: string) => { const content = await fs.readFile(fileDir, 'utf-8'); return content.toString(); }; -export const fetchConfluence = async (relativeUrl: string, config: Config) => { - const baseUrl = config.getString('confluence.baseUrl'); - const token = config.getString('confluence.token'); - const isCloud = config.getOptionalBoolean('confluence.isCloud') || false; - const authToken = isCloud ? `Basic ${token}` : `Bearer ${token}`; +export const fetchConfluence = async ( + relativeUrl: string, + config: ConfluenceConfig, +) => { + const baseUrl = config.baseUrl; + const authHeaderValue = getAuthorizationHeaderValue(config); const url = `${baseUrl}${relativeUrl}`; const response: Response = await fetch(url, { method: 'GET', headers: { - Authorization: authToken, + Authorization: authHeaderValue, }, }); if (!response.ok) { @@ -70,14 +142,12 @@ export const fetchConfluence = async (relativeUrl: string, config: Config) => { export const getAndWriteAttachments = async ( arr: Results, workspace: string, - config: Config, + config: ConfluenceConfig, mkdocsDir: string, ) => { const productArr: string[][] = []; - const baseUrl = config.getString('confluence.baseUrl'); - const token = config.getString('confluence.token'); - const isCloud = config.getOptionalBoolean('confluence.isCloud') || false; - const authToken = isCloud ? `Basic ${token}` : `Bearer ${token}`; + const baseUrl = config.baseUrl; + const authHeaderValue = getAuthorizationHeaderValue(config); await Promise.all( await arr.results.map(async (result: Result) => { const downloadLink = result._links.download; @@ -89,7 +159,7 @@ export const getAndWriteAttachments = async ( const res = await fetch(url, { method: 'GET', headers: { - Authorization: authToken, + Authorization: authHeaderValue, }, }); if (!res.ok) { @@ -116,7 +186,7 @@ export const getAndWriteAttachments = async ( return productArr; }; -export const createConfluenceVariables = async (url: string) => { +export const createConfluenceVariables = (url: string) => { let spacekey: string | undefined = undefined; let title: string | undefined = undefined; let titleWithSpaces: string | undefined = '';