diff --git a/web/eslint.config.js b/web/eslint.config.js index ec9c686..21916f5 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -43,7 +43,7 @@ export default tseslint.config( }], "array-bracket-spacing": ["error", "never"], "one-var-declaration-per-line": ["error", "initializations"], - "quotes": ["error", "double"], + "quotes": ["error", "double", { allowTemplateLiterals: true }], "semi": ["error", "never"], "comma-dangle": ["error", "always-multiline"], "max-len": ["warn", 120], diff --git a/web/package-lock.json b/web/package-lock.json index 8351492..d1b0ae3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,12 +11,15 @@ "dependencies": { "@types/react": "npm:types-react@rc", "@types/react-dom": "npm:types-react-dom@rc", + "linkify-react": "^4.1.3", "react": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002", - "react-spinners": "^0.14.1" + "react-spinners": "^0.14.1", + "sanitize-html": "^2.13.1" }, "devDependencies": { "@eslint/js": "^9.11.1", + "@types/sanitize-html": "^2.13.0", "@vitejs/plugin-react-swc": "^3.5.0", "eslint": "^9.11.1", "eslint-plugin-import": "^2.31.0", @@ -1044,6 +1047,15 @@ "@types/react": "*" } }, + "node_modules/@types/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==", + "dev": true, + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.8.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.0.tgz", @@ -1659,6 +1671,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1705,6 +1725,68 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", @@ -1880,7 +1962,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -2503,6 +2584,24 @@ "node": ">= 0.4" } }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2718,6 +2817,14 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -2882,6 +2989,15 @@ "node": ">= 0.8.0" } }, + "node_modules/linkify-react": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.3.tgz", + "integrity": "sha512-rhI3zM/fxn5BfRPHfi4r9N7zgac4vOIxub1wHIWXLA5ENTMs+BGaIaFO1D1PhmxgwhIKmJz3H7uCP0Dg5JwSlA==", + "peerDependencies": { + "linkifyjs": "^4.0.0", + "react": ">= 15.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2956,7 +3072,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -3123,6 +3238,11 @@ "node": ">=6" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3150,8 +3270,7 @@ "node_modules/picocolors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", - "dev": true + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -3178,7 +3297,6 @@ "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3415,6 +3533,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sanitize-html": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.1.tgz", + "integrity": "sha512-ZXtKq89oue4RP7abL9wp/9URJcqQNABB5GGJ2acW1sdO8JTVl92f4ygD7Yc9Ze09VAZhnt2zegeU0tbNsdcLYg==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/scheduler": { "version": "0.25.0-rc-0751fac7-20241002", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-0751fac7-20241002.tgz", @@ -3507,7 +3638,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "engines": { "node": ">=0.10.0" } diff --git a/web/package.json b/web/package.json index 96ff794..0f5d6f1 100644 --- a/web/package.json +++ b/web/package.json @@ -13,12 +13,15 @@ "dependencies": { "@types/react": "npm:types-react@rc", "@types/react-dom": "npm:types-react-dom@rc", + "linkify-react": "^4.1.3", "react": "^19.0.0-rc-0751fac7-20241002", "react-dom": "^19.0.0-rc-0751fac7-20241002", - "react-spinners": "^0.14.1" + "react-spinners": "^0.14.1", + "sanitize-html": "^2.13.1" }, "devDependencies": { "@eslint/js": "^9.11.1", + "@types/sanitize-html": "^2.13.0", "@vitejs/plugin-react-swc": "^3.5.0", "eslint": "^9.11.1", "eslint-plugin-import": "^2.31.0", diff --git a/web/src/api/media.ts b/web/src/api/media.ts new file mode 100644 index 0000000..067e2d9 --- /dev/null +++ b/web/src/api/media.ts @@ -0,0 +1,28 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/ + +export const getMediaURL = (mxc?: string): string | undefined => { + if (!mxc) { + return undefined + } + const match = mxc.match(mediaRegex) + if (!match) { + return undefined + } + return `_gomuks/media/${match[1]}/${match[2]}` +} diff --git a/web/src/ui/roomlist/Entry.tsx b/web/src/ui/roomlist/Entry.tsx index 70cd1e0..dd6371c 100644 --- a/web/src/ui/roomlist/Entry.tsx +++ b/web/src/ui/roomlist/Entry.tsx @@ -15,6 +15,7 @@ // along with this program. If not, see . import type { RoomListEntry } from "../../api/statestore.ts" import type { DBEvent } from "../../api/types/hitypes.ts" +import { getMediaURL } from "../../api/media.ts" export interface RoomListEntryProps { room: RoomListEntry @@ -35,24 +36,11 @@ function makePreviewText(evt?: DBEvent): string { return "" } -const avatarRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/ - -const getAvatarURL = (avatar?: string): string | undefined => { - if (!avatar) { - return undefined - } - const match = avatar.match(avatarRegex) - if (!match) { - return undefined - } - return `_gomuks/media/${match[1]}/${match[2]}` -} - const Entry = ({ room, setActiveRoom }: RoomListEntryProps) => { const previewText = makePreviewText(room.preview_event) return
- +
{room.name}
diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index e69de29..8edba98 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -0,0 +1,10 @@ +div.timeline-event { + width: 100%; + max-width: 100%; + overflow: hidden; + + img { + max-width: 50%; + max-height: 25vh; + } +} diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 76f68c6..6ad0617 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -13,27 +13,50 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import React from "react" import { RoomStateStore } from "../../api/statestore.ts" +import { DBEvent } from "../../api/types/hitypes.ts" +import { EventContentProps } from "./content/props.ts" +import HiddenEvent from "./content/HiddenEvent.tsx" import "./TimelineEvent.css" +import MessageBody from "./content/MessageBody.tsx" export interface TimelineEventProps { room: RoomStateStore eventRowID: number } +function getBodyType(evt: DBEvent): React.FunctionComponent { + switch (evt.type) { + case "m.room.encrypted": + switch (evt.decrypted_type) { + case "m.room.message": + return MessageBody + } + break + case "m.room.message": + return MessageBody + case "m.sticker": + } + return HiddenEvent +} + const TimelineEvent = ({ room, eventRowID }: TimelineEventProps) => { const evt = room.eventsByRowID.get(eventRowID) if (!evt) { return null } - // @ts-expect-error TODO add content types - const body = (evt.decrypted ?? evt.content).body + const BodyType = getBodyType(evt) + if (BodyType === HiddenEvent) { + return
+ +
+ } return
- {evt.decrypted_type ?? evt.type} -   - {evt.sender} -   - {body ?? {JSON.stringify(evt.decrypted ?? evt.content, null, " ")}} +
+ {evt.sender} +
+
} diff --git a/web/src/ui/timeline/TimelineView.css b/web/src/ui/timeline/TimelineView.css new file mode 100644 index 0000000..5ddf515 --- /dev/null +++ b/web/src/ui/timeline/TimelineView.css @@ -0,0 +1,3 @@ +div.timeline-view { + padding: 1rem; +} diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index f1936e2..f5cbd12 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -18,6 +18,7 @@ import { RoomStateStore } from "../../api/statestore.ts" import { useNonNullEventAsState } from "../../util/eventdispatcher.ts" import { ClientContext } from "../ClientContext.ts" import TimelineEvent from "./TimelineEvent.tsx" +import "./TimelineView.css" interface TimelineViewProps { room: RoomStateStore diff --git a/web/src/ui/timeline/content/HiddenEvent.tsx b/web/src/ui/timeline/content/HiddenEvent.tsx new file mode 100644 index 0000000..eb58bf4 --- /dev/null +++ b/web/src/ui/timeline/content/HiddenEvent.tsx @@ -0,0 +1,22 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { EventContentProps } from "./props.ts" + +const HiddenEvent = ({ event }: EventContentProps) => { + return {`{ "type": "${event.type}" }`} +} + +export default HiddenEvent diff --git a/web/src/ui/timeline/content/MessageBody.tsx b/web/src/ui/timeline/content/MessageBody.tsx new file mode 100644 index 0000000..2b6465a --- /dev/null +++ b/web/src/ui/timeline/content/MessageBody.tsx @@ -0,0 +1,84 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import sanitizeHtml from "sanitize-html" +import { ContentURI } from "../../../api/types/hitypes.ts" +import { getMediaURL } from "../../../api/media.ts" +import { sanitizeHtmlParams } from "../../../util/html.ts" +import { EventContentProps } from "./props.ts" + +interface BaseMessageEventContent { + msgtype: string + body: string + formatted_body?: string + format?: "org.matrix.custom.html" +} + +interface TextMessageEventContent extends BaseMessageEventContent { + msgtype: "m.text" | "m.notice" | "m.emote" +} + +interface MediaMessageEventContent extends BaseMessageEventContent { + msgtype: "m.image" | "m.file" | "m.audio" | "m.video" + url?: ContentURI + file?: { + url: ContentURI + k: string + v: "v2" + ext: true + alg: "A256CTR" + key_ops: string[] + kty: "oct" + } + info?: { + mimetype?: string + size?: number + w?: number + h?: number + duration?: number + } +} + +interface LocationMessageEventContent extends BaseMessageEventContent { + msgtype: "m.location" + geo_uri: string +} + +type MessageEventContent = TextMessageEventContent | MediaMessageEventContent | LocationMessageEventContent + +const MessageBody = ({ event }: EventContentProps) => { + const content = (event.decrypted ?? event.content) as MessageEventContent + switch (content.msgtype) { + case "m.text": + case "m.emote": + case "m.notice": + if (content.format === "org.matrix.custom.html") { + return
+ } + return content.body + case "m.image": + if (content.url) { + return {content.body}/ + } else if (content.file) { + return {content.body}/ + } + } + return {`{ "type": "${event.type}" }`} +} + +export default MessageBody diff --git a/web/src/ui/timeline/content/props.ts b/web/src/ui/timeline/content/props.ts new file mode 100644 index 0000000..caf571d --- /dev/null +++ b/web/src/ui/timeline/content/props.ts @@ -0,0 +1,22 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { RoomStateStore } from "../../../api/statestore.ts" +import { DBEvent } from "../../../api/types/hitypes.ts" + +export interface EventContentProps { + room: RoomStateStore + event: DBEvent +} diff --git a/web/src/util/html.ts b/web/src/util/html.ts new file mode 100644 index 0000000..b67432a --- /dev/null +++ b/web/src/util/html.ts @@ -0,0 +1,204 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// From matrix-react-sdk, Copyright 2024 The Matrix.org Foundation C.I.C. +// Originally licensed under the Apache License, Version 2.0 +// https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/Linkify.tsx#L245 +import sanitizeHtml from "sanitize-html" +import { getMediaURL } from "../api/media.ts" + +const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/ + +export const PERMITTED_URL_SCHEMES = [ + "bitcoin", + "ftp", + "geo", + "http", + "https", + "im", + "irc", + "ircs", + "magnet", + "mailto", + "matrix", + "mms", + "news", + "nntp", + "openpgp4fpr", + "sip", + "sftp", + "sms", + "smsto", + "ssh", + "tel", + "urn", + "webcal", + "wtai", + "xmpp", +] + +export const transformTags: NonNullable = { + "a": function(tagName: string, attribs: sanitizeHtml.Attributes) { + if (attribs.href) { + attribs.target = "_blank" + } else { + // Delete the href attrib if it is falsy + delete attribs.href + } + + attribs.rel = "noreferrer noopener" // https://mathiasbynens.github.io/rel-noopener/ + return { tagName, attribs } + }, + "img": function(tagName: string, attribs: sanitizeHtml.Attributes) { + const src = attribs.src + if (!src.startsWith("mxc://")) { + return { + tagName, + attribs: {}, + } + } + + const requestedWidth = Number(attribs.width) + const requestedHeight = Number(attribs.height) + const width = Math.min(requestedWidth || 800, 800) + const height = Math.min(requestedHeight || 600, 600) + // specify width/height as max values instead of absolute ones to allow object-fit to do its thing + // we only allow our own styles for this tag so overwrite the attribute + attribs.style = `max-width: ${width}px; max-height: ${height}px;` + if (requestedWidth) { + attribs.style += "width: 100%;" + } + if (requestedHeight) { + attribs.style += "height: 100%;" + } + + attribs.src = getMediaURL(src)! + return { tagName, attribs } + }, + "code": function(tagName: string, attribs: sanitizeHtml.Attributes) { + if (typeof attribs.class !== "undefined") { + // Filter out all classes other than ones starting with language- for syntax highlighting. + const classes = attribs.class.split(/\s/).filter(function(cl) { + return cl.startsWith("language-") && !cl.startsWith("language-_") + }) + attribs.class = classes.join(" ") + } + return { tagName, attribs } + }, + "*": function(tagName: string, attribs: sanitizeHtml.Attributes) { + // Delete any style previously assigned, style is an allowedTag for font, span & img, + // because attributes are stripped after transforming. + // For img this is trusted as it is generated wholly within the img transformation method. + if (tagName !== "img") { + delete attribs.style + } + + // Sanitise and transform data-mx-color and data-mx-bg-color to their CSS + // equivalents + const customCSSMapper: Record = { + "data-mx-color": "color", + "data-mx-bg-color": "background-color", + // $customAttributeKey: $cssAttributeKey + } + + let style = "" + for (const [customAttributeKey, cssAttributeKey] of Object.entries(customCSSMapper)) { + const customAttributeValue = attribs[customAttributeKey] + if ( + customAttributeValue && + typeof customAttributeValue === "string" && + COLOR_REGEX.test(customAttributeValue) + ) { + style += cssAttributeKey + ":" + customAttributeValue + ";" + delete attribs[customAttributeKey] + } + } + + if (style) { + attribs.style = style + (attribs.style || "") + } + + return { tagName, attribs } + }, +} + +export const sanitizeHtmlParams: sanitizeHtml.IOptions = { + allowedTags: [ + // These tags are suggested by the spec https://spec.matrix.org/v1.12/client-server-api/#mroommessage-msgtypes + "font", + "del", + "s", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "p", + "a", + "ul", + "ol", + "sup", + "sub", + "nl", + "li", + "b", + "i", + "u", + "strong", + "em", + "strike", + "code", + "hr", + "br", + "div", + "table", + "thead", + "caption", + "tbody", + "tr", + "th", + "td", + "pre", + "span", + "img", + "details", + "summary", + ], + allowedAttributes: { + // attribute sanitization happens after transformations, so we have to accept `style` for font, span & img + // but strip during the transformation. + // custom ones first: + font: ["color", "data-mx-bg-color", "data-mx-color", "style"], // custom to matrix + span: ["data-mx-maths", "data-mx-bg-color", "data-mx-color", "data-mx-spoiler", "style"], // custom to matrix + div: ["data-mx-maths"], + // eslint-disable-next-line id-length + a: ["href", "name", "target", "rel"], // remote target: custom to matrix + // img tags also accept width/height, we just map those to max-width & max-height during transformation + img: ["src", "alt", "title", "style"], + ol: ["start"], + code: ["class"], // We don't actually allow all classes, we filter them in transformTags + }, + // Lots of these won't come up by default because we don't allow them + selfClosing: ["img", "br", "hr", "area", "base", "basefont", "input", "link", "meta"], + // URL schemes we permit + allowedSchemes: PERMITTED_URL_SCHEMES, + allowProtocolRelative: false, + transformTags, + // 50 levels deep "should be enough for anyone" + nestingLimit: 50, +}