diff --git a/src/app/UserItemListPage.js b/src/app/UserItemListPage.js index cb34bd0..f924728 100644 --- a/src/app/UserItemListPage.js +++ b/src/app/UserItemListPage.js @@ -9,13 +9,14 @@ import { Wrap, WrapItem, } from "@chakra-ui/react"; +import { ChevronRightIcon } from "@chakra-ui/icons"; import { Heading1, MajorErrorMessage } from "./util"; import { gql, useQuery } from "@apollo/client"; import { Link, useParams } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; import HangerSpinner from "./components/HangerSpinner"; -import { ChevronRightIcon } from "@chakra-ui/icons"; +import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML"; import ItemCard from "./components/ItemCard"; import WIPCallout from "./components/WIPCallout"; @@ -28,6 +29,7 @@ function UserItemListPage() { closetList(id: $listId) { id name + description ownsOrWantsItems creator { id @@ -43,7 +45,7 @@ function UserItemListPage() { } } `, - { variables: { listId } } + { variables: { listId }, context: { sendAuth: true } } ); if (loading) { @@ -102,49 +104,20 @@ function UserItemListPage() { /> - {/* TODO: Description */} + {closetList.description && ( + {closetList.description} + )} ); } function ClosetListContents({ closetList }) { - const isCurrentUser = false; // TODO + const sortedItems = [...closetList.items].sort((a, b) => + a.name.localeCompare(b.name) + ); - // TODO: A lot of this is duplicated from UserItemsPage, find shared - // abstractions! - const hasYouWantThisBadge = (item) => - !isCurrentUser && - closetList.ownsOrWantsItems === "OWNS" && - item.currentUserWantsThis; - const hasYouOwnThisBadge = (item) => - !isCurrentUser && - closetList.ownsOrWantsItems === "WANTS" && - item.currentUserOwnsThis; - const hasAnyTradeBadge = (item) => - hasYouOwnThisBadge(item) || hasYouWantThisBadge(item); - - const sortedItems = [...closetList.items].sort((a, b) => { - // This is a cute sort hack. We sort first by, bringing "You own/want - // this!" to the top, and then sorting by name _within_ those two groups. - const aName = `${hasAnyTradeBadge(a) ? "000" : "999"} ${a.name}`; - const bName = `${hasAnyTradeBadge(b) ? "000" : "999"} ${b.name}`; - return aName.localeCompare(bName); - }); - - let tradeMatchingMode; - if (isCurrentUser) { - // On your own item list, it's not helpful to show your own trade matches! - tradeMatchingMode = "hide-all"; - } else if (closetList.ownsOrWantsItems === "OWNS") { - tradeMatchingMode = "offering"; - } else if (closetList.ownsOrWantsItems === "WANTS") { - tradeMatchingMode = "seeking"; - } else { - throw new Error( - `unexpected ownsOrWantsItems value: ${closetList.ownsOrWantsItems}` - ); - } + const tradeMatchingMode = "hide-all"; // TODO return ( diff --git a/src/app/UserItemsPage.js b/src/app/UserItemsPage.js index ab8035b..037efc5 100644 --- a/src/app/UserItemsPage.js +++ b/src/app/UserItemsPage.js @@ -34,12 +34,11 @@ import { import gql from "graphql-tag"; import { Link, useHistory, useParams } from "react-router-dom"; import { useQuery, useLazyQuery, useMutation } from "@apollo/client"; -import SimpleMarkdown from "simple-markdown"; -import DOMPurify from "dompurify"; import HangerSpinner from "./components/HangerSpinner"; import { Heading1, Heading2, Heading3 } from "./util"; import ItemCard from "./components/ItemCard"; +import MarkdownAndSafeHTML from "./components/MarkdownAndSafeHTML"; import SupportOnly from "./WardrobePage/support/SupportOnly"; import useSupport from "./WardrobePage/support/useSupport"; import useCurrentUser from "./components/useCurrentUser"; @@ -657,81 +656,6 @@ function buildClosetListPath(closetList) { return `/user/${closetList.creator.id}/lists/${ownsOrWants}/${idString}`; } -const unsafeMarkdownRules = { - autolink: SimpleMarkdown.defaultRules.autolink, - br: SimpleMarkdown.defaultRules.br, - em: SimpleMarkdown.defaultRules.em, - escape: SimpleMarkdown.defaultRules.escape, - link: SimpleMarkdown.defaultRules.link, - list: SimpleMarkdown.defaultRules.list, - newline: SimpleMarkdown.defaultRules.newline, - paragraph: SimpleMarkdown.defaultRules.paragraph, - strong: SimpleMarkdown.defaultRules.strong, - u: SimpleMarkdown.defaultRules.u, - - // DANGER: We override Markdown's `text` rule to _not_ escape HTML. This is - // intentional, to allow users to embed some limited HTML. DOMPurify is - // responsible for sanitizing the HTML afterward. Do not use these rules - // without sanitizing!! - text: { - ...SimpleMarkdown.defaultRules.text, - html: (node) => node.content, - }, -}; -const markdownParser = SimpleMarkdown.parserFor(unsafeMarkdownRules); -const unsafeMarkdownOutput = SimpleMarkdown.htmlFor( - SimpleMarkdown.ruleOutput(unsafeMarkdownRules, "html") -); - -function MarkdownAndSafeHTML({ children }) { - const htmlAndMarkdown = children; - - const unsafeHtml = unsafeMarkdownOutput(markdownParser(htmlAndMarkdown)); - - const sanitizedHtml = DOMPurify.sanitize(unsafeHtml, { - ALLOWED_TAGS: [ - "b", - "i", - "u", - "strong", - "em", - "a", - "p", - "div", - "br", - "ol", - "ul", - "li", - ], - ALLOWED_ATTR: ["href", "class"], - // URL must either start with an approved host (external link), or with a - // slash or hash (internal link). - ALLOWED_URI_REGEXP: /^https?:\/\/(impress\.openneo\.net|impress-2020\.openneo\.net|www\.neopets\.com|neopets\.com|items\.jellyneo\.net)\/|^[/#]/, - }); - - return ( - - {({ css }) => ( - - )} - - ); -} - function UserSupportMenu({ children, user }) { const { supportSecret } = useSupport(); const toast = useToast(); diff --git a/src/app/components/MarkdownAndSafeHTML.js b/src/app/components/MarkdownAndSafeHTML.js new file mode 100644 index 0000000..b97bdb7 --- /dev/null +++ b/src/app/components/MarkdownAndSafeHTML.js @@ -0,0 +1,89 @@ +import React from "react"; +import { ClassNames } from "@emotion/react"; +import { Box } from "@chakra-ui/react"; +import SimpleMarkdown from "simple-markdown"; +import DOMPurify from "dompurify"; + +const unsafeMarkdownRules = { + autolink: SimpleMarkdown.defaultRules.autolink, + br: SimpleMarkdown.defaultRules.br, + em: SimpleMarkdown.defaultRules.em, + escape: SimpleMarkdown.defaultRules.escape, + link: SimpleMarkdown.defaultRules.link, + list: SimpleMarkdown.defaultRules.list, + newline: SimpleMarkdown.defaultRules.newline, + paragraph: SimpleMarkdown.defaultRules.paragraph, + strong: SimpleMarkdown.defaultRules.strong, + u: SimpleMarkdown.defaultRules.u, + + // DANGER: We override Markdown's `text` rule to _not_ escape HTML. This is + // intentional, to allow users to embed some limited HTML. DOMPurify is + // responsible for sanitizing the HTML afterward. Do not use these rules + // without sanitizing!! + text: { + ...SimpleMarkdown.defaultRules.text, + html: (node) => node.content, + }, +}; +const markdownParser = SimpleMarkdown.parserFor(unsafeMarkdownRules); +const unsafeMarkdownOutput = SimpleMarkdown.htmlFor( + SimpleMarkdown.ruleOutput(unsafeMarkdownRules, "html") +); + +/** + * MarkdownAndSafeHTML renders its children as a Markdown string, with some + * safe inline HTML allowed. + * + * Rendering this component *should* be XSS-safe, it's designed to strip out + * bad things! Still, be careful when using it, and consider what you're doing! + */ +function MarkdownAndSafeHTML({ children }) { + const htmlAndMarkdown = children; + + const unsafeHtml = unsafeMarkdownOutput(markdownParser(htmlAndMarkdown)); + + const sanitizedHtml = DOMPurify.sanitize(unsafeHtml, { + ALLOWED_TAGS: [ + "b", + "i", + "u", + "strong", + "em", + "a", + "p", + "div", + "br", + "ol", + "ul", + "li", + ], + ALLOWED_ATTR: ["href", "class"], + // URL must either start with an approved host (external link), or with a + // slash or hash (internal link). + ALLOWED_URI_REGEXP: /^https?:\/\/(impress\.openneo\.net|impress-2020\.openneo\.net|www\.neopets\.com|neopets\.com|items\.jellyneo\.net)\/|^[/#]/, + }); + + return ( + + {({ css }) => ( + + )} + + ); +} + +export default MarkdownAndSafeHTML;