Skip to content
2 changes: 2 additions & 0 deletions apps/src/types/redux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {MapboxState} from '@cdo/apps/redux/mapbox';
import {CurrentUserState} from '@cdo/apps/templates/CurrentUserState';
import {TeacherRubricState} from '@cdo/apps/templates/rubrics/teacherRubricRedux';
import {TeacherSectionState} from '@cdo/apps/templates/teacherDashboard/teacherSectionsRedux';
import {Weblab2NetworkState} from '@cdo/apps/weblab2/redux/networkRedux';
import {Weblab2State} from '@cdo/apps/weblab2/weblab2Redux';

import {DanceState} from '../dance/danceRedux';
Expand Down Expand Up @@ -63,6 +64,7 @@ export interface RootState {
teacherRubric: TeacherRubricState;
teacherSections: TeacherSectionState;
weblab2: Weblab2State;
weblab2Network: Weblab2NetworkState;
}

// Temporary type definition for the result of
Expand Down
13 changes: 13 additions & 0 deletions apps/src/weblab2/htmlPreview/HTMLPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {EVENTS} from '@cdo/apps/metrics/AnalyticsConstants';
import experiments from '@cdo/apps/util/experiments';
import {useAppSelector, useAppDispatch} from '@cdo/apps/util/reduxHooks';
import {filterSourceForPreview} from '@cdo/apps/weblab2/htmlPreview/filterSourceForPreview';
import {
addRequestData,
addResponseData,
clearRequests,
} from '@cdo/apps/weblab2/redux/networkRedux';

import {
IframeMessageType,
Expand Down Expand Up @@ -285,6 +290,7 @@ export const HTMLPreview: React.FC = () => {
setIsStopped(false);
setIsLevelLoading(true);
setIsIframeLoaded(false);
dispatch(clearRequests());
});

useLifecycleNotifier(LifecycleEvent.LevelLoadCompleted, () => {
Expand Down Expand Up @@ -331,6 +337,12 @@ export const HTMLPreview: React.FC = () => {
Lab2Registry.getInstance()
.getMetricsReporter()
.logWarning('Service worker unavailable in HTMLPreview iframe.');
} else if (event.data.type === IframeMessageType.NETWORK_REQUEST) {
const {id, ...request} = event.data.request;
dispatch(addRequestData({id, request}));
} else if (event.data.type === IframeMessageType.NETWORK_RESPONSE) {
const {id, ...response} = event.data.response;
dispatch(addResponseData({id, response: response}));
}
};

Expand All @@ -342,6 +354,7 @@ export const HTMLPreview: React.FC = () => {
navigationHistory,
navigationHistoryIndex,
debouncedSource,
dispatch,
]);

useEffect(() => {
Expand Down
20 changes: 20 additions & 0 deletions apps/src/weblab2/htmlPreview/InnerHTMLPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ const InnerHTMLPreview = () => {
) {
setServiceWorkerReady(true);
setPreviewKey(prevKey => prevKey + 1);
} else if (
event.data.type === ProjectServiceWorkerMessageType.NETWORK_REQUEST
) {
window.parent.postMessage(
{
type: IframeMessageType.NETWORK_REQUEST,
request: event.data.requestData,
},
parentOrigin
);
} else if (
event.data.type === ProjectServiceWorkerMessageType.NETWORK_RESPONSE
) {
window.parent.postMessage(
{
type: IframeMessageType.NETWORK_RESPONSE,
response: event.data.responseData,
},
parentOrigin
);
}
};
return () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/src/weblab2/htmlPreview/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export enum IframeMessageType {
REFRESH = 'REFRESH',
LEVEL_LOADING = 'LEVEL_LOADING',
SERVICE_WORKER_UNAVAILABLE = 'SERVICE_WORKER_UNAVAILABLE',
NETWORK_REQUEST = 'NETWORK_REQUEST',
NETWORK_RESPONSE = 'NETWORK_RESPONSE',
}

export enum PreviewViewMode {
Expand All @@ -24,4 +26,6 @@ export enum ProjectServiceWorkerMessageType {
RECEIVED_SOURCE = 'RECEIVED_SOURCE',
UPDATE_FILES = 'UPDATE_FILES',
KEEP_ALIVE = 'KEEP_ALIVE',
NETWORK_REQUEST = 'NETWORK_REQUEST',
NETWORK_RESPONSE = 'NETWORK_RESPONSE',
}
36 changes: 35 additions & 1 deletion apps/src/weblab2/htmlPreview/htmlParsingHelpers.ts
Copy link
Contributor

Choose a reason for hiding this comment

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

While here, do we still need updateLinksToNonHtmlFiles?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we don't, but I'll handle that separately.

Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {findFilePathByRelativePath} from '@codebridge/utils';

import {IframeMessageType} from './constants';
import {
IframeMessageType,
PROJECT_SERVICE_WORKER_BROADCAST_CHANNEL,
ProjectServiceWorkerMessageType,
} from './constants';

// Replace links to non-html files (css and js) with their appropriate URLs (either blobs or external URLs).
// We support <link> tags for CSS files, <script> tags for JavaScript files, and <img> tags for images,
Expand Down Expand Up @@ -59,6 +63,36 @@ export const updateLinksToHtmlFiles = (doc: Document, fullFileName: string) => {
});
};

const handleCSPViolationScript = `
document.addEventListener("securitypolicyviolation",function(e){
const broadcastChannel = new BroadcastChannel("${PROJECT_SERVICE_WORKER_BROADCAST_CHANNEL}");
const requestId = crypto.randomUUID();
broadcastChannel.postMessage({
type: "${ProjectServiceWorkerMessageType.NETWORK_REQUEST}",
requestData: {
url: e.blockedURI,
cspDirectiveViolated: e.effectiveDirective,
id: requestId,
startTime: new Date().toLocaleString()
}
});
broadcastChannel.close();
});
`;

// Adds a script to the document that listens for CSP violations and broadcasts them
// via BroadcastChannel so the parent can be notified.
export const addCSPViolationListenerToDocument = (doc: Document) => {
const script = doc.createElement('script');
script.textContent = handleCSPViolationScript;
const head = doc.querySelector('head');
if (head) {
head.insertBefore(script, head.firstChild);
} else {
doc.documentElement.insertBefore(script, doc.documentElement.firstChild);
}
};

// This adds a base tag to the header of the given document, setting its href to the provided baseHref.
// If a base tag already exists, its href is updated.
export const addBaseTagToDocument = (doc: Document, baseHref: string) => {
Expand Down
6 changes: 5 additions & 1 deletion apps/src/weblab2/htmlPreview/useProjectServiceWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import {MultiFileSource} from '@cdo/apps/lab2/types';

import {ProjectServiceWorkerMessageType} from './constants';
import {generateContentSecurityPolicyForPreview} from './contentSecurityPolicyHelper';
import {addBaseTagToDocument} from './htmlParsingHelpers';
import {
addBaseTagToDocument,
addCSPViolationListenerToDocument,
} from './htmlParsingHelpers';

// Hook that handles registering and communicating with the project service worker.
function useProjectServiceWorker(
Expand Down Expand Up @@ -94,6 +97,7 @@ function useProjectServiceWorker(
const doc = parser.parseFromString(file.contents, 'text/html');
const urlSuffix = folder ? `${folder}/` : '';
addBaseTagToDocument(doc, `${window.location.origin}/${urlSuffix}`);
addCSPViolationListenerToDocument(doc);
Copy link
Contributor

Choose a reason for hiding this comment

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

Tidy!

content = doc.documentElement.outerHTML;
} else if (file.language === 'css') {
mimeType = 'text/css';
Expand Down
48 changes: 47 additions & 1 deletion apps/src/weblab2/htmlPreview/weblab2_project_service_worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const PROJECT_SERVICE_WORKER_BROADCAST_CHANNEL = 'weblab2-file-preview';
const SERVING_HTML_FILE = 'SERVING_HTML_FILE';
const RECEIVED_SOURCE = 'RECEIVED_SOURCE';
const UPDATE_FILES = 'UPDATE_FILES';
const NETWORK_REQUEST = 'NETWORK_REQUEST';
const NETWORK_RESPONSE = 'NETWORK_RESPONSE';

function main() {
let filesData = {};
Expand Down Expand Up @@ -45,7 +47,7 @@ function main() {
});

// Intercept fetch requests
self.addEventListener('fetch', event => {
self.addEventListener('fetch', async event => {
const url = new URL(event.request.url);
let requestedFile = getFilenameFromUrl(url);
const referrerFile = getFilenameFromUrl(new URL(event.request.referrer));
Expand All @@ -67,6 +69,50 @@ function main() {
filePath: requestedFile,
});
}
if (url.origin !== location.origin) {
const performanceStartTime = performance.now();
const startTime = new Date().toLocaleString();
const requestId = crypto.randomUUID();
let response;
let performanceEndTime;
let error;
broadcastChannel.postMessage({
type: NETWORK_REQUEST,
requestData: {
url: event.request.url,
method: event.request.method,
startTime,
id: requestId,
},
});
try {
response = await fetch(event.request);
performanceEndTime = performance.now();
} catch (e) {
error = e;
}
let bodyText;
try {
bodyText = response ? await response.text() : undefined;
} catch (e) {
error = e;
}
broadcastChannel.postMessage({
type: NETWORK_RESPONSE,
responseData: {
url: event.request.url,
body: bodyText,
status: response ? response.status : undefined,
timeElapsed: performanceEndTime
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice!

? Math.floor(performanceEndTime - performanceStartTime)
: undefined,
error,
id: requestId,
contentType: response?.headers?.get('Content-Type'),
},
});
return response;
}
return;
}
});
Expand Down
66 changes: 66 additions & 0 deletions apps/src/weblab2/redux/networkRedux.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {createSlice, PayloadAction} from '@reduxjs/toolkit';

import {registerReducers} from '@cdo/apps/redux';

export interface NetworkEntry {
id: string;
request: RequestData;
response?: ResponseData;
}

interface RequestData {
method: string;
startTime: string;
url: string;
cspDirectiveViolated?: string;
}

interface ResponseData {
url: string;
status: number;
timeElapsed?: number;
body?: string;
error?: Error;
contentType?: string;
}

export interface Weblab2NetworkState {
requests: NetworkEntry[];
}

const initialState: Weblab2NetworkState = {
requests: [],
};

const networkSlice = createSlice({
name: 'network',
initialState,
reducers: {
addRequestData: (
state,
action: PayloadAction<{id: string; request: RequestData}>
) => {
state.requests.push(action.payload);
},
addResponseData: (
state,
action: PayloadAction<{id: string; response: ResponseData}>
) => {
const {id, response} = action.payload;
const request = state.requests.find(r => r.id === id);
if (request) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we log if we return a response which doesn't have a corresponding request?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We'll handle that in the network tab (display an appropriate error).

request.response = response;
}
},
clearRequests: state => {
state.requests = [];
},
},
});

registerReducers({weblab2Network: networkSlice.reducer});

export const {addRequestData, addResponseData, clearRequests} =
networkSlice.actions;

export default networkSlice.reducer;