Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,33 @@ This image runs a Backstage instance pre-configured with the Stack Overflow for

---

### Required Environment Variables
## 📦 Required Environment Variables

| Variable | Description |
|:----------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `STACK_OVERFLOW_INSTANCE_URL` | The base URL of your Stack Overflow for Teams (Enterprise) instance. |
| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Enterprise instance. This token is used by the plugin's search collator to index questions into Backstage search. |
| `STACK_OVERFLOW_CLIENT_ID` | The OAuth Client ID from your Stack Overflow application. This is required to enable the secure question creation flow from within Backstage. |
### For **Enterprise** Customers:

| Variable | Description |
| :-------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `STACK_OVERFLOW_INSTANCE_URL` | The base URL of your Stack Overflow for Teams (Enterprise) instance. |
| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Enterprise instance. This token is used by the plugin’s search collator to index questions into Backstage search. |
| `STACK_OVERFLOW_CLIENT_ID` | The OAuth Client ID from your Stack Overflow application. This is required to enable the secure question creation flow from within Backstage. |
| `STACK_OVERFLOW_REDIRECT_URI` | The redirect URI where Stack Overflow should send users after completing the OAuth authentication flow. By default, this is `{app.baseUrl}/stack-overflow-teams`. For local development, you can use a redirect service like `http://redirectmeto.com/http://localhost:7007/stack-overflow-teams`. |

---

### For **Basic** and **Business** Customers:

| Variable | Description |
| :-------------------------------- | :----------------------------------------------------------------------------------------------------------------------- |
| `STACK_OVERFLOW_TEAM_NAME` | The **team name** or **team slug** from your Stack Overflow for Teams account. |
| `STACK_OVERFLOW_API_ACCESS_TOKEN` | A **read-only, no-expiry** API access token generated for your Stack Overflow Teams instance. Used for indexing content. |

📖 How to generate your API Access Token

Basic and Business customers can follow the official Stack Overflow for Teams guide to create a Personal Access Token (PAT) for API authentication:

👉 [Personal Access Tokens (PATs) for API Authentication](https://stackoverflowteams.help/en/articles/10908790-personal-access-tokens-pats-for-api-authentication)

This token should have read-only access and no expiration to be used for indexing questions into Backstage search.

---

Expand Down
9 changes: 6 additions & 3 deletions app-config.docker-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ organization:

stackoverflow:
baseUrl: ${STACK_OVERFLOW_INSTANCE_URL}
# teamName: ${STACK_OVERFLOW_TEAM_NAME}
# Required only for Enteprise Tier.

teamName: ${STACK_OVERFLOW_TEAM_NAME}
# Required only for Basic and Business Tiers.

apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN}
# The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended.

clientId: ${STACK_OVERFLOW_CLIENT_ID}
# The clientid must be for an API Application with read-write access.
# The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier.

redirectUri: ${STACK_OVERFLOW_REDIRECT_URI}
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams. Only provide if you are using the Enterprise tier.

backend:
# Used for enabling authentication, secret is shared by all backend plugins
Expand Down
12 changes: 9 additions & 3 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@ app:
organization:
name: My Company

# For Basic and Business tiers, only provide teamName and apiAccessToken
# For Enterprise tier DO NOT provide teamName.

stackoverflow:
baseUrl: ${STACK_OVERFLOW_INSTANCE_URL}
# teamName: ${STACK_OVERFLOW_TEAM_NAME}
# Required only for Enteprise Tier.

teamName: ${STACK_OVERFLOW_TEAM_NAME}
# Required only for Basic and Business Tiers.

apiAccessToken: ${STACK_OVERFLOW_API_ACCESS_TOKEN}
# The API Access Token is used for the Questions' collator, a no-expiry, read-only token is recommended.

clientId: ${STACK_OVERFLOW_CLIENT_ID}
# The clientid must be for an API Application with read-write access.
# The clientid must be for an API Application with read-write access. Only provide if you are using the Enterprise tier.

redirectUri: ${STACK_OVERFLOW_REDIRECT_URI}
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams
# If no redirectUri is specified this will return to https://<backstage-domain>/stack-overflow-teams. Only provide if you are using the Enterprise tier.

backend:
# Used for enabling authentication, secret is shared by all backend plugins
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export interface Config {
*/
stackoverflow: {
/**
* The base url of the Stack Overflow API used for the plugin
* The base url of the Stack Overflow API used for the plugin, if no BaseUrl is provided it will default to https://api.stackoverflowteams.com
*/
baseUrl: string;
baseUrl?: string;

/**
* The API Access Token to authenticate to Stack Overflow API Version 3
Expand All @@ -31,7 +31,7 @@ export interface Config {
apiAccessToken: string;

/**
* The name of the team for a Stack Overflow for Teams account
* The name of the team for a Stack Overflow for Teams account. When teamName is provided baseUrl will always be https://api.stackoverflowteams.com
*/
teamName?: string;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ export class StackOverflowQuestionsCollatorFactory
private forceOriginUrl = (baseUrl: string): string =>
`${new URL(baseUrl).origin}`;

private constructor(options: StackOverflowQuestionsCollatorFactoryOptions & { baseUrl: string }) {
this.baseUrl = this.forceOriginUrl(options.baseUrl);
private constructor(options: StackOverflowQuestionsCollatorFactoryOptions & { baseUrl?: string }) {
this.baseUrl = this.forceOriginUrl(options.baseUrl || this.stackOverflowTeamsAPI);
this.apiAccessToken = options.apiAccessToken;
this.teamName = options.teamName;
this.logger = options.logger.child({ documentType: this.type });
Expand All @@ -110,7 +110,7 @@ export class StackOverflowQuestionsCollatorFactory
) {
const apiAccessToken = config.getString('stackoverflow.apiAccessToken');
const teamName = config.getOptionalString('stackoverflow.teamName');
const baseUrl = config.getString('stackoverflow.baseUrl');
const baseUrl = config.getOptionalString('stackoverflow.baseUrl');
const requestParams = config
.getOptionalConfig('stackoverflow.requestParams')
?.get<StackOverflowQuestionsRequestParams>();
Expand All @@ -133,12 +133,18 @@ export class StackOverflowQuestionsCollatorFactory
async *execute(): AsyncGenerator<StackOverflowDocument> {
this.logger.info(`Retrieving data using Stack Overflow API Version 3`);

if (!this.baseUrl) {
this.logger.error(
`No stackoverflow.baseUrl configured in your app-config.yaml`,
if (!this.baseUrl && this.teamName) {
this.logger.info(
`Connecting to the Teams API at https://api.stackoverflowteams.com`,
);
}

if (!this.baseUrl && !this.teamName) {
this.logger.error(
`No stackoverflow.teamName has been provided while trying to connect to the Teams API.`
)
}

const params = qs.stringify(this.requestParams, {
arrayFormat: 'comma',
addQueryPrefix: true,
Expand All @@ -147,13 +153,21 @@ export class StackOverflowQuestionsCollatorFactory
let requestUrl;

if (this.teamName) {
const basePath =
this.baseUrl === this.stackOverflowTeamsAPI ? '/v3' : '/api/v3';
requestUrl = `${this.baseUrl}${basePath}/teams/${this.teamName}/questions${params}`;
requestUrl = `${this.stackOverflowTeamsAPI}/v3/teams/${this.teamName}/questions${params}`;
} else {
requestUrl = `${this.baseUrl}/api/v3/questions${params}`;
}

// The code below has been commented, it has potential compatiblity with Enterprise Private Teams but I haven't tested it and since Private Teams is not widely used I've decided to change the logic to prioritise the support for the Basic and Business Teams.

// if (this.teamName) {
// const basePath =
// this.baseUrl === this.stackOverflowTeamsAPI ? '/v3' : '/api/v3';
// requestUrl = `${this.baseUrl}${basePath}/teams/${this.teamName}/questions${params}`;
// } else {
// requestUrl = `${this.baseUrl}/api/v3/questions${params}`;
// }

let page = 1;
let totalPages = 1;
const pageSize = this.requestParams.pageSize || 50;
Expand Down
8 changes: 4 additions & 4 deletions plugins/stack-overflow-teams-backend/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ export interface Config {
apiAccessToken: string;

/**
* The name of the team for a Stack Overflow for Teams account
* The name of the team for a Stack Overflow for Teams account, required for Basic and Business tiers.
*/
teamName?: string;

/**
* Client Id for the OAuth Application, required to use the Stack Overflow for Teams Hub and write actions.
* Client Id for the OAuth Application, required only for Stack Overflow Enterprise and write actions.
*/
clientId: number;
clientId?: number;

/**
* RedirectUri for the OAuth Application, required to use the Stack Overflow for Teams Hub and write actions.
* RedirectUri for the OAuth Application, required only for Stack Overflow Enterprise and write actions.
*
* This should be your Backstage application domain ending in the plugin's <StackOverflowTeamsPage /> route
* If not specified this will got to your <app.baseUrl>/stack-overflow-teams
Expand Down
1 change: 1 addition & 0 deletions plugins/stack-overflow-teams-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@backstage/plugin-catalog-node": "^1.15.0",
"@backstage/plugin-search-backend-node": "^1.3.8",
"@backstage/plugin-search-common": "^1.2.17",
"csrf": "^3.1.0",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"jsonwebtoken": "^9.0.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const createStackOverflowApi = (baseUrl: string) => {
pageSize?: number
): Promise<T> => {
let url = teamName
? `${baseUrl}/api/v3/teams/${teamName}${endpoint}`
? `${baseUrl}/v3/teams/${teamName}${endpoint}`
: `${baseUrl}/api/v3${endpoint}`;

const queryParams = new URLSearchParams();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export function createStackOverflowAuth(
config: StackOverflowConfig,
logger: LoggerService,
) {

async function generatePKCECodeVerifier(): Promise<{
codeVerifier: string;
codeChallenge: string;
Expand All @@ -19,7 +18,16 @@ export function createStackOverflowAuth(
return { codeVerifier, codeChallenge: hashed };
}

async function getAuthUrl(): Promise<{ url: string; codeVerifier: string ; state: string}> {
async function getAuthUrl(): Promise<{
url: string;
codeVerifier: string;
state: string;
}> {
if (!config.clientId || !config.redirectUri) {
throw new Error(
'clientId and redirectUri are required for authentication',
);
}
const { codeVerifier, codeChallenge } = await generatePKCECodeVerifier();
const state = crypto.randomBytes(16).toString('hex');
const authUrl = `${config.baseUrl}/oauth?client_id=${
Expand All @@ -34,20 +42,24 @@ export function createStackOverflowAuth(
async function exchangeCodeForToken(
code: string,
codeVerifier: string,
): Promise<{accessToken: string, expires: number}> {
): Promise<{ accessToken: string; expires: number }> {
if (!config.clientId || !config.redirectUri) {
throw new Error(
'clientId and redirectUri are required for authentication',
);
}
const tokenUrl = `${config.baseUrl}/oauth/access_token/json`;
const queryParams = new URLSearchParams({
client_id: String(config.clientId),
code,
redirect_uri: config.redirectUri,
code_verifier: codeVerifier,
});

const response = await fetch(`${tokenUrl}?${queryParams.toString()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
});


if (!response.ok) {
logger.error('Failed to exchange code for access token');
Expand All @@ -56,13 +68,13 @@ export function createStackOverflowAuth(
const data = await response.json();
return {
accessToken: data.access_token,
expires: data.expires
}
expires: data.expires,
};
}

return {
getAuthUrl,
exchangeCodeForToken,
config: config
config: config,
};
}
27 changes: 21 additions & 6 deletions plugins/stack-overflow-teams-backend/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,28 @@ export const stackOverflowTeamsPlugin = createBackendPlugin({
config: coreServices.rootConfig,
},
async init({ logger, httpRouter, config }) {
const forceOriginUrl = (baseUrl: string) : string => `${new URL(baseUrl).origin}`
const forceOriginUrl = (baseUrl: string): string =>
`${new URL(baseUrl).origin}`;

const teamName = config.getOptionalString('stackoverflow.teamName');

// If teamName is provided, always use api.stackoverflowteams.com
const baseUrl = teamName
? 'https://api.stackoverflowteams.com'
: forceOriginUrl(
config.getOptionalString('stackoverflow.baseUrl') ||
'https://api.stackoverflowteams.com',
);

const stackOverflowConfig: StackOverflowConfig = {
baseUrl: forceOriginUrl(config.getString('stackoverflow.baseUrl')),
teamName: config.getOptionalString('stackoverflow.teamName'),
clientId: config.getNumber('stackoverflow.clientId'),
redirectUri: config.getOptionalString('stackoverflow.redirectUri') || `${config.getString('app.baseUrl')}/stack-overflow-teams`
baseUrl,
teamName,
clientId: config.getOptionalNumber('stackoverflow.clientId'),
redirectUri:
config.getOptionalString('stackoverflow.redirectUri') ||
`${config.getString('app.baseUrl')}/stack-overflow-teams`,
};

const stackOverflowService = await createStackOverflowService({
config: stackOverflowConfig,
logger,
Expand All @@ -38,7 +53,7 @@ export const stackOverflowTeamsPlugin = createBackendPlugin({
await createRouter({
stackOverflowConfig,
logger,
stackOverflowService
stackOverflowService,
}),
);
},
Expand Down
Loading