Skip to content

Commit

Permalink
chore: store Pontoon request tokens in storage instead of a Set insta…
Browse files Browse the repository at this point in the history
…nce in memory (#935)
  • Loading branch information
MikkCZ authored Oct 5, 2024
1 parent aade570 commit dbdc385
Show file tree
Hide file tree
Showing 2 changed files with 149 additions and 116 deletions.
12 changes: 7 additions & 5 deletions src/background/RemotePontoon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import {
} from './apiEndpoints';
import { BackgroundClientMessageType } from './BackgroundClientMessageType';
import type { GetProjectsInfoResponse } from './httpClients';
import { pontoonHttpClient, httpClient, graphqlClient } from './httpClients';
import {
pontoonHttpClient,
httpClient,
pontoonGraphqlClient,
} from './httpClients';
import { projectsListData } from './data/projectsListData';

type GetProjectsInfoProject = GetProjectsInfoResponse['projects'][number];
Expand Down Expand Up @@ -175,7 +179,7 @@ async function updateLatestTeamActivity() {

async function updateTeamsList(): Promise<StorageContent['teamsList']> {
const [pontoonData, bugzillaComponentsResponse] = await Promise.all([
graphqlClient(await getOneOption('pontoon_base_url')).getTeamsInfo(),
pontoonGraphqlClient.getTeamsInfo(),
httpClient.fetch(bugzillaTeamComponents()),
]);
const bugzillaComponents = (await bugzillaComponentsResponse.json()) as {
Expand Down Expand Up @@ -206,9 +210,7 @@ async function updateTeamsList(): Promise<StorageContent['teamsList']> {
}

async function updateProjectsList(): Promise<StorageContent['projectsList']> {
const pontoonData = await graphqlClient(
await getOneOption('pontoon_base_url'),
).getProjectsInfo();
const pontoonData = await pontoonGraphqlClient.getProjectsInfo();
const partialProjectsMap = new Map<
GetProjectsInfoProject['slug'],
GetProjectsInfoProject
Expand Down
253 changes: 142 additions & 111 deletions src/background/httpClients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,128 +18,149 @@ import { getSdk } from '../generated/pontoon.graphql';

import { pontoonGraphQL } from './apiEndpoints';

class PontoonHttpClient {
private readonly pontoonRequestTokens: Set<string>;
private readonly requestsToPontoonListener: (
details: WebRequest.OnBeforeSendHeadersDetailsType,
) => WebRequest.BlockingResponseOrPromise;

constructor() {
this.pontoonRequestTokens = new Set();
this.requestsToPontoonListener = (details) => {
return this.setSessionCookieForPontoonRequest(details);
};
const PONTOON_REQUEST_TOKEN_STORAGE_KEY_PREFIX = 'pontoon_req_token_';
const PONTOON_REQUEST_TOKEN_VALIDITY_SECONDS = 60;

listenToOptionChange('pontoon_base_url', ({ newValue: pontoonBaseUrl }) => {
this.listenForRequestsToPontoon(pontoonBaseUrl);
});
this.listenForRequestsToPontoon();
}
interface TokenInfo {
issued: string; // ISO date
}

private async listenForRequestsToPontoon(pontoonBaseUrl?: string) {
if (browser.webRequest) {
if (typeof pontoonBaseUrl === 'undefined') {
pontoonBaseUrl = await getOneOption('pontoon_base_url');
}
browser.webRequest.onBeforeSendHeaders.removeListener(
this.requestsToPontoonListener,
);
browser.webRequest.onBeforeSendHeaders.addListener(
this.requestsToPontoonListener,
{ urls: [`${pontoonBaseUrl}/*`] },
['blocking', 'requestHeaders'],
);
async function listenForRequestsToPontoon(pontoonBaseUrl?: string) {
if (browser.webRequest) {
if (typeof pontoonBaseUrl === 'undefined') {
pontoonBaseUrl = await getOneOption('pontoon_base_url');
}
browser.webRequest.onBeforeSendHeaders.removeListener(
setSessionCookieForPontoonRequest,
);
browser.webRequest.onBeforeSendHeaders.addListener(
setSessionCookieForPontoonRequest,
{ urls: [`${pontoonBaseUrl}/*`] },
['blocking', 'requestHeaders'],
);
}
}

public async fetchFromPontoonSession(url: string): Promise<Response> {
const pontoonBaseUrl = await getOneOption('pontoon_base_url');
if (!url.startsWith(`${pontoonBaseUrl}/`)) {
throw new Error(
`Attempted to fetch '${url}' with Pontoon session for '${pontoonBaseUrl}'.`,
);
}

const headers = new Headers();
headers.append('X-Requested-With', 'XMLHttpRequest');
if (browser.webRequest) {
browser.webRequest.onBeforeSendHeaders.hasListener(
this.requestsToPontoonListener,
) || (await this.listenForRequestsToPontoon());
headers.append('pontoon-addon-token', this.issueNewToken());
return fetch(url, { credentials: 'omit', headers: headers });
} else {
return fetch(url, { credentials: 'include', headers: headers });
}
async function fetchFromPontoonSession(url: string): Promise<Response> {
const pontoonBaseUrl = await getOneOption('pontoon_base_url');
if (!url.startsWith(`${pontoonBaseUrl}/`)) {
throw new Error(
`Attempted to fetch '${url}' with Pontoon session for '${pontoonBaseUrl}'.`,
);
}

private issueNewToken(): string {
const token = uuidv4();
this.pontoonRequestTokens.add(token);
return token;
const headers = new Headers();
headers.append('X-Requested-With', 'XMLHttpRequest');
if (browser.webRequest) {
browser.webRequest.onBeforeSendHeaders.hasListener(
setSessionCookieForPontoonRequest,
) || (await listenForRequestsToPontoon());
headers.append('pontoon-addon-token', await issueNewPontoonRequestToken());
return fetch(url, { credentials: 'omit', headers: headers });
} else {
return fetch(url, { credentials: 'include', headers: headers });
}
}

function tokenStorageKey(token: string) {
return `${PONTOON_REQUEST_TOKEN_STORAGE_KEY_PREFIX}_${token}`;
}

private verifyToken(token: string | undefined): boolean {
if (token) {
const valid = this.pontoonRequestTokens.has(token);
this.pontoonRequestTokens.delete(token);
return valid;
async function issueNewPontoonRequestToken(): Promise<string> {
const token = uuidv4();
const tokenInfo: TokenInfo = {
issued: new Date().toISOString(),
};
await browser.storage.session.set({
[tokenStorageKey(token)]: JSON.stringify(tokenInfo),
});
return token;
}

async function verifyPontoonRequestToken(
token: string | undefined,
): Promise<boolean> {
if (token) {
const storageKey = tokenStorageKey(token);
const tokenInfoValue: string | undefined = (
await browser.storage.session.get(storageKey)
)[storageKey];
if (typeof tokenInfoValue === 'string') {
const tokenInfo: TokenInfo = JSON.parse(tokenInfoValue);
const tokenAgeSeconds =
(Date.now() - new Date(tokenInfo.issued).getTime()) / 1000;
await browser.storage.session.remove(storageKey);
return tokenAgeSeconds < PONTOON_REQUEST_TOKEN_VALIDITY_SECONDS;
} else {
return false;
}
} else {
return false;
}
}

private async setSessionCookieForPontoonRequest(
details: WebRequest.OnBeforeSendHeadersDetailsType,
): Promise<WebRequest.BlockingResponse> {
const pontoonBaseUrl = await getOneOption('pontoon_base_url');
if (!details.url.startsWith(`${pontoonBaseUrl}/`)) {
console.warn(
`Observed a request to '${details.url}', but Pontoon is at '${pontoonBaseUrl}'. Request passed unchanged.`,
);
return details;
}

const tokens = (details.requestHeaders ?? [])
.filter((header) => header.name.toLowerCase() === 'pontoon-addon-token')
.map((header) => header.value);
const isMarked =
tokens.length > 0 && tokens.every((token) => this.verifyToken(token));
async function setSessionCookieForPontoonRequest(
details: WebRequest.OnBeforeSendHeadersDetailsType,
): Promise<WebRequest.BlockingResponse> {
const pontoonBaseUrl = await getOneOption('pontoon_base_url');
if (!details.url.startsWith(`${pontoonBaseUrl}/`)) {
console.warn(
`Observed a request to '${details.url}', but Pontoon is at '${pontoonBaseUrl}'. Request passed unchanged.`,
);
return details;
}

if (!isMarked) {
return details;
} else {
const {
contextual_identity: contextualIdentity,
pontoon_base_url: pontoonBaseUrl,
} = await getOptions(['contextual_identity', 'pontoon_base_url']);
const cookie = await browser.cookies.get({
url: pontoonBaseUrl,
name: 'sessionid',
storeId: contextualIdentity,
});
const requestHeaders = (details.requestHeaders ?? [])
.filter((header) => header.name.toLowerCase() !== 'pontoon-addon-token')
.filter((header) => header.name.toLowerCase() !== 'cookie')
.concat(
...(cookie
? [
{
name: 'Cookie',
value: `${cookie.name}=${cookie.value}`,
},
]
: []),
);
return {
...details,
requestHeaders,
};
}
const tokens = (details.requestHeaders ?? [])
.filter((header) => header.name.toLowerCase() === 'pontoon-addon-token')
.map((header) => header.value);
const isMarked =
tokens.length > 0 &&
(
await Promise.all([
tokens.map((token) => verifyPontoonRequestToken(token)),
])
).every((verified) => verified);

if (!isMarked) {
return details;
} else {
const {
contextual_identity: contextualIdentity,
pontoon_base_url: pontoonBaseUrl,
} = await getOptions(['contextual_identity', 'pontoon_base_url']);
const cookie = await browser.cookies.get({
url: pontoonBaseUrl,
name: 'sessionid',
storeId: contextualIdentity,
});
const requestHeaders = (details.requestHeaders ?? [])
.filter((header) => header.name.toLowerCase() !== 'pontoon-addon-token')
.filter((header) => header.name.toLowerCase() !== 'cookie')
.concat(
...(cookie
? [
{
name: 'Cookie',
value: `${cookie.name}=${cookie.value}`,
},
]
: []),
);
return {
...details,
requestHeaders,
};
}
}

export const pontoonHttpClient = new PontoonHttpClient();
listenToOptionChange('pontoon_base_url', ({ newValue: pontoonBaseUrl }) => {
listenForRequestsToPontoon(pontoonBaseUrl);
});
listenForRequestsToPontoon();

export const pontoonHttpClient = {
fetchFromPontoonSession,
};

export const httpClient = {
fetch: async (url: string): Promise<Response> => {
Expand Down Expand Up @@ -192,15 +213,25 @@ export interface GetProjectsInfoResponse {
projects: DeepRequired<DeepNonNullable<GetProjectsInfoQuery['projects']>>;
}

export function graphqlClient(pontoonBaseUrl: string) {
const client = getSdk(
function getGraphQLClient(pontoonBaseUrl: string) {
return getSdk(
new GraphQLClient(pontoonGraphQL(pontoonBaseUrl), {
method: 'GET',
}),
);
return {
getTeamsInfo: client.getTeamsInfo as () => Promise<GetTeamsInfoResponse>,
getProjectsInfo:
client.getProjectsInfo as () => Promise<GetProjectsInfoResponse>,
};
}

export const pontoonGraphqlClient = {
getTeamsInfo: async (): Promise<GetTeamsInfoResponse> => {
const client = getGraphQLClient(await getPontoonBaseUrl());
return (await client.getTeamsInfo()) as GetTeamsInfoResponse;
},
getProjectsInfo: async (): Promise<GetProjectsInfoResponse> => {
const client = getGraphQLClient(await getPontoonBaseUrl());
return (await client.getProjectsInfo()) as GetProjectsInfoResponse;
},
};

async function getPontoonBaseUrl(): Promise<string> {
return await getOneOption('pontoon_base_url');
}

0 comments on commit dbdc385

Please sign in to comment.