Skip to content

Commit

Permalink
Feat/add w3c svg render method shiyu proposal (#133)
Browse files Browse the repository at this point in the history
* refactor: new discrimated union typings for display results

* fix: use new display results and new higher level methods to update display results

* refactor: shift render svg method out of the render fn

* fix: ignoring of exhuastive deps eslint

* fix: handle state where svg is malformed

* fix: handle loading state

* refactor: remove explict handle of default case

* refactor: make adaptor props extends from svg renderer directly

* test: fix tests and added a new test for malformed svg

* chore: use onResult and loading component in example application

* fix: should reset state on document change

* fix: hide img until it is resolved

* chore: use new interface and show malformed as an example

* refactor: remove one unnecessary dep

* chore: use type import to prevent importing crypto module

* fix: typings in adapter

* fix: should not alter svg width

* chore: remove crypto fallback to avoid bundling it in the future

* refactor: use imports instead

* fix: load error can also be caused by cors

---------

Co-authored-by: Phan Shi Yu <[email protected]>
  • Loading branch information
phanshiyu and phanshiyu authored Apr 23, 2024
1 parent 83f6c9e commit f05b476
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 85 deletions.
3 changes: 2 additions & 1 deletion example/application/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import ReactDOM from "react-dom";
import { rawOpencerts } from "./fixtures/v2/opencerts";
import { certWithBadTemplateName, certWithNoTemplate } from "./fixtures/v2/certs-to-test-default-renderer";
import { driverLicense } from "./fixtures/v3/driverLicense";
import { svgEmbeddedDemoV2, svgHostedDemoV2 } from "./fixtures/v2/svgDemoV2";
import { malformSvgDemoV2, svgEmbeddedDemoV2, svgHostedDemoV2 } from "./fixtures/v2/svgDemoV2";
import React from "react";
import { AppContainer } from "./container";

Expand All @@ -24,6 +24,7 @@ export const App: React.FunctionComponent = (): React.ReactElement => {
{ name: "Driver License (V3)", document: driverLicense, frameSource: "http://localhost:9000" },
{ name: "Legacy (Penpal V4)", document: { id: "legacy" }, frameSource: "http://localhost:8080" },
{ name: "SVG Embedded Demo (V2)", document: svgEmbeddedDemoV2, frameSource: "" },
{ name: "Malformed SVG (V2)", document: malformSvgDemoV2, frameSource: "" },
{ name: "SVG Hosted Demo (V2)", document: svgHostedDemoV2, frameSource: "" },
]}
/>
Expand Down
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
28 changes: 28 additions & 0 deletions example/application/fixtures/v2/svgDemoV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ export const svgEmbeddedDemoV2 = {
},
};

export const malformSvgDemoV2 = {
issuers: [
{
id: "did:ethr:0xB26B4941941C51a4885E5B7D3A1B861E54405f90",
name: "Government Technology Agency of Singapore (GovTech)",
revocation: {
type: v2.RevocationType.None,
},
identityProof: {
type: v2.IdentityProofType.DNSDid,
location: "example.openattestation.com",
key: "did:ethr:0xB26B4941941C51a4885E5B7D3A1B861E54405f90#controller",
},
},
],
renderMethod: [
{
id: `<sv.`,
type: "SvgRenderingTemplate2023",
name: "SVG Demo",
},
],
qualification: "SVG rendering",
recipient: {
name: "Yourself",
},
};

export const svgHostedDemoV2 = {
issuers: [
{
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 svg load error 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 could not be loaded");
expect(queryByTestId("Svg image of the verified document")).not.toBeInTheDocument();
expect(mockHandleResult).toHaveBeenCalledWith({
status: "SVG_LOAD_ERROR",
svgDataUri: "data:image/svg+xml,",
});
});
});
/* eslint-enable jest/prefer-spy-on */
165 changes: 114 additions & 51 deletions src/components/renderer/SvgRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
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 { v2 } from "@govtechsg/open-attestation";
/* eslint-disable-next-line @typescript-eslint/no-var-requires */
const handlebars = require("handlebars");
import { ConnectionFailureTemplate, DefaultTemplate, NoTemplate, TamperedSvgTemplate } from "../../DefaultTemplate";
import type { v2 } from "@govtechsg/open-attestation";
import handlebars from "handlebars";

interface RenderMethod {
id: string;
Expand Down Expand Up @@ -32,24 +31,50 @@ 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: "SVG_LOAD_ERROR";
svgDataUri: string;
};

type PendingImgLoadDisplayResult = {
status: "PENDING_OK";
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
/** 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, err?: Error) => void;
}

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

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

const renderSvg = (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<DisplayResult | LoadingDisplayResult | PendingImgLoadDisplayResult>({
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/blocked by CORS 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: "PENDING_OK",
svgDataUri: `data:image/svg+xml,${encodeURIComponent(renderSvg(rawSvgTemplate, document))}`,
});
};

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

const urlPattern = /^https?:\/\/.*\.svg$/;
const isSvgUrl = urlPattern.test(svgInDoc);
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]);
}, [document, onResult, svgInDoc, renderMethod?.digestMultibase]);

const handleResult = (result: DisplayResult, svgToSet = "", error?: Error) => {
setFetchedSvgData(svgToSet);
const handleImgResolved = (result: ValidSvgTemplateDisplayResult) => () => {
setToDisplay(result);
if (typeof onResult === "function") {
onResult(result, error);
}
};

const renderTemplate = (template: string, document: any) => {
if (template.length === 0) return "";
const compiledTemplate = handlebars.compile(template);
return document.credentialSubject ? compiledTemplate(document.credentialSubject) : compiledTemplate(document);
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 "SVG_LOAD_ERROR":
return (
<DefaultTemplate
title="The resolved SVG could not be loaded"
description={<>The resolved SVG is either blocked or 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 "PENDING_OK":
case "OK": {
return (
<img
className={className}
style={style}
style={
toDisplay.status === "PENDING_OK"
? {
display: "none",
}
: 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: "SVG_LOAD_ERROR", svgDataUri: toDisplay.svgDataUri })}
/>
);
}
default:
return <></>;
return <NoTemplate document={document} handleObfuscation={() => null} />;
}
}
);
Expand Down
Loading

0 comments on commit f05b476

Please sign in to comment.