Skip to content

Commit 7c06922

Browse files
committed
Send Authorization header on Scratch project save
1 parent 8d1f514 commit 7c06922

File tree

2 files changed

+91
-3
lines changed

2 files changed

+91
-3
lines changed

src/scratch.jsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,20 @@ const handleScratchGuiAlert = (alertType) => {
6363
const generateNonce = () =>
6464
`${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
6565

66+
const buildQueryString = (params = {}) => {
67+
const searchParams = new URLSearchParams(
68+
Object.entries({
69+
original_id: params.originalId,
70+
is_copy: params.isCopy,
71+
is_remix: params.isRemix,
72+
title: params.title,
73+
}).filter(([, value]) => value !== undefined),
74+
);
75+
76+
const queryString = searchParams.toString();
77+
return queryString ? `?${queryString}` : "";
78+
};
79+
6680
if (!projectId) {
6781
console.error("project_id is required but not set");
6882
} else if (!apiUrl) {
@@ -80,6 +94,7 @@ if (!projectId) {
8094
requiresAuth: false,
8195
latestAccessToken: null,
8296
};
97+
let scratchFetchApi = null;
8398

8499
const getTimeoutMessage = (handshake) =>
85100
handshake.requiresAuth && !handshake.latestAccessToken
@@ -92,6 +107,32 @@ if (!projectId) {
92107
event.data?.type === "scratch-gui-set-token" &&
93108
event.data?.nonce === nonce;
94109

110+
const handleUpdateProjectData = async (currentProjectId, vmState, params) => {
111+
const creatingProject =
112+
currentProjectId === null || typeof currentProjectId === "undefined";
113+
const queryString = buildQueryString(params);
114+
const url = creatingProject
115+
? `${apiUrl}/api/scratch/projects/${queryString}`
116+
: `${apiUrl}/api/scratch/projects/${currentProjectId}${queryString}`;
117+
118+
const response = await scratchFetchApi.scratchFetch(url, {
119+
method: creatingProject ? "post" : "put",
120+
body: vmState,
121+
headers: {
122+
"Content-Type": "application/json",
123+
},
124+
credentials: "include",
125+
});
126+
127+
if (response.status !== 200) {
128+
throw response.status;
129+
}
130+
131+
const body = await response.json();
132+
body.id = creatingProject ? body["content-name"] : currentProjectId;
133+
return body;
134+
};
135+
95136
const mountGui = (accessToken) => {
96137
if (isMounted) return;
97138
isMounted = true;
@@ -108,10 +149,12 @@ if (!projectId) {
108149
assetHost={`${apiUrl}/api/scratch/assets`}
109150
basePath={`${process.env.ASSETS_URL}/scratch-gui/`}
110151
onStorageInit={(storage) => {
152+
scratchFetchApi = storage.scratchFetch;
111153
if (accessToken) {
112-
storage.scratchFetch.setMetadata("Authorization", accessToken);
154+
scratchFetchApi.setMetadata("Authorization", accessToken);
113155
}
114156
}}
157+
onUpdateProjectData={handleUpdateProjectData}
115158
onUpdateProjectId={handleUpdateProjectId}
116159
onShowCreatingRemixAlert={handleRemixingStarted}
117160
onShowRemixSuccessAlert={handleRemixingSucceeded}

src/scratch.test.js

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@ jest.mock("./components/ScratchEditor/ScratchIntegrationHOC.jsx", () => ({
55
default: (WrappedComponent) => WrappedComponent,
66
}));
77
jest.mock("@scratch/scratch-gui", () => {
8-
const MockGui = () => null;
8+
const MockGui = jest.fn(() => null);
99
MockGui.setAppElement = jest.fn();
1010
return {
1111
__esModule: true,
1212
default: MockGui,
1313
AppStateHOC: (WrappedComponent) => WrappedComponent,
1414
};
1515
});
16+
17+
const mockRenderRoot = jest.fn();
1618
jest.mock("react-dom/client", () => ({
1719
createRoot: jest.fn(() => ({
18-
render: jest.fn(),
20+
render: mockRenderRoot,
1921
})),
2022
}));
2123

@@ -69,6 +71,7 @@ describe("scratch handshake retries", () => {
6971
beforeEach(() => {
7072
jest.useFakeTimers();
7173
jest.resetModules();
74+
mockRenderRoot.mockReset();
7275
process.env = {
7376
...originalEnv,
7477
ASSETS_URL: "https://assets.example.com",
@@ -124,6 +127,48 @@ describe("scratch handshake retries", () => {
124127
expectRetriesStopped(callsAfterHandshake);
125128
});
126129

130+
test("routes project saves through scratchFetch metadata after storage init", async () => {
131+
loadScratchModule();
132+
133+
const nonce = getHandshakeNonce();
134+
dispatchSetTokenMessage({ nonce, accessToken: "token-123" });
135+
136+
const renderedTree = mockRenderRoot.mock.calls[0][0];
137+
const scratchGuiElement = renderedTree.props.children[1];
138+
const scratchStorage = {
139+
scratchFetch: {
140+
setMetadata: jest.fn(),
141+
scratchFetch: jest.fn().mockResolvedValue({
142+
status: 200,
143+
json: jest.fn().mockResolvedValue({ ok: true }),
144+
}),
145+
},
146+
};
147+
148+
scratchGuiElement.props.onStorageInit(scratchStorage);
149+
await scratchGuiElement.props.onUpdateProjectData(
150+
"project-123",
151+
'{"targets":[]}',
152+
{ title: "Saved from test" },
153+
);
154+
155+
expect(scratchStorage.scratchFetch.setMetadata).toHaveBeenCalledWith(
156+
"Authorization",
157+
"token-123",
158+
);
159+
expect(scratchStorage.scratchFetch.scratchFetch).toHaveBeenCalledWith(
160+
"https://api.example.com/api/scratch/projects/project-123?title=Saved+from+test",
161+
{
162+
method: "put",
163+
body: '{"targets":[]}',
164+
headers: {
165+
"Content-Type": "application/json",
166+
},
167+
credentials: "include",
168+
},
169+
);
170+
});
171+
127172
test("keeps retrying when auth is required but token is missing", () => {
128173
loadScratchModule();
129174

0 commit comments

Comments
 (0)