Large refactor based on feedback

Signed-off-by: Andre Wanlin <67169551+awanlin@users.noreply.github.com>
This commit is contained in:
Andre Wanlin
2023-05-11 10:18:55 -05:00
parent 1da141c611
commit 8492db6cbc
5 changed files with 274 additions and 53 deletions
@@ -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://<company-name>.atlassian.com/manage-profile/security/api-tokens`
2. Next we need to setup a string in this format: `<your-atlassian-account-mail>:<your-jira-token>`
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 `<GITHUB_BASE_URL>` 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.
@@ -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;
};
}
@@ -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,
);
}
@@ -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=');
});
});
@@ -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 = '';