Skip to content
78 changes: 75 additions & 3 deletions src/extension/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,73 @@ import { ADMIN_ORIGIN, ADMIN_ORIGIN_NEW } from './utils/admin.js';
const { host: adminHost } = new URL(ADMIN_ORIGIN);
const { host: newAdminHost } = new URL(ADMIN_ORIGIN_NEW);

/** Cache-Control max-age in seconds for added project hosts (HTML/JSON). 60 = 1 minute. */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/** Cache-Control max-age in seconds for added project hosts (HTML/JSON). 60 = 1 minute. */
/** Cache-Control max-age in seconds for added project hosts (HTML, JSON and code): 1 minute. */

export const CACHE_MAX_AGE_SECONDS = 60;

function getRandomId() {
return Math.floor(Math.random() * 1000000);
}

/**
* Returns the hostname (domain) from a project host value (may be domain or full URL).
* @param {string} host
* @returns {string}
*/
export function getHostDomain(host) {
if (!host || typeof host !== 'string') return '';
return host.startsWith('http') ? new URL(host).host : host;
}

/**
* Builds declarativeNetRequest rules that set Cache-Control on responses
* for added project hosts.
* @param {Object[]} projectConfigs Configs with host, previewHost, liveHost, reviewHost
* @returns {Object[]} Rules to add
*/
export function getCacheControlRules(projectConfigs) {
const rules = [];
projectConfigs
.flatMap((p) => [
p?.host,
p?.previewHost,
p?.liveHost,
p?.reviewHost,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out we only need rules for host and liveHost. previewHost and reviewHost already have the 60s cache TTL:

Suggested change
p?.host,
p?.previewHost,
p?.liveHost,
p?.reviewHost,
p?.host,
p?.liveHost,

].filter(Boolean).map(getHostDomain))
.filter(Boolean)
.filter((domain, i, self) => self.indexOf(domain) === i)
.forEach((domain) => {
const escaped = domain.replaceAll(/\./g, '\\.');
rules.push({
id: getRandomId(),
priority: 1,
action: {
type: 'modifyHeaders',
responseHeaders: [{
header: 'Cache-Control',
operation: 'set',
value: `max-age=${CACHE_MAX_AGE_SECONDS}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep the must-revalidate

Suggested change
value: `max-age=${CACHE_MAX_AGE_SECONDS}`,
value: `max-age=${CACHE_MAX_AGE_SECONDS}, must-revalidate`,

}],
},
condition: {
regexFilter: `^https://${escaped}/.*`,
requestMethods: ['get'],
resourceTypes: [
'main_frame',
'sub_frame',
'script',
'stylesheet',
'image',
'xmlhttprequest',
'media',
'font',
'other',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We actually don't need image, media and other

Suggested change
'main_frame',
'sub_frame',
'script',
'stylesheet',
'image',
'xmlhttprequest',
'media',
'font',
'other',
'main_frame',
'sub_frame',
'script',
'stylesheet',
'xmlhttprequest',
'font',

],
},
});
});
return rules;
}

/**
* Sets the x-auth-token header for all requests to the Admin API if project config
* has an auth token. Also sets the Access-Control-Allow-Origin header for
Expand All @@ -36,6 +99,15 @@ export async function configureAuthAndCorsHeaders() {
removeRuleIds: (await chrome.declarativeNetRequest.getSessionRules())
.map((rule) => rule.id),
});
const allRules = [];

// Cache-Control rules for added project hosts (prod, preview, live, review) – override to 1 min
const syncHandles = await getConfig('sync', 'projects') || [];
const syncConfigs = (await Promise.all(
syncHandles.map((handle) => getConfig('sync', handle)),
)).filter(Boolean);
allRules.push(...getCacheControlRules(syncConfigs));

// find projects with auth tokens and add rules for each
const projects = await getConfig('session', 'projects') || [];
const addRulesPromises = projects.map(async ({
Expand Down Expand Up @@ -152,10 +224,10 @@ export async function configureAuthAndCorsHeaders() {
return rules;
});

const addRules = (await Promise.all(addRulesPromises)).flat();
if (addRules.length > 0) {
allRules.push(...(await Promise.all(addRulesPromises)).flat());
if (allRules.length > 0) {
await chrome.declarativeNetRequest.updateSessionRules({
addRules,
addRules: allRules,
});
}
} catch (e) {
Expand Down
3 changes: 2 additions & 1 deletion src/extension/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
} from './config.js';
import { urlCache } from './url-cache.js';
import { callAdmin, createAdminUrl } from './utils/admin.js';
import { setAuthToken } from './auth.js';
import { configureAuthAndCorsHeaders, setAuthToken } from './auth.js';

export const DEV_URL = 'http://localhost:3000/';

Expand Down Expand Up @@ -78,6 +78,7 @@ export async function updateProject(project) {
projects.push(handle);
await setConfig('sync', { projects });
}
await configureAuthAndCorsHeaders();
log.info('updated project', project);
return project;
}
Expand Down
56 changes: 56 additions & 0 deletions test/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import { setUserAgent } from '@web/test-runner-commands';
import sinon from 'sinon';

import {
CACHE_MAX_AGE_SECONDS,
configureAuthAndCorsHeaders,
getCacheControlRules,
getHostDomain,
setAuthToken,
updateUserAgent,
} from '../src/extension/auth.js';
Expand Down Expand Up @@ -54,6 +57,59 @@ describe('Test auth', () => {
await configureAuthAndCorsHeaders();
});

describe('getHostDomain', () => {
it('returns hostname for full URL', () => {
expect(getHostDomain('https://example.com/path')).to.equal('example.com');
expect(getHostDomain('https://sub.example.com:443/')).to.equal('sub.example.com');
});
it('returns input for plain domain', () => {
expect(getHostDomain('example.com')).to.equal('example.com');
expect(getHostDomain('main--repo--owner.aem.live')).to.equal('main--repo--owner.aem.live');
});
it('returns empty string for invalid or empty input', () => {
expect(getHostDomain('')).to.equal('');
expect(getHostDomain(null)).to.equal('');
expect(getHostDomain(undefined)).to.equal('');
expect(getHostDomain(123)).to.equal('');
});
});

describe('getCacheControlRules', () => {
it('returns one rule per unique host', () => {
const configs = [
{ host: 'prod.example.com', previewHost: 'preview.example.com' },
{ liveHost: 'live.example.com' },
];
const rules = getCacheControlRules(configs);
expect(rules).to.have.lengthOf(3);
expect(rules.every((r) => r.action?.responseHeaders?.[0]?.header === 'Cache-Control')).to.be.true;
expect(rules.every((r) => r.action.responseHeaders[0].value === `max-age=${CACHE_MAX_AGE_SECONDS}`)).to.be.true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expect(rules.every((r) => r.action.responseHeaders[0].value === `max-age=${CACHE_MAX_AGE_SECONDS}`)).to.be.true;
expect(rules.every((r) => r.action.responseHeaders[0].value === `max-age=${CACHE_MAX_AGE_SECONDS}, must-revalidate`)).to.be.true;

const filters = rules.map((r) => r.condition.regexFilter);
expect(filters).to.include('^https://prod\\.example\\.com/.*');
expect(filters).to.include('^https://preview\\.example\\.com/.*');
expect(filters).to.include('^https://live\\.example\\.com/.*');
});
it('deduplicates same host across configs', () => {
const configs = [
{ host: 'same.com', previewHost: 'same.com' },
{ liveHost: 'same.com' },
];
const rules = getCacheControlRules(configs);
expect(rules).to.have.lengthOf(1);
expect(rules[0].condition.regexFilter).to.equal('^https://same\\.com/.*');
});
it('extracts host from full URL in config', () => {
const configs = [{ host: 'https://url-host.com/path' }];
const rules = getCacheControlRules(configs);
expect(rules).to.have.lengthOf(1);
expect(rules[0].condition.regexFilter).to.equal('^https://url-host\\.com/.*');
});
it('returns empty array for empty or no valid hosts', () => {
expect(getCacheControlRules([])).to.deep.equal([]);
expect(getCacheControlRules([{ host: '' }, { previewHost: null }])).to.deep.equal([]);
});
});

it('setAuthToken', async () => {
const updateSessionRules = sandbox.spy(chrome.declarativeNetRequest, 'updateSessionRules');
const getConfig = sandbox.spy(chrome.storage.session, 'get');
Expand Down