Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: discrete display results + svg load error handling #132

Closed
9 changes: 8 additions & 1 deletion example/application/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,14 @@ const Viewer: React.FunctionComponent<ViewerProps> = ({ document }): React.React
`}
>
{isSvg ? (
<__unsafe__not__for__production__v2__SvgRenderer document={document.document} ref={svgRef} />
<__unsafe__not__for__production__v2__SvgRenderer
document={document.document}
ref={svgRef}
onResult={(r) => {
console.log(r);
}}
loadingComponent={<div>Loading...</div>}
/>
) : (
<FrameConnector
source={document.frameSource}
Expand Down
32 changes: 26 additions & 6 deletions src/components/renderer/SvgRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* eslint-disable jest/prefer-spy-on */
// Disable the spyOn check due to known issues with mocking fetch in jsDom env
// https://stackoverflow.com/questions/74945569/cannot-access-built-in-node-js-fetch-function-from-jest-tests
import { render } from "@testing-library/react";
import { DisplayResult, SvgRenderer } from "./SvgRenderer";
import { fireEvent, render } from "@testing-library/react";
import { SvgRenderer } from "./SvgRenderer";
import fs from "fs";
import { Blob } from "buffer";
import React from "react";
Expand All @@ -13,6 +13,7 @@ import {
v2WithSvgUrlAndDigestMultibase,
v4WithOnlyTamperedEmbeddedSvg,
v4WithNoRenderMethod,
v4MalformedEmbeddedSvg,
} from "./fixtures/svgRendererSamples";
import { __unsafe__not__for__production__v2__SvgRenderer } from "./SvgV2Adapter";

Expand Down Expand Up @@ -114,7 +115,7 @@ describe("svgRenderer component", () => {
const defaultTemplate = await findByTestId("default-template");
expect(defaultTemplate.textContent).toContain("The remote content for this document has been modified");
expect(defaultTemplate.textContent).toContain(`URL: “http://mockbucket.com/static/svg_test.svg”`);
expect(mockHandleResult).toHaveBeenCalledWith(DisplayResult.DIGEST_ERROR, undefined);
expect(mockHandleResult).toHaveBeenCalledWith({ status: "DIGEST_ERROR" });
});

it("should render default template when document.RenderMethod is undefined", async () => {
Expand All @@ -141,10 +142,29 @@ describe("svgRenderer component", () => {
const defaultTemplate = await findByTestId("default-template");
expect(defaultTemplate.textContent).toContain("This document might be having loading issues");
expect(defaultTemplate.textContent).toContain(`URL: “http://mockbucket.com/static/svg_test.svg”`);
expect(mockHandleResult).toHaveBeenCalledWith(
DisplayResult.CONNECTION_ERROR,
new Error("Failed to fetch remote SVG")
expect(mockHandleResult).toHaveBeenCalledWith({
error: new Error("Failed to fetch remote SVG"),
status: "FETCH_SVG_ERROR",
});
});

it("should render svg malformed template when img load event is fired", async () => {
const svgRef = React.createRef<HTMLImageElement>();
const mockHandleResult = jest.fn();

const { findByTestId, getByAltText, queryByTestId } = render(
<SvgRenderer document={v4MalformedEmbeddedSvg} ref={svgRef} onResult={mockHandleResult} />
);

fireEvent.error(getByAltText("Svg image of the verified document"));

const defaultTemplate = await findByTestId("default-template");
expect(defaultTemplate.textContent).toContain("The resolved SVG is malformedThe resolved SVG is malformed");
expect(queryByTestId("Svg image of the verified document")).not.toBeInTheDocument();
expect(mockHandleResult).toHaveBeenCalledWith({
status: "MALFORMED_SVG_ERROR",
svgDataUri: "data:image/svg+xml,",
});
});
});
/* eslint-enable jest/prefer-spy-on */
146 changes: 102 additions & 44 deletions src/components/renderer/SvgRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { CSSProperties, useEffect, useState } from "react";
import { Sha256 } from "@aws-crypto/sha256-browser";
import bs58 from "bs58";
import { ConnectionFailureTemplate, NoTemplate, TamperedSvgTemplate } from "../../DefaultTemplate";
import { ConnectionFailureTemplate, DefaultTemplate, NoTemplate, TamperedSvgTemplate } from "../../DefaultTemplate";
import { v2 } from "@govtechsg/open-attestation";
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const handlebars = require("handlebars");
Expand Down Expand Up @@ -32,6 +32,34 @@ export interface v4OpenAttestationDocument {
renderMethod?: RenderMethod[];
}

type InvalidSvgTemplateDisplayResult =
| {
status: "DEFAULT";
}
| {
status: "DIGEST_ERROR";
}
| {
status: "FETCH_SVG_ERROR";
error: Error;
};

type ValidSvgTemplateDisplayResult =
| {
status: "OK";
svgDataUri: string;
}
| {
status: "MALFORMED_SVG_ERROR";
svgDataUri: string;
};

type LoadingDisplayResult = {
status: "LOADING";
};

export type DisplayResult = InvalidSvgTemplateDisplayResult | ValidSvgTemplateDisplayResult;

export interface SvgRendererProps {
/** The OpenAttestation v4 document to display */
document: v4OpenAttestationDocument; // TODO: Update to OpenAttestationDocument
Expand All @@ -41,15 +69,9 @@ export interface SvgRendererProps {
className?: string;
// TODO: How to handle if svg fails at img? Currently it will return twice
/** An optional callback method that returns the display result */
onResult?: (result: DisplayResult, err?: Error) => void;
}

/** Indicates the result of SVG rendering */
export enum DisplayResult {
OK = "OK",
DEFAULT = "DEFAULT",
CONNECTION_ERROR = "CONNECTION_ERROR",
DIGEST_ERROR = "DIGEST_ERROR",
onResult?: (result: DisplayResult) => void;
/** An optional component to display while loading */
loadingComponent?: React.ReactNode;
}

const fetchSvg = async (svgInDoc: string, abortController: AbortController) => {
Expand All @@ -62,105 +84,141 @@ const fetchSvg = async (svgInDoc: string, abortController: AbortController) => {
return res;
};

const renderTemplate = (template: string, document: any) => {
if (template.length === 0) return "";
const compiledTemplate = handlebars.compile(template);
return document.credentialSubject ? compiledTemplate(document.credentialSubject) : compiledTemplate(document);
};

// As specified in - https://w3c-ccg.github.io/vc-render-method/#svgrenderingtemplate2023
export const SVG_RENDERER_TYPE = "SvgRenderingTemplate2023";

/**
* Component that accepts a v4 document to fetch and display the first available template SVG
*/
const SvgRenderer = React.forwardRef<HTMLImageElement, SvgRendererProps>(
({ document, style, className, onResult }, ref) => {
const [svgFetchedData, setFetchedSvgData] = useState<string>("");
const [toDisplay, setToDisplay] = useState<DisplayResult>(DisplayResult.OK);
({ document, style, className, onResult, loadingComponent }, ref) => {
const [toDisplay, setToDisplay] = useState<
InvalidSvgTemplateDisplayResult | ValidSvgTemplateDisplayResult | LoadingDisplayResult
>({ status: "LOADING" });

const renderMethod = document.renderMethod?.find((method) => method.type === SVG_RENDERER_TYPE);
const svgInDoc = renderMethod?.id ?? "";
const urlPattern = /^https?:\/\/.*\.svg$/;
const isSvgUrl = urlPattern.test(svgInDoc);

useEffect(() => {
setToDisplay({
status: "LOADING",
});

/** for what ever reason, the SVG template is missing or invalid */
const handleInvalidSvgTemplate = (result: InvalidSvgTemplateDisplayResult) => {
setToDisplay(result);
onResult?.(result);
};

/** we have everything we need to generate the svg data uri, but we do not know if
* it is malformed or not until it is loaded by the image element, hence we do not
* call onResult here, instead we call it in the img onLoad and onError handlers
*/
const handleValidSvgTemplate = (rawSvgTemplate: string) => {
setToDisplay({
status: "OK",
svgDataUri: `data:image/svg+xml,${encodeURIComponent(renderTemplate(rawSvgTemplate, document))}`,
});
};

if (!("renderMethod" in document)) {
handleResult(DisplayResult.DEFAULT);
handleInvalidSvgTemplate({
status: "DEFAULT",
});
return;
}
const abortController = new AbortController();

if (!isSvgUrl) {
// Case 1: SVG is embedded in the doc, can directly display
handleResult(DisplayResult.OK, svgInDoc);
handleValidSvgTemplate(svgInDoc);
} else {
// Case 2: SVG is a url, fetch and check digestMultibase if provided
fetchSvg(svgInDoc, abortController)
.then((buffer) => {
const digestMultibaseInDoc = renderMethod?.digestMultibase;
const svgUint8Array = new Uint8Array(buffer ?? []);
const decoder = new TextDecoder();
const svgText = decoder.decode(svgUint8Array);
const rawSvgTemplate = decoder.decode(svgUint8Array);

if (!digestMultibaseInDoc) {
handleResult(DisplayResult.OK, svgText);
handleValidSvgTemplate(rawSvgTemplate);
} else {
const hash = new Sha256();
hash.update(svgUint8Array);
hash.digest().then((shaDigest) => {
const recomputedDigestMultibase = "z" + bs58.encode(shaDigest); // manually prefix with 'z' as per https://w3c-ccg.github.io/multibase/#mh-registry
if (recomputedDigestMultibase === digestMultibaseInDoc) {
handleResult(DisplayResult.OK, svgText);
handleValidSvgTemplate(rawSvgTemplate);
} else {
handleResult(DisplayResult.DIGEST_ERROR);
handleInvalidSvgTemplate({
status: "DIGEST_ERROR",
});
}
});
}
})
.catch((error) => {
if ((error as Error).name !== "AbortError") {
handleResult(DisplayResult.CONNECTION_ERROR, undefined, error);
handleInvalidSvgTemplate({
status: "FETCH_SVG_ERROR",
error,
});
}
});
}
return () => {
abortController.abort();
};
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [document]);

const handleResult = (result: DisplayResult, svgToSet = "", error?: Error) => {
setFetchedSvgData(svgToSet);
setToDisplay(result);
if (typeof onResult === "function") {
onResult(result, error);
}
};
}, [document, onResult, isSvgUrl, renderMethod, svgInDoc]);

const renderTemplate = (template: string, document: any) => {
if (template.length === 0) return "";
const compiledTemplate = handlebars.compile(template);
return document.credentialSubject ? compiledTemplate(document.credentialSubject) : compiledTemplate(document);
const handleImgResolved = (result: ValidSvgTemplateDisplayResult) => () => {
if (result.status === "MALFORMED_SVG_ERROR") {
setToDisplay(result);
}
onResult?.(result);
};

const compiledSvgData = `data:image/svg+xml,${encodeURIComponent(renderTemplate(svgFetchedData, document))}`;

switch (toDisplay) {
case DisplayResult.DEFAULT:
return <NoTemplate document={document} handleObfuscation={() => null} />;
case DisplayResult.CONNECTION_ERROR:
switch (toDisplay.status) {
case "LOADING":
return loadingComponent ? <>{loadingComponent}</> : null;
case "MALFORMED_SVG_ERROR":
return (
<DefaultTemplate
title="The resolved SVG is malformed"
description={<>The resolved SVG is malformed. Please contact the issuer.</>}
document={document}
/>
);
case "FETCH_SVG_ERROR":
return <ConnectionFailureTemplate document={document} source={svgInDoc} />;
case DisplayResult.DIGEST_ERROR:
case "DIGEST_ERROR":
return <TamperedSvgTemplate document={document} />;
case DisplayResult.OK:
case "OK": {
return (
<img
className={className}
style={style}
title="Svg Renderer Image"
width="100%"
src={compiledSvgData}
src={toDisplay.svgDataUri}
ref={ref}
alt="Svg image of the verified document"
onLoad={handleImgResolved({ status: "OK", svgDataUri: toDisplay.svgDataUri })}
onError={handleImgResolved({ status: "MALFORMED_SVG_ERROR", svgDataUri: toDisplay.svgDataUri })}
/>
);
}
default:
return <></>;
return <NoTemplate document={document} handleObfuscation={() => null} />;
}
}
);
Expand Down
21 changes: 7 additions & 14 deletions src/components/renderer/SvgV2Adapter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { CSSProperties } from "react";

Check warning on line 1 in src/components/renderer/SvgV2Adapter.tsx

View workflow job for this annotation

GitHub Actions / Test

'CSSProperties' is defined but never used
import { DisplayResult, SvgRenderer, v4OpenAttestationDocument } from "./SvgRenderer";
import { DisplayResult, SvgRenderer, SvgRendererProps, v4OpenAttestationDocument } from "./SvgRenderer";

Check warning on line 2 in src/components/renderer/SvgV2Adapter.tsx

View workflow job for this annotation

GitHub Actions / Test

'DisplayResult' is defined but never used
import { v2 } from "@govtechsg/open-attestation";

const mapV2toV4 = (document: v2.OpenAttestationDocument): v4OpenAttestationDocument => {
Expand All @@ -22,24 +22,17 @@
};
};

export interface __unsafe__not__for__production__v2__SvgRendererProps {
/** The OpenAttestation v4 document to display */
document: v2.OpenAttestationDocument; // TODO: Update to OpenAttestationDocument
/** Override the img style */
style?: CSSProperties;
/** Override the img className */
className?: string;
// TODO: How to handle if svg fails at img? Currently it will return twice
/** An optional callback method that returns the display result */
onResult?: (result: DisplayResult) => void;
}
export type __unsafe__not__for__production__v2__SvgRendererProps = Omit<SvgRendererProps, "document"> & {
/** The OpenAttestation v2 document to display */
document: v2.OpenAttestationDocument;
};

const __unsafe__not__for__production__v2__SvgRenderer = React.forwardRef<
HTMLImageElement,
__unsafe__not__for__production__v2__SvgRendererProps
>(({ document, style, className, onResult }, ref) => {
>(({ document, ...rest }, ref) => {
const remappedDocument = mapV2toV4(document);
return <SvgRenderer document={remappedDocument} style={style} className={className} onResult={onResult} ref={ref} />;
return <SvgRenderer {...rest} document={remappedDocument} ref={ref} />;
});

__unsafe__not__for__production__v2__SvgRenderer.displayName = "SvgRendererAdapterComponent";
Expand Down
28 changes: 28 additions & 0 deletions src/components/renderer/fixtures/svgRendererSamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,34 @@ export const v4WithOnlyTamperedEmbeddedSvg = {
},
};

export const v4MalformedEmbeddedSvg = {
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://schemata.openattestation.com/com/openattestation/4.0/alpha-context.json",
],
issuer: {
id: "did:ethr:0xB26B4941941C51a4885E5B7D3A1B861E54405f90",
type: "OpenAttestationIssuer",
name: "Government Technology Agency of Singapore (GovTech)",
// identityProof: { identityProofType: v4.IdentityProofType.DNSDid, identifier: "example.openattestation.com" },
identityProof: { identityProofType: v2.IdentityProofType.DNSDid, identifier: "example.openattestation.com" },
},
// credentialStatus: { type: "OpenAttestationCredentialStatus", credentialStatusType: v4.CredentialStatusType.None },
credentialStatus: { type: "OpenAttestationCredentialStatus", credentialStatusType: "NONE" },
renderMethod: [
{
id: "",
type: "SvgRenderingTemplate2023",
},
],
credentialSubject: {
id: "urn:uuid:a013fb9d-bb03-4056-b696-05575eceaf42",
type: ["SvgExample"],
course: { name: "SVG Basics Workshop", fromDate: "01/01/2024", endDate: "16/01/2024" },
recipient: { name: "TAN CHEN CHEN" },
},
};

export const v2WithSvgUrlAndDigestMultibase = {
issuers: [
{
Expand Down
Loading