From b70439e95bcfb140d1a527e9f127869daae7740d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 3 May 2023 04:45:33 -0500 Subject: [PATCH] Add safe search filter for NSFW rooms (#208) Fix https://github.com/matrix-org/matrix-public-archive/issues/89 --- client/css/room-directory.css | 85 +++++++-- package-lock.json | 14 +- package.json | 2 +- shared/lib/local-storage-keys.js | 29 ++++ shared/room-directory-vm-render-script.js | 1 + shared/viewmodels/AvatarViewModel.js | 2 +- .../DeveloperOptionsContentViewModel.js | 11 +- shared/viewmodels/RoomCardViewModel.js | 82 +++++++++ shared/viewmodels/RoomDirectoryViewModel.js | 126 ++++++++++---- shared/views/RoomCardView.js | 112 ++++++++++-- shared/views/RoomDirectoryView.js | 162 +++++++++++------- test/dockerfiles/Synapse.Dockerfile | 6 +- test/e2e-tests.js | 56 ++++++ 13 files changed, 547 insertions(+), 141 deletions(-) create mode 100644 shared/lib/local-storage-keys.js create mode 100644 shared/viewmodels/RoomCardViewModel.js diff --git a/client/css/room-directory.css b/client/css/room-directory.css index 0d11a4d..e5b539d 100644 --- a/client/css/room-directory.css +++ b/client/css/room-directory.css @@ -237,24 +237,22 @@ color: #abb5be; } -.RoomDirectoryView_roomList { - display: grid; - gap: 20px; +.RoomDirectoryView_mainContentSection { width: 100%; max-width: 1180px; padding-left: 20px; padding-right: 20px; - margin-top: 20px; + margin-top: 15px; margin-bottom: 0; } +.RoomDirectoryView_roomList { + display: grid; + gap: 20px; +} + .RoomDirectoryView_roomListError { display: block; - width: 100%; - max-width: 1180px; - padding-left: 20px; - padding-right: 20px; - margin-top: 20px; line-height: 1.5; } @@ -269,17 +267,14 @@ } @media (min-width: 750px) { - .RoomDirectoryView_roomList { - grid-template-columns: repeat(2, 1fr); - margin-top: 40px; + .RoomDirectoryView_mainContentSection { + margin-top: 30px; padding-left: 40px; padding-right: 40px; } - .RoomDirectoryView_roomListError { - margin-top: 40px; - padding-left: 40px; - padding-right: 40px; + .RoomDirectoryView_roomList { + grid-template-columns: repeat(2, 1fr); } } @@ -289,6 +284,40 @@ } } +.RoomDirectoryView_safeSearchToggleSection { + display: flex; + justify-content: flex-end; +} + +.RoomDirectoryView_safeSearchToggle { + display: flex; + align-items: center; +} + +.RoomDirectoryView_safeSearchToggleLabel { + padding-left: 0.3em; +} + +.RoomDirectoryView_safeSearchToggleCheckbox { + appearance: none; + + position: relative; + width: 20px; + height: 10px; + + background-color: #ffffff; + border: 2px solid rgba(200, 200, 200, 1); + box-shadow: inset -10px 0px 0px 0px rgba(200, 200, 200, 1); + border-radius: 10px; + + transition: all 0.2s ease; +} + +.RoomDirectoryView_safeSearchToggleCheckbox:checked { + border: 2px solid #2774c2; + box-shadow: inset 10px 0px 0px 0px #2774c2; +} + .RoomCardView { overflow: hidden; display: flex; @@ -301,6 +330,10 @@ border-radius: 8px; } +.RoomCardView.blockedBySafeSearch { + background-color: #d0d9e1; +} + .RoomCardView_header { display: flex; align-items: top; @@ -350,6 +383,14 @@ text-overflow: ellipsis; } +.RoomCardView_blockedBySafeSearchTopic { + margin-top: 8px; + margin-bottom: 8px; + + line-height: 1.2em; + font-style: italic; +} + .RoomCardView_footer { display: flex; /** @@ -398,14 +439,22 @@ cursor: pointer; } +.RoomCardView_viewButton[disabled] { + border-color: #2774c299; + + color: #2774c299; + + cursor: auto; +} + @media (max-width: 750px) { .RoomCardView_viewButton { padding: 8px 32px; } } -.RoomCardView_viewButtonWrapperLink:hover > .RoomCardView_viewButton, -.RoomCardView_viewButtonWrapperLink:focus > .RoomCardView_viewButton { +.RoomCardView_viewButtonWrapperLink:hover > .RoomCardView_viewButton:not([disabled]), +.RoomCardView_viewButtonWrapperLink:focus > .RoomCardView_viewButton:not([disabled]) { background-color: #2774c2; color: #ffffff; } diff --git a/package-lock.json b/package-lock.json index 5c13722..6311470 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "dompurify": "^2.3.9", "escape-string-regexp": "^4.0.0", "express": "^4.17.2", - "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.26.0-scratch", + "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.27.0-scratch", "json5": "^2.2.1", "linkedom": "^0.14.17", "matrix-public-archive-shared": "file:./shared/", @@ -3281,9 +3281,9 @@ }, "node_modules/hydrogen-view-sdk": { "name": "@mlm/hydrogen-view-sdk", - "version": "0.26.0-scratch", - "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.26.0-scratch.tgz", - "integrity": "sha512-rgFCbaI7P5eeaxsTYzfDZ1vII2Sb6uVgK7kut3rqAMPrqnc3Y7AVuY/TUrsBTaviIHJ/OH9suAHISkx9kE29ZA==", + "version": "0.27.0-scratch", + "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.27.0-scratch.tgz", + "integrity": "sha512-wv6GLeAFpdQdAVDSTSiWehslGKxn5DDcS7MtxiL8E2J9OJJkd2VoVncmNrrJ86FN5nORTpbD879HDZ1yevfi9g==", "dependencies": { "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz", "another-json": "^0.2.0", @@ -7522,9 +7522,9 @@ } }, "hydrogen-view-sdk": { - "version": "npm:@mlm/hydrogen-view-sdk@0.26.0-scratch", - "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.26.0-scratch.tgz", - "integrity": "sha512-rgFCbaI7P5eeaxsTYzfDZ1vII2Sb6uVgK7kut3rqAMPrqnc3Y7AVuY/TUrsBTaviIHJ/OH9suAHISkx9kE29ZA==", + "version": "npm:@mlm/hydrogen-view-sdk@0.27.0-scratch", + "resolved": "https://registry.npmjs.org/@mlm/hydrogen-view-sdk/-/hydrogen-view-sdk-0.27.0-scratch.tgz", + "integrity": "sha512-wv6GLeAFpdQdAVDSTSiWehslGKxn5DDcS7MtxiL8E2J9OJJkd2VoVncmNrrJ86FN5nORTpbD879HDZ1yevfi9g==", "requires": { "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz", "another-json": "^0.2.0", diff --git a/package.json b/package.json index 66e78a4..6b2ed1a 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "dompurify": "^2.3.9", "escape-string-regexp": "^4.0.0", "express": "^4.17.2", - "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.26.0-scratch", + "hydrogen-view-sdk": "npm:@mlm/hydrogen-view-sdk@^0.27.0-scratch", "json5": "^2.2.1", "linkedom": "^0.14.17", "matrix-public-archive-shared": "file:./shared/", diff --git a/shared/lib/local-storage-keys.js b/shared/lib/local-storage-keys.js new file mode 100644 index 0000000..6ff936c --- /dev/null +++ b/shared/lib/local-storage-keys.js @@ -0,0 +1,29 @@ +'use strict'; + +const assert = require('./assert'); + +const LOCAL_STORAGE_KEYS = { + addedHomeservers: 'addedHomeservers', + safeSearchEnabled: 'safeSearchEnabled', + debugActiveDateIntersectionObserver: 'debugActiveDateIntersectionObserver', +}; + +// Just make sure they match for sanity. All we really care about is that they are +// unique amongst each other. +Object.keys(LOCAL_STORAGE_KEYS).every((key) => { + const value = LOCAL_STORAGE_KEYS[key]; + const doesKeyMatchValue = key === value; + assert( + doesKeyMatchValue, + `LOCAL_STORAGE_KEYS should have keys that are the same as their values for sanity but saw ${key}=${value}.` + ); +}); + +// Make sure all of the keys/values are unique +assert( + new Set(Object.values(LOCAL_STORAGE_KEYS)).length !== Object.values(LOCAL_STORAGE_KEYS).length, + 'Duplicate values in LOCAL_STORAGE_KEYS. They should be unique otherwise ' + + 'there will be collisions and LocalStorage will be overwritten.' +); + +module.exports = LOCAL_STORAGE_KEYS; diff --git a/shared/room-directory-vm-render-script.js b/shared/room-directory-vm-render-script.js index 1c41f64..d93f6b0 100644 --- a/shared/room-directory-vm-render-script.js +++ b/shared/room-directory-vm-render-script.js @@ -85,6 +85,7 @@ async function mountHydrogen() { urlRouter: urlRouter, history: archiveHistory, // Our options + basePath: config.basePath, homeserverUrl: config.matrixServerUrl, homeserverName: config.matrixServerName, matrixPublicArchiveURLCreator, diff --git a/shared/viewmodels/AvatarViewModel.js b/shared/viewmodels/AvatarViewModel.js index 8a8af44..63c4786 100644 --- a/shared/viewmodels/AvatarViewModel.js +++ b/shared/viewmodels/AvatarViewModel.js @@ -11,7 +11,7 @@ class AvatarViewModel extends ViewModel { const { homeserverUrlToPullMediaFrom, avatarUrl, avatarTitle, avatarLetterString, entityId } = options; - assert(homeserverUrlToPullMediaFrom); + assert(!avatarUrl || homeserverUrlToPullMediaFrom); assert(avatarTitle); assert(avatarLetterString); assert(entityId); diff --git a/shared/viewmodels/DeveloperOptionsContentViewModel.js b/shared/viewmodels/DeveloperOptionsContentViewModel.js index d4cbc47..e38fb70 100644 --- a/shared/viewmodels/DeveloperOptionsContentViewModel.js +++ b/shared/viewmodels/DeveloperOptionsContentViewModel.js @@ -2,8 +2,7 @@ const { ViewModel } = require('hydrogen-view-sdk'); -const DEBUG_ACTIVE_DATE_INTERSECTION_OBSERVER_LOCAL_STORAGE_KEY = - 'debugActiveDateIntersectionObserver'; +const LOCAL_STORAGE_KEYS = require('matrix-public-archive-shared/lib/local-storage-keys'); class DeveloperOptionsContentViewModel extends ViewModel { constructor(options) { @@ -17,11 +16,13 @@ class DeveloperOptionsContentViewModel extends ViewModel { loadValuesFromPersistence() { if (window.localStorage) { this._debugActiveDateIntersectionObserver = JSON.parse( - window.localStorage.getItem(DEBUG_ACTIVE_DATE_INTERSECTION_OBSERVER_LOCAL_STORAGE_KEY) + window.localStorage.getItem(LOCAL_STORAGE_KEYS.debugActiveDateIntersectionObserver) ); this.emitChange('debugActiveDateIntersectionObserver'); } else { - console.warn(`Skipping read from LocalStorage since LocalStorage not available`); + console.warn( + `Skipping \`${LOCAL_STORAGE_KEYS.debugActiveDateIntersectionObserver}\` read from LocalStorage since LocalStorage is not available` + ); } } @@ -32,7 +33,7 @@ class DeveloperOptionsContentViewModel extends ViewModel { toggleDebugActiveDateIntersectionObserver(checkedValue) { this._debugActiveDateIntersectionObserver = checkedValue; window.localStorage.setItem( - DEBUG_ACTIVE_DATE_INTERSECTION_OBSERVER_LOCAL_STORAGE_KEY, + LOCAL_STORAGE_KEYS.debugActiveDateIntersectionObserver, this._debugActiveDateIntersectionObserver ); this.emitChange('debugActiveDateIntersectionObserver'); diff --git a/shared/viewmodels/RoomCardViewModel.js b/shared/viewmodels/RoomCardViewModel.js new file mode 100644 index 0000000..4732375 --- /dev/null +++ b/shared/viewmodels/RoomCardViewModel.js @@ -0,0 +1,82 @@ +'use strict'; + +const { ViewModel } = require('hydrogen-view-sdk'); + +const assert = require('matrix-public-archive-shared/lib/assert'); +const MatrixPublicArchiveURLCreator = require('matrix-public-archive-shared/lib/url-creator'); + +class RoomCardViewModel extends ViewModel { + constructor(options) { + super(options); + const { room, basePath, homeserverUrlToPullMediaFrom, viaServers } = options; + assert(room); + assert(basePath); + assert(homeserverUrlToPullMediaFrom); + assert(viaServers); + + this._matrixPublicArchiveURLCreator = new MatrixPublicArchiveURLCreator(basePath); + + this._roomId = room.room_id; + this._canonicalAlias = room.canonical_alias; + this._name = room.name; + this._mxcAvatarUrl = room.avatar_url; + this._homeserverUrlToPullMediaFrom = homeserverUrlToPullMediaFrom; + this._numJoinedMembers = room.num_joined_members; + this._topic = room.topic; + + this._viaServers = viaServers; + + this._blockedBySafeSearch = false; + } + + get roomId() { + return this._roomId; + } + + get canonicalAlias() { + return this._canonicalAlias; + } + + get name() { + return this._name; + } + + get mxcAvatarUrl() { + return this._mxcAvatarUrl; + } + + get homeserverUrlToPullMediaFrom() { + return this._homeserverUrlToPullMediaFrom; + } + + get numJoinedMembers() { + return this._numJoinedMembers; + } + + get topic() { + return this._topic; + } + + get archiveRoomUrl() { + return this._matrixPublicArchiveURLCreator.archiveUrlForRoom( + this._canonicalAlias || this._roomId, + { + // Only include via servers when we have to fallback to the room ID + viaServers: this._canonicalAlias ? undefined : this._viaServers, + } + ); + } + + get blockedBySafeSearch() { + return this._blockedBySafeSearch; + } + + setBlockedBySafeSearch(blockedBySafeSearch) { + if (blockedBySafeSearch !== this._blockedBySafeSearch) { + this._blockedBySafeSearch = blockedBySafeSearch; + this.emitChange('blockedBySafeSearch'); + } + } +} + +module.exports = RoomCardViewModel; diff --git a/shared/viewmodels/RoomDirectoryViewModel.js b/shared/viewmodels/RoomDirectoryViewModel.js index 781cf10..90742b8 100644 --- a/shared/viewmodels/RoomDirectoryViewModel.js +++ b/shared/viewmodels/RoomDirectoryViewModel.js @@ -1,20 +1,24 @@ 'use strict'; -const { ViewModel, ObservableArray } = require('hydrogen-view-sdk'); +const { ViewModel, ObservableMap, ApplyMap } = require('hydrogen-view-sdk'); const assert = require('matrix-public-archive-shared/lib/assert'); +const LOCAL_STORAGE_KEYS = require('matrix-public-archive-shared/lib/local-storage-keys'); const ModalViewModel = require('matrix-public-archive-shared/viewmodels/ModalViewModel'); const HomeserverSelectionModalContentViewModel = require('matrix-public-archive-shared/viewmodels/HomeserverSelectionModalContentViewModel'); +const RoomCardViewModel = require('matrix-public-archive-shared/viewmodels/RoomCardViewModel'); const DEFAULT_SERVER_LIST = ['matrix.org', 'gitter.im', 'libera.chat']; -const ADDED_HOMESERVERS_LIST_LOCAL_STORAGE_KEY = 'addedHomeservers'; +const NSFW_WORDS = ['nsfw', 'porn', 'nudes', 'sex', '18+']; +const NSFW_REGEXES = NSFW_WORDS.map((word) => new RegExp(`\\b${word}\\b`, 'i')); class RoomDirectoryViewModel extends ViewModel { constructor(options) { super(options); const { + basePath, homeserverUrl, homeserverName, matrixPublicArchiveURLCreator, @@ -24,6 +28,7 @@ class RoomDirectoryViewModel extends ViewModel { nextPaginationToken, prevPaginationToken, } = options; + assert(basePath); assert(homeserverUrl); assert(homeserverName); assert(matrixPublicArchiveURLCreator); @@ -66,26 +71,32 @@ class RoomDirectoryViewModel extends ViewModel { }) ); - this._rooms = new ObservableArray( - rooms.map((room) => { - return { - roomId: room.room_id, - canonicalAlias: room.canonical_alias, - name: room.name, - mxcAvatarUrl: room.avatar_url, + // This is based off of + // https://github.com/vector-im/hydrogen-web/blob/e77727ea5992a3ec2649edd53a42e6d75f50a0ca/src/domain/session/leftpanel/LeftPanelViewModel.js#L30-L32 + this._roomCardViewModelsMap = new ObservableMap(); + rooms.forEach((room) => { + this._roomCardViewModelsMap.set( + room.room_id, + new RoomCardViewModel({ + room, + basePath, homeserverUrlToPullMediaFrom: homeserverUrl, - numJoinedMembers: room.num_joined_members, - topic: room.topic, - archiveRoomUrl: matrixPublicArchiveURLCreator.archiveUrlForRoom( - room.canonical_alias || room.room_id, - { - // Only include via servers when we have to fallback to the room ID - viaServers: room.canonical_alias ? undefined : [this.pageSearchParameters.homeserver], - } - ), - }; - }) - ); + viaServers: [ + // If the room is being shown in the directory from this server, then surely + // we can join via this server + this._pageSearchParameters.homeserver, + ], + }) + ); + }); + this._roomCardViewModelsFilterMap = new ApplyMap(this._roomCardViewModelsMap); + this._roomCardViewModels = this._roomCardViewModelsFilterMap.sortValues((/*a, b*/) => { + // Sort doesn't matter + return 1; + }); + + this._safeSearchEnabled = true; + this.loadSafeSearchEnabledFromPersistence(); this.#setupNavigation(); } @@ -171,11 +182,11 @@ class RoomDirectoryViewModel extends ViewModel { let addedHomeserversFromPersistence = []; try { addedHomeserversFromPersistence = JSON.parse( - window.localStorage.getItem(ADDED_HOMESERVERS_LIST_LOCAL_STORAGE_KEY) + window.localStorage.getItem(LOCAL_STORAGE_KEYS.addedHomeservers) ); } catch (err) { console.warn( - `Resetting \`${ADDED_HOMESERVERS_LIST_LOCAL_STORAGE_KEY}\` stored in LocalStorage since we ran into an error parsing what was stored`, + `Resetting \`${LOCAL_STORAGE_KEYS.addedHomeservers}\` stored in LocalStorage since we ran into an error parsing what was stored`, err ); this.setAddedHomeserversList([]); @@ -184,7 +195,7 @@ class RoomDirectoryViewModel extends ViewModel { if (!Array.isArray(addedHomeserversFromPersistence)) { console.warn( - `Resetting \`${ADDED_HOMESERVERS_LIST_LOCAL_STORAGE_KEY}\` stored in LocalStorage since it wasn't an array as expected, addedHomeservers=${addedHomeserversFromPersistence}` + `Resetting \`${LOCAL_STORAGE_KEYS.addedHomeservers}\` stored in LocalStorage since it wasn't an array as expected, addedHomeservers=${addedHomeserversFromPersistence}` ); this.setAddedHomeserversList([]); return; @@ -194,7 +205,7 @@ class RoomDirectoryViewModel extends ViewModel { return; } else { console.warn( - `Skipping \`${ADDED_HOMESERVERS_LIST_LOCAL_STORAGE_KEY}\` read from LocalStorage since LocalStorage is not available` + `Skipping \`${LOCAL_STORAGE_KEYS.addedHomeservers}\` read from LocalStorage since LocalStorage is not available` ); } } @@ -202,11 +213,11 @@ class RoomDirectoryViewModel extends ViewModel { setAddedHomeserversList(addedHomeserversList) { this._addedHomeserversList = addedHomeserversList; window.localStorage.setItem( - ADDED_HOMESERVERS_LIST_LOCAL_STORAGE_KEY, + LOCAL_STORAGE_KEYS.addedHomeservers, JSON.stringify(this._addedHomeserversList) ); - // If the added homeserver list changes, make sure the default page selected + // If the added homeserver list changes, make sure the default page-selected // homeserver is still somewhere in the list. If it's no longer in the added // homeserver list, we will put it in the default available list. this._calculateAvailableHomeserverList(); @@ -218,6 +229,63 @@ class RoomDirectoryViewModel extends ViewModel { return this._addedHomeserversList; } + loadSafeSearchEnabledFromPersistence() { + // Safe search is enabled by default and only disabled with the correct 'false' value + let safeSearchEnabled = true; + + if (window.localStorage) { + const safeSearchEnabledFromPersistence = window.localStorage.getItem( + LOCAL_STORAGE_KEYS.safeSearchEnabled + ); + + if (safeSearchEnabledFromPersistence === 'false') { + safeSearchEnabled = false; + } + } else { + console.warn( + `Skipping \`${LOCAL_STORAGE_KEYS.safeSearchEnabled}\` read from LocalStorage since LocalStorage is not available` + ); + } + + this.setSafeSearchEnabled(safeSearchEnabled); + } + + setSafeSearchEnabled(safeSearchEnabled) { + this._safeSearchEnabled = safeSearchEnabled; + if (window.localStorage) { + window.localStorage.setItem( + LOCAL_STORAGE_KEYS.safeSearchEnabled, + safeSearchEnabled ? 'true' : 'false' + ); + } else { + console.warn( + `Skipping \`${LOCAL_STORAGE_KEYS.safeSearchEnabled}\` write to LocalStorage since LocalStorage is not available` + ); + } + + if (safeSearchEnabled) { + this._roomCardViewModelsFilterMap.setApply((roomId, vm) => { + // We concat the name, topic, etc together to simply do a single check against + // all of the text. + const isNsfw = NSFW_REGEXES.some((regex) => + regex.test(vm.name + ' ---- ' + vm.canonicalAlias + ' --- ' + vm.topic) + ); + vm.setBlockedBySafeSearch(isNsfw); + }); + } else { + this._roomCardViewModelsFilterMap.setApply(null); + this._roomCardViewModelsFilterMap.applyOnce((roomId, vm) => { + vm.setBlockedBySafeSearch(false); + }); + } + + this.emitChange('safeSearchEnabled'); + } + + get safeSearchEnabled() { + return this._safeSearchEnabled; + } + onNewHomeserverAdded(newHomeserver) { const addedHomeserversList = this.addedHomeserversList; this.setAddedHomeserversList(addedHomeserversList.concat(newHomeserver)); @@ -284,8 +352,8 @@ class RoomDirectoryViewModel extends ViewModel { return this._availableHomeserverList; } - get rooms() { - return this._rooms; + get roomCardViewModels() { + return this._roomCardViewModels; } } diff --git a/shared/views/RoomCardView.js b/shared/views/RoomCardView.js index 94974ec..ca1a8dd 100644 --- a/shared/views/RoomCardView.js +++ b/shared/views/RoomCardView.js @@ -1,8 +1,18 @@ 'use strict'; -const { TemplateView, AvatarView } = require('hydrogen-view-sdk'); +const { TemplateView, AvatarView, text } = require('hydrogen-view-sdk'); const AvatarViewModel = require('../viewmodels/AvatarViewModel'); +const safeSearchBlockedRoomTitle = 'Blocked by safe search'; +const safeSearchBlockedRoomDescription = + 'This room was blocked because safe search is turned on and may contain explicit content. Turn off safe search to view this room.'; + +const blockedBySafeSearchAvatarViewModel = new AvatarViewModel({ + avatarTitle: 'x', + avatarLetterString: 'x', + entityId: 'x', +}); + class RoomCardView extends TemplateView { render(t, vm) { const avatarViewModel = new AvatarViewModel({ @@ -31,6 +41,7 @@ class RoomCardView extends TemplateView { { className: { RoomCardView: true, + blockedBySafeSearch: (vm) => vm.blockedBySafeSearch, }, 'data-room-id': vm.roomId, 'data-testid': 'room-card', @@ -39,13 +50,29 @@ class RoomCardView extends TemplateView { t.a( { className: 'RoomCardView_header', - href: vm.archiveRoomUrl, + href: (vm) => { + if (vm.blockedBySafeSearch) { + // Omit the href so the link is not clickable when it's blocked by + // safe search + return false; + } + + return vm.archiveRoomUrl; + }, // Since this is the same button as the "View" link, just tab to // that instead tabindex: -1, }, [ - t.view(new AvatarView(avatarViewModel, 24)), + t.mapView( + (vm) => vm.blockedBySafeSearch, + (blockedBySafeSearch) => { + if (blockedBySafeSearch) { + return new AvatarView(blockedBySafeSearchAvatarViewModel, 24); + } + return new AvatarView(avatarViewModel, 24); + } + ), t.if( (vm) => vm.name, (t /*, vm*/) => @@ -53,36 +80,85 @@ class RoomCardView extends TemplateView { { className: 'RoomCardView_headerTitle', // We add a title so a tooltip shows the full name on hover - title: displayName, + title: (vm) => { + if (vm.blockedBySafeSearch) { + return safeSearchBlockedRoomTitle; + } + + return displayName; + }, }, - displayName + [ + (vm) => { + if (vm.blockedBySafeSearch) { + return safeSearchBlockedRoomTitle; + } + + return displayName; + }, + ] ) ), ] ), - t.a( - { - className: 'RoomCardView_alias', - href: vm.archiveRoomUrl, - // Since this is the same button as the "View" link, just tab to - // that instead - tabindex: -1, - }, - [aliasOrRoomId] + t.if( + (vm) => !vm.blockedBySafeSearch, + (t /*, vm*/) => + t.a( + { + className: 'RoomCardView_alias', + href: vm.archiveRoomUrl, + // Since this is the same button as the "View" link, just tab to + // that instead + tabindex: -1, + }, + [aliasOrRoomId] + ) + ), + t.if( + (vm) => vm.blockedBySafeSearch, + (t /*, vm*/) => + t.p({ className: 'RoomCardView_blockedBySafeSearchTopic' }, [ + safeSearchBlockedRoomDescription, + ]) + ), + t.if( + (vm) => !vm.blockedBySafeSearch, + (t /*, vm*/) => + t.p({ className: 'RoomCardView_topic', title: vm.topic || null }, [vm.topic || '']) ), - t.p({ className: 'RoomCardView_topic', title: vm.topic || null }, [vm.topic || '']), t.div({ className: 'RoomCardView_footer' }, [ t.div({ className: 'RoomCardView_footerInner' }, [ - t.div({}, [memberDisplay]), + t.div({}, [ + t.if( + (vm) => !vm.blockedBySafeSearch, + (/*t , vm*/) => text(memberDisplay) + ), + ]), t.a( { className: 'RoomCardView_viewButtonWrapperLink', - href: vm.archiveRoomUrl, - title: `View the ${displayName} room`, + href: (vm) => { + if (vm.blockedBySafeSearch) { + // Omit the href so the link is not clickable when it's blocked by + // safe search + return false; + } + + return vm.archiveRoomUrl; + }, + title: (vm) => { + if (vm.blockedBySafeSearch) { + return `Turn off safe search to view this room`; + } + + return `View the ${displayName} room`; + }, }, t.span( { className: 'RoomCardView_viewButton', + disabled: (vm) => vm.blockedBySafeSearch, }, ['View'] ) diff --git a/shared/views/RoomDirectoryView.js b/shared/views/RoomDirectoryView.js index 193b84b..3466322 100644 --- a/shared/views/RoomDirectoryView.js +++ b/shared/views/RoomDirectoryView.js @@ -19,8 +19,8 @@ class RoomDirectoryView extends TemplateView { const roomList = new ListView( { - className: 'RoomDirectoryView_roomList', - list: vm.rooms, + className: 'RoomDirectoryView_roomList RoomDirectoryView_mainContentSection', + list: vm.roomCardViewModels, parentProvidesUpdates: false, }, (room) => { @@ -210,75 +210,115 @@ class RoomDirectoryView extends TemplateView { t.if( (vm) => vm.roomFetchError, (t, vm) => { - return t.section({ className: 'RoomDirectoryView_roomListError' }, [ - t.h3('❗ Unable to fetch rooms from room directory'), - t.p({}, [ - `This may be a temporary problem with the homeserver where the room directory lives (${vm.pageSearchParameters.homeserver}) or the homeserver that the archive is pulling from (${vm.homeserverName}). You can try adjusting your search or select a different homeserver to look at. If this problem persists, please check the homeserver status and with a homeserver admin first, then open a `, - t.a( - { href: 'https://github.com/matrix-org/matrix-public-archive/issues/new' }, - 'bug report' - ), - ` with this whole section copy-pasted into the issue.`, - ]), - t.button( - { - className: 'PrimaryActionButton', - onClick: () => { - window.location.reload(); - }, - }, - 'Refresh page' - ), - t.p({}, `The exact error we ran into was:`), - t.pre( - { className: 'RoomDirectoryView_codeBlock' }, - t.code({}, vm.roomFetchError.stack) - ), - t.p({}, `The error occured with these search parameters:`), - t.pre( - { className: 'RoomDirectoryView_codeBlock' }, - t.code({}, JSON.stringify(vm.pageSearchParameters, null, 2)) - ), - t.details({}, [ - t.summary({}, 'Why are we showing so many details?'), + return t.section( + { + className: 'RoomDirectoryView_roomListError RoomDirectoryView_mainContentSection', + }, + [ + t.h3('❗ Unable to fetch rooms from room directory'), t.p({}, [ - `We're showing as much detail as we know so you're not frustrated by a generic message with no feedback on how to move forward. This also makes it easier for you to write a `, + `This may be a temporary problem with the homeserver where the room directory lives (${vm.pageSearchParameters.homeserver}) or the homeserver that the archive is pulling from (${vm.homeserverName}). You can try adjusting your search or select a different homeserver to look at. If this problem persists, please check the homeserver status and with a homeserver admin first, then open a `, t.a( { href: 'https://github.com/matrix-org/matrix-public-archive/issues/new' }, 'bug report' ), - ` with all the details necessary for us to triage it.`, + ` with this whole section copy-pasted into the issue.`, ]), - t.p({}, t.strong(`Isn't this a security risk?`)), - t.p({}, [ - `Not really. Usually, people are worried about returning details because it makes it easier for people to probe the system by getting better feedback about what's going wrong to craft exploits. But the `, - t.a( - { href: 'https://github.com/matrix-org/matrix-public-archive' }, - 'Matrix Public Archive' - ), - ` is already open source so the details of the app are already public and you can run your own instance against the same homeservers that we are to find problems.`, + t.button( + { + className: 'PrimaryActionButton', + onClick: () => { + window.location.reload(); + }, + }, + 'Refresh page' + ), + t.p({}, `The exact error we ran into was:`), + t.pre( + { className: 'RoomDirectoryView_codeBlock' }, + t.code({}, vm.roomFetchError.stack) + ), + t.p({}, `The error occured with these search parameters:`), + t.pre( + { className: 'RoomDirectoryView_codeBlock' }, + t.code({}, JSON.stringify(vm.pageSearchParameters, null, 2)) + ), + t.details({}, [ + t.summary({}, 'Why are we showing so many details?'), + t.p({}, [ + `We're showing as much detail as we know so you're not frustrated by a generic message with no feedback on how to move forward. This also makes it easier for you to write a `, + t.a( + { href: 'https://github.com/matrix-org/matrix-public-archive/issues/new' }, + 'bug report' + ), + ` with all the details necessary for us to triage it.`, + ]), + t.p({}, t.strong(`Isn't this a security risk?`)), + t.p({}, [ + `Not really. Usually, people are worried about returning details because it makes it easier for people to probe the system by getting better feedback about what's going wrong to craft exploits. But the `, + t.a( + { href: 'https://github.com/matrix-org/matrix-public-archive' }, + 'Matrix Public Archive' + ), + ` is already open source so the details of the app are already public and you can run your own instance against the same homeservers that we are to find problems.`, + ]), + t.p({}, [ + `If you find any security vulnerabilities, please `, + t.a( + { href: 'https://matrix.org/security-disclosure-policy/' }, + 'responsibly disclose' + ), + ` them to us.`, + ]), + t.p({}, [ + `If you have ideas on how we can better present these errors, please `, + t.a( + { href: 'https://github.com/matrix-org/matrix-public-archive/issues' }, + 'create an issue' + ), + `.`, + ]), ]), - t.p({}, [ - `If you find any security vulnerabilities, please `, - t.a( - { href: 'https://matrix.org/security-disclosure-policy/' }, - 'responsibly disclose' - ), - ` them to us.`, - ]), - t.p({}, [ - `If you have ideas on how we can better present these errors, please `, - t.a( - { href: 'https://github.com/matrix-org/matrix-public-archive/issues' }, - 'create an issue' - ), - `.`, - ]), - ]), - ]); + ] + ); } ), // Otherwise, display the rooms that we fetched + t.section( + { + className: + 'RoomDirectoryView_safeSearchToggleSection RoomDirectoryView_mainContentSection', + }, + [ + t.div({ className: 'RoomDirectoryView_safeSearchToggle' }, [ + t.input({ + id: 'safeSearchEnabled', + className: 'RoomDirectoryView_safeSearchToggleCheckbox', + type: 'checkbox', + checked: (vm) => vm.safeSearchEnabled, + onInput: (event) => vm.setSafeSearchEnabled(event.target.checked), + }), + t.label( + { + className: 'RoomDirectoryView_safeSearchToggleLabel', + for: 'safeSearchEnabled', + }, + [ + t.map( + (vm) => vm.safeSearchEnabled, + (safeSearchEnabled /*, t, vm*/) => { + if (safeSearchEnabled) { + return text('Safe search is on'); + } + + return text('Safe search is off'); + } + ), + ] + ), + ]), + ] + ), t.view(roomList), t.div({ className: 'RoomDirectoryView_paginationButtonCombo' }, [ t.a( diff --git a/test/dockerfiles/Synapse.Dockerfile b/test/dockerfiles/Synapse.Dockerfile index bbc9339..90407f3 100644 --- a/test/dockerfiles/Synapse.Dockerfile +++ b/test/dockerfiles/Synapse.Dockerfile @@ -4,7 +4,11 @@ # Currently this is based on Complement Synapse images which are based on the # published 'synapse:latest' image -- ie, the most recent Synapse release. -ARG SYNAPSE_VERSION=latest +# FIXME: We're pinning the version to `v1.79.0` until +# https://github.com/matrix-org/synapse/issues/15526 is fixed. Feel free to update back +# to `latest` once that issue is resolved. More context: +# https://github.com/matrix-org/matrix-public-archive/pull/208#discussion_r1183294630 +ARG SYNAPSE_VERSION=v1.79.0 FROM matrixdotorg/synapse:${SYNAPSE_VERSION} diff --git a/test/e2e-tests.js b/test/e2e-tests.js index b6ea50c..47fd8eb 100644 --- a/test/e2e-tests.js +++ b/test/e2e-tests.js @@ -2452,6 +2452,62 @@ describe('matrix-public-archive', () => { // Assert that the rooms we searched for on remote hs2 are visible assert.deepStrictEqual(roomsOnPageWithSearch.sort(), [roomXId, roomYId].sort()); }); + + it('Safe search blocks nsfw rooms by default', async () => { + const client = await getTestClientForHs(testMatrixServerUrl1); + // This is just an extra room to fill out the room directory and make sure + // that it does not appear when searching. + await createTestRoom(client); + + // Then create some NSFW rooms we will find with search + // + // We use a `timeToken` so that we can namespace these rooms away from other + // test runs against the same homeserver + const timeToken = Date.now(); + const roomPlanetPrefix = `planet-${timeToken}`; + const roomUranusId = await createTestRoom(client, { + // NSFW in title + name: `${roomPlanetPrefix}-uranus-nsfw`, + }); + const roomMarsId = await createTestRoom(client, { + name: `${roomPlanetPrefix}-mars`, + // NSFW in room topic/description + topic: 'Get your ass to mars (NSFW)', + }); + + // Browse the room directory searching the room directory for those NSFW rooms + // (narrowing down results). + archiveUrl = matrixPublicArchiveURLCreator.roomDirectoryUrl({ + searchTerm: roomPlanetPrefix, + }); + const { data: roomDirectoryWithSearchPageHtml } = await fetchEndpointAsText(archiveUrl); + const domWithSearch = parseHTML(roomDirectoryWithSearchPageHtml); + + const roomsCardsOnPageWithSearch = [ + ...domWithSearch.document.querySelectorAll(`[data-testid="room-card"]`), + ]; + + // Assert that the rooms we searched for are on the page + const roomsIdsOnPageWithSearch = roomsCardsOnPageWithSearch.map((roomCardEl) => { + return roomCardEl.getAttribute('data-room-id'); + }); + assert.deepStrictEqual(roomsIdsOnPageWithSearch.sort(), [roomUranusId, roomMarsId].sort()); + + // Sanity check that safe search does something. Assert that it's *NOT* showing + // the "nsfw" content + roomsCardsOnPageWithSearch.forEach((roomCardEl) => { + assert.match( + roomCardEl.innerHTML, + /^((?!nsfw).)*$/, + `Expected safe search to block any nsfw rooms but saw "nsfw" in the room cards: ${roomCardEl.innerHTML.replaceAll( + /nsfw/gi, + (match) => { + return chalk.yellow(match); + } + )}` + ); + }); + }); }); describe('access controls', () => {