Add safe search filter for NSFW rooms (#208)

Fix https://github.com/matrix-org/matrix-public-archive/issues/89
This commit is contained in:
Eric Eastwood 2023-05-03 04:45:33 -05:00 committed by GitHub
parent 858c9dde8b
commit b70439e95b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 547 additions and 141 deletions

View File

@ -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;
}

14
package-lock.json generated
View File

@ -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",

View File

@ -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/",

View File

@ -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;

View File

@ -85,6 +85,7 @@ async function mountHydrogen() {
urlRouter: urlRouter,
history: archiveHistory,
// Our options
basePath: config.basePath,
homeserverUrl: config.matrixServerUrl,
homeserverName: config.matrixServerName,
matrixPublicArchiveURLCreator,

View File

@ -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);

View File

@ -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');

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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']
)

View File

@ -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(

View File

@ -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}

View File

@ -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', () => {