diff --git a/CHANGELOG.md b/CHANGELOG.md index 665fcbe1..a2f49a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v3.2.0 + +### Features + +* Builtin Prosody: list existing rooms in the settings page +* Builtin Prosody: new settings to enable local C2S. For example, can be used with Matterbridge (thanks https://github.com/tytan652) + +### Fixes + +* Fix broken API diagnostic. + ## v3.1.0 ### Features diff --git a/README.md b/README.md index f92545c7..bf55c4c8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ If you have any question, or if you want to talk about this plugin, you can join ## Settings For the chat mode, and related settings, please refer to the corresponding documentation: + * [Prosody server controlled by Peertube (recommended)](documentation/prosody.md). **This is the recommanded setup**. * [Connect to an existing XMPP server with ConverseJS](documentation/conversejs.md) * [Use an external web chat tool](documentation/external.md) diff --git a/ROADMAP.md b/ROADMAP.md index d81be925..9cfb12f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,7 +29,7 @@ This roadmap is given as an indication. It will be updated as we go along accord [x] | Documentation | Rewrite documentation for more clarity. | v3.0.0 [ ] | Documentation | Add screenshots. [ ] | Documentation | User documentation. -[ ] | Builtin Prosody | Room administration: add a button in the plugin settings to open a modal with existing rooms list. +[.] | Builtin Prosody | Room administration: add a button in the plugin settings to open a modal with existing rooms list. TODO: use a modal. | v3.2.0 [ ] | Builtin Prosody | Check with yunohost how to integrate. [ ] | Settings | Translate settings page. [ ] | ConverseJS | UI: make custom templates, for a better UI/UX. Autoshow muc participants depending on the chat window width. diff --git a/assets/style.css b/assets/style.css index e5daefed..5673368a 100644 --- a/assets/style.css +++ b/assets/style.css @@ -53,4 +53,34 @@ .peertube-plugin-livechat-warning { color: orange; -} \ No newline at end of file +} + +.peertube-plugin-livechat-error { + color: red; +} + +table.peertube-plugin-livechat-prosody-list-rooms { + border: 1px solid black; + margin: 5px 0px; +} + +table.peertube-plugin-livechat-prosody-list-rooms tr:nth-child(odd) { + background-color: #eee; +} + +table.peertube-plugin-livechat-prosody-list-rooms tr:nth-child(even) { + background-color: #fff; +} + +table.peertube-plugin-livechat-prosody-list-rooms th { + background-color: var(--mainHoverColor); + border: 1px solid black; + color: var(--mainBackgroundColor); + padding: 4px 5px; +} + +table.peertube-plugin-livechat-prosody-list-rooms td { + border: 1px solid #555; + color: black; + padding: 4px 5px; +} diff --git a/client/@types/peertube.d.ts b/client/@types/peertube.d.ts index 3121a03d..a649d552 100644 --- a/client/@types/peertube.d.ts +++ b/client/@types/peertube.d.ts @@ -11,6 +11,7 @@ interface RegisterClientHelpers { // NB: getBaseRouterRoute will come with Peertube > 3.2.1 (3.3.0?) getBaseRouterRoute?: () => string isLoggedIn: () => boolean + getAuthHeader: () => { 'Authorization': string } | undefined getSettings: () => Promise<{ [ name: string ]: string }> notifier: { info: (text: string, title?: string, timeout?: number) => void @@ -63,6 +64,7 @@ interface RegisterOptions { interface Video { isLive: boolean isLocal: boolean + name: string originInstanceUrl: string uuid: string } diff --git a/client/admin-plugin-client-plugin.ts b/client/admin-plugin-client-plugin.ts index c86b7eeb..ea5b6cbd 100644 --- a/client/admin-plugin-client-plugin.ts +++ b/client/admin-plugin-client-plugin.ts @@ -1,4 +1,4 @@ -import type { ChatType } from 'shared/lib/types' +import type { ChatType, ProsodyListRoomsResult } from 'shared/lib/types' interface ActionPluginSettingsParams { npmName: string @@ -31,6 +31,134 @@ function register ({ registerHook, registerSettingsScript, peertubeHelpers }: Re diagButton.setAttribute('href', getBaseRoute() + '/settings/diagnostic') diagButton.setAttribute('target', '_blank') }) + console.log('[peertube-plugin-livechat] Initializing prosody-list-rooms button') + const listRoomsButtons: NodeListOf = + document.querySelectorAll('.peertube-plugin-livechat-prosody-list-rooms') + listRoomsButtons.forEach(listRoomsButton => { + if (listRoomsButton.classList.contains('btn')) { return } + listRoomsButton.classList.add('btn') + listRoomsButton.classList.add('action-button') + listRoomsButton.classList.add('orange-button') + listRoomsButton.onclick = async (): Promise => { + console.log('[peertube-plugin-livechat] Opening the room list...') + // TODO: use showModal (seems buggy with Peertube 3.2.1) + + try { + document.querySelectorAll('.peertube-plugin-livechat-prosody-list-rooms-content') + .forEach(dom => dom.remove()) + const container = document.createElement('div') + container.classList.add('peertube-plugin-livechat-prosody-list-rooms-content') + container.textContent = '...' + listRoomsButton.after(container) + + const response = await fetch(getBaseRoute() + '/webchat/prosody-list-rooms', { + method: 'GET', + headers: peertubeHelpers.getAuthHeader() + }) + if (!response.ok) { + throw new Error('Response is not ok') + } + const json: ProsodyListRoomsResult = await response.json() + if (!json.ok) { + container.textContent = json.error + container.classList.add('peertube-plugin-livechat-error') + } else { + const rooms = json.rooms.sort((a, b) => { + const timestampA = a.lasttimestamp ?? 0 + const timestampB = b.lasttimestamp ?? 0 + if (timestampA === timestampB) { + return a.name.localeCompare(b.name) + } else if (timestampA < timestampB) { + return 1 + } else { + return -1 + } + }) + + container.textContent = '' + const table = document.createElement('table') + table.classList.add('peertube-plugin-livechat-prosody-list-rooms') + container.append(table) + // TODO: translate labels. + const labels: any = { + RoomName: 'Room name', + RoomDescription: 'Room description', + NotFound: 'Not found', + Video: 'Video', + LastActivity: 'Last activity' + } + + const titleLineEl = document.createElement('tr') + const titleNameEl = document.createElement('th') + titleNameEl.textContent = labels.RoomName + const titleDescriptionEl = document.createElement('th') + titleDescriptionEl.textContent = labels.RoomDescription + const titleVideoEl = document.createElement('th') + titleVideoEl.textContent = labels.Video + const titleLastActivityEl = document.createElement('th') + titleLastActivityEl.textContent = labels.LastActivity + titleLineEl.append(titleNameEl) + titleLineEl.append(titleDescriptionEl) + titleLineEl.append(titleVideoEl) + titleLineEl.append(titleLastActivityEl) + table.append(titleLineEl) + rooms.forEach(room => { + const uuid = room.localpart + const href = getBaseRoute() + '/webchat/room/' + encodeURIComponent(uuid) + const lineEl = document.createElement('tr') + const nameEl = document.createElement('td') + const aEl = document.createElement('a') + aEl.textContent = room.name + aEl.target = '_blank' + const descriptionEl = document.createElement('td') + descriptionEl.textContent = room.description + const videoEl = document.createElement('td') + const lastActivityEl = document.createElement('td') + if (room.lasttimestamp && (typeof room.lasttimestamp === 'number')) { + const date = new Date(room.lasttimestamp * 1000) + lastActivityEl.textContent = date.toLocaleDateString() + ' ' + date.toLocaleTimeString() + } + nameEl.append(aEl) + lineEl.append(nameEl) + lineEl.append(descriptionEl) + lineEl.append(videoEl) + lineEl.append(lastActivityEl) + table.append(lineEl) + + if (/^[a-zA-A0-9-]+$/.test(uuid)) { + const p = fetch('/api/v1/videos/' + uuid, { + method: 'GET', + headers: peertubeHelpers.getAuthHeader() + }) + p.then(async res => { + if (!res.ok) { + videoEl.textContent = labels.NotFound + return + } + const video: Video | undefined = await res.json() + if (!video) { + videoEl.textContent = labels.NotFound + return + } + + aEl.href = href + const aVideoEl = document.createElement('a') + aVideoEl.textContent = video.name + aVideoEl.target = '_blank' + aVideoEl.href = '/videos/watch/' + uuid + videoEl.append(aVideoEl) + }, () => { + console.error('[peertube-plugin-livechat] Failed to retrieve video ' + uuid) + }) + } + }) + } + } catch (error) { + console.error(error) + peertubeHelpers.notifier.error('Room list failed') + } + } + }) } }) registerSettingsScript({ @@ -42,7 +170,15 @@ function register ({ registerHook, registerSettingsScript, peertubeHelpers }: Re case 'prosody-port': case 'prosody-peertube-uri': case 'chat-type-help-builtin-prosody': + case 'prosody-list-rooms': + case 'prosody-advanced': + case 'prosody-c2s': return options.formValues['chat-type'] !== ('builtin-prosody' as ChatType) + case 'prosody-c2s-port': + return !( + options.formValues['chat-type'] === ('builtin-prosody' as ChatType) && + options.formValues['prosody-c2s'] === true + ) case 'chat-server': case 'chat-room': case 'chat-bosh-uri': diff --git a/documentation/prosody.md b/documentation/prosody.md index 10dc4656..e7aaed4e 100644 --- a/documentation/prosody.md +++ b/documentation/prosody.md @@ -61,6 +61,27 @@ This is the port that the Prosody server will use. By default it is set to 52800 These settings are common with other chat modes. Here is the documentation: [common settings](./common.md). +### Prosody advanced settings + +#### Enable client to server connections + +This setting enable XMPP clients to connect to the builtin Prosody server. +For now, this option **only allows connections from localhost clients**. + +As example, this option can allow an instance of Matterbridge (once it could use anonymous login) *on the same machine* to bridge your chat with another services like a Matrix room. + +##### Prosody client to server port + +The port that will be used by the c2s module of the builtin Prosody server. +XMPP clients shall use this port to connect. +Change it if this port is already in use on your server. + +## Moderation + +You can list all existing chatrooms: in the plugin settings screen, there is a button «List rooms». + +You can delete old rooms: join the room, and use the menu on the top to destroy the room. + ## Notes All instance moderators and admins will be owner for created chat rooms. diff --git a/package.json b/package.json index 46c243e3..7fb12978 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "peertube-plugin-livechat", "description": "PeerTube plugin livechat", - "version": "3.1.0", + "version": "3.2.0", "author": "John Livingston", "bugs": "https://github.com/JohnXLivingston/peertube-plugin-livechat/issues", "clientScripts": [ diff --git a/prosody-modules/mod_http_peertubelivechat_list_rooms/README.md b/prosody-modules/mod_http_peertubelivechat_list_rooms/README.md new file mode 100644 index 00000000..6c4fe736 --- /dev/null +++ b/prosody-modules/mod_http_peertubelivechat_list_rooms/README.md @@ -0,0 +1,5 @@ +# mod_http_peertubelivechat_list_rooms + +This module is a custom module that allows Peertube server to list chat rooms. + +This module is part of peertube-plugin-livechat, and is under the same LICENSE. diff --git a/prosody-modules/mod_http_peertubelivechat_list_rooms/mod_http_peertubelivechat_list_rooms.lua b/prosody-modules/mod_http_peertubelivechat_list_rooms/mod_http_peertubelivechat_list_rooms.lua new file mode 100644 index 00000000..83ee98be --- /dev/null +++ b/prosody-modules/mod_http_peertubelivechat_list_rooms/mod_http_peertubelivechat_list_rooms.lua @@ -0,0 +1,62 @@ +local json = require "util.json"; +local jid_split = require"util.jid".split; +local array = require "util.array"; + +local mod_muc = module:depends"muc"; +local all_rooms = rawget(mod_muc, "all_rooms") + +module:depends"http"; + +function check_auth(routes) + local function check_request_auth(event) + local apikey = module:get_option_string("peertubelivechat_list_rooms_apikey", "") + if apikey == "" then + return false, 500; + end + if event.request.headers.authorization ~= "Bearer " .. apikey then + return false, 401; + end + return true; + end + + for route, handler in pairs(routes) do + routes[route] = function (event, ...) + local permit, code = check_request_auth(event); + if not permit then + return code; + end + return handler(event, ...); + end; + end + return routes; +end + +local function list_rooms(event) + local request, response = event.request, event.response; + local rooms_json = array(); + for room in all_rooms() do + local localpart = jid_split(room.jid); + local history = room._history; + local lasttimestamp; + if history ~= nil and #history > 0 then + lasttimestamp = history[#history].timestamp; + end + rooms_json:push({ + jid = room.jid; + localpart = localpart; + name = room:get_name() or localpart; + lang = room.get_language and room:get_language(); + description = room:get_description(); + lasttimestamp = lasttimestamp; + }) + end + + event.response.headers["Content-Type"] = "application/json"; + return json.encode_array(rooms_json); +end + +module:provides("http", { + route = check_auth { + ["GET /list-rooms"] = list_rooms; + }; +}); diff --git a/server/lib/diagnostic/prosody.ts b/server/lib/diagnostic/prosody.ts index f069eb54..64b0fe70 100644 --- a/server/lib/diagnostic/prosody.ts +++ b/server/lib/diagnostic/prosody.ts @@ -21,12 +21,14 @@ export async function diagProsody (test: string, options: RegisterServerOptions) // FIXME: these tests are very similar to tests in testProsodyCorrectlyRunning. Remove from here? // Testing the prosody config file. let prosodyPort: string + let prosodyHost: string try { const wantedConfig = await getProsodyConfig(options) const filePath = wantedConfig.paths.config result.messages.push(`Prosody will run on port '${wantedConfig.port}'`) prosodyPort = wantedConfig.port + prosodyHost = wantedConfig.host result.messages.push(`Prosody will use ${wantedConfig.baseApiUrl} as base uri from api calls`) @@ -99,7 +101,8 @@ export async function diagProsody (test: string, options: RegisterServerOptions) const testResult = await got(apiUrl, { method: 'GET', headers: { - authorization: 'Bearer ' + await getAPIKey(options) + authorization: 'Bearer ' + await getAPIKey(options), + host: prosodyHost }, responseType: 'json', resolveBodyOnly: true @@ -120,7 +123,8 @@ export async function diagProsody (test: string, options: RegisterServerOptions) const testResult = await got(apiUrl, { method: 'GET', headers: { - authorization: 'Bearer ' + await getAPIKey(options) + authorization: 'Bearer ' + await getAPIKey(options), + host: prosodyHost }, responseType: 'json', resolveBodyOnly: true diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts index cf37a8a0..ac7d4819 100644 --- a/server/lib/prosody/config.ts +++ b/server/lib/prosody/config.ts @@ -66,6 +66,7 @@ async function getProsodyFilePaths (options: RegisterServerOptions): Promise { return new Promise((resolve) => { diff --git a/server/lib/routers/webchat.ts b/server/lib/routers/webchat.ts index 636d04e9..aa23019c 100644 --- a/server/lib/routers/webchat.ts +++ b/server/lib/routers/webchat.ts @@ -1,16 +1,23 @@ import type { Router, RequestHandler, Request, Response, NextFunction } from 'express' import type { ProxyOptions } from 'express-http-proxy' -import type { ChatType } from '../../../shared/lib/types' -import { getBaseRouterRoute, getBaseStaticRoute } from '../helpers' +import type { ChatType, ProsodyListRoomsResult } from '../../../shared/lib/types' +import { getBaseRouterRoute, getBaseStaticRoute, isUserAdmin } from '../helpers' import { asyncMiddleware } from '../middlewares/async' import { getProsodyDomain } from '../prosody/config/domain' +import { getAPIKey } from '../apikey' import * as path from 'path' const bodyParser = require('body-parser') +const got = require('got') const fs = require('fs').promises const proxy = require('express-http-proxy') let httpBindRoute: RequestHandler +interface ProsodyHttpBindInfo { + host: string + port: string +} +let currentProsodyHttpBindInfo: ProsodyHttpBindInfo | null = null async function initWebchatRouter (options: RegisterServerOptions): Promise { const { @@ -97,22 +104,76 @@ async function initWebchatRouter (options: RegisterServerOptions): Promise { + if (!res.locals.authenticated) { + res.sendStatus(403) + return + } + if (!await isUserAdmin(options, res)) { + res.sendStatus(403) + return + } + + const chatType: ChatType = await options.settingsManager.getSetting('chat-type') as ChatType + if (chatType !== 'builtin-prosody') { + const message = 'Please save the settings first.' // TODO: translate? + res.status(200) + const r: ProsodyListRoomsResult = { + ok: false, + error: message + } + res.json(r) + return + } + + if (!currentProsodyHttpBindInfo) { + throw new Error('It seems that prosody is not binded... Cant list rooms.') + } + const apiUrl = `http://localhost:${currentProsodyHttpBindInfo.port}/peertubelivechat_list_rooms/list-rooms` + peertubeHelpers.logger.debug('Calling list rooms API on url: ' + apiUrl) + const rooms = await got(apiUrl, { + method: 'GET', + headers: { + authorization: 'Bearer ' + await getAPIKey(options), + host: currentProsodyHttpBindInfo.host + }, + responseType: 'json', + resolveBodyOnly: true + }) + + res.status(200) + const r: ProsodyListRoomsResult = { + ok: true, + rooms: rooms + } + res.json(r) + } + )) + return router } -function changeHttpBindRoute ({ peertubeHelpers }: RegisterServerOptions, port: string | null): void { +function changeHttpBindRoute ( + { peertubeHelpers }: RegisterServerOptions, + prosodyHttpBindInfo: ProsodyHttpBindInfo | null +): void { const logger = peertubeHelpers.logger - logger.info('Changing http-bind port for ' + (port ?? 'null')) - if (port !== null && !/^\d+$/.test(port)) { - logger.error('Port is not valid. Replacing by null') - port = null + if (prosodyHttpBindInfo && !/^\d+$/.test(prosodyHttpBindInfo.port)) { + logger.error(`Port '${prosodyHttpBindInfo.port}' is not valid. Replacing by null`) + prosodyHttpBindInfo = null } - if (port === null) { + + if (!prosodyHttpBindInfo) { + logger.info('Changing http-bind port for null') + currentProsodyHttpBindInfo = null httpBindRoute = (_req: Request, res: Response, _next: NextFunction) => { res.status(404) res.send('Not found') } } else { + logger.info('Changing http-bind port for ' + prosodyHttpBindInfo.port + ', on host ' + prosodyHttpBindInfo.host) const options: ProxyOptions = { https: false, proxyReqPathResolver: async (_req: Request): Promise => { @@ -122,7 +183,8 @@ function changeHttpBindRoute ({ peertubeHelpers }: RegisterServerOptions, port: parseReqBody: true // Note that setting this to false overrides reqAsBuffer and reqBodyEncoding below. // FIXME: should we remove cookies? } - httpBindRoute = proxy('http://localhost:' + port, options) + currentProsodyHttpBindInfo = prosodyHttpBindInfo + httpBindRoute = proxy('http://localhost:' + prosodyHttpBindInfo.port, options) } } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 26dbe209..f9c486d3 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -91,6 +91,13 @@ Please read the private: true }) + registerSetting({ + name: 'prosody-list-rooms', + label: 'List existing rooms', + type: 'html', + descriptionHTML: 'List rooms', + private: true + }) registerSetting({ name: 'prosody-port', label: 'Prosody port', @@ -103,18 +110,6 @@ Change it if this port is already in use on your server.
You can close this port on your firewall, it will not be accessed from the outer world.` }) - registerSetting({ - name: 'prosody-peertube-uri', - label: 'Peertube url for API calls', - type: 'input', - default: '', - private: true, - descriptionHTML: -`Please let this settings empty if you don't know what you are doing.
-In some rare case, Prosody can't call Peertube's API from its public URI. -You can use this field to customise Peertube's URI for Prosody modules (for example with «http://localhost:9000»).` - }) - registerSetting({ name: 'chat-server', label: 'XMPP service server', @@ -269,6 +264,50 @@ Example: height:400px;`, private: false }) + // ********** Built-in Prosody advanced settings + registerSetting({ + name: 'prosody-advanced', + type: 'html', + private: true, + descriptionHTML: '

Prosody advanced settings

' + }) + + registerSetting({ + name: 'prosody-peertube-uri', + label: 'Peertube url for API calls', + type: 'input', + default: '', + private: true, + descriptionHTML: +`Please let this settings empty if you don't know what you are doing.
+In some rare case, Prosody can't call Peertube's API from its public URI. +You can use this field to customise Peertube's URI for Prosody modules (for example with «http://localhost:9000»).` + }) + + registerSetting({ + name: 'prosody-c2s', + label: 'Enable client to server connections', + type: 'input-checkbox', + default: false, + private: true, + descriptionHTML: +`Enable XMPP clients to connect to the builtin Prosody server.
+This option alone only allows connections from localhost clients.` + }) + + registerSetting({ + name: 'prosody-c2s-port', + label: 'Prosody client to server port', + type: 'input', + default: '52822', + private: true, + descriptionHTML: +`The port that will be used by the c2s module of the builtin Prosody server.
+XMPP clients shall use this port to connect.
+Change it if this port is already in use on your server.
+Keep it close this port on your firewall for now, it will not be accessed from the outer world.` + }) + // ********** settings changes management settingsManager.onSettingsChange(async (settings: any) => { if ('chat-type' in settings) { diff --git a/shared/lib/types.ts b/shared/lib/types.ts index f9892324..d21f4ab8 100644 --- a/shared/lib/types.ts +++ b/shared/lib/types.ts @@ -1,5 +1,25 @@ type ChatType = 'disabled' | 'builtin-prosody' | 'builtin-converse' | 'external-uri' -export { - ChatType +interface ProsodyListRoomsResultError { + ok: false + error: string +} + +interface ProsodyListRoomsResultSuccess { + ok: true + rooms: Array<{ + jid: string + localpart: string + name: string + lang: string + description: string + lasttimestamp?: number + }> +} + +type ProsodyListRoomsResult = ProsodyListRoomsResultError | ProsodyListRoomsResultSuccess + +export { + ChatType, + ProsodyListRoomsResult }