@robotical/martyblocksjr 4.2.8 → 4.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,8 @@
30
30
  left: ${5 * scaleMultiplier}px;
31
31
  width: ${css_vh(25)};
32
32
  height: ${css_vh(18.75)};
33
+ -webkit-touch-callout: none;
34
+ -webkit-user-drag: none;
33
35
  }
34
36
 
35
37
  .aproject.empty {
@@ -162,6 +164,167 @@
162
164
  right: 8px;
163
165
  }
164
166
 
167
+ .project-options-button {
168
+ position: absolute;
169
+ top: ${css_vh(1.1)};
170
+ right: ${css_vh(0.8)};
171
+ z-index: 8;
172
+ width: ${css_vh(5.2)};
173
+ height: ${css_vh(5.2)};
174
+ padding: 0 0 ${css_vh(1.1)};
175
+ border: 2px solid #315F7B;
176
+ border-radius: 50%;
177
+ background: rgba(255, 255, 255, 0.94);
178
+ box-shadow: 0 ${css_vh(0.25)} ${css_vh(0.55)} rgba(27, 62, 78, 0.35);
179
+ color: #315F7B;
180
+ cursor: pointer;
181
+ font-family: 'Lato Regular';
182
+ font-size: ${css_vh(4.2)};
183
+ font-weight: bold;
184
+ line-height: 1;
185
+ -webkit-tap-highlight-color: transparent;
186
+ }
187
+
188
+ .project-options-button:hover,
189
+ .project-options-button:focus-visible,
190
+ .project-options-button[aria-expanded="true"] {
191
+ background: #EAF7FF;
192
+ outline: 3px solid #133C46;
193
+ outline-offset: 2px;
194
+ }
195
+
196
+ .project-options-menu {
197
+ position: absolute;
198
+ top: ${css_vh(6.7)};
199
+ right: ${css_vh(0.8)};
200
+ z-index: 20;
201
+ min-width: ${css_vh(18)};
202
+ padding: ${css_vh(0.7)};
203
+ border: 2px solid #315F7B;
204
+ border-radius: 12px;
205
+ background: #FFFDF7;
206
+ box-shadow: 0 ${css_vh(0.45)} ${css_vh(1.1)} rgba(27, 62, 78, 0.4);
207
+ }
208
+
209
+ .project-options-menu[hidden] {
210
+ display: none;
211
+ }
212
+
213
+ .project-options-delete {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: ${css_vh(0.8)};
217
+ width: 100%;
218
+ min-height: ${css_vh(4.8)};
219
+ padding: ${css_vh(0.7)} ${css_vh(1)};
220
+ border: 0;
221
+ border-radius: 8px;
222
+ background: transparent;
223
+ color: #9E1F28;
224
+ cursor: pointer;
225
+ font-family: 'Lato Regular';
226
+ font-size: ${css_vh(1.8)};
227
+ font-weight: bold;
228
+ text-align: left;
229
+ white-space: nowrap;
230
+ }
231
+
232
+ .project-options-delete:hover,
233
+ .project-options-delete:focus-visible {
234
+ background: #FCE5E5;
235
+ outline: 3px solid #9E1F28;
236
+ outline-offset: -1px;
237
+ }
238
+
239
+ .project-options-delete-icon {
240
+ display: inline-flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ width: ${css_vh(2.6)};
244
+ height: ${css_vh(2.6)};
245
+ border-radius: 50%;
246
+ background: #E43D44;
247
+ color: #fff;
248
+ font-size: ${css_vh(1.8)};
249
+ line-height: 1;
250
+ }
251
+
252
+ .project-delete-dialog-backdrop {
253
+ position: fixed;
254
+ top: 0;
255
+ right: 0;
256
+ bottom: 0;
257
+ left: 0;
258
+ z-index: 12000;
259
+ display: flex;
260
+ align-items: center;
261
+ justify-content: center;
262
+ padding: ${css_vh(3)};
263
+ background: rgba(27, 51, 66, 0.72);
264
+ }
265
+
266
+ .project-delete-dialog {
267
+ width: min(${css_vh(52)}, 90vw);
268
+ padding: ${css_vh(3)};
269
+ border: ${css_vh(0.4)} solid #315F7B;
270
+ border-radius: 18px;
271
+ background: #FFFDF7;
272
+ box-shadow: 0 ${css_vh(0.8)} ${css_vh(2)} rgba(0, 0, 0, 0.35);
273
+ text-align: center;
274
+ }
275
+
276
+ .project-delete-dialog-title {
277
+ margin: 0;
278
+ color: #315F7B;
279
+ font-family: 'Lato Regular';
280
+ font-size: ${css_vh(3.2)};
281
+ line-height: 1.2;
282
+ }
283
+
284
+ .project-delete-dialog-description {
285
+ margin: ${css_vh(1.6)} 0 0;
286
+ color: #416E86;
287
+ font-family: 'Lato Regular';
288
+ font-size: ${css_vh(2.1)};
289
+ line-height: 1.35;
290
+ }
291
+
292
+ .project-delete-dialog-actions {
293
+ display: flex;
294
+ justify-content: center;
295
+ gap: ${css_vh(1.4)};
296
+ margin-top: ${css_vh(2.6)};
297
+ }
298
+
299
+ .project-delete-cancel,
300
+ .project-delete-confirm {
301
+ min-width: ${css_vh(14)};
302
+ min-height: ${css_vh(5.2)};
303
+ padding: ${css_vh(0.8)} ${css_vh(1.5)};
304
+ border: 2px solid #315F7B;
305
+ border-radius: 10px;
306
+ color: #fff;
307
+ cursor: pointer;
308
+ font-family: 'Lato Regular';
309
+ font-size: ${css_vh(1.9)};
310
+ font-weight: bold;
311
+ }
312
+
313
+ .project-delete-cancel {
314
+ background: #5D9CC8;
315
+ }
316
+
317
+ .project-delete-confirm {
318
+ border-color: #9E1F28;
319
+ background: #E43D44;
320
+ }
321
+
322
+ .project-delete-cancel:focus-visible,
323
+ .project-delete-confirm:focus-visible {
324
+ outline: 3px solid #133C46;
325
+ outline-offset: 3px;
326
+ }
327
+
165
328
  .closex {
166
329
  position: relative;
167
330
  float: left;
@@ -17,6 +17,9 @@
17
17
  "categoryStopColor": "#D62222",
18
18
  "paletteBlockShadowOpacity": 0.8,
19
19
  "autoSaveInterval": 30000,
20
+ "curriculumArtifactAllowedOrigins": [
21
+ "https://cdn.sanity.io"
22
+ ],
20
23
  "defaultLocale": "en",
21
24
  "defaultLocaleShort": "en",
22
25
  "supportedLocales": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robotical/martyblocksjr",
3
- "version": "4.2.8",
3
+ "version": "4.2.9",
4
4
  "description": "ScratchJr",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,272 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const { importPackageMock } = vi.hoisted(() => ({
4
+ importPackageMock: vi.fn(),
5
+ }));
6
+
7
+ vi.mock("@/editor/ProjectCloud", () => ({
8
+ default: {
9
+ importPackage: importPackageMock,
10
+ },
11
+ }));
12
+
13
+ import {
14
+ CURRICULUM_ARTIFACT_KIND,
15
+ CURRICULUM_ARTIFACT_PLATFORM,
16
+ importCurriculumArtifactFromSearch,
17
+ parseCurriculumArtifactRequest,
18
+ sha256Hex,
19
+ validateCurriculumArtifact,
20
+ } from "@/editor/CurriculumArtifact";
21
+
22
+ function validArtifact() {
23
+ return {
24
+ kind: CURRICULUM_ARTIFACT_KIND,
25
+ formatVersion: 1,
26
+ platform: CURRICULUM_ARTIFACT_PLATFORM,
27
+ projectPackage: {
28
+ formatVersion: 1,
29
+ project: {
30
+ id: "source-project-id-must-not-be-reused",
31
+ name: "Walking starter",
32
+ version: "iOSv01",
33
+ gallery: "samples",
34
+ isgift: "1",
35
+ thumbnail: {
36
+ pagecount: 1,
37
+ md5: "thumbnail.png",
38
+ },
39
+ json: {
40
+ pages: ["page 1"],
41
+ currentPage: "page 1",
42
+ "page 1": {
43
+ sprites: ["Marty 1"],
44
+ layers: ["Marty 1"],
45
+ md5: "Farm.svg",
46
+ "Marty 1": {
47
+ id: "Marty 1",
48
+ type: "sprite",
49
+ md5: "marty.svg",
50
+ animationFrames: ["marty.svg", "marty-2.svg"],
51
+ sounds: ["hello.wav"],
52
+ scripts: [],
53
+ },
54
+ },
55
+ },
56
+ },
57
+ assets: {
58
+ "thumbnail.png": "dGh1bWI=",
59
+ "marty.svg": "PHN2Zy8+",
60
+ "marty-2.svg": "PHN2Zy8+",
61
+ "hello.wav": "UklGRg==",
62
+ },
63
+ },
64
+ };
65
+ }
66
+
67
+ function responseFor(value, url = "https://cdn.sanity.io/files/project/dataset/walking.json") {
68
+ var bytes = new TextEncoder().encode(JSON.stringify(value));
69
+ return {
70
+ ok: true,
71
+ url: url,
72
+ headers: {
73
+ get(name) {
74
+ if (name.toLowerCase() === "content-type") {
75
+ return "application/vnd.robotical.curriculum-code-artifact+json; charset=utf-8";
76
+ }
77
+ if (name.toLowerCase() === "content-length") {
78
+ return String(bytes.byteLength);
79
+ }
80
+ return null;
81
+ },
82
+ },
83
+ arrayBuffer: async function () {
84
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
85
+ },
86
+ };
87
+ }
88
+
89
+ describe("curriculum artifact import", () => {
90
+ beforeEach(() => {
91
+ importPackageMock.mockReset();
92
+ importPackageMock.mockResolvedValue({ projectId: "42" });
93
+ });
94
+
95
+ it("normalizes a valid artifact into a fresh editable project package", () => {
96
+ var result = validateCurriculumArtifact(validArtifact());
97
+
98
+ expect(result.formatVersion).toBe(1);
99
+ expect(result.project).toMatchObject({
100
+ name: "Walking starter",
101
+ version: "iOSv01",
102
+ deleted: "NO",
103
+ isgift: "0",
104
+ });
105
+ expect(result.project.id).toBeUndefined();
106
+ expect(result.project.gallery).toBeUndefined();
107
+ expect(Object.keys(result.assets).sort()).toEqual([
108
+ "hello.wav",
109
+ "marty-2.svg",
110
+ "marty.svg",
111
+ "thumbnail.png",
112
+ ]);
113
+ });
114
+
115
+ it("rejects unsupported envelopes and packages", () => {
116
+ var artifact = validArtifact();
117
+ artifact.platform = "martyblocks";
118
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/platform/);
119
+
120
+ artifact = validArtifact();
121
+ artifact.formatVersion = 2;
122
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/formatVersion/);
123
+
124
+ artifact = validArtifact();
125
+ artifact.projectPackage.formatVersion = 2;
126
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/projectPackage formatVersion/);
127
+ });
128
+
129
+ it("rejects remote, traversal and unreferenced project assets", () => {
130
+ var artifact = validArtifact();
131
+ artifact.projectPackage.project.json["page 1"].md5 = "https://evil.example/background.svg";
132
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/safe relative asset identifier/);
133
+
134
+ artifact = validArtifact();
135
+ artifact.projectPackage.project.json["page 1"]["Marty 1"].md5 = "../marty.svg";
136
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/safe relative asset identifier/);
137
+
138
+ artifact = validArtifact();
139
+ artifact.projectPackage.assets["unused.svg"] = "PHN2Zy8+";
140
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/unreferenced asset/);
141
+
142
+ artifact = validArtifact();
143
+ artifact.projectPackage.project.json["page 1"]["Marty 1"].md5 = "sprites/%2e%2e/marty.svg";
144
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/safe relative asset identifier/);
145
+ });
146
+
147
+ it("rejects project structures that the editor cannot safely recreate", () => {
148
+ var artifact = validArtifact();
149
+ delete artifact.projectPackage.project.json["page 1"].layers;
150
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/layers/);
151
+
152
+ artifact = validArtifact();
153
+ artifact.projectPackage.project.json["page 1"].layers = [];
154
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/every sprite/);
155
+
156
+ artifact = validArtifact();
157
+ delete artifact.projectPackage.project.json["page 1"]["Marty 1"].scripts;
158
+ expect(() => validateCurriculumArtifact(artifact)).toThrow(/scripts/);
159
+ });
160
+
161
+ it("accepts only configured HTTPS URLs and loopback development URLs", () => {
162
+ expect(parseCurriculumArtifactRequest(
163
+ "?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Fartifact.json" +
164
+ "&curriculumArtifactSha256=" + "a".repeat(64),
165
+ {
166
+ baseUrl: "https://app.example/blocks-jr",
167
+ allowedOrigins: ["https://cdn.sanity.io"],
168
+ }
169
+ ).url).toBe("https://cdn.sanity.io/artifact.json");
170
+
171
+ expect(parseCurriculumArtifactRequest(
172
+ "?curriculumArtifactUrl=http%3A%2F%2Flocalhost%3A3011%2Fartifact.json",
173
+ { baseUrl: "http://localhost:3011/blocks-jr" }
174
+ ).url).toBe("http://localhost:3011/artifact.json");
175
+
176
+ expect(() => parseCurriculumArtifactRequest(
177
+ "?curriculumArtifactUrl=https%3A%2F%2Fevil.example%2Fartifact.json" +
178
+ "&curriculumArtifactSha256=" + "a".repeat(64),
179
+ { baseUrl: "https://app.example/blocks-jr" }
180
+ )).toThrow(/origin is not allowed/);
181
+
182
+ expect(() => parseCurriculumArtifactRequest(
183
+ "?curriculumArtifactUrl=https%3A%2F%2Fapp.example%2Fartifact.json" +
184
+ "&curriculumArtifactSha256=" + "a".repeat(64),
185
+ { baseUrl: "https://app.example/blocks-jr" }
186
+ )).toThrow(/origin is not allowed/);
187
+
188
+ expect(() => parseCurriculumArtifactRequest(
189
+ "?curriculumArtifactUrl=%2Fartifact.json&curriculumArtifactSha256=" + "a".repeat(64),
190
+ { baseUrl: "https://app.example/blocks-jr" }
191
+ )).toThrow(/absolute URL/);
192
+ });
193
+
194
+ it("allows explicitly configured artifact origins", () => {
195
+ var request = parseCurriculumArtifactRequest(
196
+ "?curriculumArtifactUrl=https%3A%2F%2Fcontent.robotical.io%2Fartifact.json" +
197
+ "&curriculumArtifactSha256=" + "a".repeat(64),
198
+ {
199
+ baseUrl: "https://app.example/blocks-jr",
200
+ allowedOrigins: ["https://content.robotical.io"],
201
+ }
202
+ );
203
+ expect(request.url).toBe("https://content.robotical.io/artifact.json");
204
+ });
205
+
206
+ it("requires payload integrity outside loopback development", () => {
207
+ expect(() => parseCurriculumArtifactRequest(
208
+ "?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Fartifact.json",
209
+ {
210
+ baseUrl: "https://app.example/blocks-jr",
211
+ allowedOrigins: ["https://cdn.sanity.io"],
212
+ }
213
+ )).toThrow(/required outside local development/);
214
+ });
215
+
216
+ it("fetches, verifies and imports a package as a new local project", async () => {
217
+ var response = responseFor(validArtifact());
218
+ var body = await response.arrayBuffer();
219
+ var digest = sha256Hex(body);
220
+ var fetchMock = vi.fn().mockResolvedValue(response);
221
+ var result = await importCurriculumArtifactFromSearch(
222
+ "?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Ffiles%2Fproject%2Fdataset%2Fwalking.json" +
223
+ "&curriculumArtifactSha256=" + digest,
224
+ {
225
+ baseUrl: "https://app.example/blocks-jr",
226
+ allowedOrigins: ["https://cdn.sanity.io"],
227
+ fetchImplementation: fetchMock,
228
+ projectVersion: "iOSv01",
229
+ }
230
+ );
231
+
232
+ expect(result).toEqual({ projectId: "42" });
233
+ expect(fetchMock).toHaveBeenCalledWith(
234
+ "https://cdn.sanity.io/files/project/dataset/walking.json",
235
+ expect.objectContaining({
236
+ credentials: "omit",
237
+ cache: "no-store",
238
+ referrerPolicy: "no-referrer",
239
+ })
240
+ );
241
+ expect(importPackageMock).toHaveBeenCalledTimes(1);
242
+ expect(importPackageMock.mock.calls[0][0].project.id).toBeUndefined();
243
+ });
244
+
245
+ it("does not write anything when the checksum is wrong", async () => {
246
+ var fetchMock = vi.fn().mockResolvedValue(responseFor(validArtifact()));
247
+ await expect(importCurriculumArtifactFromSearch(
248
+ "?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Ffiles%2Fproject%2Fdataset%2Fwalking.json" +
249
+ "&curriculumArtifactSha256=" + "0".repeat(64),
250
+ {
251
+ baseUrl: "https://app.example/blocks-jr",
252
+ allowedOrigins: ["https://cdn.sanity.io"],
253
+ fetchImplementation: fetchMock,
254
+ }
255
+ )).rejects.toThrow(/checksum/);
256
+ expect(importPackageMock).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("does not write anything when a redirect crosses an allowed-origin boundary", async () => {
260
+ var fetchMock = vi.fn().mockResolvedValue(responseFor(validArtifact(), "https://evil.example/artifact.json"));
261
+ await expect(importCurriculumArtifactFromSearch(
262
+ "?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Ffiles%2Fproject%2Fdataset%2Fwalking.json" +
263
+ "&curriculumArtifactSha256=" + "b".repeat(64),
264
+ {
265
+ baseUrl: "https://app.example/blocks-jr",
266
+ allowedOrigins: ["https://cdn.sanity.io"],
267
+ fetchImplementation: fetchMock,
268
+ }
269
+ )).rejects.toThrow(/redirect origin/);
270
+ expect(importPackageMock).not.toHaveBeenCalled();
271
+ });
272
+ });