Terms&Conditions (#18) WIP:

* Converse module to display terms.
* Prosody module to send terms.
This commit is contained in:
John Livingston 2024-06-25 09:59:46 +02:00
parent 45a63eaecd
commit b110456029
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
11 changed files with 324 additions and 6 deletions

View File

@ -46,6 +46,7 @@ import './plugins/fullscreen/index.js'
import '../custom/plugins/size/index.js' import '../custom/plugins/size/index.js'
import '../custom/plugins/tasks/index.js' import '../custom/plugins/tasks/index.js'
import '../custom/plugins/terms/index.js'
/* END: Removable components */ /* END: Removable components */
import { CORE_PLUGINS } from './headless/shared/constants.js' import { CORE_PLUGINS } from './headless/shared/constants.js'
@ -53,6 +54,7 @@ import { ROOM_FEATURES } from './headless/plugins/muc/constants.js'
// We must add our custom plugins to CORE_PLUGINS (so it is white listed): // We must add our custom plugins to CORE_PLUGINS (so it is white listed):
CORE_PLUGINS.push('livechat-converse-size') CORE_PLUGINS.push('livechat-converse-size')
CORE_PLUGINS.push('livechat-converse-tasks') CORE_PLUGINS.push('livechat-converse-tasks')
CORE_PLUGINS.push('livechat-converse-terms')
// We must also add our custom ROOM_FEATURES, so that they correctly resets // We must also add our custom ROOM_FEATURES, so that they correctly resets
// (see headless/plugins/muc, getDiscoInfoFeatures, which loops on this const) // (see headless/plugins/muc, getDiscoInfoFeatures, which loops on this const)
ROOM_FEATURES.push('x_peertubelivechat_mute_anonymous') ROOM_FEATURES.push('x_peertubelivechat_mute_anonymous')

View File

@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { CustomElement } from 'shared/components/element.js'
import { api } from '@converse/headless/core'
import { html } from 'lit'
import { __ } from 'i18n'
import '../styles/muc-terms.scss'
export default class MUCTermsView extends CustomElement {
static get properties () {
return {
model: { type: Object, attribute: true },
termstype: { type: String, attribute: true }
}
}
async initialize () {
if (!this.model) {
return
}
this.listenTo(this.model, 'change:x_livechat_terms_' + this.termstype, () => this.requestUpdate())
}
render () {
const terms = this.model?.get('x_livechat_terms_' + this.termstype)
return html`
${terms && terms.body && !this._hideInfoBox(terms.body)
? html`
<div>
<converse-rich-text text=${terms.body} render_styling></converse-rich-text>
<i class="livechat-hide-terms-info-box" @click=${this.closeInfoBox} title=${__('Close')}>
<converse-icon class="fa fa-times" size="1em"></converse-icon>
</i>
</div>`
: ''
}`
}
closeInfoBox (ev) {
ev.preventDefault()
const terms = this.model?.get('x_livechat_terms_' + this.termstype)
if (terms) {
localStorage?.setItem('x_livechat_terms_' + this.termstype + '_hidden', terms.body)
}
this.requestUpdate()
}
_hideInfoBox (body) {
// When hiding the infobox, we store in localStorage the current body, so we will show it again if message change.
// Note: for termstype=global we don't store the MUC server, so if user join chat from different instances,
// it will show terms again
// Note: same for termstype=muc, we don't store the MUC JID, so if user changes channel,
// it will probably show terms again
const lsHideInfoBox = localStorage?.getItem('x_livechat_terms_' + this.termstype + '_hidden')
return lsHideInfoBox === body
}
}
api.elements.define('livechat-converse-muc-terms', MUCTermsView)

View File

@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: AGPL-3.0-only
import { converse, api } from '../../../src/headless/core.js'
import './components/muc-terms.js'
const { sizzle } = converse.env
converse.plugins.add('livechat-converse-terms', {
dependencies: ['converse-muc'],
initialize () {
api.listen.on('parseMUCMessage', (stanza, attrs) => {
const livechatTerms = sizzle('x-livechat-terms', stanza)
if (!livechatTerms.length) {
return attrs
}
return Object.assign(
attrs,
{
x_livechat_terms: livechatTerms[0].getAttribute('type')
}
)
})
},
overrides: {
ChatRoom: {
onMessage: function onMessage (attrs) {
if (!attrs.x_livechat_terms) {
return this.__super__.onMessage(attrs)
}
// We received a x-livechat-terms message, we don't forward it to standard onMessage,
// but we just update the room attribute.
const type = attrs.x_livechat_terms
if (type !== 'global' && type !== 'muc') {
console.error('Invalid x-livechat-terms type: ', type)
return
}
// console.info('Received a x-livechat-terms message', attrs)
const options = {}
options['x_livechat_terms_' + type] = attrs
this.set(options)
// this will be displayed by the livechat-converse-muc-terms custom element,
// which is inserted in the DOM by the muc.js template overload.
}
}
}
})

View File

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.conversejs {
livechat-converse-muc-terms {
background-color: var(--peertube-main-background);
color: var(--peertube-main-foreground);
div {
align-items: center;
border: 1px solid var(--peertube-menu-background);
display: flex;
flex-flow: row;
justify-content: space-between;
margin: 5px;
padding: 5px;
converse-rich-text {
flex-grow: 2;
max-height: 5em;
overflow-y: scroll;
white-space: pre-wrap;
}
.livechat-hide-terms-info-box {
cursor: pointer;
font-size: var(--font-size-small);
flex-shrink: 2;
}
}
}
}
.livechat-readonly .conversejs {
livechat-converse-muc-terms {
display: none !important;
}
}

View File

@ -63,7 +63,7 @@ class SlowMode extends CustomElement {
LOC_slow_mode_info, LOC_slow_mode_info,
this.model.config.get('slow_mode_duration') this.model.config.get('slow_mode_duration')
)} )}
<i class="livechat-hide-slow-mode-info-box" @click=${this.closeSlowModeInfoBox}> <i class="livechat-hide-slow-mode-info-box" @click=${this.closeSlowModeInfoBox} title=${__('Close')}>
<converse-icon class="fa fa-times" size="1em"></converse-icon> <converse-icon class="fa fa-times" size="1em"></converse-icon>
</i> </i>
</div>` </div>`

View File

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2013-2024 JC Brand <https://github.com/conversejs/converse.js/>
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
//
// SPDX-License-Identifier: MPL-2.0
// SPDX-License-Identifier: AGPL-3.0-only
// Must import the original muc.js, because it imports some custom elements files.
import '../../src/plugins/muc-views/templates/muc.js'
import { getChatRoomBodyTemplate } from '../../src/plugins/muc-views/utils.js'
import { html } from 'lit'
// Overloading the original muc.js, to add some custom elements.
export default (o) => {
return html`
<div class="flyout box-flyout">
<converse-dragresize></converse-dragresize>
${
o.model
? html`
<converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row no-gutters">
</converse-muc-heading>
<livechat-converse-muc-terms .model=${o.model} termstype="global"></livechat-converse-muc-terms>
<livechat-converse-muc-terms .model=${o.model} termstype="muc"></livechat-converse-muc-terms>
<div class="chat-body chatroom-body row no-gutters">${getChatRoomBodyTemplate(o)}</div>`
: ''}
</div>`
}

View File

@ -40,6 +40,7 @@ module.exports = merge(prod, {
alias: { alias: {
'./templates/muc-bottom-panel.js': path.resolve('custom/templates/muc-bottom-panel.js'), './templates/muc-bottom-panel.js': path.resolve('custom/templates/muc-bottom-panel.js'),
'./templates/muc-head.js': path.resolve(__dirname, 'custom/templates/muc-head.js'), './templates/muc-head.js': path.resolve(__dirname, 'custom/templates/muc-head.js'),
'./templates/muc.js': path.resolve(__dirname, 'custom/templates/muc.js'),
'../../templates/background_logo.js$': path.resolve(__dirname, 'custom/templates/background_logo.js'), '../../templates/background_logo.js$': path.resolve(__dirname, 'custom/templates/background_logo.js'),
'./templates/muc-chatarea.js': path.resolve('custom/templates/muc-chatarea.js'), './templates/muc-chatarea.js': path.resolve('custom/templates/muc-chatarea.js'),

View File

@ -0,0 +1,34 @@
<!--
SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
SPDX-License-Identifier: AGPL-3.0-only
-->
# mod_muc_peertubelivechat_terms
This module is a custom module to handle Terms&Conditions in the livechat Peertube plugin.
This module is part of peertube-plugin-livechat, and is under the same LICENSE.
## Features
When a new occupant session is created for a MUC, this module will send to the user the global terms,
and the MUC-specific terms (if defined).
This is done by sending groupchat messages.
These messages will contain a "x-livechat-terms" tag, so that livechat front-end can detect these messages, and display them differently.
For standard XMPP clients, these messages will show as standard MUC message coming from a specific nickname.
## Configuration
This modules take following options.
### muc_terms_service_nickname
The nickname that will be used by service messages.
This module reserves the nickname, so than nobody can use it in MUC rooms
(we don't want any user to spoof this nickname).
### muc_terms
The global terms.

View File

@ -0,0 +1,66 @@
-- mod_muc_peertubelivechat_terms
--
-- SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
-- SPDX-License-Identifier: AGPL-3.0-only
--
-- This file is AGPL-v3 licensed.
-- Please see the Peertube livechat plugin copyright information.
-- https://livingston.frama.io/peertube-plugin-livechat/credits/
--
local jid_escape = require "util.jid".escape;
local jid_resource = require "util.jid".resource;
local st = require "util.stanza";
local id = require "util.id";
local service_nickname = module:get_option_string("muc_terms_service_nickname", "Service");
local global_terms = module:get_option_string("muc_terms", "");
-- send the terms when joining:
function send_terms(event)
local origin = event.origin;
local room = event.room;
local occupant = event.occupant;
if global_terms then
local from = room.jid .. '/' .. jid_escape(service_nickname);
module:log("debug", "Sending global terms to %s from %s (room %s)", occupant.jid, from, room);
local message = st.message({
type = "groupchat",
to = occupant.jid,
from = from,
id = id.medium()
}, global_terms)
:tag('x-livechat-terms', { type = "global" }):up(); -- adding a custom tag to specify that it is a "terms" message, so that frontend can display it with a special template.
origin.send(message);
end
end
-- Note: we could do that on muc-occupant-joined or muc-occupant-session-new.
-- The first will not send it to multiple clients, the second will.
-- After some reflexion, i will try muc-occupant-session-new, and see if it works as expected.
module:hook("muc-occupant-session-new", send_terms);
-- reserve the service_nickname:
function enforce_nick_policy(event)
local origin, stanza = event.origin, event.stanza;
local requested_nick = jid_resource(stanza.attr.to);
local room = event.room;
if not room then return; end
if requested_nick == service_nickname then
module:log("debug", "Occupant tried to use the %s reserved nickname, blocking it.", service_nickname);
local reply = st.error_reply(stanza, "cancel", "conflict", nil, room.jid):up();
origin.send(reply);
return true;
end
end
module:hook("muc-occupant-pre-join", enforce_nick_policy);
module:hook("muc-occupant-pre-change", enforce_nick_policy);
-- security check: we must remove all "x-livechat-terms" tag, to be sure nobody tries to spoof terms!
module:hook("muc-occupant-groupchat", function(event)
event.stanza:maptags(function (child)
if child.name == 'x-livechat-terms' then
return nil;
end
return child;
end);
end, 100);

View File

@ -175,7 +175,8 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
'chat-no-anonymous', 'chat-no-anonymous',
'auto-ban-anonymous-ip', 'auto-ban-anonymous-ip',
'federation-dont-publish-remotely', 'federation-dont-publish-remotely',
'disable-channel-configuration' 'disable-channel-configuration',
'chat-terms'
]) ])
const valuesToHideInDiagnostic = new Map<string, string>() const valuesToHideInDiagnostic = new Map<string, string>()
@ -199,6 +200,9 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
let certificates: ProsodyConfigCertificates = false let certificates: ProsodyConfigCertificates = false
const useBots = !settings['disable-channel-configuration'] const useBots = !settings['disable-channel-configuration']
const bots: ProsodyConfig['bots'] = {} const bots: ProsodyConfig['bots'] = {}
const chatTerms = (typeof settings['chat-terms'] === 'string') && settings['chat-terms']
? settings['chat-terms']
: undefined
let useExternal: boolean = false let useExternal: boolean = false
try { try {
@ -260,7 +264,7 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
const roomApiUrl = baseApiUrl + 'room?apikey=' + apikey + '&jid={room.jid|jid_node}' const roomApiUrl = baseApiUrl + 'room?apikey=' + apikey + '&jid={room.jid|jid_node}'
const testApiUrl = baseApiUrl + 'test?apikey=' + apikey const testApiUrl = baseApiUrl + 'test?apikey=' + apikey
const config = new ProsodyConfigContent(paths, prosodyDomain) const config = new ProsodyConfigContent(paths, prosodyDomain, chatTerms)
if (!disableAnon) { if (!disableAnon) {
config.useAnonymous(autoBanIP) config.useAnonymous(autoBanIP)
} }

View File

@ -7,7 +7,30 @@ import type { ExternalComponent } from './components'
import { BotConfiguration } from '../../configuration/bot' import { BotConfiguration } from '../../configuration/bot'
import { userInfo } from 'os' import { userInfo } from 'os'
type ConfigEntryValue = boolean | number | string | ConfigEntryValue[] /**
* Use this class to construct a string that will be writen as a multiline Lua string.
*/
class ConfigEntryValueMultiLineString extends String {
public serialize (): string {
const s = this.toString()
let i = 0
// Lua multiline strings can be escaped by [[ ]], or [==[ ]==] with any number of =
// http://lua-users.org/wiki/StringsTutorial
// So, to have a proper value, we will check if the string contains [[ or ]],
// and try again by adding "=" until we do not found the pattern.
while (true) {
const opening = '[' + '='.repeat(i) + '['
const closing = ']' + '='.repeat(i) + ']'
if (!s.includes(opening) && !s.includes(closing)) {
break
}
i++
}
return '[' + '='.repeat(i) + '[' + s + ']' + '='.repeat(i) + ']'
}
}
type ConfigEntryValue = boolean | number | string | ConfigEntryValueMultiLineString | ConfigEntryValue[]
type ConfigEntries = Map<string, ConfigEntryValue> type ConfigEntries = Map<string, ConfigEntryValue>
@ -33,6 +56,9 @@ type ConfigLogExpiration =
ConfigLogExpirationNever | ConfigLogExpirationPeriod | ConfigLogExpirationSeconds | ConfigLogExpirationError ConfigLogExpirationNever | ConfigLogExpirationPeriod | ConfigLogExpirationSeconds | ConfigLogExpirationError
function writeValue (value: ConfigEntryValue): string { function writeValue (value: ConfigEntryValue): string {
if (value instanceof ConfigEntryValueMultiLineString) {
return value.serialize() + ';\n'
}
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value.toString() + ';\n' return value.toString() + ';\n'
} }
@ -151,7 +177,7 @@ class ProsodyConfigContent {
log: string log: string
prosodyDomain: string prosodyDomain: string
constructor (paths: ProsodyFilePaths, prosodyDomain: string) { constructor (paths: ProsodyFilePaths, prosodyDomain: string, chatTerms?: string) {
this.paths = paths this.paths = paths
this.global = new ProsodyConfigGlobal() this.global = new ProsodyConfigGlobal()
this.log = '' this.log = ''
@ -212,9 +238,16 @@ class ProsodyConfigContent {
this.muc.set('muc_room_default_history_length', 20) this.muc.set('muc_room_default_history_length', 20)
this.muc.add('modules_enabled', 'muc_slow_mode') this.muc.add('modules_enabled', 'muc_slow_mode')
this.muc.add('slow_mode_duration_form_position', 120)
this.muc.add('modules_enabled', 'pubsub_peertubelivechat') this.muc.add('modules_enabled', 'pubsub_peertubelivechat')
this.muc.add('modules_enabled', 'muc_peertubelivechat_roles') this.muc.add('modules_enabled', 'muc_peertubelivechat_roles')
this.muc.add('slow_mode_duration_form_position', 120)
this.muc.add('modules_enabled', 'muc_peertubelivechat_terms')
this.muc.set('muc_terms_service_nickname', 'Peertube')
if (chatTerms) {
this.muc.set('muc_terms', new ConfigEntryValueMultiLineString(chatTerms))
}
} }
useAnonymous (autoBanIP: boolean): void { useAnonymous (autoBanIP: boolean): void {