@galadrim/galadmin 0.2.8 → 0.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.
@@ -1,10 +1,13 @@
1
1
  import { useState } from "react";
2
2
  import Editor from "ckeditor5-custom-build"
3
3
  import { CKEditor } from '@ckeditor/ckeditor5-react'
4
+ import { useParams } from "react-router-dom";
4
5
 
5
6
  const baseURL = process.env.NODE_ENV === "development" ? 'http://localhost/admin/api' : '/_HOMEPAGE_/api';
6
7
 
7
8
  export default ({ column, onChange, initialData }) => {
9
+ const { viewIndex } = useParams();
10
+ const uploadTarget = new URLSearchParams({ viewIndex, columnId: column.__id__ });
8
11
  const [characters, setCharacters] = useState(0);
9
12
  const [words, setWords] = useState(0);
10
13
 
@@ -14,11 +17,8 @@ export default ({ column, onChange, initialData }) => {
14
17
  editor={Editor}
15
18
  config={{
16
19
  simpleUpload: {
17
- uploadUrl: `${baseURL}/uploadFileFromBetterRichTextInput`,
20
+ uploadUrl: `${baseURL}/uploadFileFromBetterRichTextInput?${uploadTarget}`,
18
21
  withCredentials: true,
19
- headers: {
20
- "file-upload-properties": JSON.stringify(column.fileUploadProperties)
21
- }
22
22
  },
23
23
  wordCount: {
24
24
  onUpdate: stats => {
@@ -0,0 +1,32 @@
1
+ import { render } from "@testing-library/react";
2
+ import { MemoryRouter, Route } from "react-router-dom";
3
+ import BetterRichTextInput from "./BetterRichTextInput";
4
+ import { CKEditor } from "@ckeditor/ckeditor5-react";
5
+
6
+ jest.mock("ckeditor5-custom-build", () => ({}));
7
+ jest.mock("@ckeditor/ckeditor5-react", () => ({ CKEditor: jest.fn(() => null) }));
8
+
9
+ it("identifies the selected server column without sending storage settings", () => {
10
+ CKEditor.mockImplementation(() => null);
11
+ const column = {
12
+ __id__: "panel-column&1", htmlField: "body",
13
+ fileUploadProperties: { s3Config: { secretAccessKey: "server-secret" } },
14
+ };
15
+ render(
16
+ <MemoryRouter initialEntries={["/records/2/1"]}>
17
+ <Route path="/records/:viewIndex/:recordId">
18
+ <BetterRichTextInput column={column} initialData={{ body: "Hello" }} onChange={() => {}} />
19
+ </Route>
20
+ </MemoryRouter>,
21
+ );
22
+ const { config, data } = CKEditor.mock.calls[0][0];
23
+ const upload = config.simpleUpload;
24
+ const url = new URL(upload.uploadUrl, "http://localhost");
25
+ expect(url.pathname).toMatch(/\/uploadFileFromBetterRichTextInput$/);
26
+ expect(url.searchParams.get("viewIndex")).toBe("2");
27
+ expect(url.searchParams.get("columnId")).toBe(column.__id__);
28
+ expect(upload.withCredentials).toBe(true);
29
+ expect(upload.headers).toBeUndefined();
30
+ expect(upload.uploadUrl).not.toContain("server-secret");
31
+ expect(data).toBe("Hello");
32
+ });
@@ -207,9 +207,7 @@ describeIntegration("authorization against real databases", () => {
207
207
  });
208
208
 
209
209
  it("/uploadFileFromBetterRichTextInput: read-only role gets 403 before any upload happens", async () => {
210
- // This endpoint carries no viewIndex: the upload target comes entirely
211
- // from the client-supplied file-upload-properties header, so it is
212
- // gated on the caller being able to write at least one view.
210
+ // The selected view must be writable before multipart data is processed.
213
211
  const config = baseConfig({
214
212
  views: [fruitView({ roles: ["editor"], readRoles: ["viewer"] })],
215
213
  });
@@ -220,6 +218,7 @@ describeIntegration("authorization against real databases", () => {
220
218
  try {
221
219
  await request(app(config, VIEWER))
222
220
  .post("/api/uploadFileFromBetterRichTextInput")
221
+ .query({ viewIndex: 0, columnId: "rich-text" })
223
222
  .set(
224
223
  "file-upload-properties",
225
224
  JSON.stringify({ publicPath: uploadRoot, subdirectory: "/sub" }),
@@ -391,34 +391,123 @@ describeIntegration("upload routes against real databases", () => {
391
391
  });
392
392
 
393
393
  describe("POST /api/uploadFileFromBetterRichTextInput", () => {
394
- it("takes the upload config from the file-upload-properties header", async () => {
395
- const s3Mock = jest.requireMock("@aws-sdk/client-s3");
396
- const app = buildAppForManager(
397
- harness.manager,
398
- baseConfig({ views: [fruitView({ primaryId: "id" })] }),
399
- );
394
+ const richColumn = (overrides: any = {}) => ({
395
+ label: "Body", dataType: "betterRichText", htmlField: "note",
396
+ fileUploadProperties: {
397
+ s3Config: {
398
+ host: "s3.example.com", accessKeyId: "k", secretAccessKey: "s",
399
+ bucket: "configured-bucket", subfolder: "editor",
400
+ pathTransform: (p: string) => `https://cdn.example.com/${p}`,
401
+ },
402
+ },
403
+ ...overrides,
404
+ });
405
+ const upload = (app: any, query: any) => request(app)
406
+ .post("/api/uploadFileFromBetterRichTextInput")
407
+ .query(query)
408
+ .attach("upload", PNG_BUFFER, { filename: "photo.png", contentType: "image/png" });
409
+ const expectNoStorage = () => {
410
+ expect(jest.requireMock("@aws-sdk/client-s3").__send).not.toHaveBeenCalled();
411
+ expect(jest.requireMock("@google-cloud/storage").__upload).not.toHaveBeenCalled();
412
+ };
413
+
414
+ it("uses the configured S3 transform and ignores client storage settings", async () => {
415
+ const column = richColumn();
416
+ const { app, colId } = appWithColumn(column);
400
417
  const res = await request(app)
401
418
  .post("/api/uploadFileFromBetterRichTextInput")
402
- .set(
403
- "file-upload-properties",
404
- JSON.stringify({
405
- s3Config: {
406
- host: "s3.example.com",
407
- accessKeyId: "k",
408
- secretAccessKey: "s",
409
- bucket: "b",
410
- },
411
- }),
412
- )
413
- .attach("upload", PNG_BUFFER, {
414
- filename: "photo.png",
415
- contentType: "image/png",
416
- })
419
+ .query({ viewIndex: 0, columnId: colId })
420
+ .set("file-upload-properties", JSON.stringify({ s3Config: { bucket: "attacker-bucket" } }))
421
+ .attach("upload", PNG_BUFFER, { filename: "photo.png", contentType: "image/png" })
417
422
  .expect(200);
418
- expect(s3Mock.PutObjectCommand).toHaveBeenCalledWith(
419
- expect.objectContaining({ Bucket: "b" }),
420
- );
421
- expect(res.body.path).toMatch(/^photo-\d+\.png$/);
423
+ const s3 = jest.requireMock("@aws-sdk/client-s3");
424
+ const key = s3.PutObjectCommand.mock.calls[0][0].Key;
425
+ expect(key).toMatch(/^editor\/photo-\d+\.png$/);
426
+ expect(s3.PutObjectCommand).toHaveBeenCalledWith(expect.objectContaining({ Bucket: "configured-bucket" }));
427
+ expect(res.body).toEqual({
428
+ path: `https://cdn.example.com/${key}`,
429
+ url: `https://s3.example.com/configured-bucket/${key}`,
430
+ aspectRatio: 1, originalFilename: "photo.png",
431
+ });
432
+ });
433
+
434
+ it("keeps GCP pathTransform on the server, even with a malformed legacy header", async () => {
435
+ const column = richColumn({ fileUploadProperties: { gcpConfig: {
436
+ host: "storage.googleapis.com", bucket: "configured-gcp", subfolder: "editor",
437
+ pathTransform: (p: string) => `https://cdn.example.com/${p}`,
438
+ } } });
439
+ const { app, colId } = appWithColumn(column);
440
+ const res = await request(app)
441
+ .post("/api/uploadFileFromBetterRichTextInput")
442
+ .query({ viewIndex: 0, columnId: colId })
443
+ .set("file-upload-properties", "not JSON")
444
+ .attach("upload", PNG_BUFFER, { filename: "photo.png", contentType: "image/png" })
445
+ .expect(200);
446
+ const gcp = jest.requireMock("@google-cloud/storage");
447
+ expect(gcp.__bucket).toHaveBeenCalledWith("configured-gcp");
448
+ const destination = gcp.__upload.mock.calls[0][1].destination;
449
+ expect(destination).toMatch(/^editor\/photo-\d+\.png$/);
450
+ expect(res.body.path).toBe(`https://cdn.example.com/${destination}`);
451
+ });
452
+
453
+ it("resolves a panel creation column", async () => {
454
+ const column = richColumn();
455
+ const app = buildAppForManager(harness.manager, baseConfig({ views: [fruitView({
456
+ recordViewPanels: [{ type: "table", label: "Notes", query: "SELECT * FROM fruit_notes",
457
+ createConfig: { tableName: "fruit_notes", recordIdKey: "fruitId", columns: [column] } }],
458
+ })] }));
459
+ const res = await upload(app, { viewIndex: 0, columnId: generateUniqueIdForObject(column) }).expect(200);
460
+ expect(res.body.path).toMatch(/^https:\/\/cdn.example.com\/editor\/photo-\d+\.png$/);
461
+ });
462
+
463
+ it.each([
464
+ {}, { viewIndex: 0 }, { viewIndex: 99, columnId: "missing" },
465
+ { viewIndex: "__proto__", columnId: "missing" },
466
+ { viewIndex: 0, columnId: "missing" }, { viewIndex: [0, 1], columnId: "missing" },
467
+ ])("rejects an invalid target before storage: %j", async (query) => {
468
+ const { app } = appWithColumn(richColumn());
469
+ await upload(app, query).expect(403);
470
+ expectNoStorage();
471
+ });
472
+
473
+ it.each([
474
+ { label: "Text", name: "note" },
475
+ { label: "Body", dataType: "betterRichText", htmlField: "note" },
476
+ ])("rejects a column without rich-text upload settings: %j", async (column) => {
477
+ const { app, colId } = appWithColumn(column);
478
+ await upload(app, { viewIndex: 0, columnId: colId }).expect(400);
479
+ expectNoStorage();
480
+ });
481
+
482
+ it("does not authorize a read-only target merely because another view is writable", async () => {
483
+ const column = richColumn();
484
+ const app = buildAppForManager(harness.manager, baseConfig({ views: [
485
+ fruitView({ roles: ["editor"], readRoles: ["viewer"], columns: [column] }),
486
+ fruitView({ roles: ["viewer"] }),
487
+ ] }), { role: "viewer" });
488
+ await upload(app, { viewIndex: 0, columnId: generateUniqueIdForObject(column) }).expect(403);
489
+ expectNoStorage();
490
+ });
491
+
492
+ it.each([false, true])("rejects a role-restricted column (panel: %s)", async (panel) => {
493
+ const column = richColumn({ roles: ["editor"] });
494
+ const view = fruitView(panel ? {
495
+ recordViewPanels: [{ type: "table", label: "Notes", query: "SELECT * FROM fruit_notes",
496
+ createConfig: { tableName: "fruit_notes", recordIdKey: "fruitId", columns: [column] } }],
497
+ } : { columns: [column] });
498
+ const app = buildAppForManager(harness.manager, baseConfig({ views: [view] }), { role: "viewer" });
499
+ await upload(app, { viewIndex: 0, columnId: generateUniqueIdForObject(column) }).expect(403);
500
+ expectNoStorage();
501
+ });
502
+
503
+ it("uses indexes from the role-filtered views", async () => {
504
+ const column = richColumn();
505
+ const app = buildAppForManager(harness.manager, baseConfig({ views: [
506
+ fruitView({ roles: ["other"] }),
507
+ fruitView({ roles: ["editor"], columns: [column] }),
508
+ ] }), { role: "editor" });
509
+ const res = await upload(app, { viewIndex: 0, columnId: generateUniqueIdForObject(column) }).expect(200);
510
+ expect(res.body.path).toMatch(/^https:\/\/cdn.example.com\//);
422
511
  });
423
512
  });
424
513
  });