Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions src/renderer/library/components/customization/Customization.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// ==LICENSE-BEGIN==
// Copyright 2017 European Digital Reading Lab. All rights reserved.
// Licensed to the Readium Foundation under one or more contributor license agreements.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file exposed on Github (readium) in the project repository.
// ==LICENSE-END==

import * as React from "react";
import DOMPurify from "dompurify";
import { useParams } from "react-router-dom";

import LibraryLayout from "../layout/LibraryLayout";
import PublicationAddButton from "../catalog/PublicationAddButton";
import { useTranslator } from "readium-desktop/renderer/common/hooks/useTranslator";
import { useSelector } from "readium-desktop/renderer/common/hooks/useSelector";
import { ILibraryRootState } from "readium-desktop/common/redux/states/renderer/libraryRootState";
import { convertMultiLangStringToString } from "readium-desktop/common/language-string";
import { decodeCustomizationRouteParam } from "../../customization/route";
import { encodeURIComponent_RFC3986 } from "@r2-utils-js/_utils/http/UrlUtils";
import { URL_PROTOCOL_THORIUMHTTPS, URL_HOST_COMMON, URL_PATH_PREFIX_CUSTOMPROFILEZIP } from "readium-desktop/common/streamerProtocol";

const secondaryHeader = <span style={{ display: "flex", justifyContent: "end", alignItems: "end", height: "53px", borderBottom: "1px solid var(--color-gray-250)", paddingBottom: "30px" }}><PublicationAddButton /></span>;

const CustomizationPage = () => {
const [__] = useTranslator();
const { hrefEncoded } = useParams<{ hrefEncoded: string }>();

const customizationManifest = useSelector((state: ILibraryRootState) => state.customization.manifest);
const locale = useSelector((state: ILibraryRootState) => state.i18n.locale);
const customizationId = customizationManifest?.identifier;
const customizationBaseUrl = customizationId ? `${URL_PROTOCOL_THORIUMHTTPS}://${URL_HOST_COMMON}/${URL_PATH_PREFIX_CUSTOMPROFILEZIP}/${encodeURIComponent_RFC3986(Buffer.from(customizationId).toString("base64"))}/` : "";

const href = hrefEncoded ? decodeCustomizationRouteParam(hrefEncoded) : undefined;
const screenLink = customizationManifest?.links?.find((ln) => ln.rel === "screen" && ln.href === href);
const title = convertMultiLangStringToString(screenLink?.title, locale) || __("catalog.customization.fallback.screen");

const [dangerousInnerHTML_CustomProfileScreenSanitized, setDangerousInnerHtml] = React.useState<string | undefined>(undefined);

React.useEffect(() => {
let cancelled = false;
setDangerousInnerHtml(undefined);

if (href && customizationBaseUrl) {
// URL is thoriumhttps:// "custom-profile-zip" protocol handler, so no use of isURL(url) and /^https?:\/\//.test(url) checks here
const url = customizationBaseUrl + encodeURIComponent_RFC3986(Buffer.from(href).toString("base64"));

fetch(url)
.then((response) => {
if (response.ok) {
return response.text();
}
return Promise.reject(response.statusText);
})
.then((rawHtmlContent) => {
if (cancelled || !rawHtmlContent) {
return;
}

const htmlSanitized = DOMPurify.sanitize(rawHtmlContent, { FORBID_TAGS: [/*"style"*/], FORBID_ATTR: [/*"style"*/] /* TODO: handle external https links */ });
// NOTE that <a href="yyy">xxx</a> is fine, caught by webContents.on("will-navigate", ...) with event.preventDefault() and shell.openExternal(...) on normalized/escaped URL and filtered on HTTP(S)://
// NOTE that the attribute target="_blank" (etc) is automatically removed by DOMPurify but would be caught by webContents.setWindowOpenHandler(...) with { action: "deny" }, although no shell.openExternal(...) in this case
setDangerousInnerHtml(htmlSanitized);
})
.catch((e) => {
console.error("Error fetching data:", e);
});
}

return () => {
cancelled = true;
};
}, [href, customizationBaseUrl]);

return (
<LibraryLayout
title={title}
secondaryHeader={secondaryHeader}
>
{
dangerousInnerHTML_CustomProfileScreenSanitized ?
<div dangerouslySetInnerHTML={{ __html: dangerousInnerHTML_CustomProfileScreenSanitized }} /> : <></>
}
</LibraryLayout>
);
};

export default CustomizationPage;
112 changes: 20 additions & 92 deletions src/renderer/library/components/layout/LibraryHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ import { ipcRenderer } from "electron";

import * as stylesHeader from "readium-desktop/renderer/assets/styles/header.scss";
import * as stylesButtons from "readium-desktop/renderer/assets/styles/components/buttons.scss";
import * as stylesModals from "readium-desktop/renderer/assets/styles/components/modals.scss";
import { useDispatch } from "readium-desktop/renderer/common/hooks/useDispatch";
import { screenReaderActions, toastActions } from "readium-desktop/common/redux/actions";
import { ToastType } from "readium-desktop/common/models/toast";
import * as Dialog from "@radix-ui/react-dialog";

import { Link } from "react-router-dom";
import classNames from "classnames";
Expand All @@ -30,13 +28,11 @@ import SVG from "readium-desktop/renderer/common/components/SVG";
import { Settings } from "../settings/Settings";
import { _APP_NAME } from "readium-desktop/preprocessor-directives";
import { buildOpdsBrowserRoute } from "../../opds/route";
import { buildCustomizationRoute } from "../../customization/route";
import { encodeURIComponent_RFC3986 } from "@r2-utils-js/_utils/http/UrlUtils";
import { URL_PROTOCOL_THORIUMHTTPS, URL_HOST_COMMON, URL_PATH_PREFIX_CUSTOMPROFILEZIP } from "readium-desktop/common/streamerProtocol";
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import DOMPurify from "dompurify";
import { useSelector } from "readium-desktop/renderer/common/hooks/useSelector";
import { useTranslator } from "readium-desktop/renderer/common/hooks/useTranslator";
import { IStringMap } from "@r2-shared-js/models/metadata-multilang";
import { convertMultiLangStringToString } from "readium-desktop/common/language-string";
// import { WizardModal } from "../Wizard";

Expand Down Expand Up @@ -171,11 +167,6 @@ const Header = () => {
}
return a;
}, [customizationManifest, locale]);
const screenZipObj = React.useMemo(() => screenZipLinks?.map(({ href, title }) => href && customizationBaseUrl ? {url: customizationBaseUrl + encodeURIComponent_RFC3986(Buffer.from(href).toString("base64")), title } : undefined), [screenZipLinks, customizationBaseUrl]);

const [screenHtmlArray, setScreenHtmlArray] = React.useState<Array<{ dangerousInnerHTML_CustomProfileScreenSanitized: string, title: string | IStringMap }>>([]);
const [cancel, setCancel] = React.useState(false);


const screenReaderActivate = useSelector((state: ILibraryRootState) => state.screenReader.activate);

Expand Down Expand Up @@ -215,51 +206,6 @@ const Header = () => {
};
}, [isAccessibilitySupportEnabled]);

React.useEffect(() => {
if (customizationId && screenZipObj?.length) {

setScreenHtmlArray([]);
setCancel(false);

for (const screenHref of screenZipObj) {

// URL is thoriumhttps:// "custom-profile-zip" protocol handler, so no use of isURL(url) and /^https?:\/\//.test(url) checks here
fetch(screenHref.url)
.then((response) => {
if (response.ok) {
return response.text();
}
return Promise.reject(response.statusText);
})
.then((rawHtmlContent) => {
// console.log("RAW HTML", rawHtmlContent);

if (cancel) {
return ;
}

if (!rawHtmlContent) {
return ;
}

const htmlSanitized = DOMPurify.sanitize(rawHtmlContent, { FORBID_TAGS: [/*"style"*/], FORBID_ATTR: [/*"style"*/] /* TODO: handle external https links */ });
// console.log(rawHtmlContent, htmlSanitized);
// NOTE that <a href="yyy">xxx</a> is fine, caught by webContents.on("will-navigate", ...) with event.preventDefault() and shell.openExternal(...) on normalized/escaped URL and filtered on HTTP(S)://
// NOTE that the attribute target="_blank" (etc) is automatically removed by DOMPurify but would be caught by webContents.setWindowOpenHandler(...) with { action: "deny" }, although no shell.openExternal(...) in this case
setScreenHtmlArray((screenHtmlArray) => [...screenHtmlArray, { dangerousInnerHTML_CustomProfileScreenSanitized: htmlSanitized, title: screenHref.title }]);
})
.catch((e) => {
console.error("Error fetching data:", e);
});
}
} else {
setScreenHtmlArray([]);
setCancel(true);
}

}, [screenZipObj, customizationId, setScreenHtmlArray, cancel, setCancel]);


const customizationCatalogs = customizationManifest?.links?.filter(({ rel }) => rel === "catalog");
if (customizationCatalogs?.length) {
for (const catalog of customizationCatalogs) {
Expand All @@ -285,6 +231,25 @@ const Header = () => {
});
}
}

if (screenZipLinks?.length) {
for (const screenLink of screenZipLinks) {
if (!screenLink.href) {
continue;
}
const route = buildCustomizationRoute(screenLink.href);
const label = convertMultiLangStringToString(screenLink.title, locale) || __("catalog.customization.fallback.screen");
headerNav.push({
route,
label,
matchRoutes: [route],
searchEnable: false,
styles: [],
svg: InfoIcon,
});
}
}

const displayScreenReaderInvite = !screenReaderActivate && isAccessibilitySupportEnabled;

return (<>
Expand Down Expand Up @@ -347,43 +312,6 @@ const Header = () => {
},
)
}
{
screenHtmlArray.length ? screenHtmlArray.map(({dangerousInnerHTML_CustomProfileScreenSanitized, title: titleStringOrObject}, index) => {

const title = convertMultiLangStringToString(titleStringOrObject, locale);

return <>
<li className={classNames("R2_CSS_CLASS__FORCE_NO_FOCUS_OUTLINE")} key={`customization-screen-${index}`} style={{ height: "inherit" }}>
<Dialog.Root>
<Dialog.Trigger asChild>
<button title={title || __("catalog.customization.fallback.screen")} className="R2_CSS_CLASS__FORCE_NO_FOCUS_OUTLINE">
<SVG ariaHidden svg={InfoIcon} />
<h3>{title || __("catalog.customization.fallback.screen")}</h3>
</button>
</Dialog.Trigger>
<Dialog.Portal>
<div className={stylesModals.modal_dialog_overlay}></div>
<Dialog.Content className={classNames(stylesModals.modal_dialog)} aria-describedby={undefined}>
{
// FALSE this to test sourcemaps:
true &&
<VisuallyHidden.Root>
<Dialog.Title>{title || __("catalog.customization.fallback.screen")}</Dialog.Title>
</VisuallyHidden.Root>
}

{
dangerousInnerHTML_CustomProfileScreenSanitized ?
<div className={stylesModals.modal_dialog_body} dangerouslySetInnerHTML={{ __html: dangerousInnerHTML_CustomProfileScreenSanitized }} /> : <></>
}

</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</li>
</>;
}) : <></>
}
</div>
<li /* style={{position: "absolute", bottom: "10px" }} */>
<Settings />
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/library/customization/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// ==LICENSE-BEGIN==
// Copyright 2017 European Digital Reading Lab. All rights reserved.
// Licensed to the Readium Foundation under one or more contributor license agreements.
// Use of this source code is governed by a BSD-style license
// that can be found in the LICENSE file exposed on Github (readium) in the project repository.
// ==LICENSE-END==

import { encodeB64, decodeB64 } from "../../common/logics/base64";

/**
* formating customization screen route to react-router route
* @param href href of the customization "screen" link (manifest.json)
*/
export function buildCustomizationRoute(href: string) {
return `/customization/${encodeB64(href)}`;
}

export function decodeCustomizationRouteParam(hrefEncoded: string) {
return decodeB64(hrefEncoded);
}
6 changes: 6 additions & 0 deletions src/renderer/library/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Catalog from "./components/catalog/Catalog";
import Browser from "./components/opds/Browser";
import Opds from "./components/opds/Opds";
import AllPublicationPage from "./components/searchResult/AllPublicationPage";
import CustomizationPage from "./components/customization/Customization";
// import TagSearchResult from "./components/searchResult/TagSearchResult";
// import TextSearchResult from "./components/searchResult/TextSearchResult";

Expand Down Expand Up @@ -103,6 +104,11 @@ const _routes = {
// exact: true,
component: Catalog,
} as Route,
"/customization": {
path: "/customization/:hrefEncoded",
// exact: true,
component: CustomizationPage,
} as Route,
"/": {
path: "/",
// exact: false,
Expand Down
Loading
Loading