diff --git a/portal/common/lib/page_fetching.ts b/portal/common/lib/page_fetching.ts index 023ad3c..30305d2 100644 --- a/portal/common/lib/page_fetching.ts +++ b/portal/common/lib/page_fetching.ts @@ -93,6 +93,7 @@ export async function resolveObjectId( if (!objectId) { // Check if there is a SuiNs name try { + // TODO: only check for SuiNs names if the subdomain is not a valid base36 string. objectId = await resolveSuiNsAddress(client, parsedUrl.subdomain); if (!objectId) { return noObjectIdFound(); diff --git a/portal/common/lib/redirects.ts b/portal/common/lib/redirects.ts index a0036a9..21179db 100644 --- a/portal/common/lib/redirects.ts +++ b/portal/common/lib/redirects.ts @@ -4,7 +4,7 @@ import { DomainDetails } from "./types/index"; import { getDomain } from "./domain_parsing"; import { aggregatorEndpoint } from "./aggregator"; -import { SuiClient } from "@mysten/sui/client"; +import { SuiObjectResponse } from "@mysten/sui/client"; /** * Redirects to the portal URL. @@ -29,8 +29,7 @@ export function redirectToAggregatorUrlResponse(scope: URL, blobId: string): Res /** * Checks if the object has a redirect in its Display representation. */ -export async function checkRedirect(client: SuiClient, objectId: string): Promise { - const object = await client.getObject({ id: objectId, options: { showDisplay: true } }); +export function checkRedirect(object: SuiObjectResponse): string | null { if (object.data && object.data.display) { let display = object.data.display; // Check if "walrus site address" is set in the display field. diff --git a/portal/common/lib/resource.test.ts b/portal/common/lib/resource.test.ts index 615edfe..1753f36 100644 --- a/portal/common/lib/resource.test.ts +++ b/portal/common/lib/resource.test.ts @@ -8,14 +8,17 @@ import { SuiClient } from "@mysten/sui/client"; import { HttpStatusCodes } from "./http/http_status_codes"; import { checkRedirect } from "./redirects"; import { fromBase64 } from "@mysten/bcs"; -import { DynamicFieldStruct } from "./bcs_data_parsing"; // Mock SuiClient methods -const getObject = vi.fn(); +const multiGetObjects = vi.fn(); const mockClient = { - getObject, + multiGetObjects, } as unknown as SuiClient; +vi.mock("@mysten/sui/utils", () => ({ + deriveDynamicFieldID: vi.fn(() => "0xdynamicFieldId"), +})) + // Mock checkRedirect vi.mock("./redirects", () => ({ checkRedirect: vi.fn(), @@ -61,99 +64,98 @@ describe("fetchResource", () => { test("should fetch resource without redirect", async () => { // Mock object response - getObject.mockResolvedValueOnce({ - data: { - bcs: { - dataType: "moveObject", - bcsBytes: "mockBcsBytes", + multiGetObjects.mockResolvedValueOnce([ + { + data: { + bcs: { + dataType: "moveObject", + bcsBytes: "mockBcsBytes", + }, }, }, - }); + { + data: { + bcs: { + dataType: "moveObject", + bcsBytes: "mockBcsBytes", + }, + }, + }, + ]); (fromBase64 as any).mockReturnValueOnce("decodedBcsBytes"); const result = await fetchResource(mockClient, "0x1", "/path", new Set()); expect(result).toEqual({ blob_id: "0xresourceBlobId", - objectId: "0x3cf9bff169db6f780a0a3cae7b3b770097c26342ad0c08604bc80728cfa37bdc", + objectId: "0xdynamicFieldId", version: undefined, }); - expect(mockClient.getObject).toHaveBeenCalledWith({ - id: "0x3cf9bff169db6f780a0a3cae7b3b770097c26342ad0c08604bc80728cfa37bdc", - options: { showBcs: true }, - }); + expect(checkRedirect).toHaveBeenCalledTimes(1); }); test("should follow redirect and recursively fetch resource", async () => { - // Mock the redirect check to return a redirect ID on the first call - (checkRedirect as any).mockResolvedValueOnce( - "0x51813e7d4040265af8bd6c757f52accbe11e6df5b9cf3d6696a96e3f54fad096", - ); - (checkRedirect as any).mockResolvedValueOnce(undefined); - - // Mock the first resource object response - getObject.mockResolvedValueOnce({ - data: { - bcs: { - dataType: "moveObject", - bcsBytes: "mockBcsBytes", + const mockObject = { + "objectId": "0x26dc2460093a9d6d31b58cb0ed1e72b19d140542a49be7472a6f25d542cb5cc3", + "version": "150835605", + "digest": "DDD7ZZvLkBQjq1kJpRsPDMpqhvYtGM878SdCTfF42ywE", + "display": { + "data": { + "walrus site address": "0x2" }, + "error": null }, - }); - - // Mock the final resource object response - getObject.mockResolvedValueOnce({ + "bcs": { + "dataType": "moveObject", + "type": "0x1::flatland::Flatlander", + "hasPublicTransfer": true, + "version": 150835605, + } + }; + + const mockResource = { data: { bcs: { dataType: "moveObject", bcsBytes: "mockBcsBytes", }, }, - }); + }; - const result = await fetchResource(mockClient, "0x1", "/path", new Set()); + (checkRedirect as any).mockResolvedValueOnce(undefined); + + multiGetObjects + .mockResolvedValueOnce([mockObject, mockResource]) + .mockResolvedValueOnce([mockObject, mockResource]); + + const result = await fetchResource( + mockClient, + "0x26dc2460093a9d6d31b58cb0ed1e72b19d140542a49be7472a6f25d542cb5cc3", + "/path", + new Set()); // Verify the results expect(result).toEqual({ blob_id: "0xresourceBlobId", - objectId: "0x3cf9bff169db6f780a0a3cae7b3b770097c26342ad0c08604bc80728cfa37bdc", + objectId: "0xdynamicFieldId", version: undefined, }); - expect(checkRedirect).toHaveBeenCalledTimes(1); + expect(checkRedirect).toHaveBeenCalledTimes(2); }); test("should return NOT_FOUND if the resource does not contain a blob_id", async () => { const seenResources = new Set(); - const mockResource = {}; // No blob_id - - (checkRedirect as any).mockResolvedValueOnce( - "0x51813e7d4040265af8bd6c757f52accbe11e6df5b9cf3d6696a96e3f54fad096", - ); - - // Mock getObject to return a valid BCS object - getObject.mockResolvedValueOnce({ - data: { - bcs: { - dataType: "moveObject", - bcsBytes: "mockBcsBytes", - }, - }, - }); - getObject.mockResolvedValueOnce({ - data: { - bcs: { - dataType: "moveObject", - bcsBytes: "mockBcsBytes", + (checkRedirect as any).mockResolvedValueOnce(undefined); + multiGetObjects.mockReturnValue([ + { + data: { + bcs: { + dataType: "moveObject", + }, }, }, - }); - - // Mock fromBase64 to simulate the decoding process - (fromBase64 as any).mockReturnValueOnce("decodedBcsBytes"); - - // Mock DynamicFieldStruct to return a resource without a blob_id - (DynamicFieldStruct as any).mockImplementation(() => ({ - parse: () => ({ value: mockResource }), - })); + {}, + ]); + (fromBase64 as any).mockReturnValueOnce(undefined); const result = await fetchResource(mockClient, "0x1", "/path", seenResources); @@ -168,7 +170,10 @@ describe("fetchResource", () => { (checkRedirect as any).mockResolvedValueOnce(null); // Mock to simulate that dynamic fields are not found - getObject.mockResolvedValueOnce(undefined); + multiGetObjects.mockReturnValue([ + {data: {bcs: {dataType: "moveObject"}}}, + {}, + ]); const result = await fetchResource(mockClient, "0x1", "/path", seenResources); diff --git a/portal/common/lib/resource.ts b/portal/common/lib/resource.ts index 3675019..eff5530 100644 --- a/portal/common/lib/resource.ts +++ b/portal/common/lib/resource.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { HttpStatusCodes } from "./http/http_status_codes"; -import { SuiClient, SuiObjectData } from "@mysten/sui/client"; +import { SuiClient, SuiObjectData, SuiObjectResponse } from "@mysten/sui/client"; import { Resource, VersionedResource } from "./types"; import { MAX_REDIRECT_DEPTH, RESOURCE_PATH_MOVE_TYPE } from "./constants"; import { checkRedirect } from "./redirects"; @@ -36,51 +36,104 @@ export async function fetchResource( seenResources: Set, depth: number = 0, ): Promise { - if (seenResources.has(objectId)) { - return HttpStatusCodes.LOOP_DETECTED; - } - if (depth >= MAX_REDIRECT_DEPTH) { - return HttpStatusCodes.TOO_MANY_REDIRECTS; - } - - const redirectPromise = checkRedirect(client, objectId); - seenResources.add(objectId); + const error = checkForErrors(objectId, seenResources, depth); + if (error) return error; + // The dynamic field object ID can be derived, without + // making a request to the network. const dynamicFieldId = deriveDynamicFieldID( objectId, RESOURCE_PATH_MOVE_TYPE, bcs.string().serialize(path).toBytes(), ); - console.log("Derived dynamic field objectID: ", dynamicFieldId); - // Fetch page data. - const pageData = await client.getObject({ - id: dynamicFieldId, - options: { showBcs: true }, - }); + const [ + primaryObjectResponse, + dynamicFieldResponse + ] = await fetchObjectPairData(client, objectId, dynamicFieldId); + + seenResources.add(objectId); + + const redirectId = checkRedirect(primaryObjectResponse); + if (redirectId) { + return fetchResource(client, redirectId, path, seenResources, depth + 1); + } + + return extractResource(dynamicFieldResponse, dynamicFieldId); +} - // If no page data found. - if (!pageData || !pageData.data) { - const redirectId = await redirectPromise; - if (redirectId) { - return fetchResource(client, redirectId, path, seenResources, depth + 1); - } +/** +* Fetches the data of a parentObject and its' dynamicFieldObject. +* @param client: A SuiClient to interact with the Sui network. +* @param objectId: The objectId of the parentObject (e.g. site::Site). +* @param dynamicFieldId: The Id of the dynamicFieldObject (e.g. site::Resource). +* @returns A tuple of SuiObjectResponse[] or an HttpStatusCode in case of an error. +*/ +async function fetchObjectPairData( + client: SuiClient, + objectId: string, + dynamicFieldId: string +): Promise { + // MultiGetObjects returns the objects *always* in the order they were requested. + const pageData = await client.multiGetObjects( + { + ids: [ + objectId, + dynamicFieldId + ], + options: { showBcs: true, showDisplay: true } + }, + ); + // MultiGetObjects returns the objects *always* in the order they were requested. + const primaryObjectResponse: SuiObjectResponse = pageData[0]; + const dynamicFieldResponse: SuiObjectResponse = pageData[1]; + + return [primaryObjectResponse, dynamicFieldResponse] +} + +/** +* Extracts the resource data from the dynamicFieldObject. +* @param dynamicFieldResponse: contains the data of the dynamicFieldObject +* @param dynamicFieldId: The Id of the dynamicFieldObject (e.g. site::Resource). +* @returns A VersionedResource or an HttpStatusCode in case of an error. +*/ +function extractResource( + dynamicFieldResponse: SuiObjectResponse, + dynamicFieldId: string): VersionedResource | HttpStatusCodes +{ + if (!dynamicFieldResponse.data) { console.log("No page data found"); return HttpStatusCodes.NOT_FOUND; } - const siteResource = getResourceFields(pageData.data); + const siteResource = getResourceFields(dynamicFieldResponse.data); if (!siteResource || !siteResource.blob_id) { return HttpStatusCodes.NOT_FOUND; } return { ...siteResource, - version: pageData.data?.version, + version: dynamicFieldResponse.data.version, objectId: dynamicFieldId, } as VersionedResource; } +/** +* Checks for loop detection and too many redirects. +* @param objectId +* @param seenResources +* @param depth +* @returns +*/ +function checkForErrors( + objectId: string, + seenResources: Set, depth: number +): HttpStatusCodes | null { + if (seenResources.has(objectId)) return HttpStatusCodes.LOOP_DETECTED; + if (depth >= MAX_REDIRECT_DEPTH) return HttpStatusCodes.TOO_MANY_REDIRECTS; + return null; +} + /** * Parses the resource information from the Sui object data response. */ diff --git a/portal/pnpm-lock.yaml b/portal/pnpm-lock.yaml index 6bd6a84..806b633 100644 --- a/portal/pnpm-lock.yaml +++ b/portal/pnpm-lock.yaml @@ -43,7 +43,7 @@ importers: version: 2.1.3(@types/node@22.7.5)(terser@5.35.0) webpack: specifier: ^5.95.0 - version: 5.95.0(webpack-cli@5.1.4) + version: 5.95.0 server: dependencies: @@ -83,7 +83,7 @@ importers: version: link:../common ts-loader: specifier: ^9.5.1 - version: 9.5.1(typescript@5.6.3)(webpack@5.95.0) + version: 9.5.1(typescript@5.6.3)(webpack@5.95.0(webpack-cli@5.1.4)) tsc: specifier: ^2.0.4 version: 2.0.4 @@ -93,13 +93,13 @@ importers: devDependencies: copy-webpack-plugin: specifier: ^12.0.2 - version: 12.0.2(webpack@5.95.0) + version: 12.0.2(webpack@5.95.0(webpack-cli@5.1.4)) css-minimizer-webpack-plugin: specifier: ^7.0.0 - version: 7.0.0(webpack@5.95.0) + version: 7.0.0(webpack@5.95.0(webpack-cli@5.1.4)) html-minimizer-webpack-plugin: specifier: ^5.0.0 - version: 5.0.0(webpack@5.95.0) + version: 5.0.0(webpack@5.95.0(webpack-cli@5.1.4)) vitest: specifier: ^2.1.3 version: 2.1.3(@types/node@22.7.5)(terser@5.35.0) @@ -3468,17 +3468,17 @@ snapshots: '@webassemblyjs/ast': 1.12.1 '@xtuc/long': 4.2.2 - '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.95.0)': + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0))(webpack@5.95.0(webpack-cli@5.1.4))': dependencies: webpack: 5.95.0(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0) - '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.95.0)': + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0))(webpack@5.95.0(webpack-cli@5.1.4))': dependencies: webpack: 5.95.0(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0) - '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.1.0)(webpack@5.95.0)': + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0))(webpack-dev-server@5.1.0(webpack-cli@5.1.4)(webpack@5.95.0))(webpack@5.95.0(webpack-cli@5.1.4))': dependencies: webpack: 5.95.0(webpack-cli@5.1.4) webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0) @@ -3771,7 +3771,7 @@ snapshots: cookie@0.7.1: {} - copy-webpack-plugin@12.0.2(webpack@5.95.0): + copy-webpack-plugin@12.0.2(webpack@5.95.0(webpack-cli@5.1.4)): dependencies: fast-glob: 3.3.2 glob-parent: 6.0.2 @@ -3793,7 +3793,7 @@ snapshots: dependencies: postcss: 8.4.47 - css-minimizer-webpack-plugin@7.0.0(webpack@5.95.0): + css-minimizer-webpack-plugin@7.0.0(webpack@5.95.0(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 cssnano: 7.0.6(postcss@8.4.47) @@ -4281,7 +4281,7 @@ snapshots: dependencies: html-minifier-terser: 7.2.0 parse5: 7.2.0 - webpack: 5.95.0(webpack-cli@5.1.4) + webpack: 5.95.0 html-minifier-terser@6.1.0: dependencies: @@ -4303,7 +4303,7 @@ snapshots: relateurl: 0.2.7 terser: 5.35.0 - html-minimizer-webpack-plugin@5.0.0(webpack@5.95.0): + html-minimizer-webpack-plugin@5.0.0(webpack@5.95.0(webpack-cli@5.1.4)): dependencies: '@types/html-minifier-terser': 7.0.2 html-minifier-terser: 7.2.0 @@ -5329,7 +5329,7 @@ snapshots: tapable@2.2.1: {} - terser-webpack-plugin@5.3.10(webpack@5.95.0): + terser-webpack-plugin@5.3.10(webpack@5.95.0(webpack-cli@5.1.4)): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 @@ -5338,6 +5338,15 @@ snapshots: terser: 5.35.0 webpack: 5.95.0(webpack-cli@5.1.4) + terser-webpack-plugin@5.3.10(webpack@5.95.0): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.35.0 + webpack: 5.95.0 + terser@5.35.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -5385,7 +5394,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-loader@9.5.1(typescript@5.6.3)(webpack@5.95.0): + ts-loader@9.5.1(typescript@5.6.3)(webpack@5.95.0(webpack-cli@5.1.4)): dependencies: chalk: 4.1.2 enhanced-resolve: 5.17.1 @@ -5527,9 +5536,9 @@ snapshots: webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.95.0) - '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.95.0) - '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack-dev-server@5.1.0)(webpack@5.95.0) + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0))(webpack@5.95.0(webpack-cli@5.1.4)) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0))(webpack@5.95.0(webpack-cli@5.1.4)) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0))(webpack-dev-server@5.1.0(webpack-cli@5.1.4)(webpack@5.95.0))(webpack@5.95.0(webpack-cli@5.1.4)) colorette: 2.0.20 commander: 10.0.1 cross-spawn: 7.0.3 @@ -5543,7 +5552,7 @@ snapshots: optionalDependencies: webpack-dev-server: 5.1.0(webpack-cli@5.1.4)(webpack@5.95.0) - webpack-dev-middleware@7.4.2(webpack@5.95.0): + webpack-dev-middleware@7.4.2(webpack@5.95.0(webpack-cli@5.1.4)): dependencies: colorette: 2.0.20 memfs: 4.14.0 @@ -5582,7 +5591,7 @@ snapshots: serve-index: 1.9.1 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.2(webpack@5.95.0) + webpack-dev-middleware: 7.4.2(webpack@5.95.0(webpack-cli@5.1.4)) ws: 8.18.0 optionalDependencies: webpack: 5.95.0(webpack-cli@5.1.4) @@ -5607,7 +5616,7 @@ snapshots: webpack-sources@3.2.3: {} - webpack@5.95.0(webpack-cli@5.1.4): + webpack@5.95.0: dependencies: '@types/estree': 1.0.6 '@webassemblyjs/ast': 1.12.1 @@ -5632,6 +5641,36 @@ snapshots: terser-webpack-plugin: 5.3.10(webpack@5.95.0) watchpack: 2.4.2 webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + webpack@5.95.0(webpack-cli@5.1.4): + dependencies: + '@types/estree': 1.0.6 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + acorn: 8.13.0 + acorn-import-attributes: 1.9.5(acorn@8.13.0) + browserslist: 4.24.0 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.17.1 + es-module-lexer: 1.5.4 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(webpack@5.95.0(webpack-cli@5.1.4)) + watchpack: 2.4.2 + webpack-sources: 3.2.3 optionalDependencies: webpack-cli: 5.1.4(webpack-dev-server@5.1.0)(webpack@5.95.0) transitivePeerDependencies: