snapshot of https://github.com/jitsi/jitsi-meet.git @ fd1749dd28f4138aa80234fc7f311b19aaffa0e7 (2026-09-15)
This commit is contained in:
177
resources/cloud-api.swagger
Normal file
177
resources/cloud-api.swagger
Normal file
@@ -0,0 +1,177 @@
|
||||
swagger: "2.0"
|
||||
info:
|
||||
description: "Documents the REST calls used by Jitsi Meet to integrate with other services"
|
||||
version: "1.0.0"
|
||||
title: "Swagger Video"
|
||||
termsOfService: "https://jitsi.org/CloudAPITOS/"
|
||||
contact:
|
||||
email: "team@jitsi.org"
|
||||
host: "jitsi-api.jitsi.org"
|
||||
basePath: "/"
|
||||
tags:
|
||||
- name: "conferenceMapper"
|
||||
description: "Conference to ID Mapper"
|
||||
externalDocs:
|
||||
description: "Conference API Details"
|
||||
url: "https://jitsi.org/CloudAPI"
|
||||
- name: "phoneNumberList"
|
||||
description: "List of dial-in numbers"
|
||||
schemes:
|
||||
- "https"
|
||||
paths:
|
||||
/conferenceMapper:
|
||||
get:
|
||||
tags:
|
||||
- "conferenceMapper"
|
||||
summary: "Create or retrieve conference ID mapping"
|
||||
description: "When called with a conference, creates a new ID and both stores and returns the result. When called with an ID, returns the mapping if previously created."
|
||||
operationId: "GETconferenceMapper"
|
||||
consumes:
|
||||
- "application/json"
|
||||
produces:
|
||||
- "application/json"
|
||||
parameters:
|
||||
- in: "query"
|
||||
name: "conference"
|
||||
type: "string"
|
||||
format: "JID"
|
||||
description: "Full JID (room@conference.server.domain) for the conference to create or return existing conference mapping. Used preferentially over all other input parameters (search by conference)"
|
||||
- in: "query"
|
||||
name: "id"
|
||||
type: "number"
|
||||
description: "ID to search for existing conference mapping. Only used when provided alone (search by ID)"
|
||||
responses:
|
||||
200:
|
||||
description: "mapping search performed"
|
||||
schema:
|
||||
$ref: "#/definitions/ConferenceMapperDetails"
|
||||
405:
|
||||
description: "Invalid input"
|
||||
post:
|
||||
tags:
|
||||
- "conferenceMapper"
|
||||
summary: "Create or retrieve conference ID mapping"
|
||||
description: "When called with a conference, creates a new ID and both stores and returns the result. When called with an ID, returns the mapping if previously created."
|
||||
operationId: "POSTconferenceMapper"
|
||||
consumes:
|
||||
- "application/json"
|
||||
produces:
|
||||
- "application/json"
|
||||
parameters:
|
||||
- in: "body"
|
||||
name: "body"
|
||||
description: "Conference Mapper Request"
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/ConferenceMapperRequest"
|
||||
responses:
|
||||
200:
|
||||
description: "mapping search performed"
|
||||
schema:
|
||||
$ref: "#/definitions/ConferenceMapperDetails"
|
||||
405:
|
||||
description: "Invalid input"
|
||||
|
||||
/phoneNumberList:
|
||||
get:
|
||||
tags:
|
||||
- "phoneNumberList"
|
||||
summary: "Returns a list phone numbers by country"
|
||||
description: "Used to populate the Share The Link section of jitsi-meet"
|
||||
operationId: "phoneNumberList"
|
||||
produces:
|
||||
- "application/json"
|
||||
responses:
|
||||
200:
|
||||
description: "successful operation"
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/PhoneNumberListAnswer"
|
||||
/waiting-queue/golive:
|
||||
post:
|
||||
tags:
|
||||
- waitingQueueGoLive
|
||||
summary: Post a go live request
|
||||
description: Mark the conference as live, notify all visitors waiting.
|
||||
operationId: goLive
|
||||
consumes:
|
||||
- "application/json"
|
||||
parameters:
|
||||
- in: "body"
|
||||
name: "body"
|
||||
description: "Go Live Request"
|
||||
required: true
|
||||
schema:
|
||||
"$ref": "#/definitions/GoLiveRequest"
|
||||
responses:
|
||||
'200':
|
||||
description: Successful operation
|
||||
'404':
|
||||
description: Missing conference
|
||||
'500':
|
||||
description: Failed operation
|
||||
securityDefinitions:
|
||||
token:
|
||||
type: "apiKey"
|
||||
name: "token"
|
||||
in: "query"
|
||||
Bearer:
|
||||
type: "apiKey"
|
||||
name: "Authorization"
|
||||
in: "header"
|
||||
definitions:
|
||||
|
||||
ConferenceMapperRequest:
|
||||
description: "Request to create or find a conference mapping"
|
||||
type: "object"
|
||||
properties:
|
||||
id:
|
||||
type: "number"
|
||||
description: "ID to search for existing conference mapping. Only used when provided alone (search by ID)"
|
||||
conference:
|
||||
type: "string"
|
||||
format: "JID"
|
||||
description: "Full JID (room@conference.server.domain) for the conference to create or return existing conference mapping. Used preferentially over all other input parameters (search by conference)"
|
||||
room:
|
||||
type: "string"
|
||||
description: "Room part of the conference. Required if 'conference' is not provided. Used to generate a 'conference' value (search by conference)"
|
||||
domain:
|
||||
type: "string"
|
||||
description: "Domain part of the conference. Used if 'conference' is not provided. Defaults to domain of the API endpoint. Used to generate a 'conference' value (search by conference)"
|
||||
|
||||
ConferenceMapperDetails:
|
||||
description: "Conference mapping between conference JID and numeric ID"
|
||||
type: "object"
|
||||
properties:
|
||||
id:
|
||||
type: "number"
|
||||
description: "Unique ID mapped to conference"
|
||||
conference:
|
||||
type: "string"
|
||||
format: "JID"
|
||||
description: "Full JID for the conference OR boolean false if no conference was found (search by ID)"
|
||||
|
||||
PhoneNumberListAnswer:
|
||||
description: "Answer with Phone number list and additional information"
|
||||
type: "object"
|
||||
properties:
|
||||
countryCode:
|
||||
type: string
|
||||
formattedNumber:
|
||||
type: string
|
||||
tollFree:
|
||||
type: boolean
|
||||
example:
|
||||
- {"countryCode":"US","tollFree":false,"formattedNumber":"+1 123-456-7890"}
|
||||
- {"countryCode":"UK","tollFree":true,"formattedNumber":"+44 123 456 7890"}
|
||||
|
||||
GoLiveRequest:
|
||||
type: object
|
||||
properties:
|
||||
conference:
|
||||
type: string
|
||||
externalDocs:
|
||||
description: "Find out more about the Jitsi Cloud API"
|
||||
url: "https://jitsi.org/CloudAPI"
|
||||
|
||||
47
resources/coturn-le-update.sh
Executable file
47
resources/coturn-le-update.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
# This script is updating the Let's Encrypt certificates on renew or when installing
|
||||
# The only param it gets is the domain and expects the certificates to use are updated
|
||||
# in /etc/jitsi/meet folder.
|
||||
DOMAIN=$1
|
||||
|
||||
if [ -z "$DOMAIN" ] ; then
|
||||
echo "You need to pass the domain as parameter."
|
||||
exit 10;
|
||||
fi
|
||||
|
||||
COTURN_CERT_DIR="/etc/coturn/certs"
|
||||
TURN_CONFIG="/etc/turnserver.conf"
|
||||
|
||||
# Execute only if turnconfig exist and is one managed by jitsi-meet
|
||||
if [ -f $TURN_CONFIG ] && grep -q "jitsi-meet coturn config" "$TURN_CONFIG" ; then
|
||||
# create a directory to store certs if it does not exists
|
||||
if [ ! -d "$COTURN_CERT_DIR" ]; then
|
||||
mkdir -p $COTURN_CERT_DIR
|
||||
chown -R turnserver:turnserver /etc/coturn/
|
||||
chmod -R 700 /etc/coturn/
|
||||
fi
|
||||
|
||||
# Make sure the certificate and private key files are
|
||||
# never world readable, even just for an instant while
|
||||
# we're copying them into daemon_cert_root.
|
||||
umask 077
|
||||
|
||||
cp "/etc/jitsi/meet/${DOMAIN}.crt" "$COTURN_CERT_DIR/${DOMAIN}.fullchain.pem"
|
||||
cp "/etc/jitsi/meet/${DOMAIN}.key" "$COTURN_CERT_DIR/${DOMAIN}.privkey.pem"
|
||||
|
||||
# Apply the proper file ownership and permissions for
|
||||
# the daemon to read its certificate and key.
|
||||
chown turnserver "$COTURN_CERT_DIR/${DOMAIN}.fullchain.pem" \
|
||||
"$COTURN_CERT_DIR/${DOMAIN}.privkey.pem"
|
||||
chmod 400 "$COTURN_CERT_DIR/${DOMAIN}.fullchain.pem" \
|
||||
"$COTURN_CERT_DIR/${DOMAIN}.privkey.pem"
|
||||
|
||||
echo "Configuring turnserver"
|
||||
sed -i "/^cert/c\cert=\/etc\/coturn\/certs\/${DOMAIN}.fullchain.pem" $TURN_CONFIG
|
||||
sed -i "/^pkey/c\pkey=\/etc\/coturn\/certs\/${DOMAIN}.privkey.pem" $TURN_CONFIG
|
||||
|
||||
service coturn restart
|
||||
fi
|
||||
153
resources/custom-theme/custom-theme.json
Normal file
153
resources/custom-theme/custom-theme.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"spacing": [ 0, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128 ],
|
||||
"breakpoints": {
|
||||
"values": {
|
||||
"0": 0,
|
||||
"320": 320,
|
||||
"400": 400,
|
||||
"480": 480
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
"uiBackground": "#040404",
|
||||
"ui01": "#141414",
|
||||
"ui02": "#292929",
|
||||
"ui03": "#3D3D3D",
|
||||
"ui04": "#525252",
|
||||
"ui05": "#666",
|
||||
"action01": "#0056E0",
|
||||
"action01Hover": "#246FE5",
|
||||
"action01Active": "#0045B3",
|
||||
"disabled01": "#00225A",
|
||||
"action02": "#3D3D3D",
|
||||
"action02Hover": "#525252",
|
||||
"action02Active": "#292929",
|
||||
"action03": "transparent",
|
||||
"action03Hover": "#525252",
|
||||
"action03Active": "#292929",
|
||||
"actionDanger": "#CB2233",
|
||||
"actionDangerHover": "#E04757",
|
||||
"actionDangerActive": "#A21B29",
|
||||
"bottomSheet": "#111111",
|
||||
"text01": "#FFF",
|
||||
"text02": "#C2C2C2",
|
||||
"text03": "#858585",
|
||||
"text04": "#AAAAAA",
|
||||
"textError": "#E04757",
|
||||
"icon01": "#FFF",
|
||||
"icon02": "#C2C2C2",
|
||||
"icon03": "#858585",
|
||||
"iconError": "#E04757",
|
||||
"field01": "#040404",
|
||||
"link01": "#669AEC",
|
||||
"link01Hover": "#99BBF3",
|
||||
"link01Active": "#246FE5",
|
||||
"success01": "#1EC26A",
|
||||
"success02": "#1EC26A",
|
||||
"warning01": "#F8AE1A",
|
||||
"warning02": "#ED9E1B",
|
||||
"support01": "#FF9B42",
|
||||
"support02": "#F96E57",
|
||||
"support03": "#DF486F",
|
||||
"support04": "#B23683",
|
||||
"support05": "#73348C",
|
||||
"support06": "#6A50D3",
|
||||
"support07": "#4380E2",
|
||||
"support08": "#00A8B3",
|
||||
"support09": "#2AA076"
|
||||
},
|
||||
"typography": {
|
||||
"font": {
|
||||
"weightRegular": "400",
|
||||
"weightSemiBold": "600"
|
||||
},
|
||||
"labelRegular": {
|
||||
"fontSize": "0.75rem",
|
||||
"lineHeight": "1rem",
|
||||
"fontWeight": "400",
|
||||
"letterSpacing": 0.16
|
||||
},
|
||||
"labelBold": {
|
||||
"fontSize": "0.75rem",
|
||||
"lineHeight": "1rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0.16
|
||||
},
|
||||
"bodyShortRegular": {
|
||||
"fontSize": "0.875rem",
|
||||
"lineHeight": "1.125rem",
|
||||
"fontWeight": "400",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"bodyShortBold": {
|
||||
"fontSize": "0.875rem",
|
||||
"lineHeight": "1.125rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"bodyShortRegularLarge": {
|
||||
"fontSize": "1rem",
|
||||
"lineHeight": "1.5rem",
|
||||
"fontWeight": "400",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"bodyShortBoldLarge": {
|
||||
"fontSize": "1rem",
|
||||
"lineHeight": "1.5rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"bodyLongRegular": {
|
||||
"fontSize": "0.875rem",
|
||||
"lineHeight": "1.5rem",
|
||||
"fontWeight": "400",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"bodyLongBold": {
|
||||
"fontSize": "0.875rem",
|
||||
"lineHeight": "1.5rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"heading1": {
|
||||
"fontSize": "3.375rem",
|
||||
"lineHeight": "4rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"heading2": {
|
||||
"fontSize": "2.625rem",
|
||||
"lineHeight": "3.125rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"heading3": {
|
||||
"fontSize": "2rem",
|
||||
"lineHeight": "2.5rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"heading4": {
|
||||
"fontSize": "1.75rem",
|
||||
"lineHeight": "2.25rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"heading5": {
|
||||
"fontSize": "1.25rem",
|
||||
"lineHeight": "1.75rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
},
|
||||
"heading6": {
|
||||
"fontSize": "1rem",
|
||||
"lineHeight": "1.625rem",
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0
|
||||
}
|
||||
},
|
||||
"shape": {
|
||||
"borderRadius": 6,
|
||||
"boxShadow": "inset 0px -1px 0px rgba(255, 255, 255, 0.15)"
|
||||
}
|
||||
}
|
||||
50
resources/encode-sound.sh
Executable file
50
resources/encode-sound.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [--mp3] [--opus] [--stereo] <input file ...>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--stereo)
|
||||
STEREO=true
|
||||
shift
|
||||
;;
|
||||
--mp3)
|
||||
MP3=true
|
||||
shift
|
||||
;;
|
||||
--opus)
|
||||
OPUS=true
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ $# -lt 1 ] ;then
|
||||
usage
|
||||
fi
|
||||
|
||||
if [ "$MP3" != "true" ] ;then
|
||||
if [ "$OPUS" != "true" ] ;then
|
||||
echo "At least one of --mp3 or --opus is required"
|
||||
usage
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "STEREO=$STEREO MP3=$MP3 OPUS=$OPUS"
|
||||
|
||||
AC1="-ac 1"
|
||||
if [ "$STEREO" = "true" ] ;then
|
||||
AC1=""
|
||||
fi
|
||||
|
||||
for i in "$@" ;do
|
||||
if [ "$MP3" = "true" ] ;then
|
||||
ffmpeg -i "$i" -codec:a libmp3lame -qscale:a 9 -map_metadata -1 $AC1 "${i%.*}.mp3"
|
||||
fi
|
||||
if [ "$OPUS" = "true" ] ;then
|
||||
ffmpeg -i "$i" -c:a libopus -b:a 30k -vbr on -compression_level 10 -map_metadata -1 $AC1 "${i%.*}.opus"
|
||||
fi
|
||||
done
|
||||
133
resources/extra-large-conference/README.md
Normal file
133
resources/extra-large-conference/README.md
Normal file
@@ -0,0 +1,133 @@
|
||||
WARNING: This is still a Work In Progress
|
||||
|
||||
The final implementation may diverge. Currently, only the participants after a
|
||||
configured threshold will be just viewers (visitors) and there is no promotion
|
||||
mechanism to become a main participant yet.
|
||||
|
||||
TODO:
|
||||
* Polls
|
||||
* Speaker stats
|
||||
* call duration
|
||||
|
||||
# Low-latency conference streaming to very large audiences
|
||||
|
||||
To have a low-latency conference with a very large audience, the media and
|
||||
signaling load must be spread beyond what can be handled by a typical Jitsi
|
||||
installation. A call with 10k participants requires around 50 bridges on decent
|
||||
vms (8+ cores). The main participants of a conference with a very large
|
||||
audience will share a main prosody, like with normal conferences, and
|
||||
additional prosody vms are needed to support signaling to the audience.
|
||||
|
||||
In the example configuration we use a 16 core machine. Eight of the cores are
|
||||
used for the main prosody and other services (nginx, jicofo, etc) and the other
|
||||
eight cores are used to run prosody services for visitors, i.e., "visitor
|
||||
prosodies".
|
||||
|
||||
We consider 2000 participants per visitor node a safe value. So eight visitor
|
||||
prosodies will be enough for one 10k participants meeting.
|
||||
|
||||
<img src="imgs/visitors-prosody.svg" alt="diagram of a central prosody connected to several visitor prosodies" width="500"/>
|
||||
|
||||
# Configuration
|
||||
If using older than Prosody 0.12.4 you need to apply the patch - s2sout_override1.patch and s2sout_override2.patch.
|
||||
Use the `pre-configure.sh` script to configure your system, passing it the
|
||||
number of visitor prosodies to set up.
|
||||
`./pre-configure.sh 8`
|
||||
|
||||
The script will add for each visitor prosody:
|
||||
- folders in `/etc/`
|
||||
- a systemd unit file in `/lib/systemd/system/`
|
||||
- a user for jicofo
|
||||
- a config entry in jicofo.conf
|
||||
|
||||
Setting up configuration for the main prosody is a manual process:
|
||||
- Add to the enabled modules list in the general part (e.g. [here](https://github.com/bjc/prosody/blob/76bf6d511f851c7cde8a81257afaaae0fb7a4160/prosody.cfg.lua.dist#L33)):
|
||||
```
|
||||
"s2s_bidi";
|
||||
"certs_s2soutinjection";
|
||||
"s2sout_override";
|
||||
"s2s_whitelist";
|
||||
```
|
||||
|
||||
- Add the following config also in the general part (matching the number of prosodies you generated config for):
|
||||
```
|
||||
-- targets must be IPs, not hostnames
|
||||
s2sout_override = {
|
||||
["conference.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
|
||||
["v1.meet.jitsi"] = "tcp://127.0.0.1:52691"; -- needed for v1.meet.jitsi->visitors.jitmeet.example.com
|
||||
["polls.v1.meet.jitsi"] = "tcp://127.0.0.1:52691";
|
||||
["conference.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
|
||||
["v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
|
||||
["polls.v2.meet.jitsi"] = "tcp://127.0.0.1:52692";
|
||||
["conference.v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
|
||||
["v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
|
||||
["polls.v3.meet.jitsi"] = "tcp://127.0.0.1:52693";
|
||||
["conference.v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
|
||||
["v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
|
||||
["polls.v4.meet.jitsi"] = "tcp://127.0.0.1:52694";
|
||||
["conference.v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
|
||||
["v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
|
||||
["polls.v5.meet.jitsi"] = "tcp://127.0.0.1:52695";
|
||||
["conference.v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
|
||||
["v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
|
||||
["polls.v6.meet.jitsi"] = "tcp://127.0.0.1:52696";
|
||||
["conference.v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
|
||||
["v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
|
||||
["polls.v7.meet.jitsi"] = "tcp://127.0.0.1:52697";
|
||||
["conference.v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
|
||||
["v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
|
||||
["polls.v8.meet.jitsi"] = "tcp://127.0.0.1:52698";
|
||||
}
|
||||
-- allowed list of server-2-server connections
|
||||
s2s_whitelist = {
|
||||
"conference.v1.meet.jitsi", "conference.v2.meet.jitsi", "conference.v3.meet.jitsi", "conference.v4.meet.jitsi",
|
||||
"conference.v5.meet.jitsi", "conference.v6.meet.jitsi", "conference.v7.meet.jitsi", "conference.v8.meet.jitsi",
|
||||
'polls.v1.meet.jitsi', 'polls.v2.meet.jitsi', 'polls.v3.meet.jitsi', 'polls.v4.meet.jitsi',
|
||||
'polls.v5.meet.jitsi', 'polls.v6.meet.jitsi', 'polls.v7.meet.jitsi', 'polls.v8.meet.jitsi'
|
||||
};
|
||||
```
|
||||
|
||||
- Make sure s2s is not in modules_disabled
|
||||
- Enable `"visitors";` module under the main virtual host (e.g. [here](https://github.com/jitsi/jitsi-meet/blob/f42772ec5bcc87ff6de17423d36df9bcad6e770d/doc/debian/jitsi-meet-prosody/prosody.cfg.lua-jvb.example#L57))
|
||||
You can add under main virtual host the config: `visitors_ignore_list = { "recorder.jitmeet.example.com" }` to ignore jibri and transcribers from the visitor logic and use them only in the main prosody conference.
|
||||
- Create the visitors component in /etc/prosody/conf.d/jitmeet.example.com.cfg.lua:
|
||||
```
|
||||
Component "visitors.jitmeet.example.com" "visitors_component"
|
||||
auto_allow_visitor_promotion = true
|
||||
admins = { "focus@auth.jitmeet.example.com" }
|
||||
```
|
||||
- Make sure you add the correct upstreams to nginx config
|
||||
```
|
||||
upstream v1 {
|
||||
zone upstreams 64K;
|
||||
server 127.0.0.1:52801;
|
||||
keepalive 2;
|
||||
}
|
||||
upstream v2 {
|
||||
zone upstreams 64K;
|
||||
server 127.0.0.1:52802;
|
||||
keepalive 2;
|
||||
}
|
||||
```
|
||||
|
||||
After configuring you can set the maximum number of main participants, before
|
||||
redirecting to visitors.
|
||||
```
|
||||
hocon -f /etc/jitsi/jicofo/jicofo.conf set "jicofo.visitors.enabled" true
|
||||
hocon -f /etc/jitsi/jicofo/jicofo.conf set "jicofo.visitors.max-participants" 30
|
||||
```
|
||||
Now restart prosody and jicofo
|
||||
```
|
||||
service prosody restart
|
||||
service jicofo restart
|
||||
service nginx restart
|
||||
```
|
||||
|
||||
Now after the main 30 participants join, the rest will be visitors using the
|
||||
visitor nodes.
|
||||
|
||||
To enable promotion where visitors need to be approved by a moderator to join the meeting:
|
||||
- you need to switch `auto_allow_visitor_promotion=false`.
|
||||
- You need to enable http requests to jicofo by editing config.js and adding a nginx rule for it.
|
||||
- In /etc/jitsi/meet/jitmeet.example.com-config.js uncomment conferenceRequestUrl.
|
||||
- In jitsi-meet nginx config make sure you have the conference-request location rules.
|
||||
2109
resources/extra-large-conference/imgs/visitors-prosody.svg
Normal file
2109
resources/extra-large-conference/imgs/visitors-prosody.svg
Normal file
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 198 KiB |
63
resources/extra-large-conference/pre-configure.sh
Executable file
63
resources/extra-large-conference/pre-configure.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_DIR=`dirname "$0"`
|
||||
cd $SCRIPT_DIR
|
||||
|
||||
NUMBER_OF_INSTANCES=$1
|
||||
|
||||
if ! [[ $NUMBER_OF_INSTANCES =~ ^[0-9]+([.][0-9]+)?$ ]] ; then
|
||||
echo "error: Not a number param" >&2;
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Will configure $NUMBER_OF_INSTANCES number of visitor prosodies"
|
||||
set -e
|
||||
set -x
|
||||
|
||||
JICOFO_HOSTNAME=$(echo get jitsi-videobridge/jvb-hostname | sudo debconf-communicate jicofo | cut -d' ' -f2-)
|
||||
|
||||
# Install SystemD template unit (once, outside loop)
|
||||
cp prosody-v@.service /usr/lib/systemd/system/prosody-v@.service
|
||||
systemctl daemon-reload
|
||||
|
||||
# Configure prosody instances
|
||||
for (( i=1 ; i<=${NUMBER_OF_INSTANCES} ; i++ ));
|
||||
do
|
||||
mkdir /etc/prosody-v${i}
|
||||
ln -s /etc/prosody/certs /etc/prosody-v${i}/certs
|
||||
cp prosody.cfg.lua.visitor.template /etc/prosody-v${i}/prosody.cfg.lua
|
||||
sed -i "s/vX/v${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
|
||||
sed -i "s/jitmeet.example.com/$JICOFO_HOSTNAME/g" /etc/prosody-v${i}/prosody.cfg.lua
|
||||
# fix the ports
|
||||
sed -i "s/52691/5269${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
|
||||
sed -i "s/52221/5222${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
|
||||
sed -i "s/52801/5280${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
|
||||
sed -i "s/52811/5281${i}/g" /etc/prosody-v${i}/prosody.cfg.lua
|
||||
# Enable and start the systemd instance
|
||||
systemctl enable --now prosody-v@${i}.service
|
||||
done
|
||||
|
||||
# Configure jicofo
|
||||
HOCON_CONFIG="/etc/jitsi/jicofo/jicofo.conf"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.bridge.selection-strategy" "VisitorSelectionStrategy"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.bridge.visitor-selection-strategy" "RegionBasedBridgeSelectionStrategy"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.bridge.participant-selection-strategy" "RegionBasedBridgeSelectionStrategy"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.bridge.topology-strategy" "VisitorTopologyStrategy"
|
||||
|
||||
PASS=$(hocon -f $HOCON_CONFIG get "jicofo.xmpp.client.password")
|
||||
for (( i=1 ; i<=${NUMBER_OF_INSTANCES} ; i++ ));
|
||||
do
|
||||
prosodyctl --config /etc/prosody-v${i}/prosody.cfg.lua register focus auth.meet.jitsi $PASS
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.enabled" true
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.conference-service" "conference.v${i}.meet.jitsi"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.hostname" 127.0.0.1
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.port" 5222${i}
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.domain" "auth.meet.jitsi"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.xmpp-domain" "v${i}.meet.jitsi"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.password" "${PASS}"
|
||||
hocon -f $HOCON_CONFIG set "jicofo.xmpp.visitors.v${i}.disable-certificate-verification" true
|
||||
done
|
||||
|
||||
# Restart all prosody visitor instances
|
||||
systemctl restart prosody-v@*
|
||||
systemctl restart jicofo
|
||||
46
resources/extra-large-conference/prosody-v@.service
Normal file
46
resources/extra-large-conference/prosody-v@.service
Normal file
@@ -0,0 +1,46 @@
|
||||
[Unit]
|
||||
### see man systemd.unit
|
||||
Description=Prosody v%i (visitor v%i) JVB XMPP Server
|
||||
Documentation=https://prosody.im/doc
|
||||
|
||||
Requires=network-online.target
|
||||
After=network-online.target network.target mariadb.service mysql.service postgresql.service
|
||||
Before=biboumi.service
|
||||
|
||||
[Service]
|
||||
### see man systemd.service
|
||||
Type=simple
|
||||
|
||||
# Start by executing the main executable
|
||||
# Note: -F option requires Prosody 0.11.5 or later
|
||||
ExecStart=/usr/bin/prosody --config /etc/prosody-v%i/prosody.cfg.lua -F
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-abnormal
|
||||
|
||||
### see man systemd.exec
|
||||
User=prosody
|
||||
Group=prosody
|
||||
UMask=0027
|
||||
|
||||
RuntimeDirectory=prosody-v%i
|
||||
ConfigurationDirectory=prosody-v%i
|
||||
StateDirectory=prosody-v%i
|
||||
StateDirectoryMode=0750
|
||||
LogsDirectory=prosody-v%i
|
||||
WorkingDirectory=~
|
||||
|
||||
# Set stdin to /dev/null since Prosody does not need it
|
||||
StandardInput=null
|
||||
|
||||
# Direct stdout/-err to journald for use with log = "*stdout"
|
||||
StandardOutput=journal
|
||||
StandardError=inherit
|
||||
|
||||
# Allow binding low ports
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
### see man systemd.unit
|
||||
WantedBy=multi-user.target
|
||||
|
||||
# vim: filetype=systemd
|
||||
@@ -0,0 +1,145 @@
|
||||
---------- Server-wide settings ----------
|
||||
s2s_ports = { 52691 };
|
||||
c2s_ports = { 52221 }
|
||||
http_ports = { 52801 }
|
||||
https_ports = { 52811 }
|
||||
|
||||
daemonize = true;
|
||||
|
||||
-- we use a common jid for jicofo
|
||||
admins = {
|
||||
'focus@auth.meet.jitsi'
|
||||
}
|
||||
|
||||
-- Enable use of native prosody 0.11 support for epoll over select
|
||||
network_backend = 'epoll';
|
||||
network_settings = {
|
||||
tcp_backlog = 511;
|
||||
}
|
||||
|
||||
modules_enabled = {
|
||||
'saslauth';
|
||||
'tls';
|
||||
'disco';
|
||||
'posix';
|
||||
|
||||
'secure_interfaces';
|
||||
|
||||
-- jitsi
|
||||
'websocket';
|
||||
'bosh';
|
||||
's2s_bidi';
|
||||
's2s_whitelist';
|
||||
's2sout_override';
|
||||
'certs_s2soutinjection';
|
||||
};
|
||||
|
||||
s2s_whitelist = {
|
||||
'conference.jitmeet.example.com', -- needed for visitors to send messages to main room
|
||||
'visitors.jitmeet.example.com'; -- needed for sending promotion request to visitors.jitmeet.example.com component
|
||||
'jitmeet.example.com'; -- unavailable presences back to main room
|
||||
'polls.jitmeet.example.com'; -- polls component
|
||||
};
|
||||
|
||||
s2sout_override = {
|
||||
["conference.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for visitors to send messages to main room
|
||||
["jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for the main room when connecting in to send main participants
|
||||
["visitors.jitmeet.example.com"] = "tcp://127.0.0.1:5269"; -- needed for sending promotion request to visitors.jitmeet.example.com component
|
||||
['polls.jitmeet.example.com'] = "tcp://127.0.0.1:5269"; -- polls component
|
||||
}
|
||||
|
||||
external_service_secret = '__turnSecret__';
|
||||
|
||||
external_services = {
|
||||
{ type = 'stun', host = 'jitmeet.example.com', port = 3478 },
|
||||
{ type = 'turn', host = 'jitmeet.example.com', port = 3478, transport = 'udp', secret = true, ttl = 86400, algorithm = 'turn' },
|
||||
{ type = 'turns', host = 'jitmeet.example.com', port = 5349, transport = 'tcp', secret = true, ttl = 86400, algorithm = 'turn' }
|
||||
};
|
||||
|
||||
muc_mapper_domain_base = 'vX.meet.jitsi';
|
||||
main_domain = 'jitmeet.example.com';
|
||||
|
||||
-- https://prosody.im/doc/modules/mod_smacks
|
||||
smacks_max_unacked_stanzas = 5;
|
||||
smacks_hibernation_time = 60;
|
||||
-- this is dropped in 0.12
|
||||
smacks_max_hibernated_sessions = 1;
|
||||
smacks_max_old_sessions = 1;
|
||||
|
||||
unlimited_jids = { 'focus@auth.meet.jitsi' }
|
||||
limits = {
|
||||
c2s = {
|
||||
rate = '512kb/s';
|
||||
};
|
||||
}
|
||||
|
||||
modules_disabled = {
|
||||
'offline';
|
||||
'pubsub';
|
||||
'register';
|
||||
};
|
||||
|
||||
allow_registration = false;
|
||||
authentication = 'internal_hashed'
|
||||
storage = 'internal'
|
||||
log = {
|
||||
-- Log files (change 'info' to 'debug' for debug logs):
|
||||
info = '/var/log/prosody-vX/prosody.log';
|
||||
error = '/var/log/prosody-vX/prosody.err';
|
||||
}
|
||||
|
||||
consider_websocket_secure = true;
|
||||
consider_bosh_secure = true;
|
||||
bosh_max_inactivity = 60;
|
||||
|
||||
plugin_paths = { '/usr/share/jitsi-meet/prosody-plugins/' }
|
||||
|
||||
----------- Virtual hosts -----------
|
||||
VirtualHost 'vX.meet.jitsi'
|
||||
authentication = 'jitsi-anonymous'
|
||||
ssl = {
|
||||
key = '/etc/prosody/certs/jitmeet.example.com.key';
|
||||
certificate = '/etc/prosody/certs/jitmeet.example.com.crt';
|
||||
}
|
||||
modules_enabled = {
|
||||
'bosh';
|
||||
'ping';
|
||||
'external_services';
|
||||
'smacks';
|
||||
'jiconop';
|
||||
'conference_duration';
|
||||
'features_identity';
|
||||
}
|
||||
main_muc = 'conference.vX.meet.jitsi';
|
||||
|
||||
VirtualHost 'auth.meet.jitsi'
|
||||
modules_enabled = {
|
||||
'limits_exception';
|
||||
'ping';
|
||||
'smacks';
|
||||
}
|
||||
authentication = 'internal_hashed'
|
||||
smacks_hibernation_time = 15;
|
||||
|
||||
Component 'conference.vX.meet.jitsi' 'muc'
|
||||
storage = 'memory'
|
||||
muc_room_cache_size = 10000
|
||||
restrict_room_creation = true
|
||||
modules_enabled = {
|
||||
'muc_hide_all';
|
||||
'muc_domain_mapper';
|
||||
'muc_meeting_id';
|
||||
'fmuc';
|
||||
's2s_bidi';
|
||||
's2s_whitelist';
|
||||
's2sout_override';
|
||||
}
|
||||
muc_room_default_presence_broadcast = {
|
||||
visitor = false;
|
||||
participant = true;
|
||||
moderator = true;
|
||||
};
|
||||
muc_room_locking = false
|
||||
muc_room_default_public_jids = true
|
||||
|
||||
Component 'polls.vX.meet.jitsi' 'polls_component'
|
||||
268
resources/file-sharing.yaml
Normal file
268
resources/file-sharing.yaml
Normal file
@@ -0,0 +1,268 @@
|
||||
openapi: 3.0.1
|
||||
info:
|
||||
title: File Sharing API
|
||||
description: Management of the file sharing feature
|
||||
version: 1.0.0
|
||||
servers:
|
||||
- url: https://your-server-here
|
||||
description: Generated server url
|
||||
security:
|
||||
- HttpBearerKey: []
|
||||
tags:
|
||||
- name: Content sharing history
|
||||
description: crud operation for shared documents
|
||||
paths:
|
||||
/v1/documents/sessions/{sessionId}/files:
|
||||
get:
|
||||
tags:
|
||||
- Document sharing history
|
||||
summary: Return a list of metadata with pre-sign urls for download
|
||||
description: Used to get the list of files in past meetings
|
||||
operationId: retrieveContentSharingHistory_1
|
||||
parameters:
|
||||
- name: sessionId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
default: 0
|
||||
- name: page-size
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CssFileMetadataResponse'
|
||||
post:
|
||||
tags:
|
||||
- Document sharing history
|
||||
summary: Save an document and metadata
|
||||
description: Add a document to a meeting - allowed only to those with file-sharing feature if any
|
||||
operationId: addDocumentInMeeting
|
||||
parameters:
|
||||
- name: sessionId
|
||||
in: path
|
||||
description: The session ID of the meeting.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
required:
|
||||
- file
|
||||
- metadata
|
||||
type: object
|
||||
properties:
|
||||
metadata:
|
||||
type: string
|
||||
description: The metadata of the document in JSON format. Must conform to FileMetadata schema.
|
||||
example: '{"conferenceFullName":"myroomname@conference.tenant.jitsi-meet.example.com","timestamp":1741017572040,"fileSize":1042157,"fileId":"e393a7e5-e790-4f43-836e-d27238201904"}'
|
||||
file:
|
||||
type: string
|
||||
description: The file to be uploaded.
|
||||
format: binary
|
||||
responses:
|
||||
'200':
|
||||
description: Document added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AddDocumentResponse'
|
||||
example:
|
||||
fileId: e393a7e5-e790-4f43-836e-d27238201904
|
||||
/v1/documents/sessions/{sessionId}/files/{fileId}:
|
||||
get:
|
||||
tags:
|
||||
- Document sharing history
|
||||
summary: Get file pre-signed URL plus document info
|
||||
description: Used by UI to get the presign url for the file before serving it to the user who needs it
|
||||
operationId: getDocumentInfoDuringMeeting
|
||||
parameters:
|
||||
- name: sessionId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: fileId
|
||||
in: path
|
||||
description: The file ID to be deleted.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
'*/*':
|
||||
schema:
|
||||
$ref: '#/components/schemas/DocumentMetadataResponse'
|
||||
delete:
|
||||
tags:
|
||||
- Document sharing history
|
||||
summary: Delete a file by sessionId and fileId
|
||||
description: Delete a file by sessionId and fileId allowed by the "file-upload" feature
|
||||
operationId: deleteFile
|
||||
parameters:
|
||||
- name: sessionId
|
||||
in: path
|
||||
description: The session ID of the meeting.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: 86bf35e2-62a5-497e-9cae-efd35139f81f
|
||||
- name: fileId
|
||||
in: path
|
||||
description: The file ID to be deleted.
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
components:
|
||||
schemas:
|
||||
FileMetadata:
|
||||
type: object
|
||||
required:
|
||||
- fileId
|
||||
- conferenceFullName
|
||||
- timestamp
|
||||
- fileSize
|
||||
properties:
|
||||
fileId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Client-generated unique identifier for the file
|
||||
example: e393a7e5-e790-4f43-836e-d27238201904
|
||||
conferenceFullName:
|
||||
type: string
|
||||
description: Full name of the conference/meeting room
|
||||
example: myroomname@conference.tenant.jitsi-meet.example.com
|
||||
timestamp:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Upload timestamp in milliseconds since epoch
|
||||
example: 1741017572040
|
||||
fileSize:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Size of the file in bytes
|
||||
example: 1042157
|
||||
description: Metadata structure that must be sent as JSON string in the metadata field
|
||||
Payload:
|
||||
type: object
|
||||
properties:
|
||||
isBreakout:
|
||||
type: boolean
|
||||
conference:
|
||||
type: string
|
||||
AddDocumentResponse:
|
||||
type: object
|
||||
properties:
|
||||
fileId:
|
||||
type: string
|
||||
description: File ID of the added document
|
||||
example: e393a7e5-e790-4f43-836e-d27238201904
|
||||
description: Response body containing the fileId of the added document
|
||||
CssFileMetadataResponse:
|
||||
type: object
|
||||
properties:
|
||||
objectId:
|
||||
type: string
|
||||
description: Object id - can be file id
|
||||
example: e393a7e5-e790-4f43-836e-d27238201904
|
||||
sessionId:
|
||||
type: string
|
||||
description: Session id
|
||||
example: 85a32e37-ddd5-45de-89a6-e94ccffe547a
|
||||
timestamp:
|
||||
type: integer
|
||||
description: Added timestamp
|
||||
format: int64
|
||||
example: 124
|
||||
contentType:
|
||||
type: string
|
||||
description: Content type
|
||||
example: application/pdf
|
||||
objectName:
|
||||
type: string
|
||||
description: Object name
|
||||
example: e393a7e5-e790-4f43-836e-d27238201904
|
||||
initiatorId:
|
||||
type: string
|
||||
description: User id for the author
|
||||
example: f56g5y4
|
||||
preSignedUrl:
|
||||
type: string
|
||||
description: Presign url - expires after 24h for JaaS
|
||||
example: https://oracle.com/presigned-url
|
||||
description: Response body containing the file metadata
|
||||
PaginatedResponseCssFileMetadataResponse:
|
||||
type: object
|
||||
properties:
|
||||
content:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/CssFileMetadataResponse'
|
||||
nextStartWith:
|
||||
type: string
|
||||
DocumentMetadataResponse:
|
||||
type: object
|
||||
properties:
|
||||
fileId:
|
||||
type: string
|
||||
description: File ID of the document
|
||||
example: e393a7e5-e790-4f43-836e-d27238201904
|
||||
sessionId:
|
||||
type: string
|
||||
description: Session ID of the document
|
||||
example: 85a32e37-ddd5-45de-89a6-e94ccffe547a
|
||||
fileName:
|
||||
type: string
|
||||
description: Filename
|
||||
example: sample.pdf
|
||||
customerId:
|
||||
type: string
|
||||
description: Customer id
|
||||
example: vthtryv56yb65
|
||||
userId:
|
||||
type: string
|
||||
description: User id
|
||||
example: dvdsvfhjv
|
||||
presignedUrl:
|
||||
type: string
|
||||
description: Presign url - points to document sharing since for CSS the link expires in 3h
|
||||
example: https://content-sharing-url.com
|
||||
createdAt:
|
||||
type: integer
|
||||
description: Created at
|
||||
format: int64
|
||||
example: 1745436546
|
||||
fileSize:
|
||||
type: integer
|
||||
description: File Size
|
||||
format: int64
|
||||
example: 124
|
||||
description: Response body containing the document metadata
|
||||
securitySchemes:
|
||||
HttpBearerKey:
|
||||
type: http
|
||||
description: Http request Authorization header
|
||||
scheme: bearer
|
||||
BIN
resources/img/appstore-badge.png
Normal file
BIN
resources/img/appstore-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
resources/img/f-droid-badge.png
Normal file
BIN
resources/img/f-droid-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
resources/img/google-play-badge.png
Normal file
BIN
resources/img/google-play-badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
74
resources/install-letsencrypt-cert.sh
Executable file
74
resources/install-letsencrypt-cert.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "-------------------------------------------------------------------------"
|
||||
echo "This script will:"
|
||||
echo "- Need a working DNS record pointing to this machine(for hostname ${DOMAIN})"
|
||||
echo "- Install additional dependencies in order to request Let’s Encrypt certificate (acme.sh)"
|
||||
echo "- Configure and reload nginx or apache2, whichever is used"
|
||||
echo "- Configure the coturn server to use Let's Encrypt certificate and add required deploy hooks"
|
||||
echo "- Configure renew of certificate"
|
||||
echo ""
|
||||
|
||||
EMAIL=$1
|
||||
|
||||
if [ -z "$EMAIL" ]; then
|
||||
echo "You need to agree to the ACME server's Subscriber Agreement (https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf) "
|
||||
echo "by providing an email address for important account notifications"
|
||||
|
||||
echo -n "Enter your email and press [ENTER]: "
|
||||
read EMAIL
|
||||
fi
|
||||
|
||||
DOMAIN=$2
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
DEB_CONF_RESULT=$(debconf-show jitsi-meet-web-config | grep jitsi-meet/jvb-hostname)
|
||||
DOMAIN="${DEB_CONF_RESULT##*:}"
|
||||
fi
|
||||
# remove whitespace
|
||||
DOMAIN="$(echo -e "${DOMAIN}" | tr -d '[:space:]')"
|
||||
|
||||
|
||||
export HOME=/opt/acmesh
|
||||
curl https://get.acme.sh | sh -s email=$EMAIL
|
||||
|
||||
# Checks whether nginx or apache is installed
|
||||
NGINX_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'nginx' 2>/dev/null | awk '{print $3}' || true)"
|
||||
NGINX_FULL_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'nginx-full' 2>/dev/null | awk '{print $3}' || true)"
|
||||
NGINX_EXTRAS_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'nginx-extras' 2>/dev/null | awk '{print $3}' || true)"
|
||||
OPENRESTY_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'openresty' 2>/dev/null | awk '{print $3}' || true)"
|
||||
APACHE_INSTALL_CHECK="$(dpkg-query -f '${Status}' -W 'apache2' 2>/dev/null | awk '{print $3}' || true)"
|
||||
|
||||
RELOAD_CMD=""
|
||||
if [ "$NGINX_INSTALL_CHECK" = "installed" ] || [ "$NGINX_INSTALL_CHECK" = "unpacked" ] \
|
||||
|| [ "$NGINX_FULL_INSTALL_CHECK" = "installed" ] || [ "$NGINX_FULL_INSTALL_CHECK" = "unpacked" ] \
|
||||
|| [ "$NGINX_EXTRAS_INSTALL_CHECK" = "installed" ] || [ "$NGINX_EXTRAS_INSTALL_CHECK" = "unpacked" ]; then
|
||||
RELOAD_CMD="systemctl force-reload nginx.service"
|
||||
elif [ "$OPENRESTY_INSTALL_CHECK" = "installed" ] || [ "$OPENRESTY_INSTALL_CHECK" = "unpacked" ] ; then
|
||||
RELOAD_CMD="systemctl force-reload openresty.service"
|
||||
elif [ "$APACHE_INSTALL_CHECK" = "installed" ] || [ "$APACHE_INSTALL_CHECK" = "unpacked" ] ; then
|
||||
RELOAD_CMD="systemctl force-reload apache2.service"
|
||||
else
|
||||
RELOAD_CMD="echo 'No webserver found'"
|
||||
fi
|
||||
|
||||
RELOAD_CMD+=" && /usr/share/jitsi-meet/scripts/coturn-le-update.sh ${DOMAIN}"
|
||||
|
||||
ISSUE_FAILED_CODE=0
|
||||
ISSUE_CERT_CMD="/opt/acmesh/.acme.sh/acme.sh -f --issue -d ${DOMAIN} -w /usr/share/jitsi-meet --server letsencrypt"
|
||||
eval "${ISSUE_CERT_CMD}" || ISSUE_FAILED_CODE=$?
|
||||
|
||||
INSTALL_CERT_CMD="/opt/acmesh/.acme.sh/acme.sh -f --install-cert -d ${DOMAIN} --key-file /etc/jitsi/meet/${DOMAIN}.key --fullchain-file /etc/jitsi/meet/${DOMAIN}.crt --reloadcmd \"${RELOAD_CMD}\""
|
||||
if [ ${ISSUE_FAILED_CODE} -ne 0 ] ; then
|
||||
# it maybe this certificate already exists (code 2 - skip, no need to renew)
|
||||
if [ ${ISSUE_FAILED_CODE} -eq 2 ]; then
|
||||
eval "$INSTALL_CERT_CMD"
|
||||
else
|
||||
echo "Issuing the certificate from Let's Encrypt failed, continuing ..."
|
||||
echo "You can retry later by executing:"
|
||||
echo "/usr/share/jitsi-meet/scripts/install-letsencrypt-cert.sh $EMAIL"
|
||||
fi
|
||||
else
|
||||
eval "$INSTALL_CERT_CMD"
|
||||
fi
|
||||
8
resources/lang-sort.sh
Executable file
8
resources/lang-sort.sh
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash -e
|
||||
|
||||
for file in ./lang/*.json; do
|
||||
echo "Sorting and standardizing ${file}"
|
||||
t=$(mktemp)
|
||||
jq --indent 4 -S "." "${file}" > "${t}"
|
||||
mv "${t}" "${file}"
|
||||
done
|
||||
116
resources/prosody-plugins/README.md
Normal file
116
resources/prosody-plugins/README.md
Normal file
@@ -0,0 +1,116 @@
|
||||
#### room._data fields
|
||||
- allModerators - If is set to true, all authenticated participants are moderators. You need a custom module to set participants as moderators based on the field.
|
||||
- allowUnauthenticatedAccess - Option that works only with tenants. When it is false only jwt authenticated participants(visitors) are allowed in the meeting and their token(or sub claim for jaas) should match the one in the room. Default value when missing is true.
|
||||
- av_can_unmute - Default value is missing/true. If set to false, when the first moderator joining the room we enable AV moderation and disable the ability for participants to unmute themselves.
|
||||
- av_first_moderator_joined - When av_can_unmute is set to false, this flag is used to indicate that the first moderator has joined the room and AV moderation is enabled.
|
||||
- breakout_rooms - A table containing breakout rooms created in the main room. The keys are the JIDs of the breakout rooms, and the values are their subjects.
|
||||
- breakout_rooms_active - Whether there was a breakout room created in the main room.
|
||||
- breakout_rooms_counter - A counter for breakout rooms created in the main room.
|
||||
- lobby_disabled - Whether lobby was disabled for the room by the backend.
|
||||
- flip_participant_nick - Used in mod_muc_flip, when flipping a participant we store the nick of the second device/participant. Same processing as kicked_participant_nick.
|
||||
- hideDisplayNameForGuests - When set to true, the display name of participants is hidden for guests.
|
||||
- jicofo_lock - A boolean value, when set to true the room is locked waiting for Jicofo to join. All attempts to join will be queued until Jicofo joins.
|
||||
- kicked_participant_nick - Used in mod_muc_flip, when flipping a participant we store the nick of the initial device/participant so we can kick it after the second on joins, everything happens in the same pass when the second join presence is processed, so concurrency and overwriting are not a concern.
|
||||
- lobby_extra_reason - Used in mod_muc_lobby_rooms to store the reason for creating a lobby room. This is set by the global event 'create-lobby-room' and can be used to provide additional context or information about the lobby room creation. Used by mod_muc_wait_for_host with value 'waiting-for-host'. Value is sent as the error reply 'registration-required'.
|
||||
- lobby_skip_display_name_check - Used in mod_muc_lobby_rooms to skip the display name check for the lobby room. This is used when the lobby room is created with a specific reason that does not require a display name check via the global event 'create-lobby-room'.
|
||||
- lobbyroom - Holds the JID of the lobby room if it exists. It is set by mod_muc_lobby_rooms.lua when a lobby room is created. Value is available in the room config form for all clients to use it.
|
||||
- max_occupants - The maximum number of occupants allowed in the room used by mod_muc_max_occupants.
|
||||
- meetingId - A unique identifier for the meeting, generated by mod_muc_meeting_id. Set in the room config form for all clients to use it. It is also sent to all visitor nodes if any.
|
||||
- moderators - A list of moderator ids (from the token) that are allowed to join the room. The list is sent to jicofo to take decisions about forwarding to main room or visitor node in case of large meetings. Those participants will be moderators. You need a custom module to set participants as moderators based on the field.
|
||||
- participants - A list of participants ids (from the token) that are allowed to join the room. The list is sent to jicofo to take decisions about forwarding to main room or visitor node in case of large meetings. Those participants will not be moderators.
|
||||
- participants_details - Used in mod_muc_flip to store details about authenticated participants in the room. A table where the keys are participant id from the token and the values are their room jid.
|
||||
- persist_lobby - A boolean value, when set to true the lobby room will be persistent and not destroyed after the main room is destroyed. Used by mod_persistent_lobby.
|
||||
- subject - The subject of the room. Set by subject.lib.lua in prosody.
|
||||
|
||||
# room fields added by jitsi
|
||||
- _connected_vnodes - A cache object that holds connected vnodes for the room. It is used by mod_visitors_component.lua to manage connected vnodes.
|
||||
- _jid_nick - Prosody's internal table that maps connection JIDs to room jids in the room. It is used by mod_muc_max_occupants.lua.
|
||||
- _jitsi_go_live_sent - A boolean value indicating whether the go live request has been sent for the room. It is used by mod_visitors_component.lua to manage the go live state of the room.
|
||||
- _main_room_lobby_enabled - A boolean value indicating whether the main room lobby is enabled. It is set by mod_fmuc.lua when the main room lobby is enabled or disabled.
|
||||
- _muc_messages_count - A counter for the number of messages sent in the room. It is used by mod_measure_message_count.lua to track message counts for analytics.
|
||||
- _muc_messages_limit - A boolean value indicating whether the message limit is enabled for the room. It is used by mod_muc_limit_messages.lua to control the message limit in the room.
|
||||
- _muc_messages_limit_count - A counter for the number of messages sent in the room when the message limit is enabled. It is used by mod_muc_limit_messages.lua to track message counts for the limit.
|
||||
- _muc_polls_count - A counter for the number of polls created in the room. It is used by mod_measure_message_count.lua to track poll counts for analytics.
|
||||
- _occupants - Prosody's internal table that holds room occupants.
|
||||
- _transcription_languages - A table containing transcription languages for each occupant in the room. The keys are occupant JIDs and the values are language codes. Used by mod_fmuc.lua to manage transcription languages for occupants.
|
||||
- av_moderation - A table containing media types as keys ('audio', 'video', 'desktop') and arrays of occupant nicknames that are allowed to unmute themselves. Used by mod_av_moderation_component.lua.
|
||||
- av_moderation_actors - A table containing media types as keys ('audio', 'video', 'desktop') and occupant nicknames of the moderators that performed the last AV moderation action. Used by mod_av_moderation_component.lua.
|
||||
- av_moderation_startMuted_restore - Keeps the startMuted metadata state of the room before AV moderation was applied. Used by mod_av_moderation_component.lua.
|
||||
- broadcast_timer - A timer used to broadcast the list of breakout rooms in the main room. It is set by mod_muc_breakout_rooms.lua to periodically update the list of breakout rooms.
|
||||
- close_timer - A timer used to close the main room when all occupants have left. It is set by mod_muc_breakout_rooms.lua to clean up the main room after all participants left from breakout room.
|
||||
- created_timestamp - A timestamp in milliseconds when the room was created. It is set by mod_conference_duration.lua and used to calculate the conference duration. It is also used by mod_measure_message_count.lua to calculate the room duration for analytics.
|
||||
- has_host - Whether the host (an authenticated user) has arrived in the room.
|
||||
- is_vpaas - A boolean value indicating whether the room is a VPAAS room, optimization for the is_vpaas function in util.internal.lib.lua.
|
||||
- jibri_throttle - A throttle object used to limit the number of Jibri requests per room. It is created in mod_filter_iq_jibri.lua to prevent abuse of Jibri resources.
|
||||
- jitsi_meet_tenant_mismatch - A boolean value indicating whether there is a tenant mismatch comparing room tenant and jwt tenant value.
|
||||
- jitsi_shared_files - A table containing shared files in the room. The keys are file IDs and the values are file objects with properties like fileId, name, size, type, and url. Used by mod_filesharing_component.lua to manage file sharing in the room.
|
||||
- jitsiMetadata - The metadata for the room. Example values:
|
||||
```json
|
||||
{
|
||||
"allownersEnabled": true,
|
||||
"asyncTranscription": true,
|
||||
"conferencePresetsServiceEnabled": true,
|
||||
"participantsSoftLimit": 200,
|
||||
"permissions": {
|
||||
"groupChatRestricted": true,
|
||||
"pollCreationRestricted": true
|
||||
},
|
||||
"recording": {
|
||||
"autoAudioRecording": true,
|
||||
"autoVideoRecording": true,
|
||||
"autoTranscriptions": true,
|
||||
"isTranscribingEnabled": true
|
||||
},
|
||||
"startMuted": {
|
||||
"audio": true,
|
||||
"video": true
|
||||
},
|
||||
"transcriberType": "GOOGLE | ORACLE_CLOUD_AI_SPEECH | EGHT_WHISPER",
|
||||
"visitors": {
|
||||
"autoPromote": true,
|
||||
"live": true
|
||||
},
|
||||
"visitorsEnabled": true
|
||||
}
|
||||
```
|
||||
- join_rate_presence_queue - A queue used to hold join presence requests when the join rate throttle is exceeded. It is set by mod_muc_rate_limit.lua to manage join requests in the room.
|
||||
- join_rate_queue_timer - A timer used to process the join rate presence queue. It is set by mod_muc_rate_limit.lua to periodically check and process join requests in the queue.
|
||||
- join_rate_throttle - A throttle object used to limit the rate of join requests in the room. It is set by mod_muc_rate_limit.lua to prevent high join rate in case of large conferences.
|
||||
- leave_rate_presence_queue - A queue used to hold leave presence requests when the leave rate throttle is exceeded. It is set by mod_muc_rate_limit.lua to manage leave requests in the room.
|
||||
- leave_rate_queue_timer - A timer used to process the leave rate presence queue. It is set by mod_muc_rate_limit.lua to periodically check and process leave requests in the queue.
|
||||
- leave_rate_throttle - A throttle object used to limit the rate of leave requests in the room. It is set by mod_muc_rate_limit.lua to prevent high leave rate in case of large conferences.
|
||||
- main_room - For lobby or breakout rooms it is the main room object.
|
||||
- moderators_list - A set containing the JIDs of moderators in the room. It is used by mod_fmuc.lua to manage moderators in the room.
|
||||
- polls - A table containing polls created in the room. The keys are poll IDs and the values are poll objects with properties like id, question, options, votes, and creator. Used by mod_polls.lua to manage polls in the room.
|
||||
- pre_join_queue - A queue used to hold join requests when the room is locked waiting for Jicofo to join. It is set by mod_muc_meeting_id.lua when the room is locked. The queue is processed once Jicofo joins the room.
|
||||
- send_default_permissions_to - A table containing the default permissions to be sent to occupants in the room. The keys are bare JIDs and the values are boolean values indicating whether the permissions should be sent. Used by mod_jitsi_permissions.lua to manage permissions in the room.
|
||||
- sent_initial_metadata - A table containing the initial metadata sent to occupants in the room. The keys are bare JIDs and the values are boolean values indicating whether the metadata has been sent. Used by mod_room_metadata_component.lua to manage metadata in the room.
|
||||
- speakerStats - A table containing speaker statistics for occupants in the room. The keys are occupant JIDs and the values are objects with properties like dominantSpeakerId, faceLandmarks, and sessionId. Used by mod_speakerstats_component.lua to manage speaker statistics in the room.
|
||||
- visitors_destroy_timer - A timer used to destroy the room when there are no main occupants or visitors left. It is set by mod_fmuc.lua to clean up the room after a certain period of inactivity.
|
||||
|
||||
# session fields added by jitsi
|
||||
- jitsi_breakout_main_jid - Once an occupant joins main room we mark this to check it when joining breakout rooms.
|
||||
- jitsi_meet_context_user - The context from the jwt token, added after token verify.
|
||||
- jitsi_meet_context_group - The group from the jwt context, added after token verify.
|
||||
- jitsi_meet_context_features - The features from the context, added after token verify.
|
||||
- jitsi_meet_context_room - The room settings from the jwt context, added after token verify.
|
||||
- jitsi_meet_room - The room name in jwt token, added after token verify.
|
||||
- jitsi_meet_verified_room - The jid of the room the session was verified for on join. Set by mod_token_verification.lua and used to re-check claims that are refreshed later, when a resuming connection presents a different token.
|
||||
- jitsi_meet_str_tenant - The tenant in the context. Added after token verify.
|
||||
- jitsi_meet_domain - The domain in the jwt ('sub' claim). Added after token verify. Can be the domain if not tenant is used or the tenant itself in lowercase.
|
||||
- customusername - from a query parameter to be used with combination with "pre-jitsi-authentication" event to pre-set a known jid to a session.
|
||||
- jitsi_web_query_room - room name from the query.
|
||||
- jitsi_web_query_prefix - the tenant from the query specified as a param named 'prefix'.
|
||||
- auth_token - The token, set before verify and cleared if verification fails.
|
||||
- jitsi_meet_tenant_mismatch - The tenant field from the token and the query param for tenant do not match.
|
||||
- previd - Used for stream resumption.
|
||||
- user_region - the region header from the http request received.
|
||||
- user_agent_header - the user agent header from the http request received.
|
||||
- jitsi_throttle - used by rate limit module.
|
||||
- jitsi_throttle_counter - used by rate limit module.
|
||||
- force_permissions_update - Indicate that on next self-presence update the permissions should be resent to the client. Used by mod_jitsi_permissions.lua to manage permissions updates for the session.
|
||||
- granted_jitsi_meet_context_user_id - when affiliation was changed (grant moderation) this holds the id of the actor.
|
||||
- granted_jitsi_meet_context_group_id - when affiliation was changed (grant moderation) this holds the group of the actor.
|
||||
|
||||
#### Notes:
|
||||
When modules need to store data they should do it in the room object in _data or directly. The data needs to be a simple as strings or table of strings, they should not add objects like room, sessions or occupants that cannot be serialized. Attaching data to the room object makes reloading modules safe and guarantees data will be wiped once the room is destroyed.
|
||||
271
resources/prosody-plugins/luajwtjitsi.lib.lua
Normal file
271
resources/prosody-plugins/luajwtjitsi.lib.lua
Normal file
@@ -0,0 +1,271 @@
|
||||
local cjson_safe = require 'cjson.safe'
|
||||
local basexx = require 'basexx'
|
||||
local digest = require 'openssl.digest'
|
||||
local hmac = require 'openssl.hmac'
|
||||
local pkey = require 'openssl.pkey'
|
||||
|
||||
-- Generates an RSA signature of the data.
|
||||
-- @param data The data to be signed.
|
||||
-- @param key The private signing key in PEM format.
|
||||
-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512.
|
||||
-- @return The signature or nil and an error message.
|
||||
local function signRS (data, key, algo)
|
||||
local privkey = pkey.new(key)
|
||||
if privkey == nil then
|
||||
return nil, 'Not a private PEM key'
|
||||
else
|
||||
local datadigest = digest.new(algo):update(data)
|
||||
return privkey:sign(datadigest)
|
||||
end
|
||||
end
|
||||
|
||||
-- Verifies an RSA signature on the data.
|
||||
-- @param data The signed data.
|
||||
-- @param signature The signature to be verified.
|
||||
-- @param key The public key of the signer.
|
||||
-- @param algo The digest algorithm to user when generating the signature: sha256, sha384, or sha512.
|
||||
-- @return True if the signature is valid, false otherwise. Also returns false if the key is invalid.
|
||||
local function verifyRS (data, signature, key, algo)
|
||||
local pubkey = pkey.new(key)
|
||||
if pubkey == nil then
|
||||
return false
|
||||
end
|
||||
|
||||
local datadigest = digest.new(algo):update(data)
|
||||
return pubkey:verify(signature, datadigest)
|
||||
end
|
||||
|
||||
local alg_sign = {
|
||||
['HS256'] = function(data, key) return hmac.new(key, 'sha256'):final(data) end,
|
||||
['HS384'] = function(data, key) return hmac.new(key, 'sha384'):final(data) end,
|
||||
['HS512'] = function(data, key) return hmac.new(key, 'sha512'):final(data) end,
|
||||
['RS256'] = function(data, key) return signRS(data, key, 'sha256') end,
|
||||
['RS384'] = function(data, key) return signRS(data, key, 'sha384') end,
|
||||
['RS512'] = function(data, key) return signRS(data, key, 'sha512') end
|
||||
}
|
||||
|
||||
local alg_verify = {
|
||||
['HS256'] = function(data, signature, key) return signature == alg_sign['HS256'](data, key) end,
|
||||
['HS384'] = function(data, signature, key) return signature == alg_sign['HS384'](data, key) end,
|
||||
['HS512'] = function(data, signature, key) return signature == alg_sign['HS512'](data, key) end,
|
||||
['RS256'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha256') end,
|
||||
['RS384'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha384') end,
|
||||
['RS512'] = function(data, signature, key) return verifyRS(data, signature, key, 'sha512') end
|
||||
}
|
||||
|
||||
-- Splits a token into segments, separated by '.'.
|
||||
-- @param token The full token to be split.
|
||||
-- @return A table of segments.
|
||||
local function split_token(token)
|
||||
local segments={}
|
||||
for str in string.gmatch(token, "([^\\.]+)") do
|
||||
table.insert(segments, str)
|
||||
end
|
||||
return segments
|
||||
end
|
||||
|
||||
-- Parses a JWT token into it's header, body, and signature.
|
||||
-- @param token The JWT token to be parsed.
|
||||
-- @return A JSON header and body represented as a table, and a signature.
|
||||
local function parse_token(token)
|
||||
local segments=split_token(token)
|
||||
if #segments ~= 3 then
|
||||
return nil, nil, nil, "Invalid token"
|
||||
end
|
||||
|
||||
local header, err = cjson_safe.decode(basexx.from_url64(segments[1]))
|
||||
if err then
|
||||
return nil, nil, nil, "Invalid header"
|
||||
end
|
||||
|
||||
local body, err = cjson_safe.decode(basexx.from_url64(segments[2]))
|
||||
if err then
|
||||
return nil, nil, nil, "Invalid body"
|
||||
end
|
||||
|
||||
local sig, err = basexx.from_url64(segments[3])
|
||||
if err then
|
||||
return nil, nil, nil, "Invalid signature"
|
||||
end
|
||||
|
||||
return header, body, sig
|
||||
end
|
||||
|
||||
-- Removes the signature from a JWT token.
|
||||
-- @param token A JWT token.
|
||||
-- @return The token without its signature.
|
||||
local function strip_signature(token)
|
||||
local segments=split_token(token)
|
||||
if #segments ~= 3 then
|
||||
return nil, nil, nil, "Invalid token"
|
||||
end
|
||||
|
||||
table.remove(segments)
|
||||
return table.concat(segments, ".")
|
||||
end
|
||||
|
||||
-- Verifies that a claim is in a list of allowed claims. Allowed claims can be exact values, or the
|
||||
-- catch all wildcard '*'.
|
||||
-- @param claim The claim to be verified.
|
||||
-- @param acceptedClaims A table of accepted claims.
|
||||
-- @return True if the claim was allowed, false otherwise.
|
||||
local function verify_claim(claim, acceptedClaims)
|
||||
for i, accepted in ipairs(acceptedClaims) do
|
||||
if accepted == '*' then
|
||||
return true;
|
||||
end
|
||||
if claim == accepted then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Encodes the data into a signed JWT token.
|
||||
-- @param data The data the put in the body of the JWT token.
|
||||
-- @param key The key to use for signing the JWT token.
|
||||
-- @param alg The signature algorithm to use: HS256, HS384, HS512, RS256, RS384, or RS512.
|
||||
-- @param header Additional values to put in the JWT header.
|
||||
-- @param The resulting JWT token, or nil and an error message.
|
||||
function M.encode(data, key, alg, header)
|
||||
if type(data) ~= 'table' then return nil, "Argument #1 must be table" end
|
||||
if type(key) ~= 'string' then return nil, "Argument #2 must be string" end
|
||||
|
||||
alg = alg or "HS256"
|
||||
|
||||
if not alg_sign[alg] then
|
||||
return nil, "Algorithm not supported"
|
||||
end
|
||||
|
||||
header = header or {}
|
||||
|
||||
header['typ'] = 'JWT'
|
||||
header['alg'] = alg
|
||||
|
||||
local headerEncoded, err = cjson_safe.encode(header)
|
||||
if headerEncoded == nil then
|
||||
return nil, err
|
||||
end
|
||||
|
||||
local dataEncoded, err = cjson_safe.encode(data)
|
||||
if dataEncoded == nil then
|
||||
return nil, err
|
||||
end
|
||||
|
||||
local segments = {
|
||||
basexx.to_url64(headerEncoded),
|
||||
basexx.to_url64(dataEncoded)
|
||||
}
|
||||
|
||||
local signing_input = table.concat(segments, ".")
|
||||
local signature, error = alg_sign[alg](signing_input, key)
|
||||
if signature == nil then
|
||||
return nil, error
|
||||
end
|
||||
|
||||
segments[#segments+1] = basexx.to_url64(signature)
|
||||
|
||||
return table.concat(segments, ".")
|
||||
end
|
||||
|
||||
-- Verify that the token is valid, and if it is return the decoded JSON payload data.
|
||||
-- @param token The token to verify.
|
||||
-- @param expectedAlgo The signature algorithm the caller expects the token to be signed with:
|
||||
-- HS256, HS384, HS512, RS256, RS384, or RS512.
|
||||
-- @param key The verification key used for the signature.
|
||||
-- @param acceptedIssuers Optional table of accepted issuers. If not nil, the 'iss' claim will be
|
||||
-- checked against this list.
|
||||
-- @param acceptedAudiences Optional table of accepted audiences. If not nil, the 'aud' claim will
|
||||
-- be checked against this list.
|
||||
-- @return A table representing the JSON body of the token, or nil and an error message.
|
||||
function M.verify(token, expectedAlgo, key, acceptedIssuers, acceptedAudiences)
|
||||
if type(token) ~= 'string' then return nil, "token argument must be string" end
|
||||
if type(expectedAlgo) ~= 'string' then return nil, "algorithm argument must be string" end
|
||||
if type(key) ~= 'string' then return nil, "key argument must be string" end
|
||||
if acceptedIssuers ~= nil and type(acceptedIssuers) ~= 'table' then
|
||||
return nil, "acceptedIssuers argument must be table"
|
||||
end
|
||||
if acceptedAudiences ~= nil and type(acceptedAudiences) ~= 'table' then
|
||||
return nil, "acceptedAudiences argument must be table"
|
||||
end
|
||||
|
||||
if not alg_verify[expectedAlgo] then
|
||||
return nil, "Algorithm not supported"
|
||||
end
|
||||
|
||||
local header, body, sig, err = parse_token(token)
|
||||
if err ~= nil then
|
||||
return nil, err
|
||||
end
|
||||
|
||||
-- Validate header
|
||||
if not header.typ or header.typ ~= "JWT" then
|
||||
return nil, "Invalid typ"
|
||||
end
|
||||
|
||||
if not header.alg or header.alg ~= expectedAlgo then
|
||||
return nil, "Invalid or incorrect alg"
|
||||
end
|
||||
|
||||
-- Validate signature
|
||||
if not alg_verify[expectedAlgo](strip_signature(token), sig, key) then
|
||||
return nil, 'Invalid signature'
|
||||
end
|
||||
|
||||
-- Validate body
|
||||
if body.exp and type(body.exp) ~= "number" then
|
||||
return nil, "exp must be number"
|
||||
end
|
||||
|
||||
if body.nbf and type(body.nbf) ~= "number" then
|
||||
return nil, "nbf must be number"
|
||||
end
|
||||
|
||||
|
||||
if body.exp and os.time() >= body.exp then
|
||||
local extra_msg = '';
|
||||
if body.iat then
|
||||
extra_msg = ", valid for:"..tostring(body.exp-body.iat).." sec";
|
||||
end
|
||||
return nil, "Not acceptable by exp ("..tostring(os.time()-body.exp).." sec since expired"..extra_msg..")"
|
||||
end
|
||||
|
||||
if body.nbf and os.time() < body.nbf then
|
||||
return nil, "Not acceptable by nbf"
|
||||
end
|
||||
|
||||
if acceptedIssuers ~= nil then
|
||||
local issClaim = body.iss;
|
||||
if issClaim == nil then
|
||||
return nil, "'iss' claim is missing";
|
||||
end
|
||||
if not verify_claim(issClaim, acceptedIssuers) then
|
||||
return nil, "invalid 'iss' claim";
|
||||
end
|
||||
end
|
||||
|
||||
if acceptedAudiences ~= nil then
|
||||
local audClaim = body.aud;
|
||||
if audClaim == nil then
|
||||
return nil, "'aud' claim is missing";
|
||||
end
|
||||
if not verify_claim(audClaim, acceptedAudiences) then
|
||||
return nil, "invalid 'aud' claim";
|
||||
end
|
||||
end
|
||||
|
||||
return body
|
||||
end
|
||||
|
||||
-- Expose internals for unit testing (harmless in production).
|
||||
M._internals = {
|
||||
verify_claim = verify_claim,
|
||||
split_token = split_token,
|
||||
parse_token = parse_token,
|
||||
strip_signature = strip_signature,
|
||||
}
|
||||
|
||||
return M
|
||||
697
resources/prosody-plugins/mod_audio_translation_component.lua
Normal file
697
resources/prosody-plugins/mod_audio_translation_component.lua
Normal file
@@ -0,0 +1,697 @@
|
||||
-- mod_audio_translation_component.lua
|
||||
--
|
||||
-- Prosody component that collects per-receiver live-translation subscriptions
|
||||
-- and exposes the aggregated <senderId, set<language>> map to jicofo (and only
|
||||
-- jicofo) through RoomMetadata.
|
||||
--
|
||||
-- ── Subscription updates (client → component) ────────────────────────────────
|
||||
-- A receiver sends a <message> to this component containing an <audio-translation
|
||||
-- xmlns='http://jitsi.org/jitmeet'> child whose text is a JSON object that maps a
|
||||
-- sender's endpoint id to a target language:
|
||||
--
|
||||
-- { "a1b2c3d4": "en", "b2c3d4e5": "" }
|
||||
--
|
||||
-- The payload is a DELTA merged into the receiver's existing subscription map:
|
||||
-- · "<lang>" adds/replaces the subscription for that sender (one language per
|
||||
-- sender — a new language replaces the previous one).
|
||||
-- · "" removes the subscription for that sender (no-op if absent).
|
||||
-- Senders/languages not mentioned in the delta are left untouched.
|
||||
--
|
||||
-- The whole stanza is applied atomically: if any entry fails validation the
|
||||
-- entire delta is rejected with an error reply and no state changes.
|
||||
--
|
||||
-- Processing (on_message):
|
||||
-- 1. The sender must be an occupant of the room identified by the session's
|
||||
-- jitsi_web_query_room field (set from the WebSocket ?room= URL param).
|
||||
-- 2. If the feature is disabled in RoomMetadata (audioTranslation.enabled ==
|
||||
-- false) the message is a silent no-op. A missing flag means enabled.
|
||||
-- 3. The receiver must have the 'live-translation-subscribe' permission, which
|
||||
-- everybody has unless the token explicitly sets it to "false".
|
||||
-- 4. Each entry is validated: senderId is 8 hex digits and a current occupant,
|
||||
-- language is alphabetical and at most 20 characters (or "" to remove).
|
||||
-- 5. A receiver may hold at most audio_translation_max_subscriptions distinct
|
||||
-- subscriptions (unlimited by default; set the option to enforce a cap).
|
||||
--
|
||||
-- ── Aggregate map (component → jicofo) ───────────────────────────────────────
|
||||
-- Whenever the set of subscriptions changes, a debounced task recomputes the
|
||||
-- aggregate { [senderId] = { lang, ... } } (the union of all receivers' requests)
|
||||
-- and stores it on room._data.audioTranslationRequests, then fires
|
||||
-- 'room-metadata-changed' so mod_room_metadata_component broadcasts it. That
|
||||
-- module injects audioTranslationRequests into the metadata only for admin
|
||||
-- (jicofo) occupants, so regular clients never see it.
|
||||
--
|
||||
-- ── Directed listeners push (component → each sender) ─────────────────────────────
|
||||
-- Alongside the jicofo aggregate, each sender is told which receivers currently translate it.
|
||||
-- An inverted map { [senderId] = set<receiverId> } is maintained in lockstep with the forward
|
||||
-- subscription map (updated incrementally on every delta and on occupant-leave), and a directed
|
||||
-- <translation-listeners xmlns='http://jitsi.org/jitmeet'> message (sorted JSON array) is sent to
|
||||
-- each sender whose listener set changed, so a client badges the receivers translating the local
|
||||
-- user. Only senders flagged dirty since the last publish are reconsidered, so the cost is
|
||||
-- proportional to what changed rather than to room size. Gated exactly like the aggregate; an empty
|
||||
-- array clears a sender whose last listener left. Handled in publish_listeners(), kept separate so
|
||||
-- the jicofo-facing publish() is unchanged.
|
||||
--
|
||||
-- ── Enable-flag write gating ─────────────────────────────────────────────────
|
||||
-- The audioTranslation metadata key (which carries the room-level enable flag)
|
||||
-- is written through mod_room_metadata_component. This module hooks
|
||||
-- 'jitsi-metadata-allow-moderation' to gate that write behind the
|
||||
-- 'live-translation' permission (moderators by default) and to sanitise the
|
||||
-- stored value to { enabled = <bool> }.
|
||||
--
|
||||
-- Component "audiotranslation.jitmeet.example.com" "audio_translation_component"
|
||||
-- muc_component = "conference.jitmeet.example.com"
|
||||
-- breakout_rooms_component = "breakout.jitmeet.example.com"
|
||||
local json = require 'util.json';
|
||||
local st = require 'util.stanza';
|
||||
local jid_resource = require 'util.jid'.resource;
|
||||
local timer = require 'util.timer';
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local is_feature_allowed = util.is_feature_allowed;
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
|
||||
local process_host_module = util.process_host_module;
|
||||
|
||||
local AUDIO_TRANSLATION_NS = 'http://jitsi.org/jitmeet';
|
||||
local ELEMENT_NAME = 'audio-translation';
|
||||
|
||||
-- RoomMetadata key carrying the room-level enable flag ({ enabled = bool }).
|
||||
-- Client/preset writable through mod_room_metadata_component; read-only here.
|
||||
local ENABLED_METADATA_KEY = 'audioTranslation';
|
||||
-- Aggregate map exposed to jicofo only. Stored on room._data (never in
|
||||
-- jitsiMetadata) so it is never broadcast to regular clients.
|
||||
local REQUESTS_METADATA_KEY = 'audioTranslationRequests';
|
||||
-- Per-(sender, language) subscriber counts. Client-visible (kept in jitsiMetadata): counts only, never
|
||||
-- identities. Clients combine these with the bridge's synthetic-source sending state to show how many
|
||||
-- participants are still hearing a given speaker.
|
||||
local LISTENER_COUNTS_METADATA_KEY = 'audioTranslationListenerCounts';
|
||||
|
||||
local ENABLE_PERMISSION = 'live-translation';
|
||||
local SUBSCRIBE_PERMISSION = 'live-translation-subscribe';
|
||||
|
||||
local LANG_MAX_LENGTH = 20;
|
||||
|
||||
local muc_component_host = module:get_option_string('muc_component');
|
||||
if muc_component_host == nil then
|
||||
module:log('error', 'No muc_component specified. No muc to operate on!');
|
||||
return;
|
||||
end
|
||||
|
||||
-- Breakout rooms live on a separate MUC component; hook it too (optional).
|
||||
local breakout_rooms_component_host = module:get_option_string('breakout_rooms_component');
|
||||
|
||||
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
|
||||
if not main_virtual_host then
|
||||
module:log('warn', 'No muc_mapper_domain_base option set.');
|
||||
return;
|
||||
end
|
||||
|
||||
-- Maximum distinct subscriptions a single receiver may hold. Unlimited unless the option is set.
|
||||
local max_subscriptions = module:get_option_number('audio_translation_max_subscriptions');
|
||||
-- Coalesce bursts of subscription changes into a single metadata update.
|
||||
local debounce_interval = module:get_option_number('audio_translation_debounce_interval', 0.5);
|
||||
|
||||
module:log('info', 'Starting audio_translation for %s (max_subscriptions=%s, debounce=%ss)',
|
||||
muc_component_host, max_subscriptions and tostring(max_subscriptions) or 'unlimited', tostring(debounce_interval));
|
||||
|
||||
local main_muc_module;
|
||||
|
||||
-- Returns (creating if needed) the per-room translation state:
|
||||
-- { receivers = { [receiverId] = { [senderId] = lang } }, -- forward map (source of truth)
|
||||
-- last_published = <json|nil>, -- jicofo aggregate dedup
|
||||
-- listeners_by_sender = { [senderId] = { [receiverId] = true } }, -- inverted map, kept in lockstep
|
||||
-- dirty_senders = { [senderId] = true }, -- senders changed since last publish
|
||||
-- last_sent_listeners = { [senderId] = <json> } } -- directed-push dedup
|
||||
-- receivers drives per-receiver atomic updates and the jicofo aggregate; listeners_by_sender is
|
||||
-- maintained alongside it so the directed per-sender push costs work proportional to what changed.
|
||||
local function get_state(room)
|
||||
if not room._audio_translation then
|
||||
room._audio_translation = {
|
||||
receivers = {};
|
||||
listeners_by_sender = {};
|
||||
dirty_senders = {};
|
||||
last_sent_listeners = {};
|
||||
};
|
||||
end
|
||||
|
||||
return room._audio_translation;
|
||||
end
|
||||
|
||||
-- Flags a sender for reconsideration on the next listeners publish.
|
||||
local function mark_sender_dirty(state, sender_id)
|
||||
state.dirty_senders[sender_id] = true;
|
||||
end
|
||||
|
||||
-- Records that receiver_id now translates sender_id in the inverted map, flagging the sender dirty.
|
||||
local function add_listener(state, sender_id, receiver_id)
|
||||
local set = state.listeners_by_sender[sender_id];
|
||||
|
||||
if not set then
|
||||
set = {};
|
||||
state.listeners_by_sender[sender_id] = set;
|
||||
end
|
||||
set[receiver_id] = true;
|
||||
mark_sender_dirty(state, sender_id);
|
||||
end
|
||||
|
||||
-- Records that receiver_id no longer translates sender_id, dropping the set once it empties and
|
||||
-- flagging the sender dirty so the change is pushed (an empty set clears the sender).
|
||||
local function remove_listener(state, sender_id, receiver_id)
|
||||
local set = state.listeners_by_sender[sender_id];
|
||||
|
||||
if set then
|
||||
set[receiver_id] = nil;
|
||||
if next(set) == nil then
|
||||
state.listeners_by_sender[sender_id] = nil;
|
||||
end
|
||||
end
|
||||
mark_sender_dirty(state, sender_id);
|
||||
end
|
||||
|
||||
-- Flags every currently-tracked sender dirty. Used when room-wide gating (the enable flag) flips,
|
||||
-- which changes every sender's pushed list at once with no per-sender delta to key off.
|
||||
local function mark_all_senders_dirty(state)
|
||||
for sender_id in pairs(state.listeners_by_sender) do
|
||||
state.dirty_senders[sender_id] = true;
|
||||
end
|
||||
for sender_id in pairs(state.last_sent_listeners) do
|
||||
state.dirty_senders[sender_id] = true;
|
||||
end
|
||||
end
|
||||
|
||||
-- The endpoint id of an occupant is the resource part of its in-room nick.
|
||||
local function endpoint_id(occupant)
|
||||
return jid_resource(occupant.nick);
|
||||
end
|
||||
|
||||
-- True unless the feature is explicitly disabled (audioTranslation.enabled == false).
|
||||
-- A missing audioTranslation key, or a key without an explicit enabled=false,
|
||||
-- means enabled.
|
||||
local function is_enabled(room)
|
||||
local at = room.jitsiMetadata and room.jitsiMetadata[ENABLED_METADATA_KEY];
|
||||
|
||||
if type(at) == 'table' and at.enabled == false then
|
||||
return false;
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
-- Everyone may subscribe unless the token explicitly sets the permission to false.
|
||||
local function subscribe_allowed(session)
|
||||
local features = session and session.jitsi_meet_context_features;
|
||||
|
||||
if features then
|
||||
local v = features[SUBSCRIBE_PERMISSION];
|
||||
|
||||
if v == false or v == 'false' then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
local function is_valid_sender_id(id)
|
||||
return type(id) == 'string' and id:match('^%x%x%x%x%x%x%x%x$') ~= nil;
|
||||
end
|
||||
|
||||
local function is_valid_language(lang)
|
||||
return type(lang) == 'string' and #lang <= LANG_MAX_LENGTH and lang:match('^%a+$') ~= nil;
|
||||
end
|
||||
|
||||
-- Applies a delta to the receiver's subscription map atomically.
|
||||
-- Returns true on success, or false plus an (error-type, condition) pair.
|
||||
local function apply_delta(room, receiver_id, delta)
|
||||
local state = get_state(room);
|
||||
local current = state.receivers[receiver_id] or {};
|
||||
|
||||
-- Work on a copy so a mid-delta failure leaves the stored map untouched.
|
||||
local next_map = {};
|
||||
|
||||
for sender_id, lang in pairs(current) do
|
||||
next_map[sender_id] = lang;
|
||||
end
|
||||
|
||||
for sender_id, lang in pairs(delta) do
|
||||
if not is_valid_sender_id(sender_id) then
|
||||
return false, 'modify', 'bad-request';
|
||||
end
|
||||
|
||||
if type(lang) ~= 'string' then
|
||||
return false, 'modify', 'bad-request';
|
||||
end
|
||||
|
||||
if lang == '' then
|
||||
-- Removal; benign no-op when the sender is not currently subscribed.
|
||||
next_map[sender_id] = nil;
|
||||
else
|
||||
if not is_valid_language(lang) then
|
||||
return false, 'modify', 'bad-request';
|
||||
end
|
||||
|
||||
if not room:get_occupant_by_nick(room.jid..'/'..sender_id) then
|
||||
return false, 'cancel', 'item-not-found';
|
||||
end
|
||||
|
||||
next_map[sender_id] = lang;
|
||||
end
|
||||
end
|
||||
|
||||
local count = 0;
|
||||
|
||||
for _ in pairs(next_map) do
|
||||
count = count + 1;
|
||||
end
|
||||
|
||||
if max_subscriptions and count > max_subscriptions then
|
||||
return false, 'cancel', 'policy-violation';
|
||||
end
|
||||
|
||||
state.receivers[receiver_id] = next_map;
|
||||
|
||||
-- Update the inverted map in lockstep, now that the delta has fully validated (so a rejected
|
||||
-- delta never mutates it). Only membership changes move listeners; a language-only change
|
||||
-- (sender present before and after) is not a listener change and leaves the sender untouched.
|
||||
for sender_id in pairs(current) do
|
||||
if not next_map[sender_id] then
|
||||
remove_listener(state, sender_id, receiver_id);
|
||||
end
|
||||
end
|
||||
for sender_id in pairs(next_map) do
|
||||
if not current[sender_id] then
|
||||
add_listener(state, sender_id, receiver_id);
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
-- Builds the aggregate { [senderId] = { lang, ... } } (union over all receivers),
|
||||
-- or nil when there are no subscriptions.
|
||||
local function compute_aggregate(room)
|
||||
local sets = {};
|
||||
local any = false;
|
||||
|
||||
for _, subs in pairs(get_state(room).receivers) do
|
||||
for sender_id, lang in pairs(subs) do
|
||||
local set = sets[sender_id];
|
||||
|
||||
if not set then
|
||||
set = {};
|
||||
sets[sender_id] = set;
|
||||
end
|
||||
set[lang] = true;
|
||||
any = true;
|
||||
end
|
||||
end
|
||||
|
||||
if not any then
|
||||
return nil;
|
||||
end
|
||||
|
||||
local out = {};
|
||||
|
||||
for sender_id, set in pairs(sets) do
|
||||
local langs = {};
|
||||
|
||||
for lang in pairs(set) do
|
||||
langs[#langs + 1] = lang;
|
||||
end
|
||||
table.sort(langs);
|
||||
out[sender_id] = langs;
|
||||
end
|
||||
|
||||
return out;
|
||||
end
|
||||
|
||||
-- Builds { [senderId] = { [lang] = <subscriber count> } }, or nil when there are no subscriptions.
|
||||
local function compute_listener_counts(room)
|
||||
local out = {};
|
||||
local any = false;
|
||||
|
||||
for _, subs in pairs(get_state(room).receivers) do
|
||||
for sender_id, lang in pairs(subs) do
|
||||
local counts = out[sender_id];
|
||||
|
||||
if not counts then
|
||||
counts = {};
|
||||
out[sender_id] = counts;
|
||||
end
|
||||
counts[lang] = (counts[lang] or 0) + 1;
|
||||
any = true;
|
||||
end
|
||||
end
|
||||
|
||||
return any and out or nil;
|
||||
end
|
||||
|
||||
-- Recomputes the aggregate and, when it changed, publishes it to jicofo via
|
||||
-- RoomMetadata. Stored on room._data so mod_room_metadata_component only forwards
|
||||
-- it to admin (jicofo) occupants.
|
||||
-- While the feature is disabled for the room the published map is cleared (nil) so
|
||||
-- jicofo stops translation; the receivers' subscriptions are kept in memory and
|
||||
-- republished if the room is re-enabled.
|
||||
local function publish(room)
|
||||
if not room._audio_translation or not main_muc_module then
|
||||
return;
|
||||
end
|
||||
|
||||
-- Neutral, default-open extension point: an external module may veto publishing
|
||||
-- the translation request map by returning false from this event. No handler, or
|
||||
-- any non-false return, means allowed. This keeps any gating/entitlement logic out
|
||||
-- of this module (open by default). Follows the same false=deny / nil=no-opinion
|
||||
-- convention as 'jitsi-metadata-allow-moderation'.
|
||||
local allow_publish = main_muc_module:fire_event('jitsi-audio-translation-allow-publish', { room = room; }) ~= false;
|
||||
|
||||
-- The request set the receivers have asked for, independent of gating. Computed
|
||||
-- unconditionally so we can tell "nothing requested" apart from "requests suppressed".
|
||||
local requested = compute_aggregate(room);
|
||||
local gated = allow_publish and is_enabled(room);
|
||||
local aggregate = gated and requested or nil;
|
||||
local listener_counts = gated and compute_listener_counts(room) or nil;
|
||||
-- Deduped on both: counts can change while the language set does not (a second receiver picking a
|
||||
-- language another receiver already requested). Stays nil when there is nothing to publish, so a gated
|
||||
-- or empty room still broadcasts nothing.
|
||||
local encoded = (aggregate or listener_counts)
|
||||
and ((aggregate and json.encode(aggregate) or '')
|
||||
..'|'..(listener_counts and json.encode(listener_counts) or ''))
|
||||
or nil;
|
||||
|
||||
if encoded == room._audio_translation.last_published then
|
||||
return;
|
||||
end
|
||||
room._audio_translation.last_published = encoded;
|
||||
|
||||
-- Log when we transition to suppressing a non-empty request set (feature disabled for
|
||||
-- the room, or an external handler vetoed publishing). Deduped by last_published above,
|
||||
-- so this fires on the transition, not on every update. Normally the client hides the
|
||||
-- control, so a suppressed request set is worth being able to discover.
|
||||
if aggregate == nil and requested ~= nil then
|
||||
module:log('warn', 'Not publishing audio-translation requests in room %s: %s', room.jid,
|
||||
allow_publish and 'disabled for the room' or 'publishing vetoed by a handler');
|
||||
end
|
||||
|
||||
room._data.audioTranslationRequests = aggregate;
|
||||
room.jitsiMetadata = room.jitsiMetadata or {};
|
||||
room.jitsiMetadata[LISTENER_COUNTS_METADATA_KEY] = listener_counts;
|
||||
|
||||
main_muc_module:fire_event('room-metadata-changed', { room = room; });
|
||||
end
|
||||
|
||||
-- Pushes each dirty sender the sorted receiver ids currently translating it, as a directed
|
||||
-- <translation-listeners> message, then clears the dirty set. Reads the incrementally-maintained
|
||||
-- inverted map (listeners_by_sender), so the cost is proportional to what changed, not room size.
|
||||
-- Kept separate from publish() so the jicofo-facing aggregate path stays untouched. Gated like the
|
||||
-- aggregate: while disabled/vetoed every dirty sender resolves to an empty list. Change-detected per
|
||||
-- sender against last_sent_listeners; an empty array is sent (then the tracker entry dropped) to
|
||||
-- clear a sender whose last listener left.
|
||||
local function publish_listeners(room)
|
||||
if not room._audio_translation or not main_muc_module then
|
||||
return;
|
||||
end
|
||||
|
||||
local state = room._audio_translation;
|
||||
local dirty = state.dirty_senders;
|
||||
|
||||
if not dirty or next(dirty) == nil then
|
||||
return;
|
||||
end
|
||||
state.dirty_senders = {};
|
||||
|
||||
local allow_publish = main_muc_module:fire_event('jitsi-audio-translation-allow-publish', { room = room; }) ~= false;
|
||||
local enabled = allow_publish and is_enabled(room);
|
||||
|
||||
for sender_id in pairs(dirty) do
|
||||
local list = {};
|
||||
local set = enabled and state.listeners_by_sender[sender_id];
|
||||
|
||||
if set then
|
||||
for receiver_id in pairs(set) do
|
||||
list[#list + 1] = receiver_id;
|
||||
end
|
||||
-- Sorted so set-iteration order never produces a spurious change.
|
||||
table.sort(list);
|
||||
end
|
||||
|
||||
-- util.json encodes an empty table as "{}"; force "[]" so clients always parse an array.
|
||||
local encoded_listeners = (#list > 0) and json.encode(list) or '[]';
|
||||
|
||||
if encoded_listeners ~= state.last_sent_listeners[sender_id] then
|
||||
local occupant = room:get_occupant_by_nick(room.jid..'/'..sender_id);
|
||||
|
||||
if occupant then
|
||||
module:send(st.message({ from = module.host; to = occupant.jid; })
|
||||
:tag('translation-listeners', { xmlns = AUDIO_TRANSLATION_NS; })
|
||||
:text(encoded_listeners):up());
|
||||
end
|
||||
|
||||
-- Only a delivered push is recorded; for a missing occupant the entry is dropped so a
|
||||
-- rejoin with the same endpoint id gets a fresh push instead of being change-suppressed.
|
||||
if occupant and #list > 0 then
|
||||
state.last_sent_listeners[sender_id] = encoded_listeners;
|
||||
else
|
||||
state.last_sent_listeners[sender_id] = nil;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Schedules a debounced publish so a burst of updates produces a single metadata
|
||||
-- broadcast.
|
||||
local function schedule_publish(room)
|
||||
local state = get_state(room);
|
||||
|
||||
if state.publish_scheduled then
|
||||
return;
|
||||
end
|
||||
state.publish_scheduled = true;
|
||||
|
||||
timer.add_task(debounce_interval, function()
|
||||
if room._audio_translation then
|
||||
room._audio_translation.publish_scheduled = nil;
|
||||
end
|
||||
publish(room);
|
||||
publish_listeners(room);
|
||||
end);
|
||||
end
|
||||
|
||||
-- Prunes a leaving occupant from its room's state (as receiver and sender). Shared by both MUC hooks.
|
||||
local function prune_occupant(room, occupant)
|
||||
if not room._audio_translation or is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local state = room._audio_translation;
|
||||
local id = endpoint_id(occupant);
|
||||
local changed = false;
|
||||
|
||||
local own = state.receivers[id];
|
||||
|
||||
if own then
|
||||
state.receivers[id] = nil;
|
||||
for sender_id in pairs(own) do
|
||||
remove_listener(state, sender_id, id);
|
||||
end
|
||||
changed = true;
|
||||
end
|
||||
|
||||
for _, subs in pairs(state.receivers) do
|
||||
if subs[id] then
|
||||
subs[id] = nil;
|
||||
changed = true;
|
||||
end
|
||||
end
|
||||
if state.listeners_by_sender[id] or state.last_sent_listeners[id] then
|
||||
state.listeners_by_sender[id] = nil;
|
||||
state.last_sent_listeners[id] = nil;
|
||||
state.dirty_senders[id] = nil;
|
||||
changed = true;
|
||||
end
|
||||
|
||||
if changed then
|
||||
schedule_publish(room);
|
||||
end
|
||||
end
|
||||
|
||||
function on_message(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
if stanza.attr.type == 'error' then
|
||||
return; -- Do not reply to error stanzas (avoids loops).
|
||||
end
|
||||
|
||||
if not origin or not origin.jitsi_web_query_room then
|
||||
return false;
|
||||
end
|
||||
|
||||
local at = stanza:get_child(ELEMENT_NAME, AUDIO_TRANSLATION_NS);
|
||||
|
||||
if not at then
|
||||
return true; -- Not for us.
|
||||
end
|
||||
|
||||
local body = at:get_text();
|
||||
|
||||
if not body or body == '' then
|
||||
return true;
|
||||
end
|
||||
|
||||
local main_room = get_room_by_name_and_subdomain(
|
||||
origin.jitsi_web_query_room, origin.jitsi_web_query_prefix or '');
|
||||
|
||||
if not main_room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
origin.jitsi_web_query_prefix, origin.jitsi_web_query_room);
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'item-not-found'));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
local from = stanza.attr.from;
|
||||
-- The receiver may be in a breakout (different MUC); ?room= is always the main room. Resolve the
|
||||
-- room it is actually in (get_occupant_by_real_jid fans out into breakouts) and track it there.
|
||||
local occupant, room = get_occupant_by_real_jid(main_room, from);
|
||||
|
||||
if not occupant then
|
||||
module:log('warn', '%s is not an occupant of %s', from, main_room.jid);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
-- Disabled for the room → no-op. Log it: the client normally hides the control when
|
||||
-- translation is disabled, so a subscription arriving here is unexpected and worth
|
||||
-- being able to discover.
|
||||
if not is_enabled(room) then
|
||||
module:log('warn', 'Ignoring audio-translation subscription from %s: disabled for room %s', from, room.jid);
|
||||
return true;
|
||||
end
|
||||
|
||||
if not subscribe_allowed(origin) then
|
||||
module:log('warn', '%s is not allowed to subscribe to translations in %s', from, room.jid);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
local delta = json.decode(body);
|
||||
|
||||
if type(delta) ~= 'table' then
|
||||
module:log('warn', 'Invalid audio_translation payload from %s: %s', from, body);
|
||||
origin.send(st.error_reply(stanza, 'modify', 'bad-request'));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
local ok, err_type, err_condition = apply_delta(room, endpoint_id(occupant), delta);
|
||||
|
||||
if not ok then
|
||||
origin.send(st.error_reply(stanza, err_type, err_condition));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
schedule_publish(room);
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
module:hook('message/host', on_message);
|
||||
|
||||
function process_main_muc_loaded(main_muc, host_module)
|
||||
main_muc_module = host_module;
|
||||
|
||||
module:log('info', 'Hooked audio_translation to muc events on %s', muc_component_host);
|
||||
|
||||
-- Prune a leaving occupant's subscriptions (shared with the breakout MUC hook below).
|
||||
host_module:hook('muc-occupant-left', function(event)
|
||||
prune_occupant(event.room, event.occupant);
|
||||
end);
|
||||
|
||||
-- React when the room-level enable flag is toggled: republish so the aggregate
|
||||
-- map is cleared when disabled (translation stops) or restored when re-enabled.
|
||||
host_module:hook('jitsi-metadata-updated', function(event)
|
||||
local room = event.room;
|
||||
|
||||
if event.key == ENABLED_METADATA_KEY and room._audio_translation
|
||||
and not is_healthcheck_room(room.jid) then
|
||||
-- Room-wide gating changes every sender's pushed list at once, with no per-sender delta,
|
||||
-- so flag them all for the listeners pass (publish() recomputes the aggregate itself).
|
||||
mark_all_senders_dirty(room._audio_translation);
|
||||
schedule_publish(room);
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
-- Hook occupant-left on the breakout MUC so breakout subscriptions are pruned on leave. Publishing
|
||||
-- still flows through main_muc_module, whose room-metadata-changed handler broadcasts the given room.
|
||||
function process_breakout_muc_loaded(breakout_muc, host_module)
|
||||
module:log('info', 'Hooked audio_translation to breakout muc events on %s', breakout_rooms_component_host);
|
||||
|
||||
host_module:hook('muc-occupant-left', function(event)
|
||||
prune_occupant(event.room, event.occupant);
|
||||
end);
|
||||
end
|
||||
|
||||
process_host_module(muc_component_host, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_main_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if event.module == 'muc' then
|
||||
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
if breakout_rooms_component_host then
|
||||
process_host_module(breakout_rooms_component_host, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_breakout_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for breakout muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if event.module == 'muc' then
|
||||
process_breakout_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
-- Advertise the component as a disco identity on the main virtual host so
|
||||
-- clients can discover it (handled by mod_features_identity), and gate writes to
|
||||
-- the audioTranslation enable flag behind the 'live-translation' permission
|
||||
-- (moderators by default) while sanitising the stored value.
|
||||
process_host_module(main_virtual_host, function(host_module)
|
||||
module:context(host_module.host):fire_event('jitsi-add-identity', {
|
||||
name = ELEMENT_NAME; host = module.host;
|
||||
});
|
||||
|
||||
host_module:hook('jitsi-metadata-allow-moderation', function(event)
|
||||
if event.key ~= ENABLED_METADATA_KEY then
|
||||
return; -- No opinion on other keys.
|
||||
end
|
||||
|
||||
local session = event.session;
|
||||
local is_moderator = event.actor and event.actor.role == 'moderator';
|
||||
local features = session and session.jitsi_meet_context_features;
|
||||
|
||||
if not is_feature_allowed(ENABLE_PERMISSION, features, is_moderator) then
|
||||
return false;
|
||||
end
|
||||
|
||||
local data = event.data;
|
||||
local enabled = type(data) == 'table' and (data.enabled == true or data.enabled == 'true');
|
||||
|
||||
return { enabled = enabled };
|
||||
end);
|
||||
end);
|
||||
97
resources/prosody-plugins/mod_auth_jitsi-anonymous.lua
Normal file
97
resources/prosody-plugins/mod_auth_jitsi-anonymous.lua
Normal file
@@ -0,0 +1,97 @@
|
||||
-- Anonymous authentication with extras:
|
||||
-- * session resumption
|
||||
-- Copyright (C) 2021-present 8x8, Inc.
|
||||
|
||||
local generate_uuid = require "util.uuid".generate;
|
||||
local new_sasl = require "util.sasl".new;
|
||||
local sasl = require "util.sasl";
|
||||
local sessions = prosody.full_sessions;
|
||||
|
||||
module:depends("jitsi_session");
|
||||
|
||||
-- define auth provider
|
||||
local provider = {};
|
||||
|
||||
function provider.test_password(username, password)
|
||||
return nil, "Password based auth not supported";
|
||||
end
|
||||
|
||||
function provider.get_password(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.set_password(username, password)
|
||||
return nil, "Set password not supported";
|
||||
end
|
||||
|
||||
function provider.user_exists(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.create_user(username, password)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.delete_user(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.get_sasl_handler(session)
|
||||
-- Custom session matching so we can resume session even with randomly
|
||||
-- generated user IDs.
|
||||
local function get_username(self, message)
|
||||
|
||||
local resuming = false;
|
||||
if (session.previd ~= nil) then
|
||||
for _, session1 in pairs(sessions) do
|
||||
if (session1.resumption_token == session.previd) then
|
||||
self.username = session1.username;
|
||||
resuming = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
else
|
||||
self.username = message;
|
||||
end
|
||||
|
||||
if not resuming then
|
||||
session.auth_token = nil;
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
return new_sasl(module.host, { anonymous = get_username });
|
||||
end
|
||||
|
||||
module:provides("auth", provider);
|
||||
|
||||
local function anonymous(self, message)
|
||||
-- Same as the vanilla anonymous auth plugin
|
||||
local username = generate_uuid();
|
||||
|
||||
-- This calls the handler created in 'provider.get_sasl_handler(session)'
|
||||
local result, err, msg = self.profile.anonymous(self, username, self.realm);
|
||||
|
||||
if result == true then
|
||||
if (self.username == nil) then
|
||||
-- Session was not resumed
|
||||
self.username = username;
|
||||
end
|
||||
return "success";
|
||||
else
|
||||
return "failure", err, msg;
|
||||
end
|
||||
end
|
||||
|
||||
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);
|
||||
|
||||
module:hook("pre-resource-unbind", function (e)
|
||||
local error, session = e.error, e.session;
|
||||
|
||||
prosody.events.fire_event('jitsi-pre-session-unbind', {
|
||||
jid = session.full_jid,
|
||||
session = session,
|
||||
error = error
|
||||
});
|
||||
end, 11);
|
||||
69
resources/prosody-plugins/mod_auth_jitsi-shared-secret.lua
Normal file
69
resources/prosody-plugins/mod_auth_jitsi-shared-secret.lua
Normal file
@@ -0,0 +1,69 @@
|
||||
-- Authentication with shared secret where the username is ignored
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local new_sasl = require "util.sasl".new;
|
||||
local saslprep = require "util.encodings".stringprep.saslprep;
|
||||
local secure_equals = require "util.hashes".equals;
|
||||
|
||||
local shared_secret = module:get_option_string('shared_secret');
|
||||
-- TODO: shared_secret_prev is probably broken. get_sasl_handler() returns shared_secret
|
||||
-- from its plain callback, so Prosody rejects any password other than the current secret.
|
||||
-- provider.test_password() handles shared_secret_prev correctly but is not called in the
|
||||
-- PLAIN SASL flow. To fix, get_sasl_handler() should use a plain_test profile instead.
|
||||
local shared_secret_prev = module:get_option_string('shared_secret_prev');
|
||||
if shared_secret == nil then
|
||||
module:log('error', 'No shared_secret specified. No secret to operate on!');
|
||||
return;
|
||||
end
|
||||
|
||||
module:depends("jitsi_session");
|
||||
|
||||
-- define auth provider
|
||||
local provider = {};
|
||||
|
||||
function provider.test_password(username, password)
|
||||
password = saslprep(password);
|
||||
if not password then
|
||||
return nil, "Password fails SASLprep.";
|
||||
end
|
||||
|
||||
if secure_equals(password, saslprep(shared_secret)) then
|
||||
return true;
|
||||
elseif (shared_secret_prev ~= nil and secure_equals(password, saslprep(shared_secret_prev))) then
|
||||
module:log("info", "Accepting login using previous shared secret, username=%s", username);
|
||||
return true;
|
||||
else
|
||||
return nil, "Auth failed. Invalid username or password.";
|
||||
end
|
||||
end
|
||||
|
||||
function provider.get_password(username)
|
||||
return shared_secret;
|
||||
end
|
||||
|
||||
function provider.set_password(username, password)
|
||||
return nil, "Set password not supported";
|
||||
end
|
||||
|
||||
function provider.user_exists(username)
|
||||
return true; -- all usernames exist
|
||||
end
|
||||
|
||||
function provider.create_user(username, password)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.delete_user(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.get_sasl_handler(session)
|
||||
local getpass_authentication_profile = {
|
||||
plain = function(_, username, realm)
|
||||
return shared_secret, true;
|
||||
end
|
||||
};
|
||||
return new_sasl(module.host, getpass_authentication_profile);
|
||||
end
|
||||
|
||||
module:provides("auth", provider);
|
||||
272
resources/prosody-plugins/mod_auth_token.lua
Normal file
272
resources/prosody-plugins/mod_auth_token.lua
Normal file
@@ -0,0 +1,272 @@
|
||||
-- Token authentication
|
||||
-- Copyright (C) 2021-present 8x8, Inc.
|
||||
|
||||
local formdecode = require "util.http".formdecode;
|
||||
local generate_uuid = require "util.uuid".generate;
|
||||
local new_sasl = require "util.sasl".new;
|
||||
local sasl = require "util.sasl";
|
||||
local token_util = module:require "token/util".new(module);
|
||||
local sessions = prosody.full_sessions;
|
||||
|
||||
-- no token configuration
|
||||
if token_util == nil then
|
||||
return;
|
||||
end
|
||||
|
||||
module:depends("jitsi_session");
|
||||
|
||||
local measure_pre_fetch_fail = module:measure('pre_fetch_fail', 'counter');
|
||||
local measure_verify_fail = module:measure('verify_fail', 'counter');
|
||||
local measure_success = module:measure('success', 'counter');
|
||||
local measure_ban = module:measure('ban', 'counter');
|
||||
local measure_post_auth_fail = module:measure('post_auth_fail', 'counter');
|
||||
local measure_resume_room_mismatch = module:measure('resume_room_mismatch', 'counter');
|
||||
|
||||
-- The JWT-derived authorization state that c2s-session-updated refreshes from
|
||||
-- the connection that resumes a hibernating session.
|
||||
local TOKEN_CLAIM_FIELDS = {
|
||||
'auth_token';
|
||||
'jitsi_meet_context_user';
|
||||
'jitsi_meet_context_group';
|
||||
'jitsi_meet_context_features';
|
||||
'jitsi_meet_context_room';
|
||||
'jitsi_meet_room';
|
||||
'jitsi_meet_str_tenant';
|
||||
'jitsi_meet_domain';
|
||||
'jitsi_meet_tenant_mismatch';
|
||||
'jitsi_meet_auth_issuer';
|
||||
};
|
||||
|
||||
-- define auth provider
|
||||
local provider = {};
|
||||
|
||||
local host = module.host;
|
||||
|
||||
module:hook("pre-resource-unbind", function (e)
|
||||
local error, session = e.error, e.session;
|
||||
|
||||
prosody.events.fire_event('jitsi-pre-session-unbind', {
|
||||
jid = session.full_jid,
|
||||
session = session,
|
||||
error = error
|
||||
});
|
||||
end, 11);
|
||||
|
||||
function provider.test_password(username, password)
|
||||
return nil, "Password based auth not supported";
|
||||
end
|
||||
|
||||
function provider.get_password(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.set_password(username, password)
|
||||
return nil, "Set password not supported";
|
||||
end
|
||||
|
||||
function provider.user_exists(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.create_user(username, password)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function provider.delete_user(username)
|
||||
return nil;
|
||||
end
|
||||
|
||||
function first_stage_auth(session)
|
||||
-- retrieve custom public key from server and save it on the session
|
||||
local pre_event_result = prosody.events.fire_event("pre-jitsi-authentication-fetch-key", session);
|
||||
if pre_event_result ~= nil and pre_event_result.res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token on pre authentication stage:%s, reason:%s", pre_event_result.error, pre_event_result.reason);
|
||||
session.auth_token = nil;
|
||||
measure_pre_fetch_fail(1);
|
||||
return pre_event_result;
|
||||
end
|
||||
|
||||
local res, error, reason = token_util:process_and_verify_token(session);
|
||||
if res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token err:%s, reason:%s tenant:%s room:%s user_agent:%s",
|
||||
error, reason, session.jitsi_web_query_prefix, session.jitsi_web_query_room,
|
||||
session.user_agent_header);
|
||||
session.auth_token = nil;
|
||||
measure_verify_fail(1);
|
||||
return { res = res, error = error, reason = reason };
|
||||
end
|
||||
|
||||
local shouldAllow = prosody.events.fire_event("jitsi-access-ban-check", session);
|
||||
if shouldAllow == false then
|
||||
module:log("warn", "user is banned")
|
||||
measure_ban(1);
|
||||
return { res = false, error = "not-allowed", reason = "user is banned" };
|
||||
end
|
||||
|
||||
return { verify_result = res, custom_username = prosody.events.fire_event("pre-jitsi-authentication", session) };
|
||||
end
|
||||
|
||||
function second_stage_auth(session)
|
||||
local post_event_result = prosody.events.fire_event("post-jitsi-authentication", session);
|
||||
if post_event_result ~= nil and post_event_result.res == false then
|
||||
module:log("warn",
|
||||
"Error verifying token on post authentication stage :%s, reason:%s", post_event_result.error, post_event_result.reason);
|
||||
session.auth_token = nil;
|
||||
measure_post_auth_fail(1);
|
||||
return post_event_result;
|
||||
end
|
||||
end
|
||||
|
||||
function provider.get_sasl_handler(session)
|
||||
|
||||
local function get_username_from_token(self, message)
|
||||
|
||||
local s1_result = first_stage_auth(session);
|
||||
if s1_result.res == false then
|
||||
return s1_result.res, s1_result.error, s1_result.reason;
|
||||
end
|
||||
|
||||
if s1_result.custom_username then
|
||||
self.username = s1_result.custom_username;
|
||||
elseif session.previd ~= nil then
|
||||
for _, session1 in pairs(sessions) do
|
||||
if (session1.resumption_token == session.previd) then
|
||||
self.username = session1.username;
|
||||
break;
|
||||
end
|
||||
end
|
||||
else
|
||||
self.username = message;
|
||||
end
|
||||
|
||||
local s2_result = second_stage_auth(session);
|
||||
if s2_result and s2_result.res ~= nil then
|
||||
return s2_result.res, s2_result.error, s2_result.reason;
|
||||
end
|
||||
|
||||
measure_success(1);
|
||||
session._jitsi_auth_done = true;
|
||||
return s1_result.verify_result;
|
||||
end
|
||||
|
||||
return new_sasl(host, { anonymous = get_username_from_token });
|
||||
end
|
||||
|
||||
module:provides("auth", provider);
|
||||
|
||||
local function anonymous(self, message)
|
||||
|
||||
local username = generate_uuid();
|
||||
|
||||
-- This calls the handler created in 'provider.get_sasl_handler(session)'
|
||||
local result, err, msg = self.profile.anonymous(self, username, self.realm);
|
||||
|
||||
if result == true then
|
||||
if (self.username == nil) then
|
||||
self.username = username;
|
||||
end
|
||||
return "success";
|
||||
else
|
||||
return "failure", err, msg;
|
||||
end
|
||||
end
|
||||
|
||||
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);
|
||||
|
||||
-- Fired by Prosody's sessionmanager.update_session() during smacks (XEP-0198) session
|
||||
-- resumption. The naming can be counter-intuitive:
|
||||
-- event.session (session) – the OLD hibernating session being kept alive.
|
||||
-- It holds all MUC state, auth state, and custom
|
||||
-- fields accumulated during the call.
|
||||
-- event.from_session (from_session) – the NEW incoming TCP session being absorbed and
|
||||
-- then retired. It has JWT fields freshly extracted
|
||||
-- from the reconnect URL by mod_jitsi_session, but
|
||||
-- no MUC state (it never joined a room).
|
||||
-- The copies below refresh the old session's JWT claims with the latest values from the
|
||||
-- new connection (useful when the client reconnects with a rotated token). Fields that
|
||||
-- represent runtime MUC state (e.g. jitsi_breakout_main_jid, set by
|
||||
-- mod_muc_breakout_rooms when the session joins the main room) must NOT be copied here —
|
||||
-- from_session will have nil for those, which would silently overwrite the valid value
|
||||
-- on the old session.
|
||||
module:hook_global('c2s-session-updated', function (event)
|
||||
local session, from_session = event.session, event.from_session;
|
||||
|
||||
if not from_session.auth_token then
|
||||
return;
|
||||
end
|
||||
|
||||
-- The room claim is scoped to a conference on muc-room-pre-create and
|
||||
-- muc-occupant-pre-join, which do not run again for a session that is past
|
||||
-- its join, so the claims coming from the resuming connection are scoped
|
||||
-- here instead: the ones verified on join are snapshotted before anything
|
||||
-- below mutates the session, and are kept when the refreshed ones do not
|
||||
-- cover the conference the session is in. The check belongs to whichever
|
||||
-- module owns room verification (mod_token_verification, on the MUC
|
||||
-- component); with nothing answering the event the claims are refreshed
|
||||
-- as they come, the same way a join is not room-checked without it.
|
||||
local reverify_rooms = session.auth_token ~= from_session.auth_token;
|
||||
local verified_claims = {};
|
||||
|
||||
if reverify_rooms then
|
||||
for _, field in ipairs(TOKEN_CLAIM_FIELDS) do
|
||||
verified_claims[field] = session[field];
|
||||
end
|
||||
end
|
||||
|
||||
-- we care to handle sessions from other hosts (anonymous hosts)
|
||||
-- Skip if from_session was already authenticated by its own token-auth module
|
||||
-- (indicated by _jitsi_auth_done=true), to avoid a second module instance
|
||||
-- on a different VirtualHost (e.g. hs256.localhost) incorrectly re-verifying
|
||||
-- a token signed for the original host and then closing the session.
|
||||
if module.host ~= event.from_session.host and not from_session._jitsi_auth_done then
|
||||
-- Handle session updates (e.g., when a session is resumed on some anonymous host with a token we need to do all the checks here)
|
||||
session.auth_token = event.from_session.auth_token;
|
||||
|
||||
local s1_result = first_stage_auth(session);
|
||||
if s1_result.res == false then
|
||||
event.session:close();
|
||||
return;
|
||||
end
|
||||
|
||||
local s2_result = second_stage_auth(session);
|
||||
if s2_result and s2_result.res == false then
|
||||
event.session:close();
|
||||
return;
|
||||
end
|
||||
session._jitsi_auth_done = true;
|
||||
end
|
||||
|
||||
if not session._jitsi_auth_done then
|
||||
module:log('warn', 'Impossible case hit where session did not pass auth flow');
|
||||
event.session:close();
|
||||
return;
|
||||
end
|
||||
|
||||
-- copy all the custom fields we set in the session
|
||||
session.auth_token = from_session.auth_token;
|
||||
session.jitsi_meet_context_user = from_session.jitsi_meet_context_user;
|
||||
session.jitsi_meet_context_group = from_session.jitsi_meet_context_group;
|
||||
session.jitsi_meet_context_features = from_session.jitsi_meet_context_features;
|
||||
session.jitsi_meet_context_room = from_session.jitsi_meet_context_room;
|
||||
session.jitsi_meet_room = from_session.jitsi_meet_room;
|
||||
session.jitsi_meet_str_tenant = from_session.jitsi_meet_str_tenant;
|
||||
session.jitsi_meet_domain = from_session.jitsi_meet_domain;
|
||||
session.jitsi_meet_tenant_mismatch = from_session.jitsi_meet_tenant_mismatch;
|
||||
|
||||
if reverify_rooms then
|
||||
local result = prosody.events.fire_event('jitsi-verify-session-rooms', { session = session; });
|
||||
|
||||
if result and result.res == false then
|
||||
module:log('warn',
|
||||
'Token presented on resume does not authorize room:%s err:%s reason:%s, keeping claims from join',
|
||||
result.room, result.error, result.reason);
|
||||
measure_resume_room_mismatch(1);
|
||||
|
||||
for _, field in ipairs(TOKEN_CLAIM_FIELDS) do
|
||||
session[field] = verified_claims[field];
|
||||
end
|
||||
end
|
||||
end
|
||||
end, 1);
|
||||
437
resources/prosody-plugins/mod_av_moderation_component.lua
Normal file
437
resources/prosody-plugins/mod_av_moderation_component.lua
Normal file
@@ -0,0 +1,437 @@
|
||||
local util = module:require 'util';
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local process_host_module = util.process_host_module;
|
||||
local table_shallow_copy = util.table_shallow_copy;
|
||||
local is_admin = util.is_admin;
|
||||
local is_focus = util.is_focus;
|
||||
local array = require "util.array";
|
||||
local json = require 'cjson.safe';
|
||||
local st = require 'util.stanza';
|
||||
|
||||
local muc_component_host = module:get_option_string('muc_component');
|
||||
if muc_component_host == nil then
|
||||
module:log('error', 'No muc_component specified. No muc to operate on!');
|
||||
return;
|
||||
end
|
||||
|
||||
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
|
||||
if not main_virtual_host then
|
||||
module:log('warn', 'No "muc_mapper_domain_base" option set, disabling AV moderation.');
|
||||
return ;
|
||||
end
|
||||
|
||||
module:log('info', 'Starting av_moderation for %s', muc_component_host);
|
||||
|
||||
-- Returns the index of the given element in the table
|
||||
-- @param table in which to look
|
||||
-- @param elem the element for which to find the index
|
||||
function get_index_in_table(table, elem)
|
||||
for index, value in pairs(table) do
|
||||
if value == elem then
|
||||
return index
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Sends a json-message to the destination jid
|
||||
-- @param to_jid the destination jid
|
||||
-- @param json_message the message content to send
|
||||
function send_json_message(to_jid, json_message)
|
||||
local stanza = st.message({ from = module.host; to = to_jid; })
|
||||
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_message):up();
|
||||
module:send(stanza);
|
||||
end
|
||||
|
||||
-- Notifies that av moderation has been enabled or disabled
|
||||
-- @param jid the jid to notify, if missing will notify all occupants
|
||||
-- @param enable whether it is enabled or disabled
|
||||
-- @param room the room
|
||||
-- @param actorJid the jid that is performing the enable/disable operation (the muc jid)
|
||||
-- @param mediaType the media type for the moderation
|
||||
function notify_occupants_enable(jid, enable, room, actorJid, mediaType)
|
||||
local body_json = {};
|
||||
body_json.type = 'av_moderation';
|
||||
body_json.enabled = enable;
|
||||
body_json.room = internal_room_jid_match_rewrite(room.jid);
|
||||
body_json.actor = internal_room_jid_match_rewrite(actorJid);
|
||||
body_json.mediaType = mediaType;
|
||||
local body_json_str, error = json.encode(body_json);
|
||||
|
||||
if not body_json_str then
|
||||
module:log('error', 'error encoding json room:%s error:%s', room.jid, error);
|
||||
return;
|
||||
end
|
||||
|
||||
if jid then
|
||||
send_json_message(jid, body_json_str)
|
||||
else
|
||||
for _, occupant in room:each_occupant() do
|
||||
send_json_message(occupant.jid, body_json_str)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Notifies about a change to the whitelist. Notifies all moderators and admin and the jid itself
|
||||
-- @param jid the jid to notify about the change
|
||||
-- @param moderators whether to notify all moderators in the room
|
||||
-- @param room the room where to send it
|
||||
-- @param mediaType used only when a participant is approved (not sent to moderators)
|
||||
-- @param removed whether the jid is removed or added
|
||||
function notify_whitelist_change(jid, moderators, room, mediaType, removed)
|
||||
local body_json = {};
|
||||
body_json.type = 'av_moderation';
|
||||
body_json.room = internal_room_jid_match_rewrite(room.jid);
|
||||
-- we will be modifying it, so we need a copy
|
||||
body_json.whitelists = table_shallow_copy(room.av_moderation);
|
||||
if removed then
|
||||
body_json.removed = true;
|
||||
end
|
||||
body_json.mediaType = mediaType;
|
||||
|
||||
-- sanitize, make sure we don't have an empty array as it will encode it as {} not as []
|
||||
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
|
||||
if body_json.whitelists[mediaType] and #body_json.whitelists[mediaType] == 0 then
|
||||
body_json.whitelists[mediaType] = nil;
|
||||
end
|
||||
end
|
||||
|
||||
local moderators_body_json_str, error = json.encode(body_json);
|
||||
|
||||
if not moderators_body_json_str then
|
||||
module:log('error', 'error encoding moderator json room:%s error:%s', room.jid, error);
|
||||
return;
|
||||
end
|
||||
|
||||
body_json.whitelists = nil;
|
||||
if not removed then
|
||||
body_json.approved = true; -- we want to send to participants only that they were approved to unmute
|
||||
end
|
||||
local participant_body_json_str, error = json.encode(body_json);
|
||||
|
||||
if not participant_body_json_str then
|
||||
module:log('error', 'error encoding participant json room:%s error:%s', room.jid, error);
|
||||
return;
|
||||
end
|
||||
|
||||
for _, occupant in room:each_occupant() do
|
||||
if moderators and occupant.role == 'moderator' then
|
||||
send_json_message(occupant.jid, moderators_body_json_str);
|
||||
elseif occupant.jid == jid then
|
||||
-- if the occupant is not moderator we send him that it is approved
|
||||
-- if it is moderator we update him with the list, this is moderator joining or grant moderation was executed
|
||||
if occupant.role == 'moderator' then
|
||||
send_json_message(occupant.jid, moderators_body_json_str);
|
||||
else
|
||||
send_json_message(occupant.jid, participant_body_json_str);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Notifies jid that is approved. This is a moderator to jid message to ask to unmute,
|
||||
-- @param jid the jid to notify about the change
|
||||
-- @param from the jid that triggered this
|
||||
-- @param room the room where to send it
|
||||
-- @param mediaType the mediaType it was approved for
|
||||
function notify_jid_approved(jid, from, room, mediaType)
|
||||
local body_json = {};
|
||||
body_json.type = 'av_moderation';
|
||||
body_json.room = internal_room_jid_match_rewrite(room.jid);
|
||||
body_json.approved = true; -- we want to send to participants only that they were approved to unmute
|
||||
body_json.mediaType = mediaType;
|
||||
body_json.from = from;
|
||||
|
||||
local json_message, error = json.encode(body_json);
|
||||
if not json_message then
|
||||
module:log('error', 'skip sending json message to:%s error:%s', jid, error);
|
||||
return;
|
||||
end
|
||||
|
||||
send_json_message(jid, json_message);
|
||||
end
|
||||
|
||||
function start_av_moderation(room, mediaType, occupant)
|
||||
if not room.av_moderation then
|
||||
room.av_moderation = {};
|
||||
room.av_moderation_actors = {};
|
||||
end
|
||||
room.av_moderation[mediaType] = array();
|
||||
|
||||
-- add all current moderators to the new whitelist
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
if room_occupant.role == 'moderator' and not is_focus(room_occupant.nick) then
|
||||
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
|
||||
end
|
||||
end
|
||||
|
||||
-- We want to set startMuted policy in metadata, in case of new participants are joining to respect
|
||||
-- it, that will be enforced by jicofo
|
||||
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
|
||||
|
||||
-- We want to keep the previous value of startMuted for this mediaType if av moderation is disabled
|
||||
-- to be able to restore
|
||||
local av_moderation_startMuted_restore = room.av_moderation_startMuted_restore or {};
|
||||
av_moderation_startMuted_restore[mediaType] = startMutedMetadata[mediaType];
|
||||
room.av_moderation_startMuted_restore = av_moderation_startMuted_restore;
|
||||
|
||||
startMutedMetadata[mediaType] = true;
|
||||
room.jitsiMetadata.startMuted = startMutedMetadata;
|
||||
|
||||
room.av_moderation_actors[mediaType] = occupant.nick;
|
||||
end
|
||||
|
||||
-- receives messages from clients to the component sending A/V moderation enable/disable commands or adding
|
||||
-- jids to the whitelist
|
||||
function on_message(event)
|
||||
local session = event.origin;
|
||||
|
||||
-- Check the type of the incoming stanza to avoid loops:
|
||||
if event.stanza.attr.type == 'error' then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
return false;
|
||||
end
|
||||
|
||||
local moderation_command = event.stanza:get_child('av_moderation');
|
||||
|
||||
if moderation_command then
|
||||
-- get room name with tenant and find room
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- check that the participant requesting is a moderator and is an occupant in the room
|
||||
local from = event.stanza.attr.from;
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
if not occupant then
|
||||
module:log('warn', 'No occupant %s found for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
if occupant.role ~= 'moderator' then
|
||||
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
local mediaType = moderation_command.attr.mediaType;
|
||||
if mediaType then
|
||||
if mediaType ~= 'audio' and mediaType ~= 'video' and mediaType ~= 'desktop' then
|
||||
module:log('warn', 'Wrong mediaType %s for %s', mediaType, room.jid);
|
||||
return false;
|
||||
end
|
||||
else
|
||||
module:log('warn', 'Missing mediaType for %s', room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if moderation_command.attr.enable ~= nil then
|
||||
local enabled;
|
||||
if moderation_command.attr.enable == 'true' then
|
||||
enabled = true;
|
||||
if room.av_moderation and room.av_moderation[mediaType] then
|
||||
module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
|
||||
return true;
|
||||
else
|
||||
start_av_moderation(room, mediaType, occupant);
|
||||
end
|
||||
else
|
||||
enabled = false;
|
||||
if not room.av_moderation then
|
||||
module:log('warn', 'Concurrent moderator enable/disable request or something is out of sync');
|
||||
return true;
|
||||
else
|
||||
room.av_moderation[mediaType] = nil;
|
||||
room.av_moderation_actors[mediaType] = nil;
|
||||
|
||||
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
|
||||
local av_moderation_startMuted_restore = room.av_moderation_startMuted_restore or {};
|
||||
startMutedMetadata[mediaType] = av_moderation_startMuted_restore[mediaType];
|
||||
room.jitsiMetadata.startMuted = startMutedMetadata;
|
||||
|
||||
local is_empty = true;
|
||||
for key,_ in pairs(room.av_moderation) do
|
||||
if room.av_moderation[key] then
|
||||
is_empty = false;
|
||||
end
|
||||
end
|
||||
if is_empty then
|
||||
room.av_moderation = nil;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- send message to all occupants
|
||||
notify_occupants_enable(nil, enabled, room, occupant.nick, mediaType);
|
||||
|
||||
if enabled then
|
||||
-- inform all moderators for the newly created whitelist
|
||||
notify_whitelist_change(nil, true, room, mediaType);
|
||||
end
|
||||
|
||||
return true;
|
||||
elseif moderation_command.attr.jidToWhitelist then
|
||||
local occupant_jid = moderation_command.attr.jidToWhitelist;
|
||||
-- check if jid is in the room, if so add it to whitelist
|
||||
-- inform all moderators and admins and the jid
|
||||
local occupant_to_add = room:get_occupant_by_nick(room_jid_match_rewrite(occupant_jid));
|
||||
if not occupant_to_add then
|
||||
module:log('warn', 'No occupant %s found for %s', occupant_jid, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if room.av_moderation then
|
||||
local whitelist = room.av_moderation[mediaType];
|
||||
if not whitelist then
|
||||
whitelist = array{};
|
||||
room.av_moderation[mediaType] = whitelist;
|
||||
end
|
||||
whitelist:push(occupant_jid);
|
||||
|
||||
notify_whitelist_change(occupant_to_add.jid, true, room, mediaType, false);
|
||||
|
||||
return true;
|
||||
else
|
||||
-- this is a moderator asking the jid to unmute without enabling av moderation
|
||||
-- let's just send the event
|
||||
notify_jid_approved(occupant_to_add.jid, occupant.nick, room, mediaType);
|
||||
end
|
||||
elseif moderation_command.attr.jidToBlacklist then
|
||||
local occupant_jid = moderation_command.attr.jidToBlacklist;
|
||||
-- check if jid is in the room, if so remove it from the whitelist
|
||||
-- inform all moderators and admins
|
||||
local occupant_to_remove = room:get_occupant_by_nick(room_jid_match_rewrite(occupant_jid));
|
||||
if not occupant_to_remove then
|
||||
module:log('warn', 'No occupant %s found for %s', occupant_jid, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if room.av_moderation then
|
||||
local whitelist = room.av_moderation[mediaType];
|
||||
if whitelist then
|
||||
local index = get_index_in_table(whitelist, occupant_jid)
|
||||
if(index) then
|
||||
whitelist:pop(index);
|
||||
notify_whitelist_change(occupant_to_remove.jid, true, room, mediaType, true);
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- return error
|
||||
return false
|
||||
end
|
||||
|
||||
-- handles new occupants to inform them about the state enabled/disabled, new moderators also get and the whitelist
|
||||
function occupant_joined(event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
-- when first moderator joins if av_can_unmute from password preset is set to false, we enable av moderation for both
|
||||
-- audio and video, and set the first moderator as the actor that enabled it
|
||||
if room._data.av_can_unmute ~= nil
|
||||
and not room._data.av_first_moderator_joined
|
||||
|
||||
-- occupant.role is not reflecting the actual role after set_affiliation is used in same occupant_joined event
|
||||
and room:get_role(occupant.nick) == 'moderator' then
|
||||
|
||||
if not room._data.av_can_unmute then
|
||||
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
|
||||
start_av_moderation(room, mediaType, occupant);
|
||||
|
||||
notify_occupants_enable(nil, true, room, occupant.nick, mediaType);
|
||||
|
||||
notify_whitelist_change(nil, true, room, mediaType);
|
||||
end
|
||||
|
||||
room._data.av_first_moderator_joined = true;
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
if room.av_moderation then
|
||||
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
|
||||
if room.av_moderation[mediaType] then
|
||||
notify_occupants_enable(
|
||||
occupant.jid, true, room, room.av_moderation_actors[mediaType], mediaType);
|
||||
end
|
||||
end
|
||||
|
||||
-- NOTE for some reason event.occupant.role is not reflecting the actual occupant role (when changed
|
||||
-- from allowners module) but iterating over room occupants returns the correct role
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
-- if it is a moderator, make sure it is whitelisted and send the whitelist to every moderator
|
||||
if room_occupant.nick == occupant.nick and room_occupant.role == 'moderator' then
|
||||
local moderator_jid = internal_room_jid_match_rewrite(room_occupant.nick);
|
||||
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
|
||||
local whitelist = room.av_moderation[mediaType];
|
||||
if whitelist then
|
||||
if not get_index_in_table(whitelist, moderator_jid) then
|
||||
whitelist:push(moderator_jid);
|
||||
end
|
||||
notify_whitelist_change(nil, true, room, mediaType);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- when a occupant was granted moderator we need to update him with the whitelist
|
||||
function occupant_affiliation_changed(event)
|
||||
local room = event.room;
|
||||
if not room.av_moderation or is_healthcheck_room(room.jid) or is_admin(event.jid)
|
||||
or event.affiliation ~= 'owner' then
|
||||
return;
|
||||
end
|
||||
|
||||
-- in any enabled media type add the new moderator to the whitelist
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
if room_occupant.bare_jid == event.jid then
|
||||
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
|
||||
if room.av_moderation[mediaType] then
|
||||
room.av_moderation[mediaType]:push(internal_room_jid_match_rewrite(room_occupant.nick));
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
|
||||
-- as we will handle it in occupant_joined
|
||||
if event.actor and event.affiliation == 'owner' then
|
||||
-- notify all moderators for the new grant moderator and the change in whitelists
|
||||
for _,mediaType in pairs({'audio', 'video', 'desktop'}) do
|
||||
if room.av_moderation[mediaType] then
|
||||
notify_whitelist_change(nil, true, room, mediaType);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- we will receive messages from the clients
|
||||
module:hook('message/host', on_message);
|
||||
|
||||
process_host_module(muc_component_host, function(host_module, host)
|
||||
module:log('info','Hook to muc events on %s', host);
|
||||
host_module:hook('muc-occupant-joined', occupant_joined, -2); -- make sure it runs after allowners or similar
|
||||
host_module:hook('muc-set-affiliation', occupant_affiliation_changed, -1);
|
||||
end);
|
||||
|
||||
process_host_module(main_virtual_host, function(host_module)
|
||||
module:context(host_module.host):fire_event('jitsi-add-identity', {
|
||||
name = 'av_moderation'; host = module.host;
|
||||
});
|
||||
end);
|
||||
23
resources/prosody-plugins/mod_certs_s2soutinjection.lua
Normal file
23
resources/prosody-plugins/mod_certs_s2soutinjection.lua
Normal file
@@ -0,0 +1,23 @@
|
||||
-- global module
|
||||
-- validates certificates for all hosts used for s2soutinjection or s2sout_override
|
||||
module:set_global();
|
||||
|
||||
local s2s_overrides = module:get_option("s2s_connect_overrides");
|
||||
|
||||
if not s2s_overrides then
|
||||
s2s_overrides = module:get_option("s2sout_override");
|
||||
end
|
||||
|
||||
function attach(event)
|
||||
local session = event.session;
|
||||
|
||||
if s2s_overrides and s2s_overrides[event.host] then
|
||||
session.cert_chain_status = 'valid';
|
||||
session.cert_identity_status = 'valid';
|
||||
|
||||
return true;
|
||||
end
|
||||
end
|
||||
module:wrap_event('s2s-check-certificate', function (handlers, event_name, event_data)
|
||||
return attach(event_data);
|
||||
end);
|
||||
205
resources/prosody-plugins/mod_client_proxy.lua
Normal file
205
resources/prosody-plugins/mod_client_proxy.lua
Normal file
@@ -0,0 +1,205 @@
|
||||
-- Taken from prosody-modules (https://hg.prosody.im/prosody-modules/) at
|
||||
-- revision 4317:456b9f608fcf, with mod_roster_command.patch applied.
|
||||
|
||||
if module:get_host_type() ~= "component" then
|
||||
error("proxy_component should be loaded as component", 0);
|
||||
end
|
||||
|
||||
local jid_split = require "util.jid".split;
|
||||
local jid_bare = require "util.jid".bare;
|
||||
local jid_prep = require "util.jid".prep;
|
||||
local st = require "util.stanza";
|
||||
local array = require "util.array";
|
||||
|
||||
local target_address = module:get_option_string("target_address");
|
||||
|
||||
sessions = array{};
|
||||
local sessions = sessions;
|
||||
|
||||
local function handle_target_presence(stanza)
|
||||
local type = stanza.attr.type;
|
||||
module:log("debug", "received presence from destination: %s", type)
|
||||
local _, _, resource = jid_split(stanza.attr.from);
|
||||
if type == "error" then
|
||||
-- drop all known sessions
|
||||
for k in pairs(sessions) do
|
||||
sessions[k] = nil
|
||||
end
|
||||
module:log(
|
||||
"debug",
|
||||
"received error presence, dropping all target sessions",
|
||||
resource
|
||||
)
|
||||
elseif type == "unavailable" then
|
||||
for k in pairs(sessions) do
|
||||
if sessions[k] == resource then
|
||||
sessions[k] = nil
|
||||
module:log(
|
||||
"debug",
|
||||
"dropped target session: %s",
|
||||
resource
|
||||
)
|
||||
break
|
||||
end
|
||||
end
|
||||
elseif not type then
|
||||
-- available
|
||||
local found = false;
|
||||
for k in pairs(sessions) do
|
||||
if sessions[k] == resource then
|
||||
found = true;
|
||||
break
|
||||
end
|
||||
end
|
||||
if not found then
|
||||
module:log(
|
||||
"debug",
|
||||
"registered new target session: %s",
|
||||
resource
|
||||
)
|
||||
sessions:push(resource)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function handle_from_target(stanza)
|
||||
local type = stanza.attr.type
|
||||
module:log(
|
||||
"debug",
|
||||
"non-presence stanza from target: name = %s, type = %s",
|
||||
stanza.name,
|
||||
type
|
||||
)
|
||||
if stanza.name == "iq" then
|
||||
if type == "error" or type == "result" then
|
||||
-- de-NAT message
|
||||
local _, _, denatted_to_unprepped = jid_split(stanza.attr.to);
|
||||
local denatted_to = jid_prep(denatted_to_unprepped);
|
||||
if not denatted_to then
|
||||
module:log(
|
||||
"debug",
|
||||
"cannot de-NAT stanza, invalid to: %s",
|
||||
denatted_to_unprepped
|
||||
)
|
||||
return
|
||||
end
|
||||
local denatted_from = module:get_host();
|
||||
|
||||
module:log(
|
||||
"debug",
|
||||
"de-NAT-ed stanza: from: %s -> %s, to: %s -> %s",
|
||||
stanza.attr.from,
|
||||
denatted_from,
|
||||
stanza.attr.to,
|
||||
denatted_to
|
||||
)
|
||||
|
||||
stanza.attr.from = denatted_from
|
||||
stanza.attr.to = denatted_to
|
||||
|
||||
module:send(stanza)
|
||||
else
|
||||
-- FIXME: we don’t support NATing outbund requests atm.
|
||||
module:send(st.error_reply(stanza, "cancel", "feature-not-implemented"))
|
||||
end
|
||||
elseif stanza.name == "message" then
|
||||
-- not implemented yet, we need a way to ensure that routing doesn’t
|
||||
-- break
|
||||
module:send(st.error_reply(stanza, "cancel", "feature-not-implemented"))
|
||||
end
|
||||
end
|
||||
|
||||
local function handle_to_target(stanza)
|
||||
local type = stanza.attr.type;
|
||||
module:log(
|
||||
"debug",
|
||||
"stanza to target: name = %s, type = %s",
|
||||
stanza.name, type
|
||||
)
|
||||
if stanza.name == "presence" then
|
||||
if type ~= "error" then
|
||||
module:send(st.error_reply(stanza, "cancel", "bad-request"))
|
||||
return
|
||||
end
|
||||
elseif stanza.name == "iq" then
|
||||
if type == "get" or type == "set" then
|
||||
if #sessions == 0 then
|
||||
-- no sessions available to send to
|
||||
module:log("debug", "no sessions to send to!")
|
||||
module:send(st.error_reply(stanza, "cancel", "service-unavailable"))
|
||||
return
|
||||
end
|
||||
|
||||
-- find a target session
|
||||
local target_session = sessions:random()
|
||||
local target = target_address .. "/" .. target_session
|
||||
|
||||
-- encode sender JID in resource
|
||||
local natted_from = module:get_host() .. "/" .. stanza.attr.from;
|
||||
|
||||
module:log(
|
||||
"debug",
|
||||
"NAT-ed stanza: from: %s -> %s, to: %s -> %s",
|
||||
stanza.attr.from,
|
||||
natted_from,
|
||||
stanza.attr.to,
|
||||
target
|
||||
)
|
||||
|
||||
stanza.attr.from = natted_from
|
||||
stanza.attr.to = target
|
||||
|
||||
module:send(stanza)
|
||||
end
|
||||
-- FIXME: handle and forward result/error correctly
|
||||
elseif stanza.name == "message" then
|
||||
-- not implemented yet, we need a way to ensure that routing doesn’t
|
||||
-- break
|
||||
module:send(st.error_reply(stanza, "cancel", "feature-not-implemented"))
|
||||
end
|
||||
end
|
||||
|
||||
local function stanza_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza
|
||||
module:log("debug", "received stanza from %s session", origin.type)
|
||||
|
||||
local bare_from = jid_bare(stanza.attr.from);
|
||||
local _, _, to = jid_split(stanza.attr.to);
|
||||
if bare_from == target_address then
|
||||
-- from our target, to whom?
|
||||
if not to then
|
||||
-- directly to component
|
||||
if stanza.name == "presence" then
|
||||
handle_target_presence(stanza)
|
||||
else
|
||||
module:send(st.error_reply(stanza, "cancel", "bad-request"))
|
||||
return true
|
||||
end
|
||||
else
|
||||
-- to someone else
|
||||
handle_from_target(stanza)
|
||||
end
|
||||
else
|
||||
handle_to_target(stanza)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
module:hook("iq/bare", stanza_handler, -1);
|
||||
module:hook("message/bare", stanza_handler, -1);
|
||||
module:hook("presence/bare", stanza_handler, -1);
|
||||
module:hook("iq/full", stanza_handler, -1);
|
||||
module:hook("message/full", stanza_handler, -1);
|
||||
module:hook("presence/full", stanza_handler, -1);
|
||||
module:hook("iq/host", stanza_handler, -1);
|
||||
module:hook("message/host", stanza_handler, -1);
|
||||
module:hook("presence/host", stanza_handler, -1);
|
||||
|
||||
module:log("debug", "loaded proxy on %s", module:get_host())
|
||||
|
||||
subscription_request = st.presence({
|
||||
type = "subscribe",
|
||||
to = target_address,
|
||||
from = module:get_host()}
|
||||
)
|
||||
module:send(subscription_request)
|
||||
51
resources/prosody-plugins/mod_conference_duration.lua
Normal file
51
resources/prosody-plugins/mod_conference_duration.lua
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Records the timestamp (ms since epoch) when a MUC room first reaches two
|
||||
-- occupants and exposes it as the muc#roominfo_created_timestamp field in
|
||||
-- disco#info responses, so clients can display how long the conference has run.
|
||||
local it = require "util.iterators";
|
||||
local process_host_module = module:require "util".process_host_module;
|
||||
|
||||
local main_muc_component_config = module:get_option_string('main_muc');
|
||||
if main_muc_component_config == nil then
|
||||
module:log('error', 'lobby not enabled missing main_muc config');
|
||||
return ;
|
||||
end
|
||||
|
||||
-- Returns the meeting created timestamp form data.
|
||||
function getMeetingCreatedTSConfig(room)
|
||||
return {
|
||||
name = "muc#roominfo_created_timestamp";
|
||||
type = "text-single";
|
||||
label = "The meeting created_timestamp.";
|
||||
value = room.created_timestamp or "";
|
||||
};
|
||||
end
|
||||
|
||||
function occupant_joined(event)
|
||||
local room = event.room;
|
||||
local occupant = event.occupant;
|
||||
|
||||
local participant_count = it.count(room:each_occupant());
|
||||
|
||||
if participant_count > 1 then
|
||||
if room.created_timestamp == nil then
|
||||
room.created_timestamp = string.format('%i', os.time() * 1000); -- Lua provides UTC time in seconds, so convert to milliseconds
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
process_host_module(main_muc_component_config, function(host_module, host)
|
||||
-- add meeting Id to the disco info requests to the room
|
||||
host_module:hook("muc-disco#info", function(event)
|
||||
table.insert(event.form, getMeetingCreatedTSConfig(event.room));
|
||||
end);
|
||||
|
||||
-- Marks the created timestamp in the room object
|
||||
host_module:hook("muc-occupant-joined", occupant_joined, -1);
|
||||
end);
|
||||
|
||||
-- DEPRECATED and will be removed, giving time for mobile clients to update
|
||||
local conference_duration_component
|
||||
= module:get_option_string("conference_duration_component", "conferenceduration."..module.host);
|
||||
if conference_duration_component then
|
||||
module:add_identity("component", "conference_duration", conference_duration_component);
|
||||
end
|
||||
54
resources/prosody-plugins/mod_debug_traceback.lua
Normal file
54
resources/prosody-plugins/mod_debug_traceback.lua
Normal file
@@ -0,0 +1,54 @@
|
||||
module:set_global();
|
||||
|
||||
local traceback = require "util.debug".traceback;
|
||||
local pposix = require "util.pposix";
|
||||
local os_date = os.date;
|
||||
local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, {
|
||||
yyyymmdd = function (t)
|
||||
return os_date("%Y%m%d", t);
|
||||
end;
|
||||
hhmmss = function (t)
|
||||
return os_date("%H%M%S", t);
|
||||
end;
|
||||
});
|
||||
|
||||
local count = 0;
|
||||
|
||||
local function get_filename(filename_template)
|
||||
filename_template = filename_template;
|
||||
return render_filename(filename_template, {
|
||||
paths = prosody.paths;
|
||||
pid = pposix.getpid();
|
||||
count = count;
|
||||
time = os.time();
|
||||
});
|
||||
end
|
||||
|
||||
local default_filename_template = "{paths.data}/traceback-{pid}-{count}.log";
|
||||
local filename_template = module:get_option_string("debug_traceback_filename", default_filename_template);
|
||||
local signal_name = module:get_option_string("debug_traceback_signal", "SIGUSR1");
|
||||
|
||||
function dump_traceback()
|
||||
module:log("info", "Received %s, writing traceback", signal_name);
|
||||
|
||||
local tb = traceback();
|
||||
module:fire_event("debug_traceback/triggered", { traceback = tb });
|
||||
|
||||
local f, err = io.open(get_filename(filename_template), "a+");
|
||||
if not f then
|
||||
module:log("error", "Unable to write traceback: %s", err);
|
||||
return;
|
||||
end
|
||||
f:write("-- Traceback generated at ", os.date("%b %d %H:%M:%S"), " --\n");
|
||||
f:write(tb, "\n");
|
||||
f:write("-- End of traceback --\n");
|
||||
f:close();
|
||||
count = count + 1;
|
||||
end
|
||||
|
||||
local mod_posix = module:depends("posix");
|
||||
if rawget(mod_posix, "features") and mod_posix.features.signal_events then
|
||||
module:hook("signal/"..signal_name, dump_traceback);
|
||||
else
|
||||
require"util.signal".signal(signal_name, dump_traceback);
|
||||
end
|
||||
102
resources/prosody-plugins/mod_end_conference.lua
Normal file
102
resources/prosody-plugins/mod_end_conference.lua
Normal file
@@ -0,0 +1,102 @@
|
||||
-- Allows a conference moderator to destroy a MUC room by sending a message
|
||||
-- stanza containing an <end_conference/> child element to this component. The
|
||||
-- module looks up the room from the sender's jitsi_web_query_room / prefix
|
||||
-- session fields (set from the WebSocket URL query string by mod_jitsi_session),
|
||||
-- verifies the sender is an occupant with moderator role, and calls room:destroy().
|
||||
-- Must be loaded as a dedicated Prosody component (type "end_conference"); the
|
||||
-- muc_component option must point to the MUC component host.
|
||||
--
|
||||
-- Component "endconference.jitmeet.example.com" "end_conference"
|
||||
-- muc_component = muc.jitmeet.example.com
|
||||
--
|
||||
local util = module:require 'util';
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local process_host_module = util.process_host_module;
|
||||
|
||||
local END_CONFERENCE_REASON = 'The meeting has been terminated';
|
||||
|
||||
-- Since this file serves as both the host module and the component, we rely on the assumption that
|
||||
-- end_conference_component var would only be define for the host and not in the end_conference component
|
||||
-- TODO: Remove this if block after several stable releases when people update their configs
|
||||
local end_conference_component = module:get_option_string('end_conference_component');
|
||||
if end_conference_component then
|
||||
module:log('warn', 'Please update your config by removing muc_end_conference module from '
|
||||
.. 'the list of loaded modules in the main virtual host.');
|
||||
module:depends("features_identity");
|
||||
return; -- nothing left to do if called as host module
|
||||
end
|
||||
|
||||
-- What follows is logic for the end_conference component
|
||||
|
||||
module:depends("jitsi_session");
|
||||
|
||||
local muc_component_host = module:get_option_string('muc_component');
|
||||
if muc_component_host == nil then
|
||||
module:log('error', 'No muc_component specified. No muc to operate on!');
|
||||
return;
|
||||
end
|
||||
|
||||
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
|
||||
if not main_virtual_host then
|
||||
module:log('warn', 'No "muc_mapper_domain_base" option set, disabling end conference component.');
|
||||
return ;
|
||||
end
|
||||
|
||||
module:log('info', 'Starting end_conference for %s', muc_component_host);
|
||||
|
||||
-- receives messages from clients to the component to end a conference
|
||||
function on_message(event)
|
||||
local session = event.origin;
|
||||
|
||||
-- Check the type of the incoming stanza to avoid loops:
|
||||
if event.stanza.attr.type == 'error' then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
return false;
|
||||
end
|
||||
|
||||
local moderation_command = event.stanza:get_child('end_conference');
|
||||
|
||||
if moderation_command then
|
||||
-- get room name with tenant and find room
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- check that the participant requesting is a moderator and is an occupant in the room
|
||||
local from = event.stanza.attr.from;
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
if not occupant then
|
||||
module:log('warn', 'No occupant %s found for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
if occupant.role ~= 'moderator' then
|
||||
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- destroy the room
|
||||
room:destroy(nil, END_CONFERENCE_REASON);
|
||||
module:log('info', 'Room %s destroyed by occupant %s', room.jid, from);
|
||||
return true;
|
||||
end
|
||||
|
||||
-- return error
|
||||
return false
|
||||
end
|
||||
|
||||
|
||||
-- we will receive messages from the clients
|
||||
module:hook('message/host', on_message);
|
||||
|
||||
process_host_module(main_virtual_host, function(host_module)
|
||||
module:context(host_module.host):fire_event('jitsi-add-identity', {
|
||||
name = 'end_conference'; host = module.host;
|
||||
});
|
||||
end);
|
||||
8
resources/prosody-plugins/mod_features_identity.lua
Normal file
8
resources/prosody-plugins/mod_features_identity.lua
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Other components can use the event 'jitsi-add-identity' to attach identity which
|
||||
-- will be advertised by the main virtual host and discovered by clients.
|
||||
-- With this we avoid having an almost empty module to just add identity with an extra config
|
||||
|
||||
module:hook('jitsi-add-identity', function(event)
|
||||
module:log('info', 'Adding identity %s for host %s', event.name, event.host);
|
||||
module:add_identity('component', event.name, event.host);
|
||||
end);
|
||||
245
resources/prosody-plugins/mod_filesharing_component.lua
Normal file
245
resources/prosody-plugins/mod_filesharing_component.lua
Normal file
@@ -0,0 +1,245 @@
|
||||
local json = require 'cjson.safe';
|
||||
local jid = require 'util.jid';
|
||||
local st = require 'util.stanza';
|
||||
|
||||
local util = module:require 'util';
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local is_admin = util.is_admin;
|
||||
local process_host_module = util.process_host_module;
|
||||
|
||||
local FILE_SHARING_IDENTITY_TYPE = 'file-sharing';
|
||||
local JSON_TYPE_ADD_FILE = 'add';
|
||||
local JSON_TYPE_REMOVE_FILE = 'remove';
|
||||
local JSON_TYPE_LIST_FILES = 'list';
|
||||
local NICK_NS = 'http://jabber.org/protocol/nick';
|
||||
|
||||
-- this is the main virtual host of the main prosody that this vnode serves
|
||||
local main_domain = module:get_option_string('main_domain');
|
||||
-- only the visitor prosody has main_domain setting
|
||||
local is_visitor_prosody = main_domain ~= nil;
|
||||
|
||||
local muc_component_host = module:get_option_string('muc_component');
|
||||
if muc_component_host == nil then
|
||||
module:log('error', 'No muc_component specified. No muc to operate on!');
|
||||
return;
|
||||
end
|
||||
|
||||
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
if not muc_domain_base then
|
||||
module:log("warn", "No 'muc_domain_base' option set, disabling file sharing component.");
|
||||
return ;
|
||||
end
|
||||
|
||||
-- receives messages from clients to the component sending file sharing commands for adding or removing files
|
||||
function on_message(event)
|
||||
local session, stanza = event.origin, event.stanza;
|
||||
|
||||
-- Check the type of the incoming stanza to avoid loops:
|
||||
if stanza.attr.type == 'error' then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
return false;
|
||||
end
|
||||
|
||||
local message = stanza:get_child(FILE_SHARING_IDENTITY_TYPE, 'http://jitsi.org/jitmeet');
|
||||
|
||||
if not message then
|
||||
return false;
|
||||
end
|
||||
|
||||
-- get room name with tenant and find room
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s', session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- check that the participant sending the message is an occupant in the room
|
||||
local from = stanza.attr.from;
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
|
||||
if not occupant then
|
||||
module:log('warn', 'No occupant %s found for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if not is_feature_allowed(
|
||||
'file-upload',
|
||||
session.jitsi_meet_context_features,
|
||||
room:get_affiliation(stanza.attr.from) == 'owner') then
|
||||
session.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
return true;
|
||||
end
|
||||
|
||||
if message.attr.type == JSON_TYPE_ADD_FILE then
|
||||
local msg_obj, error = json.decode(message:get_text());
|
||||
|
||||
if error then
|
||||
module:log('error','Error decoding data error:%s %s', error, stanza);
|
||||
return false;
|
||||
end
|
||||
|
||||
if not msg_obj.fileId then
|
||||
module:log('error', 'Error missing required field: %s', stanza);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- make sure we overwrite data for sender so we avoid spoofing
|
||||
msg_obj.authorParticipantId = jid.resource(occupant.nick);
|
||||
msg_obj.authorParticipantJid = from;
|
||||
|
||||
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
|
||||
if nick_element then
|
||||
msg_obj.authorParticipantName = nick_element:get_text();
|
||||
else
|
||||
msg_obj.authorParticipantName = 'anonymous';
|
||||
end
|
||||
msg_obj.conferenceFullName = internal_room_jid_match_rewrite(room.jid);
|
||||
|
||||
module:context(muc_domain_base):fire_event('jitsi-filesharing-add', {
|
||||
room = room; file = msg_obj; actor = occupant.nick;
|
||||
});
|
||||
|
||||
module:context(muc_domain_base):fire_event('jitsi-filesharing-updated', {
|
||||
room = room;
|
||||
});
|
||||
|
||||
return true;
|
||||
elseif message.attr.type == JSON_TYPE_REMOVE_FILE then
|
||||
if not message.attr.fileId then
|
||||
module:log('error', 'Error missing required field: %s', stanza);
|
||||
return true;
|
||||
end
|
||||
|
||||
module:context(muc_domain_base):fire_event('jitsi-filesharing-remove', {
|
||||
room = room; id = message.attr.fileId; actor = occupant.nick;
|
||||
});
|
||||
|
||||
module:context(muc_domain_base):fire_event('jitsi-filesharing-updated', {
|
||||
room = room;
|
||||
});
|
||||
|
||||
return true;
|
||||
else
|
||||
-- return error.
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
-- handles new occupants to inform them about any file shared by other participants
|
||||
function occupant_joined(event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
|
||||
-- healthcheck rooms does not have shared files
|
||||
if not room.jitsi_shared_files
|
||||
or is_admin(occupant.bare_jid)
|
||||
or not room.jitsi_shared_files
|
||||
or next(room.jitsi_shared_files) == nil then
|
||||
return;
|
||||
end
|
||||
|
||||
|
||||
-- send file list to the new occupant
|
||||
local json_msg, error = json.encode({
|
||||
type = FILE_SHARING_IDENTITY_TYPE,
|
||||
event = JSON_TYPE_LIST_FILES,
|
||||
files = room.jitsi_shared_files
|
||||
});
|
||||
|
||||
local stanza = st.message({ from = module.host; to = occupant.jid; })
|
||||
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
|
||||
:text(json_msg):up();
|
||||
|
||||
module:send(stanza);
|
||||
end
|
||||
|
||||
process_host_module(muc_component_host, function(host_module, host)
|
||||
module:log('info','Hook to muc events on %s', host);
|
||||
host_module:hook('muc-occupant-joined', occupant_joined, -10); -- make sure it runs after allowners or similar
|
||||
end);
|
||||
|
||||
-- we will receive messages from the clients
|
||||
module:hook('message/host', on_message);
|
||||
|
||||
process_host_module(muc_domain_base, function(host_module, host)
|
||||
module:context(muc_domain_base):fire_event('jitsi-add-identity', {
|
||||
name = FILE_SHARING_IDENTITY_TYPE; host = module.host;
|
||||
});
|
||||
module:context(muc_domain_base):hook('jitsi-filesharing-add', function(event)
|
||||
local actor, file, room = event.actor, event.file, event.room;
|
||||
|
||||
if not room.jitsi_shared_files then
|
||||
room.jitsi_shared_files = {};
|
||||
end
|
||||
|
||||
room.jitsi_shared_files[file.fileId] = file;
|
||||
|
||||
local json_msg, error = json.encode({
|
||||
type = FILE_SHARING_IDENTITY_TYPE,
|
||||
event = JSON_TYPE_ADD_FILE,
|
||||
file = file
|
||||
});
|
||||
|
||||
if not json_msg then
|
||||
module:log('error', 'skip sending add request room:%s error:%s', room.jid, error);
|
||||
return false
|
||||
end
|
||||
|
||||
local stanza = st.message({ from = module.host; }):tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
|
||||
:text(json_msg):up();
|
||||
|
||||
-- send add file to all occupants except jicofo and sender
|
||||
-- if this is visitor prosody send it only to visitors
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
local send_event = not is_admin(room_occupant.bare_jid) and room_occupant.nick ~= actor;
|
||||
if is_visitor_prosody then
|
||||
send_event = room_occupant.role == 'visitor';
|
||||
end
|
||||
if send_event then
|
||||
local to_send = st.clone(stanza);
|
||||
to_send.attr.to = room_occupant.jid;
|
||||
module:send(to_send);
|
||||
end
|
||||
end
|
||||
end);
|
||||
module:context(muc_domain_base):hook('jitsi-filesharing-remove', function(event)
|
||||
local actor, id, room = event.actor, event.id, event.room;
|
||||
|
||||
if not room.jitsi_shared_files then
|
||||
return;
|
||||
end
|
||||
|
||||
room.jitsi_shared_files[id] = nil;
|
||||
|
||||
local json_msg, error = json.encode({
|
||||
type = FILE_SHARING_IDENTITY_TYPE,
|
||||
event = JSON_TYPE_REMOVE_FILE,
|
||||
fileId = id
|
||||
});
|
||||
|
||||
if not json_msg then
|
||||
module:log('error', 'skip sending remove request room:%s error:%s', room.jid, error);
|
||||
return false
|
||||
end
|
||||
|
||||
local stanza = st.message({ from = module.host; }):tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
|
||||
:text(json_msg):up();
|
||||
|
||||
-- send remove file to all occupants except jicofo and sender
|
||||
-- if this is visitor prosody send it only to visitors
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
local send_event = not is_admin(room_occupant.bare_jid) and room_occupant.nick ~= actor;
|
||||
if is_visitor_prosody then
|
||||
send_event = room_occupant.role == 'visitor';
|
||||
end
|
||||
if send_event then
|
||||
local to_send = st.clone(stanza);
|
||||
to_send.attr.to = room_occupant.jid;
|
||||
module:send(to_send);
|
||||
end
|
||||
end
|
||||
end);
|
||||
end);
|
||||
80
resources/prosody-plugins/mod_filter_iq_jibri.lua
Normal file
80
resources/prosody-plugins/mod_filter_iq_jibri.lua
Normal file
@@ -0,0 +1,80 @@
|
||||
-- Filters Jibri recording/livestreaming IQ stanzas (http://jitsi.org/protocol/jibri)
|
||||
-- on the main VirtualHost. start/stop actions are allowed only when the sender's JWT
|
||||
-- token grants the required feature ('recording' for file mode, 'livestreaming' for
|
||||
-- stream mode), or, absent token features, when the sender holds moderator role in the
|
||||
-- room. status IQs are always passed through. Blocked stanzas receive an
|
||||
-- auth/forbidden error reply. Rate-limited per IP and per room.
|
||||
-- This module is enabled under the main virtual host.
|
||||
local cache = require 'util.cache';
|
||||
local new_throttle = require 'util.throttle'.create;
|
||||
local st = require "util.stanza";
|
||||
local jid_bare = require "util.jid".bare;
|
||||
local util = module:require 'util';
|
||||
local is_feature_allowed = util.is_feature_allowed;
|
||||
local get_ip = util.get_ip;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
|
||||
local limit_jibri_reach_ip_attempts;
|
||||
local limit_jibri_reach_room_attempts;
|
||||
local rates_per_ip;
|
||||
local function load_config()
|
||||
limit_jibri_reach_ip_attempts = module:get_option_number("max_number_ip_attempts_per_minute", 9);
|
||||
limit_jibri_reach_room_attempts = module:get_option_number("max_number_room_attempts_per_minute", 3);
|
||||
-- The size of the cache that saves state for IP addresses
|
||||
cache_size = module:get_option_number("jibri_rate_limit_cache_size", 10000);
|
||||
|
||||
-- Maps an IP address to a util.throttle which keeps the rate of attempts to reach jibri events from that IP.
|
||||
rates_per_ip = cache.new(cache_size);
|
||||
end
|
||||
load_config();
|
||||
|
||||
-- filters jibri iq in case of requested from jwt authenticated session that
|
||||
-- has features in the user context, but without feature for recording
|
||||
module:hook("pre-iq/full", function(event)
|
||||
local stanza = event.stanza;
|
||||
if stanza.name == "iq" then
|
||||
local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri');
|
||||
if jibri then
|
||||
local session = event.origin;
|
||||
local token = session.auth_token;
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(jid_bare(stanza.attr.to)));
|
||||
if not room then
|
||||
session.send(st.error_reply(stanza, 'cancel', 'item-not-found'));
|
||||
return true;
|
||||
end
|
||||
local occupant = room:get_occupant_by_real_jid(stanza.attr.from);
|
||||
local recording_mode_lower = jibri.attr.recording_mode and jibri.attr.recording_mode:lower();
|
||||
local feature = recording_mode_lower == 'file' and 'recording' or 'livestreaming';
|
||||
local is_allowed = is_feature_allowed(
|
||||
feature,
|
||||
session.jitsi_meet_context_features,
|
||||
occupant ~= nil and occupant.role == 'moderator');
|
||||
|
||||
local action_lower = jibri.attr.action and jibri.attr.action:lower();
|
||||
if action_lower == 'start' or action_lower == 'stop' then
|
||||
if not is_allowed then
|
||||
module:log('info', 'Filtering jibri start recording, stanza:%s', tostring(stanza));
|
||||
session.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
return true;
|
||||
end
|
||||
|
||||
local ip = get_ip(session);
|
||||
if not rates_per_ip:get(ip) then
|
||||
rates_per_ip:set(ip, new_throttle(limit_jibri_reach_ip_attempts, 60));
|
||||
end
|
||||
|
||||
if not room.jibri_throttle then
|
||||
room.jibri_throttle = new_throttle(limit_jibri_reach_room_attempts, 60);
|
||||
end
|
||||
|
||||
if not rates_per_ip:get(ip):poll(1) or not room.jibri_throttle:poll(1) then
|
||||
module:log('warn', 'Filtering jibri start recording, ip:%s, room:%s stanza:%s',
|
||||
ip, room.jid, tostring(stanza));
|
||||
session.send(st.error_reply(stanza, 'wait', 'policy-violation'));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end);
|
||||
326
resources/prosody-plugins/mod_filter_iq_rayo.lua
Normal file
326
resources/prosody-plugins/mod_filter_iq_rayo.lua
Normal file
@@ -0,0 +1,326 @@
|
||||
-- Filters outbound-call and transcription IQ stanzas (Rayo dial, urn:xmpp:rayo:1)
|
||||
-- on the main VirtualHost. Allows a stanza through only when the sender's JWT token
|
||||
-- grants the required feature ('outbound-call' or 'transcription'), or, absent token
|
||||
-- features, when the sender holds owner affiliation in the room. Blocked stanzas
|
||||
-- receive an auth/forbidden error reply. Optionally rate-limits outgoing calls per
|
||||
-- session when max_number_outgoing_calls is configured.
|
||||
-- This module is enabled under the main virtual host.
|
||||
local new_throttle = require "util.throttle".create;
|
||||
local st = require "util.stanza";
|
||||
local jid = require "util.jid";
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local is_feature_allowed = util.is_feature_allowed;
|
||||
local is_sip_jigasi = util.is_sip_jigasi;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local process_host_module = util.process_host_module;
|
||||
local jid_bare = require "util.jid".bare;
|
||||
|
||||
local sessions = prosody.full_sessions;
|
||||
|
||||
local measure_drop = module:measure('drop', 'counter');
|
||||
|
||||
local main_muc_component_host = module:get_option_string('main_muc');
|
||||
if main_muc_component_host == nil then
|
||||
module:log('error', 'main_muc not configured. Cannot proceed.');
|
||||
return;
|
||||
end
|
||||
local main_muc_service;
|
||||
|
||||
|
||||
-- this is the main virtual host of the main prosody that this vnode serves
|
||||
local main_domain = module:get_option_string('main_domain');
|
||||
-- only the visitor prosody has main_domain setting
|
||||
local is_visitor_prosody = main_domain ~= nil;
|
||||
|
||||
-- this is the main virtual host of this vnode
|
||||
local local_domain = module:get_option_string('muc_mapper_domain_base');
|
||||
|
||||
local parentCtx = module:context(local_domain);
|
||||
if parentCtx == nil then
|
||||
log("error",
|
||||
"Failed to start - unable to get parent context for host: %s",
|
||||
tostring(local_domain));
|
||||
return;
|
||||
end
|
||||
local token_util = module:require "token/util".new(parentCtx);
|
||||
|
||||
-- no token configuration but required
|
||||
if token_util == nil then
|
||||
module:log("error", "no token configuration but it is required");
|
||||
return;
|
||||
end
|
||||
|
||||
-- The maximum number of simultaneous calls,
|
||||
-- and also the maximum number of new calls per minute that a session is allowed to create.
|
||||
local limit_outgoing_calls;
|
||||
local function load_config()
|
||||
limit_outgoing_calls = module:get_option_number("max_number_outgoing_calls", -1);
|
||||
end
|
||||
load_config();
|
||||
|
||||
-- Normalise CR and LF in values sourced outside of XML (e.g. JWT claims) to
|
||||
-- spaces before inserting them into stanza attributes. Prosody's util.stanza
|
||||
-- serializer does not escape raw control characters, so this keeps the
|
||||
-- forwarded attribute values consistent with how expat already normalises
|
||||
-- whitespace in XML-attribute-sourced values.
|
||||
local function sanitize_header_value(v)
|
||||
return (tostring(v):gsub("[\r\n]", " "));
|
||||
end
|
||||
|
||||
-- Header names to use to push extra data extracted from token, if any
|
||||
local OUT_INITIATOR_USER_ATTR_NAME = "X-outbound-call-initiator-user";
|
||||
local OUT_INITIATOR_GROUP_ATTR_NAME = "X-outbound-call-initiator-group";
|
||||
local OUT_ROOM_NAME_ATTR_NAME = "JvbRoomName";
|
||||
local OUT_ROOM_PASS_ATTR_NAME = 'JvbRoomPassword';
|
||||
|
||||
local OUTGOING_CALLS_THROTTLE_INTERVAL = 60; -- if max_number_outgoing_calls is enabled it will be
|
||||
-- the max number of outgoing calls a user can try for a minute
|
||||
|
||||
-- Per-room throttle for transcription dials: at most 1 allowed per this interval (seconds).
|
||||
-- Burst requests within the window after a successful dial are dropped so that
|
||||
-- only one Jigasi transcriber is started per conference.
|
||||
local TRANSCRIPTION_THROTTLE_INTERVAL = 10;
|
||||
local transcription_room_throttles = {};
|
||||
|
||||
-- filters rayo iq in case of requested from not jwt authenticated sessions
|
||||
-- or if the session has features in user context and it doesn't mention
|
||||
-- feature "outbound-call" to be enabled
|
||||
module:hook("pre-iq/full", function(event)
|
||||
local stanza = event.stanza;
|
||||
if stanza.name == "iq" then
|
||||
local dial = stanza:get_child('dial', 'urn:xmpp:rayo:1');
|
||||
if dial then
|
||||
local session = event.origin;
|
||||
local token = session.auth_token;
|
||||
|
||||
-- find header with attr name 'JvbRoomName' or 'JvbRoomPassword' and extract the values
|
||||
local roomName;
|
||||
local roomPassword;
|
||||
-- Remove any 'header' element if it already exists, so it cannot be spoofed by a client
|
||||
dial:maptags(function(tag)
|
||||
if tag.name == 'header' then
|
||||
if tag.attr.name == OUT_ROOM_NAME_ATTR_NAME then
|
||||
roomName = tag.attr.value;
|
||||
elseif tag.attr.name == OUT_ROOM_PASS_ATTR_NAME then
|
||||
roomPassword = tag.attr.value;
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
return tag;
|
||||
end);
|
||||
|
||||
local room_jid = jid.bare(stanza.attr.to);
|
||||
local room_real_jid = room_jid_match_rewrite(room_jid);
|
||||
local room = main_muc_service.get_room_from_jid(room_real_jid);
|
||||
local feature = (dial.attr.to and dial.attr.to:lower()) == 'jitsi_meet_transcribe' and 'transcription' or 'outbound-call';
|
||||
local error_message = nil;
|
||||
|
||||
if not room or room:get_occupant_jid(stanza.attr.from) == nil then
|
||||
error_message = "not in room";
|
||||
elseif roomName == nil then
|
||||
error_message = OUT_ROOM_NAME_ATTR_NAME.." header missing";
|
||||
elseif roomName ~= room_jid then
|
||||
error_message = OUT_ROOM_NAME_ATTR_NAME.." header mismatch";
|
||||
elseif (token ~= nil and not token_util:verify_room(session, room_real_jid)) then
|
||||
error_message = "no token or token room mismatch";
|
||||
elseif not is_feature_allowed(
|
||||
feature,
|
||||
session.jitsi_meet_context_features,
|
||||
room:get_affiliation(stanza.attr.from) == 'owner') then
|
||||
|
||||
error_message = "feature not allowed";
|
||||
end
|
||||
|
||||
if error_message then
|
||||
module:log("warn", "Filtering stanza dial, %s, stanza:%s", error_message, tostring(stanza));
|
||||
session.send(st.error_reply(stanza, "auth", "forbidden"));
|
||||
return true;
|
||||
end
|
||||
|
||||
-- we get current user_id or group, or the one from the granted one
|
||||
-- so guests and the user that granted rights are sharing same limit, as guest can be without token
|
||||
local user_id, group_id = nil, session.jitsi_meet_context_group;
|
||||
if session.jitsi_meet_context_user then
|
||||
user_id = session.jitsi_meet_context_user["id"];
|
||||
else
|
||||
user_id = session.granted_jitsi_meet_context_user_id;
|
||||
group_id = session.granted_jitsi_meet_context_group_id;
|
||||
end
|
||||
|
||||
-- now lets check any limits for outgoing calls if configured
|
||||
if feature == 'outbound-call' and limit_outgoing_calls > 0 then
|
||||
if not session.dial_out_throttle then
|
||||
-- module:log("debug", "Enabling dial-out throttle session=%s.", session);
|
||||
session.dial_out_throttle = new_throttle(limit_outgoing_calls, OUTGOING_CALLS_THROTTLE_INTERVAL);
|
||||
end
|
||||
|
||||
if not session.dial_out_throttle:poll(1) -- we first check the throttle so we can mark one incoming dial for the balance
|
||||
or get_concurrent_outgoing_count(user_id, group_id) >= limit_outgoing_calls
|
||||
then
|
||||
module:log("warn",
|
||||
"Filtering stanza dial, stanza:%s, outgoing calls limit reached", tostring(stanza));
|
||||
measure_drop(1);
|
||||
session.send(st.error_reply(stanza, "cancel", "resource-constraint"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- Per-room throttle for transcription: drop burst dials so only one
|
||||
-- Jigasi transcriber is dispatched per conference per interval.
|
||||
if feature == 'transcription' then
|
||||
if not transcription_room_throttles[room_real_jid] then
|
||||
transcription_room_throttles[room_real_jid] = new_throttle(1, TRANSCRIPTION_THROTTLE_INTERVAL);
|
||||
end
|
||||
if not transcription_room_throttles[room_real_jid]:poll(1) then
|
||||
module:log("warn",
|
||||
"Filtering transcription dial for room %s - per-room throttle hit (limit: 1 per %ds)",
|
||||
room_real_jid, TRANSCRIPTION_THROTTLE_INTERVAL);
|
||||
measure_drop(1);
|
||||
session.send(st.error_reply(stanza, "cancel", "resource-constraint"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- now lets insert token information if any
|
||||
if session and user_id then
|
||||
-- adds initiator user id from token
|
||||
dial:tag("header", {
|
||||
xmlns = "urn:xmpp:rayo:1",
|
||||
name = OUT_INITIATOR_USER_ATTR_NAME,
|
||||
value = sanitize_header_value(user_id)});
|
||||
dial:up();
|
||||
|
||||
-- Add the initiator group information if it is present
|
||||
if session.jitsi_meet_context_group then
|
||||
dial:tag("header", {
|
||||
xmlns = "urn:xmpp:rayo:1",
|
||||
name = OUT_INITIATOR_GROUP_ATTR_NAME,
|
||||
value = sanitize_header_value(session.jitsi_meet_context_group) });
|
||||
dial:up();
|
||||
end
|
||||
end
|
||||
|
||||
-- we want to instruct jigasi to enter the main room, so send the correct main room jid
|
||||
if is_visitor_prosody then
|
||||
roomName = string.gsub(roomName, local_domain, main_domain);
|
||||
end
|
||||
dial:tag("header", {
|
||||
xmlns = "urn:xmpp:rayo:1",
|
||||
name = OUT_ROOM_NAME_ATTR_NAME,
|
||||
value = sanitize_header_value(roomName)
|
||||
});
|
||||
dial:up();
|
||||
if roomPassword then
|
||||
dial:tag("header", {
|
||||
xmlns = "urn:xmpp:rayo:1",
|
||||
name = OUT_ROOM_PASS_ATTR_NAME,
|
||||
value = sanitize_header_value(roomPassword)
|
||||
});
|
||||
dial:up();
|
||||
end
|
||||
end
|
||||
end
|
||||
end, 1); -- make sure we run before domain mapper
|
||||
|
||||
--- Finds and returns the number of concurrent outgoing calls for a user
|
||||
-- @param context_user the user id extracted from the token
|
||||
-- @param context_group the group id extracted from the token
|
||||
-- @return returns the count of concurrent calls
|
||||
function get_concurrent_outgoing_count(context_user, context_group)
|
||||
local count = 0;
|
||||
local rooms = main_muc_service.live_rooms();
|
||||
|
||||
-- now lets iterate over rooms and occupants and search for
|
||||
-- call initiated by the user
|
||||
for room in rooms do
|
||||
for _, occupant in room:each_occupant() do
|
||||
for _, presence in occupant:each_session() do
|
||||
|
||||
local initiator = is_sip_jigasi(presence);
|
||||
|
||||
local found_user = false;
|
||||
local found_group = false;
|
||||
|
||||
if initiator then
|
||||
initiator:maptags(function (tag)
|
||||
if tag.name == "header"
|
||||
and tag.attr.name == OUT_INITIATOR_USER_ATTR_NAME then
|
||||
found_user = tag.attr.value == context_user;
|
||||
elseif tag.name == "header"
|
||||
and tag.attr.name == OUT_INITIATOR_GROUP_ATTR_NAME then
|
||||
found_group = tag.attr.value == context_group;
|
||||
end
|
||||
|
||||
return tag;
|
||||
end );
|
||||
-- if found a jigasi participant initiated by the concurrent
|
||||
-- participant, count it
|
||||
if found_user
|
||||
and (context_group == nil or found_group) then
|
||||
count = count + 1;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return count;
|
||||
end
|
||||
|
||||
module:hook_global('config-reloaded', load_config);
|
||||
|
||||
function process_main_muc_loaded(main_muc, host_module)
|
||||
module:log('debug', 'Main muc loaded');
|
||||
|
||||
main_muc_service = main_muc;
|
||||
|
||||
-- Clean up the per-room throttle entry when a room is destroyed so the
|
||||
-- table does not grow without bound across many short-lived conferences.
|
||||
host_module:hook('muc-room-destroyed', function(event)
|
||||
transcription_room_throttles[event.room.jid] = nil;
|
||||
end);
|
||||
end
|
||||
|
||||
process_host_module(main_muc_component_host, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_main_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
-- when recording participants may enable and backend transcriptions
|
||||
-- it is possible that participant is not moderator, but has the features enabled for
|
||||
-- transcribing, we need to allow that operation
|
||||
module:hook('jitsi-metadata-allow-moderation', function (event)
|
||||
local data, key, occupant, session = event.data, event.key, event.actor, event.session;
|
||||
|
||||
if key == 'recording' and data and (data.isTranscribingEnabled ~= nil or data.isRecordingRequested ~= nil) then
|
||||
-- if it is recording we want to allow setting in metadata if not moderator but features
|
||||
-- are present
|
||||
if session.jitsi_meet_context_features
|
||||
and is_feature_allowed('transcription', session.jitsi_meet_context_features) then
|
||||
local res = {};
|
||||
res.isTranscribingEnabled = data.isTranscribingEnabled;
|
||||
res.isRecordingRequested = data.isRecordingRequested;
|
||||
return res;
|
||||
elseif not session.jitsi_meet_context_features and occupant.role == 'moderator' then
|
||||
return data;
|
||||
else
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
return nil;
|
||||
end);
|
||||
|
||||
61
resources/prosody-plugins/mod_filter_messages.lua
Normal file
61
resources/prosody-plugins/mod_filter_messages.lua
Normal file
@@ -0,0 +1,61 @@
|
||||
-- Filters MUC group-chat messages when the room operator has restricted chat.
|
||||
--
|
||||
-- When room.jitsiMetadata.permissions.groupChatRestricted is true, a sender
|
||||
-- must have the 'send-groupchat' feature set to true in their JWT context
|
||||
-- (session.jitsi_meet_context_features) to send a message. Any message that
|
||||
-- fails this check is rejected with a <not-allowed/> error stanza.
|
||||
--
|
||||
-- Feature evaluation via is_feature_allowed:
|
||||
-- send-groupchat = true in token features → allowed
|
||||
-- send-groupchat = false in token features → blocked
|
||||
-- feature key absent from token features → blocked
|
||||
-- no token features at all (anonymous) → blocked
|
||||
--
|
||||
-- Messages without a <body> (polls, lobby notifications, etc.) are always
|
||||
-- passed through regardless of the restriction flag.
|
||||
--
|
||||
-- Hooks:
|
||||
-- message/bare — ordinary MUC group-chat messages
|
||||
-- jitsi-visitor-groupchat-pre-route — visitor messages routed via the
|
||||
-- visitors component
|
||||
--
|
||||
-- Load on the MUC component.
|
||||
local util = module:require 'util';
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local st = require 'util.stanza';
|
||||
|
||||
local function on_message(event)
|
||||
local stanza = event.stanza;
|
||||
local body = stanza:get_child('body');
|
||||
local session = event.origin;
|
||||
|
||||
if not body or not session then
|
||||
-- we ignore messages without body - lobby, polls ...
|
||||
return;
|
||||
end
|
||||
|
||||
-- get room name with tenant and find room.
|
||||
-- this should already been through domain mapper and this should be the real room jid [tenant]name format
|
||||
local room = get_room_from_jid(stanza.attr.to);
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s', stanza.attr.to);
|
||||
return;
|
||||
end
|
||||
|
||||
if room.jitsiMetadata and room.jitsiMetadata.permissions
|
||||
and room.jitsiMetadata.permissions.groupChatRestricted
|
||||
and not is_feature_allowed('send-groupchat', session.jitsi_meet_context_features) then
|
||||
|
||||
local reply = st.error_reply(stanza, 'cancel', 'not-allowed', 'Sending group messages not allowed');
|
||||
if session.type == 's2sin' or session.type == 's2sout' then
|
||||
reply.skipMapping = true;
|
||||
end
|
||||
module:send(reply);
|
||||
|
||||
-- let's filter this message
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
module:hook('message/bare', on_message); -- room messages
|
||||
module:hook('jitsi-visitor-groupchat-pre-route', on_message); -- visitors messages
|
||||
280
resources/prosody-plugins/mod_firewall/actions.lib.lua
Normal file
280
resources/prosody-plugins/mod_firewall/actions.lib.lua
Normal file
@@ -0,0 +1,280 @@
|
||||
local unpack = table.unpack or unpack;
|
||||
|
||||
local interpolation = require "util.interpolation";
|
||||
local template = interpolation.new("%b$$", function (s) return ("%q"):format(s) end);
|
||||
|
||||
--luacheck: globals meta idsafe
|
||||
local action_handlers = {};
|
||||
|
||||
|
||||
-- Takes an XML string and returns a code string that builds that stanza
|
||||
-- using st.stanza()
|
||||
local function compile_xml(data)
|
||||
local code = {};
|
||||
local first, short_close = true, nil;
|
||||
for tagline, text in data:gmatch("<([^>]+)>([^<]*)") do
|
||||
if tagline:sub(-1,-1) == "/" then
|
||||
tagline = tagline:sub(1, -2);
|
||||
short_close = true;
|
||||
end
|
||||
if tagline:sub(1,1) == "/" then
|
||||
code[#code+1] = (":up()");
|
||||
else
|
||||
local name, attr = tagline:match("^(%S*)%s*(.*)$");
|
||||
local attr_str = {};
|
||||
for k, _, v in attr:gmatch("(%S+)=([\"'])([^%2]-)%2") do
|
||||
if #attr_str == 0 then
|
||||
table.insert(attr_str, ", { ");
|
||||
else
|
||||
table.insert(attr_str, ", ");
|
||||
end
|
||||
if k:find("^%a%w*$") then
|
||||
table.insert(attr_str, string.format("%s = %q", k, v));
|
||||
else
|
||||
table.insert(attr_str, string.format("[%q] = %q", k, v));
|
||||
end
|
||||
end
|
||||
if #attr_str > 0 then
|
||||
table.insert(attr_str, " }");
|
||||
end
|
||||
if first then
|
||||
code[#code+1] = (string.format("st.stanza(%q %s)", name, #attr_str>0 and table.concat(attr_str) or ", nil"));
|
||||
first = nil;
|
||||
else
|
||||
code[#code+1] = (string.format(":tag(%q%s)", name, table.concat(attr_str)));
|
||||
end
|
||||
end
|
||||
if text and text:find("%S") then
|
||||
code[#code+1] = (string.format(":text(%q)", text));
|
||||
elseif short_close then
|
||||
short_close = nil;
|
||||
code[#code+1] = (":up()");
|
||||
end
|
||||
end
|
||||
return table.concat(code, "");
|
||||
end
|
||||
|
||||
function action_handlers.PASS()
|
||||
return "do return pass_return end"
|
||||
end
|
||||
|
||||
function action_handlers.DROP()
|
||||
return "do return true end";
|
||||
end
|
||||
|
||||
function action_handlers.DEFAULT()
|
||||
return "do return false end";
|
||||
end
|
||||
|
||||
function action_handlers.RETURN()
|
||||
return "do return end"
|
||||
end
|
||||
|
||||
function action_handlers.STRIP(tag_desc)
|
||||
local code = {};
|
||||
local name, xmlns = tag_desc:match("^(%S+) (.+)$");
|
||||
if not name then
|
||||
name, xmlns = tag_desc, nil;
|
||||
end
|
||||
if name == "*" then
|
||||
name = nil;
|
||||
end
|
||||
code[#code+1] = ("local stanza_xmlns = stanza.attr.xmlns; ");
|
||||
code[#code+1] = "stanza:maptags(function (tag) if ";
|
||||
if name then
|
||||
code[#code+1] = ("tag.name == %q and "):format(name);
|
||||
end
|
||||
if xmlns then
|
||||
code[#code+1] = ("(tag.attr.xmlns or stanza_xmlns) == %q "):format(xmlns);
|
||||
else
|
||||
code[#code+1] = ("tag.attr.xmlns == stanza_xmlns ");
|
||||
end
|
||||
code[#code+1] = "then return nil; end return tag; end );";
|
||||
return table.concat(code);
|
||||
end
|
||||
|
||||
function action_handlers.INJECT(tag)
|
||||
return "stanza:add_child("..compile_xml(tag)..")", { "st" };
|
||||
end
|
||||
|
||||
local error_types = {
|
||||
["bad-request"] = "modify";
|
||||
["conflict"] = "cancel";
|
||||
["feature-not-implemented"] = "cancel";
|
||||
["forbidden"] = "auth";
|
||||
["gone"] = "cancel";
|
||||
["internal-server-error"] = "cancel";
|
||||
["item-not-found"] = "cancel";
|
||||
["jid-malformed"] = "modify";
|
||||
["not-acceptable"] = "modify";
|
||||
["not-allowed"] = "cancel";
|
||||
["not-authorized"] = "auth";
|
||||
["payment-required"] = "auth";
|
||||
["policy-violation"] = "modify";
|
||||
["recipient-unavailable"] = "wait";
|
||||
["redirect"] = "modify";
|
||||
["registration-required"] = "auth";
|
||||
["remote-server-not-found"] = "cancel";
|
||||
["remote-server-timeout"] = "wait";
|
||||
["resource-constraint"] = "wait";
|
||||
["service-unavailable"] = "cancel";
|
||||
["subscription-required"] = "auth";
|
||||
["undefined-condition"] = "cancel";
|
||||
["unexpected-request"] = "wait";
|
||||
};
|
||||
|
||||
|
||||
local function route_modify(make_new, to, drop)
|
||||
local reroute, deps = "session.send(newstanza)", { "st" };
|
||||
if to then
|
||||
reroute = ("newstanza.attr.to = %q; core_post_stanza(session, newstanza)"):format(to);
|
||||
deps[#deps+1] = "core_post_stanza";
|
||||
end
|
||||
return ([[do local newstanza = st.%s; %s;%s end]])
|
||||
:format(make_new, reroute, drop and " return true" or ""), deps;
|
||||
end
|
||||
|
||||
function action_handlers.BOUNCE(with)
|
||||
local error = with and with:match("^%S+") or "service-unavailable";
|
||||
local error_type = error:match(":(%S+)");
|
||||
if not error_type then
|
||||
error_type = error_types[error] or "cancel";
|
||||
else
|
||||
error = error:match("^[^:]+");
|
||||
end
|
||||
error, error_type = string.format("%q", error), string.format("%q", error_type);
|
||||
local text = with and with:match(" %((.+)%)$");
|
||||
if text then
|
||||
text = string.format("%q", text);
|
||||
else
|
||||
text = "nil";
|
||||
end
|
||||
local route_modify_code, deps = route_modify(("error_reply(stanza, %s, %s, %s)"):format(error_type, error, text), nil, true);
|
||||
deps[#deps+1] = "type";
|
||||
deps[#deps+1] = "name";
|
||||
return [[if type == "error" or (name == "iq" and type == "result") then return true; end -- Don't reply to 'error' stanzas, or iq results
|
||||
]]..route_modify_code, deps;
|
||||
end
|
||||
|
||||
function action_handlers.REDIRECT(where)
|
||||
return route_modify("clone(stanza)", where, true);
|
||||
end
|
||||
|
||||
function action_handlers.COPY(where)
|
||||
return route_modify("clone(stanza)", where, false);
|
||||
end
|
||||
|
||||
function action_handlers.REPLY(with)
|
||||
return route_modify(("reply(stanza):body(%q)"):format(with));
|
||||
end
|
||||
|
||||
function action_handlers.FORWARD(where)
|
||||
local code = [[
|
||||
local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
|
||||
local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza);
|
||||
core_post_stanza(session, newstanza);
|
||||
]];
|
||||
return code:format(where), { "core_post_stanza", "current_host" };
|
||||
end
|
||||
|
||||
function action_handlers.LOG(string)
|
||||
local level = string:match("^%[(%a+)%]") or "info";
|
||||
string = string:gsub("^%[%a+%] ?", "");
|
||||
local meta_deps = {};
|
||||
local code = meta(("(session.log or log)(%q, '%%s', %q);"):format(level, string), meta_deps);
|
||||
return code, meta_deps;
|
||||
end
|
||||
|
||||
function action_handlers.RULEDEP(dep)
|
||||
return "", { dep };
|
||||
end
|
||||
|
||||
function action_handlers.EVENT(name)
|
||||
return ("fire_event(%q, event)"):format(name);
|
||||
end
|
||||
|
||||
function action_handlers.JUMP_EVENT(name)
|
||||
return ("do return fire_event(%q, event); end"):format(name);
|
||||
end
|
||||
|
||||
function action_handlers.JUMP_CHAIN(name)
|
||||
return template([[do
|
||||
local ret = fire_event($chain_event$, event);
|
||||
if ret ~= nil then
|
||||
if ret == false then
|
||||
log("debug", "Chain %q accepted stanza (ret %s)", $chain_name$, tostring(ret));
|
||||
return pass_return;
|
||||
end
|
||||
log("debug", "Chain %q rejected stanza (ret %s)", $chain_name$, tostring(ret));
|
||||
return ret;
|
||||
end
|
||||
end]], { chain_event = "firewall/chains/"..name, chain_name = name });
|
||||
end
|
||||
|
||||
function action_handlers.MARK_ORIGIN(name)
|
||||
return [[session.firewall_marked_]]..idsafe(name)..[[ = current_timestamp;]], { "timestamp" };
|
||||
end
|
||||
|
||||
function action_handlers.UNMARK_ORIGIN(name)
|
||||
return [[session.firewall_marked_]]..idsafe(name)..[[ = nil;]]
|
||||
end
|
||||
|
||||
function action_handlers.MARK_USER(name)
|
||||
return ([[if session.username and session.host == current_host then
|
||||
fire_event("firewall/marked/user", {
|
||||
username = session.username;
|
||||
mark = %q;
|
||||
timestamp = current_timestamp;
|
||||
});
|
||||
else
|
||||
log("warn", "Attempt to MARK a remote user - only local users may be marked");
|
||||
end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)), {
|
||||
"current_host";
|
||||
"timestamp";
|
||||
};
|
||||
end
|
||||
|
||||
function action_handlers.UNMARK_USER(name)
|
||||
return ([[if session.username and session.host == current_host then
|
||||
fire_event("firewall/unmarked/user", {
|
||||
username = session.username;
|
||||
mark = %q;
|
||||
});
|
||||
else
|
||||
log("warn", "Attempt to UNMARK a remote user - only local users may be marked");
|
||||
end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name));
|
||||
end
|
||||
|
||||
function action_handlers.ADD_TO(spec)
|
||||
local list_name, value = spec:match("(%S+) (.+)");
|
||||
local meta_deps = {};
|
||||
value = meta(("%q"):format(value), meta_deps);
|
||||
return ("list_%s:add(%s);"):format(list_name, value), { "list:"..list_name, unpack(meta_deps) };
|
||||
end
|
||||
|
||||
function action_handlers.UNSUBSCRIBE_SENDER()
|
||||
return "rostermanager.unsubscribed(to_node, to_host, bare_from);\
|
||||
rostermanager.roster_push(to_node, to_host, bare_from);\
|
||||
core_post_stanza(session, st.presence({ from = bare_to, to = bare_from, type = \"unsubscribed\" }));",
|
||||
{ "rostermanager", "core_post_stanza", "st", "split_to", "bare_to", "bare_from" };
|
||||
end
|
||||
|
||||
function action_handlers.REPORT_TO(spec)
|
||||
local where, reason, text = spec:match("^%s*(%S+) *(%S*) *(.*)$");
|
||||
if reason == "spam" then
|
||||
reason = "urn:xmpp:reporting:spam";
|
||||
elseif reason == "abuse" or not reason then
|
||||
reason = "urn:xmpp:reporting:abuse";
|
||||
end
|
||||
local code = [[
|
||||
local newstanza = st.stanza("message", { to = %q, from = current_host }):tag("forwarded", { xmlns = "urn:xmpp:forward:0" });
|
||||
local tmp_stanza = st.clone(stanza); tmp_stanza.attr.xmlns = "jabber:client"; newstanza:add_child(tmp_stanza):up();
|
||||
newstanza:tag("report", { xmlns = "urn:xmpp:reporting:1", reason = %q })
|
||||
do local text = %q; if text ~= "" then newstanza:text_tag("text", text); end end
|
||||
newstanza:up();
|
||||
core_post_stanza(session, newstanza);
|
||||
]];
|
||||
return code:format(where, reason, text), { "core_post_stanza", "current_host", "st" };
|
||||
end
|
||||
|
||||
return action_handlers;
|
||||
384
resources/prosody-plugins/mod_firewall/conditions.lib.lua
Normal file
384
resources/prosody-plugins/mod_firewall/conditions.lib.lua
Normal file
@@ -0,0 +1,384 @@
|
||||
--luacheck: globals meta idsafe
|
||||
local condition_handlers = {};
|
||||
|
||||
local jid = require "util.jid";
|
||||
local unpack = table.unpack or unpack;
|
||||
|
||||
-- Helper to convert user-input strings (yes/true//no/false) to a bool
|
||||
local function string_to_boolean(s)
|
||||
s = s:lower();
|
||||
return s == "yes" or s == "true";
|
||||
end
|
||||
|
||||
-- Return a code string for a condition that checks whether the contents
|
||||
-- of variable with the name 'name' matches any of the values in the
|
||||
-- comma/space/pipe delimited list 'values'.
|
||||
local function compile_comparison_list(name, values)
|
||||
local conditions = {};
|
||||
for value in values:gmatch("[^%s,|]+") do
|
||||
table.insert(conditions, ("%s == %q"):format(name, value));
|
||||
end
|
||||
return table.concat(conditions, " or ");
|
||||
end
|
||||
|
||||
function condition_handlers.KIND(kind)
|
||||
assert(kind, "Expected stanza kind to match against");
|
||||
return compile_comparison_list("name", kind), { "name" };
|
||||
end
|
||||
|
||||
local wildcard_equivs = { ["*"] = ".*", ["?"] = "." };
|
||||
|
||||
local function compile_jid_match_part(part, match)
|
||||
if not match then
|
||||
return part.." == nil";
|
||||
end
|
||||
local pattern = match:match("^<(.*)>$");
|
||||
if pattern then
|
||||
if pattern == "*" then
|
||||
return part;
|
||||
end
|
||||
if pattern:find("^<.*>$") then
|
||||
pattern = pattern:match("^<(.*)>$");
|
||||
else
|
||||
pattern = pattern:gsub("%p", "%%%0"):gsub("%%(%p)", wildcard_equivs);
|
||||
end
|
||||
return ("(%s and %s:find(%q))"):format(part, part, "^"..pattern.."$");
|
||||
else
|
||||
return ("%s == %q"):format(part, match);
|
||||
end
|
||||
end
|
||||
|
||||
local function compile_jid_match(which, match_jid)
|
||||
local match_node, match_host, match_resource = jid.split(match_jid);
|
||||
local conditions = {};
|
||||
conditions[#conditions+1] = compile_jid_match_part(which.."_node", match_node);
|
||||
conditions[#conditions+1] = compile_jid_match_part(which.."_host", match_host);
|
||||
if match_resource then
|
||||
conditions[#conditions+1] = compile_jid_match_part(which.."_resource", match_resource);
|
||||
end
|
||||
return table.concat(conditions, " and ");
|
||||
end
|
||||
|
||||
function condition_handlers.TO(to)
|
||||
return compile_jid_match("to", to), { "split_to" };
|
||||
end
|
||||
|
||||
function condition_handlers.FROM(from)
|
||||
return compile_jid_match("from", from), { "split_from" };
|
||||
end
|
||||
|
||||
function condition_handlers.FROM_FULL_JID()
|
||||
return "not "..compile_jid_match_part("from_resource", nil), { "split_from" };
|
||||
end
|
||||
|
||||
function condition_handlers.FROM_EXACTLY(from)
|
||||
local metadeps = {};
|
||||
return ("from == %s"):format(metaq(from, metadeps)), { "from", unpack(metadeps) };
|
||||
end
|
||||
|
||||
function condition_handlers.TO_EXACTLY(to)
|
||||
local metadeps = {};
|
||||
return ("to == %s"):format(metaq(to, metadeps)), { "to", unpack(metadeps) };
|
||||
end
|
||||
|
||||
function condition_handlers.TO_SELF()
|
||||
-- Intentionally not using 'to' here, as that defaults to bare JID when nil
|
||||
return ("stanza.attr.to == nil");
|
||||
end
|
||||
|
||||
function condition_handlers.TYPE(type)
|
||||
assert(type, "Expected 'type' value to match against");
|
||||
return compile_comparison_list("(type or (name == 'message' and 'normal') or (name == 'presence' and 'available'))", type), { "type", "name" };
|
||||
end
|
||||
|
||||
local function zone_check(zone, which)
|
||||
local zone_var = zone;
|
||||
if zone == "$local" then zone_var = "_local" end
|
||||
local which_not = which == "from" and "to" or "from";
|
||||
return ("(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s]) "
|
||||
.."and not(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s])"
|
||||
)
|
||||
:format(zone_var, which, zone_var, which, zone_var, which,
|
||||
zone_var, which_not, zone_var, which_not, zone_var, which_not), {
|
||||
"split_to", "split_from", "bare_to", "bare_from", "zone:"..zone
|
||||
};
|
||||
end
|
||||
|
||||
function condition_handlers.ENTERING(zone)
|
||||
return zone_check(zone, "to");
|
||||
end
|
||||
|
||||
function condition_handlers.LEAVING(zone)
|
||||
return zone_check(zone, "from");
|
||||
end
|
||||
|
||||
-- IN ROSTER? (parameter is deprecated)
|
||||
function condition_handlers.IN_ROSTER(yes_no)
|
||||
local in_roster_requirement = string_to_boolean(yes_no or "yes"); -- COMPAT w/ older scripts
|
||||
return "not "..(in_roster_requirement and "not" or "").." roster_entry", { "roster_entry" };
|
||||
end
|
||||
|
||||
function condition_handlers.IN_ROSTER_GROUP(group)
|
||||
return ("not not (roster_entry and roster_entry.groups[%q])"):format(group), { "roster_entry" };
|
||||
end
|
||||
|
||||
function condition_handlers.SUBSCRIBED()
|
||||
return "(bare_to == bare_from or to_node and rostermanager.is_contact_subscribed(to_node, to_host, bare_from))",
|
||||
{ "rostermanager", "split_to", "bare_to", "bare_from" };
|
||||
end
|
||||
|
||||
function condition_handlers.PENDING_SUBSCRIPTION_FROM_SENDER()
|
||||
return "(bare_to == bare_from or to_node and rostermanager.is_contact_pending_in(to_node, to_host, bare_from))",
|
||||
{ "rostermanager", "split_to", "bare_to", "bare_from" };
|
||||
end
|
||||
|
||||
function condition_handlers.PAYLOAD(payload_ns)
|
||||
return ("stanza:get_child(nil, %q)"):format(payload_ns);
|
||||
end
|
||||
|
||||
function condition_handlers.INSPECT(path)
|
||||
if path:find("=") then
|
||||
local query, match_type, value = path:match("(.-)([~/$]*)=(.*)");
|
||||
if not(query:match("#$") or query:match("@[^/]+")) then
|
||||
error("Stanza path does not return a string (append # for text content or @name for value of named attribute)", 0);
|
||||
end
|
||||
local meta_deps = {};
|
||||
local quoted_value = ("%q"):format(value);
|
||||
if match_type:find("$", 1, true) then
|
||||
match_type = match_type:gsub("%$", "");
|
||||
quoted_value = meta(quoted_value, meta_deps);
|
||||
end
|
||||
if match_type == "~" then -- Lua pattern match
|
||||
return ("(stanza:find(%q) or ''):match(%s)"):format(query, quoted_value), meta_deps;
|
||||
elseif match_type == "/" then -- find literal substring
|
||||
return ("(stanza:find(%q) or ''):find(%s, 1, true)"):format(query, quoted_value), meta_deps;
|
||||
elseif match_type == "" then -- exact match
|
||||
return ("stanza:find(%q) == %s"):format(query, quoted_value), meta_deps;
|
||||
else
|
||||
error("Unrecognised comparison '"..match_type.."='", 0);
|
||||
end
|
||||
end
|
||||
return ("stanza:find(%q)"):format(path);
|
||||
end
|
||||
|
||||
function condition_handlers.FROM_GROUP(group_name)
|
||||
return ("group_contains(%q, bare_from)"):format(group_name), { "group_contains", "bare_from" };
|
||||
end
|
||||
|
||||
function condition_handlers.TO_GROUP(group_name)
|
||||
return ("group_contains(%q, bare_to)"):format(group_name), { "group_contains", "bare_to" };
|
||||
end
|
||||
|
||||
function condition_handlers.CROSSING_GROUPS(group_names)
|
||||
local code = {};
|
||||
for group_name in group_names:gmatch("([^, ][^,]+)") do
|
||||
group_name = group_name:match("^%s*(.-)%s*$"); -- Trim leading/trailing whitespace
|
||||
-- Just check that's it is crossing from outside group to inside group
|
||||
table.insert(code, ("(group_contains(%q, bare_to) and group_contains(%q, bare_from))"):format(group_name, group_name))
|
||||
end
|
||||
return "not "..table.concat(code, " or "), { "group_contains", "bare_to", "bare_from" };
|
||||
end
|
||||
|
||||
-- COMPAT w/0.12: Deprecated
|
||||
function condition_handlers.FROM_ADMIN_OF(host)
|
||||
return ("is_admin(bare_from, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_from" };
|
||||
end
|
||||
|
||||
-- COMPAT w/0.12: Deprecated
|
||||
function condition_handlers.TO_ADMIN_OF(host)
|
||||
return ("is_admin(bare_to, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_to" };
|
||||
end
|
||||
|
||||
-- COMPAT w/0.12: Deprecated
|
||||
function condition_handlers.FROM_ADMIN()
|
||||
return ("is_admin(bare_from, current_host)"), { "is_admin", "bare_from", "current_host" };
|
||||
end
|
||||
|
||||
-- COMPAT w/0.12: Deprecated
|
||||
function condition_handlers.TO_ADMIN()
|
||||
return ("is_admin(bare_to, current_host)"), { "is_admin", "bare_to", "current_host" };
|
||||
end
|
||||
|
||||
-- MAY: permission_to_check
|
||||
function condition_handlers.MAY(permission_to_check)
|
||||
return ("module:may(%q, event)"):format(permission_to_check);
|
||||
end
|
||||
|
||||
function condition_handlers.TO_ROLE(role_name)
|
||||
return ("get_jid_role(bare_to, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_to" };
|
||||
end
|
||||
|
||||
function condition_handlers.FROM_ROLE(role_name)
|
||||
return ("get_jid_role(bare_from, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_from" };
|
||||
end
|
||||
|
||||
local day_numbers = { sun = 0, mon = 2, tue = 3, wed = 4, thu = 5, fri = 6, sat = 7 };
|
||||
|
||||
local function current_time_check(op, hour, minute)
|
||||
hour, minute = tonumber(hour), tonumber(minute);
|
||||
local adj_op = op == "<" and "<" or ">="; -- Start time inclusive, end time exclusive
|
||||
if minute == 0 then
|
||||
return "(current_hour"..adj_op..hour..")";
|
||||
else
|
||||
return "((current_hour"..op..hour..") or (current_hour == "..hour.." and current_minute"..adj_op..minute.."))";
|
||||
end
|
||||
end
|
||||
|
||||
local function resolve_day_number(day_name)
|
||||
return assert(day_numbers[day_name:sub(1,3):lower()], "Unknown day name: "..day_name);
|
||||
end
|
||||
|
||||
function condition_handlers.DAY(days)
|
||||
local conditions = {};
|
||||
for day_range in days:gmatch("[^,]+") do
|
||||
local day_start, day_end = day_range:match("(%a+)%s*%-%s*(%a+)");
|
||||
if day_start and day_end then
|
||||
local day_start_num, day_end_num = resolve_day_number(day_start), resolve_day_number(day_end);
|
||||
local op = "and";
|
||||
if day_end_num < day_start_num then
|
||||
op = "or";
|
||||
end
|
||||
table.insert(conditions, ("current_day >= %d %s current_day <= %d"):format(day_start_num, op, day_end_num));
|
||||
elseif day_range:find("%a") then
|
||||
local day = resolve_day_number(day_range:match("%a+"));
|
||||
table.insert(conditions, "current_day == "..day);
|
||||
else
|
||||
error("Unable to parse day/day range: "..day_range);
|
||||
end
|
||||
end
|
||||
assert(#conditions>0, "Expected a list of days or day ranges");
|
||||
return "("..table.concat(conditions, ") or (")..")", { "time:day" };
|
||||
end
|
||||
|
||||
function condition_handlers.TIME(ranges)
|
||||
local conditions = {};
|
||||
for range in ranges:gmatch("([^,]+)") do
|
||||
local clause = {};
|
||||
range = range:lower()
|
||||
:gsub("(%d+):?(%d*) *am", function (h, m) return tostring(tonumber(h)%12)..":"..(tonumber(m) or "00"); end)
|
||||
:gsub("(%d+):?(%d*) *pm", function (h, m) return tostring(tonumber(h)%12+12)..":"..(tonumber(m) or "00"); end);
|
||||
local start_hour, start_minute = range:match("(%d+):(%d+) *%-");
|
||||
local end_hour, end_minute = range:match("%- *(%d+):(%d+)");
|
||||
local op = tonumber(start_hour) > tonumber(end_hour) and " or " or " and ";
|
||||
if start_hour and end_hour then
|
||||
table.insert(clause, current_time_check(">", start_hour, start_minute));
|
||||
table.insert(clause, current_time_check("<", end_hour, end_minute));
|
||||
end
|
||||
if #clause == 0 then
|
||||
error("Unable to parse time range: "..range);
|
||||
end
|
||||
table.insert(conditions, "("..table.concat(clause, " "..op.." ")..")");
|
||||
end
|
||||
return table.concat(conditions, " or "), { "time:hour,min" };
|
||||
end
|
||||
|
||||
function condition_handlers.LIMIT(spec)
|
||||
local name, param = spec:match("^(%w+) on (.+)$");
|
||||
local meta_deps = {};
|
||||
|
||||
if not name then
|
||||
name = spec:match("^%w+$");
|
||||
if not name then
|
||||
error("Unable to parse LIMIT specification");
|
||||
end
|
||||
else
|
||||
param = meta(("%q"):format(param), meta_deps);
|
||||
end
|
||||
|
||||
if not param then
|
||||
return ("not global_throttle_%s:poll(1)"):format(name), { "globalthrottle:"..name, unpack(meta_deps) };
|
||||
end
|
||||
return ("not multi_throttle_%s:poll_on(%s, 1)"):format(name, param), { "multithrottle:"..name, unpack(meta_deps) };
|
||||
end
|
||||
|
||||
function condition_handlers.ORIGIN_MARKED(name_and_time)
|
||||
local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$");
|
||||
if not name then
|
||||
name = name_and_time:match("^%s*([%w_]+)%s*$");
|
||||
end
|
||||
if not name then
|
||||
error("Error parsing mark name, see documentation for usage examples");
|
||||
end
|
||||
if time then
|
||||
return ("(current_timestamp - (session.firewall_marked_%s or 0)) < %d"):format(idsafe(name), tonumber(time)), { "timestamp" };
|
||||
end
|
||||
return ("not not session.firewall_marked_"..idsafe(name));
|
||||
end
|
||||
|
||||
function condition_handlers.USER_MARKED(name_and_time)
|
||||
local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$");
|
||||
if not name then
|
||||
name = name_and_time:match("^%s*([%w_]+)%s*$");
|
||||
end
|
||||
if not name then
|
||||
error("Error parsing mark name, see documentation for usage examples");
|
||||
end
|
||||
if time then
|
||||
return ([[(
|
||||
current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0)
|
||||
) < %d]]):format(idsafe(name), tonumber(time)), { "timestamp" };
|
||||
end
|
||||
return ("not not (session.firewall_marks and session.firewall_marks."..idsafe(name)..")");
|
||||
end
|
||||
|
||||
function condition_handlers.SENT_DIRECTED_PRESENCE_TO_SENDER()
|
||||
return "not not (session.directed and session.directed[from])", { "from" };
|
||||
end
|
||||
|
||||
-- TO FULL JID?
|
||||
function condition_handlers.TO_FULL_JID()
|
||||
return "not not full_sessions[to]", { "to", "full_sessions" };
|
||||
end
|
||||
|
||||
-- CHECK LIST: spammers contains $<@from>
|
||||
function condition_handlers.CHECK_LIST(list_condition)
|
||||
local list_name, expr = list_condition:match("(%S+) contains (.+)$");
|
||||
if not (list_name and expr) then
|
||||
error("Error parsing list check, syntax: LISTNAME contains EXPRESSION");
|
||||
end
|
||||
local meta_deps = {};
|
||||
expr = meta(("%q"):format(expr), meta_deps);
|
||||
return ("list_%s:contains(%s) == true"):format(list_name, expr), { "list:"..list_name, unpack(meta_deps) };
|
||||
end
|
||||
|
||||
-- SCAN: body for word in badwords
|
||||
function condition_handlers.SCAN(scan_expression)
|
||||
local search_name, pattern_name, list_name = scan_expression:match("(%S+) for (%S+) in (%S+)$");
|
||||
if not (search_name) then
|
||||
error("Error parsing SCAN expression, syntax: SEARCH for PATTERN in LIST");
|
||||
end
|
||||
return ("scan_list(list_%s, %s)"):format(
|
||||
list_name,
|
||||
"tokens_"..search_name.."_"..pattern_name
|
||||
), {
|
||||
"scan_list",
|
||||
"tokens:"..search_name.."-"..pattern_name, "list:"..list_name
|
||||
};
|
||||
end
|
||||
|
||||
-- COUNT: lines in body < 10
|
||||
local valid_comp_ops = { [">"] = ">", ["<"] = "<", ["="] = "==", ["=="] = "==", ["<="] = "<=", [">="] = ">=" };
|
||||
function condition_handlers.COUNT(count_expression)
|
||||
local pattern_name, search_name, comparator_expression = count_expression:match("(%S+) in (%S+) (.+)$");
|
||||
if not (pattern_name) then
|
||||
error("Error parsing COUNT expression, syntax: PATTERN in SEARCH COMPARATOR");
|
||||
end
|
||||
local value;
|
||||
comparator_expression = comparator_expression:gsub("%d+", function (value_string)
|
||||
value = tonumber(value_string);
|
||||
return "";
|
||||
end);
|
||||
if not value then
|
||||
error("Error parsing COUNT expression, expected value");
|
||||
end
|
||||
local comp_op = comparator_expression:gsub("%s+", "");
|
||||
assert(valid_comp_ops[comp_op], "Error parsing COUNT expression, unknown comparison operator: "..comp_op);
|
||||
return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format(
|
||||
search_name, pattern_name, comp_op, value
|
||||
), {
|
||||
"it_count",
|
||||
"search:"..search_name, "pattern:"..pattern_name
|
||||
};
|
||||
end
|
||||
|
||||
return condition_handlers;
|
||||
335
resources/prosody-plugins/mod_firewall/definitions.lib.lua
Normal file
335
resources/prosody-plugins/mod_firewall/definitions.lib.lua
Normal file
@@ -0,0 +1,335 @@
|
||||
|
||||
-- Name arguments are unused here
|
||||
-- luacheck: ignore 212
|
||||
|
||||
local definition_handlers = {};
|
||||
|
||||
local http = require "net.http";
|
||||
local timer = require "util.timer";
|
||||
local set = require"util.set";
|
||||
local new_throttle = require "util.throttle".create;
|
||||
local hashes = require "util.hashes";
|
||||
local jid = require "util.jid";
|
||||
local lfs = require "lfs";
|
||||
|
||||
local multirate_cache_size = module:get_option_number("firewall_multirate_cache_limit", 1000);
|
||||
|
||||
function definition_handlers.ZONE(zone_name, zone_members)
|
||||
local zone_member_list = {};
|
||||
for member in zone_members:gmatch("[^, ]+") do
|
||||
zone_member_list[#zone_member_list+1] = member;
|
||||
end
|
||||
return set.new(zone_member_list)._items;
|
||||
end
|
||||
|
||||
-- Helper function used by RATE handler
|
||||
local function evict_only_unthrottled(name, throttle)
|
||||
throttle:update();
|
||||
-- Check whether the throttle is at max balance (i.e. totally safe to forget about it)
|
||||
if throttle.balance < throttle.max then
|
||||
-- Not safe to forget
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
function definition_handlers.RATE(name, line)
|
||||
local rate = assert(tonumber(line:match("([%d.]+)")), "Unable to parse rate");
|
||||
local burst = tonumber(line:match("%(%s*burst%s+([%d.]+)%s*%)")) or 1;
|
||||
local max_throttles = tonumber(line:match("%(%s*entries%s+([%d]+)%s*%)")) or multirate_cache_size;
|
||||
local deny_when_full = not line:match("%(allow overflow%)");
|
||||
return {
|
||||
single = function ()
|
||||
return new_throttle(rate*burst, burst);
|
||||
end;
|
||||
|
||||
multi = function ()
|
||||
local cache = require "util.cache".new(max_throttles, deny_when_full and evict_only_unthrottled or nil);
|
||||
return {
|
||||
poll_on = function (_, key, amount)
|
||||
assert(key, "no key");
|
||||
local throttle = cache:get(key);
|
||||
if not throttle then
|
||||
throttle = new_throttle(rate*burst, burst);
|
||||
if not cache:set(key, throttle) then
|
||||
module:log("warn", "Multirate '%s' has hit its maximum number of active throttles (%d), denying new events", name, max_throttles);
|
||||
return false;
|
||||
end
|
||||
end
|
||||
return throttle:poll(amount);
|
||||
end;
|
||||
}
|
||||
end;
|
||||
};
|
||||
end
|
||||
|
||||
local list_backends = {
|
||||
-- %LIST name: memory (limit: number)
|
||||
memory = {
|
||||
init = function (self, type, opts)
|
||||
if opts.limit then
|
||||
local have_cache_lib, cache_lib = pcall(require, "util.cache");
|
||||
if not have_cache_lib then
|
||||
error("In-memory lists with a size limit require Prosody 0.10");
|
||||
end
|
||||
self.cache = cache_lib.new((assert(tonumber(opts.limit), "Invalid list limit")));
|
||||
if not self.cache.table then
|
||||
error("In-memory lists with a size limit require a newer version of Prosody 0.10");
|
||||
end
|
||||
self.items = self.cache:table();
|
||||
else
|
||||
self.items = {};
|
||||
end
|
||||
end;
|
||||
add = function (self, item)
|
||||
self.items[item] = true;
|
||||
end;
|
||||
remove = function (self, item)
|
||||
self.items[item] = nil;
|
||||
end;
|
||||
contains = function (self, item)
|
||||
return self.items[item] == true;
|
||||
end;
|
||||
};
|
||||
|
||||
-- %LIST name: http://example.com/ (ttl: number, pattern: pat, hash: sha1)
|
||||
http = {
|
||||
init = function (self, url, opts)
|
||||
local poll_interval = assert(tonumber(opts.ttl or "3600"), "invalid ttl for <"..url.."> (expected number of seconds)");
|
||||
local pattern = opts.pattern or "([^\r\n]+)\r?\n";
|
||||
assert(pcall(string.match, "", pattern), "invalid pattern for <"..url..">");
|
||||
if opts.hash then
|
||||
assert(opts.hash:match("^%w+$") and type(hashes[opts.hash]) == "function", "invalid hash function: "..opts.hash);
|
||||
self.hash_function = hashes[opts.hash];
|
||||
end
|
||||
local etag;
|
||||
local failure_count = 0;
|
||||
local retry_intervals = { 60, 120, 300 };
|
||||
-- By default only check the certificate if net.http supports SNI
|
||||
local sni_supported = http.feature and http.features.sni;
|
||||
local insecure = false;
|
||||
if opts.checkcert == "never" then
|
||||
insecure = true;
|
||||
elseif (opts.checkcert == nil or opts.checkcert == "when-sni") and not sni_supported then
|
||||
insecure = false;
|
||||
end
|
||||
local function update_list()
|
||||
http.request(url, {
|
||||
insecure = insecure;
|
||||
headers = {
|
||||
["If-None-Match"] = etag;
|
||||
};
|
||||
}, function (body, code, response)
|
||||
local next_poll = poll_interval;
|
||||
if code == 200 and body then
|
||||
etag = response.headers.etag;
|
||||
local items = {};
|
||||
for entry in body:gmatch(pattern) do
|
||||
items[entry] = true;
|
||||
end
|
||||
self.items = items;
|
||||
module:log("debug", "Fetched updated list from <%s>", url);
|
||||
elseif code == 304 then
|
||||
module:log("debug", "List at <%s> is unchanged", url);
|
||||
elseif code == 0 or (code >= 400 and code <=599) then
|
||||
module:log("warn", "Failed to fetch list from <%s>: %d %s", url, code, tostring(body));
|
||||
failure_count = failure_count + 1;
|
||||
next_poll = retry_intervals[failure_count] or retry_intervals[#retry_intervals];
|
||||
end
|
||||
if next_poll > 0 then
|
||||
timer.add_task(next_poll+math.random(0, 60), update_list);
|
||||
end
|
||||
end);
|
||||
end
|
||||
update_list();
|
||||
end;
|
||||
add = function ()
|
||||
end;
|
||||
remove = function ()
|
||||
end;
|
||||
contains = function (self, item)
|
||||
if self.hash_function then
|
||||
item = self.hash_function(item);
|
||||
end
|
||||
return self.items and self.items[item] == true;
|
||||
end;
|
||||
};
|
||||
|
||||
-- %LIST: file:/path/to/file
|
||||
file = {
|
||||
init = function (self, file_spec, opts)
|
||||
local n, items = 0, {};
|
||||
self.items = items;
|
||||
local filename = file_spec:gsub("^file:", "");
|
||||
if opts.missing == "ignore" and not lfs.attributes(filename, "mode") then
|
||||
module:log("debug", "Ignoring missing list file: %s", filename);
|
||||
return;
|
||||
end
|
||||
local file, err = io.open(filename);
|
||||
if not file then
|
||||
module:log("warn", "Failed to open list from %s: %s", filename, err);
|
||||
return;
|
||||
else
|
||||
for line in file:lines() do
|
||||
if not items[line] then
|
||||
n = n + 1;
|
||||
items[line] = true;
|
||||
end
|
||||
end
|
||||
end
|
||||
module:log("debug", "Loaded %d items from %s", n, filename);
|
||||
end;
|
||||
add = function (self, item)
|
||||
self.items[item] = true;
|
||||
end;
|
||||
remove = function (self, item)
|
||||
self.items[item] = nil;
|
||||
end;
|
||||
contains = function (self, item)
|
||||
return self.items and self.items[item] == true;
|
||||
end;
|
||||
};
|
||||
|
||||
-- %LIST: pubsub:pubsub.example.com/node
|
||||
-- TODO or the actual URI scheme? Bit overkill maybe?
|
||||
-- TODO Publish items back to the service?
|
||||
-- Step 1: Receiving pubsub events and storing them in the list
|
||||
-- We'll start by using only the item id.
|
||||
-- TODO Invent some custom schema for this? Needed for just a set of strings?
|
||||
pubsubitemid = {
|
||||
init = function(self, pubsub_spec, opts)
|
||||
local service_addr, node = pubsub_spec:match("^pubsubitemid:([^/]*)/(.*)");
|
||||
if not service_addr then
|
||||
module:log("warn", "Invalid list specification (expected 'pubsubitemid:<service>/<node>', got: '%s')", pubsub_spec);
|
||||
return;
|
||||
end
|
||||
module:depends("pubsub_subscription");
|
||||
module:add_item("pubsub-subscription", {
|
||||
service = service_addr;
|
||||
node = node;
|
||||
on_subscribed = function ()
|
||||
self.items = {};
|
||||
end;
|
||||
on_item = function (event)
|
||||
self:add(event.item.attr.id);
|
||||
end;
|
||||
on_retract = function (event)
|
||||
self:remove(event.item.attr.id);
|
||||
end;
|
||||
on_purge = function ()
|
||||
self.items = {};
|
||||
end;
|
||||
on_unsubscribed = function ()
|
||||
self.items = nil;
|
||||
end;
|
||||
on_delete= function ()
|
||||
self.items = nil;
|
||||
end;
|
||||
});
|
||||
-- TODO Initial fetch? Or should mod_pubsub_subscription do this?
|
||||
end;
|
||||
add = function (self, item)
|
||||
if self.items then
|
||||
self.items[item] = true;
|
||||
end
|
||||
end;
|
||||
remove = function (self, item)
|
||||
if self.items then
|
||||
self.items[item] = nil;
|
||||
end
|
||||
end;
|
||||
contains = function (self, item)
|
||||
return self.items and self.items[item] == true;
|
||||
end;
|
||||
};
|
||||
};
|
||||
list_backends.https = list_backends.http;
|
||||
|
||||
local normalize_functions = {
|
||||
upper = string.upper, lower = string.lower;
|
||||
md5 = hashes.md5, sha1 = hashes.sha1, sha256 = hashes.sha256;
|
||||
prep = jid.prep, bare = jid.bare;
|
||||
};
|
||||
|
||||
local function wrap_list_method(list_method, filter)
|
||||
return function (self, item)
|
||||
return list_method(self, filter(item));
|
||||
end
|
||||
end
|
||||
|
||||
local function create_list(list_backend, list_def, opts)
|
||||
if not list_backends[list_backend] then
|
||||
error("Unknown list type '"..list_backend.."'", 0);
|
||||
end
|
||||
local list = setmetatable({}, { __index = list_backends[list_backend] });
|
||||
if list.init then
|
||||
list:init(list_def, opts);
|
||||
end
|
||||
if opts.filter then
|
||||
local filters = {};
|
||||
for func_name in opts.filter:gmatch("[%w_]+") do
|
||||
if func_name == "log" then
|
||||
table.insert(filters, function (s)
|
||||
--print("&&&&&", s);
|
||||
module:log("debug", "Checking list <%s> for: %s", list_def, s);
|
||||
return s;
|
||||
end);
|
||||
else
|
||||
assert(normalize_functions[func_name], "Unknown list filter: "..func_name);
|
||||
table.insert(filters, normalize_functions[func_name]);
|
||||
end
|
||||
end
|
||||
|
||||
local filter;
|
||||
local n = #filters;
|
||||
if n == 1 then
|
||||
filter = filters[1];
|
||||
else
|
||||
function filter(s)
|
||||
for i = 1, n do
|
||||
s = filters[i](s or "");
|
||||
end
|
||||
return s;
|
||||
end
|
||||
end
|
||||
|
||||
list.add = wrap_list_method(list.add, filter);
|
||||
list.remove = wrap_list_method(list.remove, filter);
|
||||
list.contains = wrap_list_method(list.contains, filter);
|
||||
end
|
||||
return list;
|
||||
end
|
||||
|
||||
--[[
|
||||
%LIST spammers: memory (source: /etc/spammers.txt)
|
||||
|
||||
%LIST spammers: memory (source: /etc/spammers.txt)
|
||||
|
||||
|
||||
%LIST spammers: http://example.com/blacklist.txt
|
||||
]]
|
||||
|
||||
function definition_handlers.LIST(list_name, list_definition)
|
||||
local list_backend = list_definition:match("^%w+");
|
||||
local opts = {};
|
||||
local opt_string = list_definition:match("^%S+%s+%((.+)%)");
|
||||
if opt_string then
|
||||
for opt_k, opt_v in opt_string:gmatch("(%w+): ?([^,]+)") do
|
||||
opts[opt_k] = opt_v;
|
||||
end
|
||||
end
|
||||
return create_list(list_backend, list_definition:match("^%S+"), opts);
|
||||
end
|
||||
|
||||
function definition_handlers.PATTERN(name, pattern)
|
||||
local ok, err = pcall(string.match, "", pattern);
|
||||
if not ok then
|
||||
error("Invalid pattern '"..name.."': "..err);
|
||||
end
|
||||
return pattern;
|
||||
end
|
||||
|
||||
function definition_handlers.SEARCH(name, pattern)
|
||||
return pattern;
|
||||
end
|
||||
|
||||
return definition_handlers;
|
||||
35
resources/prosody-plugins/mod_firewall/marks.lib.lua
Normal file
35
resources/prosody-plugins/mod_firewall/marks.lib.lua
Normal file
@@ -0,0 +1,35 @@
|
||||
local mark_storage = module:open_store("firewall_marks");
|
||||
local mark_map_storage = module:open_store("firewall_marks", "map");
|
||||
|
||||
local user_sessions = prosody.hosts[module.host].sessions;
|
||||
|
||||
module:hook("firewall/marked/user", function (event)
|
||||
local user = user_sessions[event.username];
|
||||
local marks = user and user.firewall_marks;
|
||||
if user and not marks then
|
||||
-- Load marks from storage to cache on the user object
|
||||
marks = mark_storage:get(event.username) or {};
|
||||
user.firewall_marks = marks; --luacheck: ignore 122
|
||||
end
|
||||
if marks then
|
||||
marks[event.mark] = event.timestamp;
|
||||
end
|
||||
local ok, err = mark_map_storage:set(event.username, event.mark, event.timestamp);
|
||||
if not ok then
|
||||
module:log("error", "Failed to mark user %q with %q: %s", event.username, event.mark, err);
|
||||
end
|
||||
return true;
|
||||
end, -1);
|
||||
|
||||
module:hook("firewall/unmarked/user", function (event)
|
||||
local user = user_sessions[event.username];
|
||||
local marks = user and user.firewall_marks;
|
||||
if marks then
|
||||
marks[event.mark] = nil;
|
||||
end
|
||||
local ok, err = mark_map_storage:set(event.username, event.mark, nil);
|
||||
if not ok then
|
||||
module:log("error", "Failed to unmark user %q with %q: %s", event.username, event.mark, err);
|
||||
end
|
||||
return true;
|
||||
end, -1);
|
||||
784
resources/prosody-plugins/mod_firewall/mod_firewall.lua
Normal file
784
resources/prosody-plugins/mod_firewall/mod_firewall.lua
Normal file
@@ -0,0 +1,784 @@
|
||||
|
||||
local lfs = require "lfs";
|
||||
local resolve_relative_path = require "core.configmanager".resolve_relative_path;
|
||||
local envload = require "util.envload".envload;
|
||||
local logger = require "util.logger".init;
|
||||
local it = require "util.iterators";
|
||||
local set = require "util.set";
|
||||
|
||||
local have_features, features = pcall(require, "core.features");
|
||||
features = have_features and features.available or set.new();
|
||||
|
||||
-- [definition_type] = definition_factory(param)
|
||||
local definitions = module:shared("definitions");
|
||||
|
||||
-- When a definition instance has been instantiated, it lives here
|
||||
-- [definition_type][definition_name] = definition_object
|
||||
local active_definitions = {
|
||||
ZONE = {
|
||||
-- Default zone that includes all local hosts
|
||||
["$local"] = setmetatable({}, { __index = prosody.hosts });
|
||||
};
|
||||
};
|
||||
|
||||
local default_chains = {
|
||||
preroute = {
|
||||
type = "event";
|
||||
priority = 0.1;
|
||||
"pre-message/bare", "pre-message/full", "pre-message/host";
|
||||
"pre-presence/bare", "pre-presence/full", "pre-presence/host";
|
||||
"pre-iq/bare", "pre-iq/full", "pre-iq/host";
|
||||
};
|
||||
deliver = {
|
||||
type = "event";
|
||||
priority = 0.1;
|
||||
"message/bare", "message/full", "message/host";
|
||||
"presence/bare", "presence/full", "presence/host";
|
||||
"iq/bare", "iq/full", "iq/host";
|
||||
};
|
||||
deliver_remote = {
|
||||
type = "event"; "route/remote";
|
||||
priority = 0.1;
|
||||
};
|
||||
};
|
||||
|
||||
local extra_chains = module:get_option("firewall_extra_chains", {});
|
||||
|
||||
local chains = {};
|
||||
for k,v in pairs(default_chains) do
|
||||
chains[k] = v;
|
||||
end
|
||||
for k,v in pairs(extra_chains) do
|
||||
chains[k] = v;
|
||||
end
|
||||
|
||||
-- Returns the input if it is safe to be used as a variable name, otherwise nil
|
||||
function idsafe(name)
|
||||
return name:match("^%a[%w_]*$");
|
||||
end
|
||||
|
||||
local meta_funcs = {
|
||||
bare = function (code)
|
||||
return "jid_bare("..code..")", {"jid_bare"};
|
||||
end;
|
||||
node = function (code)
|
||||
return "(jid_split("..code.."))", {"jid_split"};
|
||||
end;
|
||||
host = function (code)
|
||||
return "(select(2, jid_split("..code..")))", {"jid_split"};
|
||||
end;
|
||||
resource = function (code)
|
||||
return "(select(3, jid_split("..code..")))", {"jid_split"};
|
||||
end;
|
||||
};
|
||||
|
||||
-- Run quoted (%q) strings through this to allow them to contain code. e.g.: LOG=Received: $(stanza:top_tag())
|
||||
function meta(s, deps, extra)
|
||||
return (s:gsub("$(%b())", function (expr)
|
||||
expr = expr:gsub("\\(.)", "%1");
|
||||
return [["..tostring(]]..expr..[[).."]];
|
||||
end)
|
||||
:gsub("$(%b<>)", function (expr)
|
||||
expr = expr:sub(2,-2);
|
||||
local default = "<undefined>";
|
||||
expr = expr:gsub("||(%b\"\")$", function (default_string)
|
||||
default = stripslashes(default_string:sub(2,-2));
|
||||
return "";
|
||||
end);
|
||||
local func_chain = expr:match("|[%w|]+$");
|
||||
if func_chain then
|
||||
expr = expr:sub(1, -1-#func_chain);
|
||||
end
|
||||
local code;
|
||||
if expr:match("^@") then
|
||||
-- Skip stanza:find() for simple attribute lookup
|
||||
local attr_name = expr:sub(2);
|
||||
if deps and (attr_name == "to" or attr_name == "from" or attr_name == "type") then
|
||||
-- These attributes may be cached in locals
|
||||
code = attr_name;
|
||||
table.insert(deps, attr_name);
|
||||
else
|
||||
code = "stanza.attr["..("%q"):format(attr_name).."]";
|
||||
end
|
||||
elseif expr:match("^%w+#$") then
|
||||
code = ("stanza:get_child_text(%q)"):format(expr:sub(1, -2));
|
||||
else
|
||||
code = ("stanza:find(%q)"):format(expr);
|
||||
end
|
||||
if func_chain then
|
||||
for func_name in func_chain:gmatch("|(%w+)") do
|
||||
-- to/from are already available in local variables, use those if possible
|
||||
if (code == "to" or code == "from") and func_name == "bare" then
|
||||
code = "bare_"..code;
|
||||
table.insert(deps, code);
|
||||
elseif (code == "to" or code == "from") and (func_name == "node" or func_name == "host" or func_name == "resource") then
|
||||
table.insert(deps, "split_"..code);
|
||||
code = code.."_"..func_name;
|
||||
else
|
||||
assert(meta_funcs[func_name], "unknown function: "..func_name);
|
||||
local new_code, new_deps = meta_funcs[func_name](code);
|
||||
code = new_code;
|
||||
if new_deps and #new_deps > 0 then
|
||||
assert(deps, "function not supported here: "..func_name);
|
||||
for _, dep in ipairs(new_deps) do
|
||||
table.insert(deps, dep);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return "\"..tostring("..code.." or "..("%q"):format(default)..")..\"";
|
||||
end)
|
||||
:gsub("$$(%a+)", extra or {})
|
||||
:gsub([[^""%.%.]], "")
|
||||
:gsub([[%.%.""$]], ""));
|
||||
end
|
||||
|
||||
function metaq(s, ...)
|
||||
return meta(("%q"):format(s), ...);
|
||||
end
|
||||
|
||||
local escape_chars = {
|
||||
a = "\a", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t",
|
||||
v = "\v", ["\\"] = "\\", ["\""] = "\"", ["\'"] = "\'"
|
||||
};
|
||||
function stripslashes(s)
|
||||
return (s:gsub("\\(.)", escape_chars));
|
||||
end
|
||||
|
||||
-- Dependency locations:
|
||||
-- <type lib>
|
||||
-- <type global>
|
||||
-- function handler()
|
||||
-- <local deps>
|
||||
-- if <conditions> then
|
||||
-- <actions>
|
||||
-- end
|
||||
-- end
|
||||
|
||||
local available_deps = {
|
||||
st = { global_code = [[local st = require "util.stanza";]]};
|
||||
it = { global_code = [[local it = require "util.iterators";]]};
|
||||
it_count = { global_code = [[local it_count = it.count;]], depends = { "it" } };
|
||||
current_host = { global_code = [[local current_host = module.host;]] };
|
||||
jid_split = {
|
||||
global_code = [[local jid_split = require "util.jid".split;]];
|
||||
};
|
||||
jid_bare = {
|
||||
global_code = [[local jid_bare = require "util.jid".bare;]];
|
||||
};
|
||||
to = { local_code = [[local to = stanza.attr.to or jid_bare(session.full_jid);]]; depends = { "jid_bare" } };
|
||||
from = { local_code = [[local from = stanza.attr.from;]] };
|
||||
type = { local_code = [[local type = stanza.attr.type;]] };
|
||||
name = { local_code = [[local name = stanza.name;]] };
|
||||
split_to = { -- The stanza's split to address
|
||||
depends = { "jid_split", "to" };
|
||||
local_code = [[local to_node, to_host, to_resource = jid_split(to);]];
|
||||
};
|
||||
split_from = { -- The stanza's split from address
|
||||
depends = { "jid_split", "from" };
|
||||
local_code = [[local from_node, from_host, from_resource = jid_split(from);]];
|
||||
};
|
||||
bare_to = { depends = { "jid_bare", "to" }, local_code = "local bare_to = jid_bare(to)"};
|
||||
bare_from = { depends = { "jid_bare", "from" }, local_code = "local bare_from = jid_bare(from)"};
|
||||
group_contains = {
|
||||
global_code = [[local group_contains = module:depends("groups").group_contains]];
|
||||
};
|
||||
is_admin = require"core.usermanager".is_admin and { global_code = [[local is_admin = require "core.usermanager".is_admin;]]} or nil;
|
||||
get_jid_role = require "core.usermanager".get_jid_role and { global_code = [[local get_jid_role = require "core.usermanager".get_jid_role;]] } or nil;
|
||||
core_post_stanza = { global_code = [[local core_post_stanza = prosody.core_post_stanza;]] };
|
||||
zone = { global_code = function (zone)
|
||||
local var = zone;
|
||||
if var == "$local" then
|
||||
var = "_local"; -- See #1090
|
||||
else
|
||||
assert(idsafe(var), "Invalid zone name: "..zone);
|
||||
end
|
||||
return ("local zone_%s = zones[%q] or {};"):format(var, zone);
|
||||
end };
|
||||
date_time = { global_code = [[local os_date = os.date]]; local_code = [[local current_date_time = os_date("*t");]] };
|
||||
time = { local_code = function (what)
|
||||
local defs = {};
|
||||
for field in what:gmatch("%a+") do
|
||||
table.insert(defs, ("local current_%s = current_date_time.%s;"):format(field, field));
|
||||
end
|
||||
return table.concat(defs, " ");
|
||||
end, depends = { "date_time" }; };
|
||||
timestamp = { global_code = [[local get_time = require "socket".gettime;]]; local_code = [[local current_timestamp = get_time();]]; };
|
||||
globalthrottle = {
|
||||
global_code = function (throttle)
|
||||
assert(idsafe(throttle), "Invalid rate limit name: "..throttle);
|
||||
assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle);
|
||||
return ("local global_throttle_%s = rates.%s:single();"):format(throttle, throttle);
|
||||
end;
|
||||
};
|
||||
multithrottle = {
|
||||
global_code = function (throttle)
|
||||
assert(pcall(require, "util.cache"), "Using LIMIT with 'on' requires Prosody 0.10 or higher");
|
||||
assert(idsafe(throttle), "Invalid rate limit name: "..throttle);
|
||||
assert(active_definitions.RATE[throttle], "Unknown rate limit: "..throttle);
|
||||
return ("local multi_throttle_%s = rates.%s:multi();"):format(throttle, throttle);
|
||||
end;
|
||||
};
|
||||
full_sessions = {
|
||||
global_code = [[local full_sessions = prosody.full_sessions;]];
|
||||
};
|
||||
rostermanager = {
|
||||
global_code = [[local rostermanager = require "core.rostermanager";]];
|
||||
};
|
||||
roster_entry = {
|
||||
local_code = [[local roster_entry = (to_node and rostermanager.load_roster(to_node, to_host) or {})[bare_from];]];
|
||||
depends = { "rostermanager", "split_to", "bare_from" };
|
||||
};
|
||||
list = { global_code = function (list)
|
||||
assert(idsafe(list), "Invalid list name: "..list);
|
||||
assert(active_definitions.LIST[list], "Unknown list: "..list);
|
||||
return ("local list_%s = lists[%q];"):format(list, list);
|
||||
end
|
||||
};
|
||||
search = {
|
||||
local_code = function (search_name)
|
||||
local search_path = assert(active_definitions.SEARCH[search_name], "Undefined search path: "..search_name);
|
||||
return ("local search_%s = tostring(stanza:find(%q) or \"\")"):format(search_name, search_path);
|
||||
end;
|
||||
};
|
||||
pattern = {
|
||||
local_code = function (pattern_name)
|
||||
local pattern = assert(active_definitions.PATTERN[pattern_name], "Undefined pattern: "..pattern_name);
|
||||
return ("local pattern_%s = %q"):format(pattern_name, pattern);
|
||||
end;
|
||||
};
|
||||
tokens = {
|
||||
local_code = function (search_and_pattern)
|
||||
local search_name, pattern_name = search_and_pattern:match("^([^%-]+)-(.+)$");
|
||||
local code = ([[local tokens_%s_%s = {};
|
||||
if search_%s then
|
||||
for s in search_%s:gmatch(pattern_%s) do
|
||||
tokens_%s_%s[s] = true;
|
||||
end
|
||||
end
|
||||
]]):format(search_name, pattern_name, search_name, search_name, pattern_name, search_name, pattern_name);
|
||||
return code, { "search:"..search_name, "pattern:"..pattern_name };
|
||||
end;
|
||||
};
|
||||
scan_list = {
|
||||
global_code = [[local function scan_list(list, items) for item in pairs(items) do if list:contains(item) then return true; end end end]];
|
||||
}
|
||||
};
|
||||
|
||||
local function include_dep(dependency, code)
|
||||
local dep, dep_param = dependency:match("^([^:]+):?(.*)$");
|
||||
local dep_info = available_deps[dep];
|
||||
if not dep_info then
|
||||
module:log("error", "Dependency not found: %s", dep);
|
||||
return;
|
||||
end
|
||||
if code.included_deps[dependency] ~= nil then
|
||||
if code.included_deps[dependency] ~= true then
|
||||
module:log("error", "Circular dependency on %s", dep);
|
||||
end
|
||||
return;
|
||||
end
|
||||
code.included_deps[dependency] = false; -- Pending flag (used to detect circular references)
|
||||
for _, dep_dep in ipairs(dep_info.depends or {}) do
|
||||
include_dep(dep_dep, code);
|
||||
end
|
||||
if dep_info.global_code then
|
||||
if dep_param ~= "" then
|
||||
local global_code, deps = dep_info.global_code(dep_param);
|
||||
if deps then
|
||||
for _, dep_dep in ipairs(deps) do
|
||||
include_dep(dep_dep, code);
|
||||
end
|
||||
end
|
||||
table.insert(code.global_header, global_code);
|
||||
else
|
||||
table.insert(code.global_header, dep_info.global_code);
|
||||
end
|
||||
end
|
||||
if dep_info.local_code then
|
||||
if dep_param ~= "" then
|
||||
local local_code, deps = dep_info.local_code(dep_param);
|
||||
if deps then
|
||||
for _, dep_dep in ipairs(deps) do
|
||||
include_dep(dep_dep, code);
|
||||
end
|
||||
end
|
||||
table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..local_code.."\n");
|
||||
else
|
||||
table.insert(code, "\n\t\t-- "..dep.."\n\t\t"..dep_info.local_code.."\n");
|
||||
end
|
||||
end
|
||||
code.included_deps[dependency] = true;
|
||||
end
|
||||
|
||||
local definition_handlers = module:require("definitions");
|
||||
local condition_handlers = module:require("conditions");
|
||||
local action_handlers = module:require("actions");
|
||||
|
||||
if module:get_option_boolean("firewall_experimental_user_marks", true) then
|
||||
module:require"marks";
|
||||
end
|
||||
|
||||
local function new_rule(ruleset, chain)
|
||||
assert(chain, "no chain specified");
|
||||
local rule = { conditions = {}, actions = {}, deps = {} };
|
||||
table.insert(ruleset[chain], rule);
|
||||
return rule;
|
||||
end
|
||||
|
||||
local function parse_firewall_rules(filename)
|
||||
local line_no = 0;
|
||||
|
||||
local function errmsg(err)
|
||||
return "Error compiling "..filename.." on line "..line_no..": "..err;
|
||||
end
|
||||
|
||||
local ruleset = {
|
||||
deliver = {};
|
||||
};
|
||||
|
||||
local chain = "deliver"; -- Default chain
|
||||
local rule;
|
||||
|
||||
local file, err = io.open(filename);
|
||||
if not file then return nil, err; end
|
||||
|
||||
local state; -- nil -> "rules" -> "actions" -> nil -> ...
|
||||
|
||||
local line_hold;
|
||||
for line in file:lines() do
|
||||
line = line:match("^%s*(.-)%s*$");
|
||||
if line_hold and line:sub(-1,-1) ~= "\\" then
|
||||
line = line_hold..line;
|
||||
line_hold = nil;
|
||||
elseif line:sub(-1,-1) == "\\" then
|
||||
line_hold = (line_hold or "")..line:sub(1,-2);
|
||||
end
|
||||
line_no = line_no + 1;
|
||||
|
||||
if line_hold or line:find("^[#;]") then -- luacheck: ignore 542
|
||||
-- No action; comment or partial line
|
||||
elseif line == "" then
|
||||
if state == "rules" then
|
||||
return nil, ("Expected an action on line %d for preceding criteria")
|
||||
:format(line_no);
|
||||
end
|
||||
state = nil;
|
||||
elseif not(state) and line:sub(1, 2) == "::" then
|
||||
chain = line:gsub("^::%s*", "");
|
||||
local chain_info = chains[chain];
|
||||
if not chain_info then
|
||||
if chain:match("^user/") then
|
||||
chains[chain] = { type = "event", priority = 1, pass_return = false };
|
||||
else
|
||||
return nil, errmsg("Unknown chain: "..chain);
|
||||
end
|
||||
elseif chain_info.type ~= "event" then
|
||||
return nil, errmsg("Only event chains supported at the moment");
|
||||
end
|
||||
ruleset[chain] = ruleset[chain] or {};
|
||||
elseif not(state) and line:sub(1,1) == "%" then -- Definition (zone, limit, etc.)
|
||||
local what, name = line:match("^%%%s*([%w_]+) +([^ :]+)");
|
||||
if not definition_handlers[what] then
|
||||
return nil, errmsg("Definition of unknown object: "..what);
|
||||
elseif not name or not idsafe(name) then
|
||||
return nil, errmsg("Invalid "..what.." name");
|
||||
end
|
||||
|
||||
local val = line:match(": ?(.*)$");
|
||||
if not val and line:find(":<") then -- Read from file
|
||||
local fn = line:match(":< ?(.-)%s*$");
|
||||
if not fn then
|
||||
return nil, errmsg("Unable to parse filename");
|
||||
end
|
||||
local f, err = io.open(fn);
|
||||
if not f then return nil, errmsg(err); end
|
||||
val = f:read("*a"):gsub("\r?\n", " "):gsub("%s+$", "");
|
||||
end
|
||||
if not val then
|
||||
return nil, errmsg("No value given for definition");
|
||||
end
|
||||
val = stripslashes(val);
|
||||
local ok, ret = pcall(definition_handlers[what], name, val);
|
||||
if not ok then
|
||||
return nil, errmsg(ret);
|
||||
end
|
||||
|
||||
if not active_definitions[what] then
|
||||
active_definitions[what] = {};
|
||||
end
|
||||
active_definitions[what][name] = ret;
|
||||
elseif line:find("^[%w_ ]+[%.=]") then
|
||||
-- Action
|
||||
if state == nil then
|
||||
-- This is a standalone action with no conditions
|
||||
rule = new_rule(ruleset, chain);
|
||||
end
|
||||
state = "actions";
|
||||
-- Action handlers?
|
||||
local action = line:match("^[%w_ ]+"):upper():gsub(" ", "_");
|
||||
if not action_handlers[action] then
|
||||
return nil, ("Unknown action on line %d: %s"):format(line_no, action or "<unknown>");
|
||||
end
|
||||
table.insert(rule.actions, "-- "..line)
|
||||
local ok, action_string, action_deps = pcall(action_handlers[action], line:match("=(.+)$"));
|
||||
if not ok then
|
||||
return nil, errmsg(action_string);
|
||||
end
|
||||
table.insert(rule.actions, action_string);
|
||||
for _, dep in ipairs(action_deps or {}) do
|
||||
table.insert(rule.deps, dep);
|
||||
end
|
||||
elseif state == "actions" then -- state is actions but action pattern did not match
|
||||
state = nil; -- Awaiting next rule, etc.
|
||||
table.insert(ruleset[chain], rule);
|
||||
rule = nil;
|
||||
else
|
||||
if not state then
|
||||
state = "rules";
|
||||
rule = new_rule(ruleset, chain);
|
||||
end
|
||||
-- Check standard modifiers for the condition (e.g. NOT)
|
||||
local negated;
|
||||
local condition = line:match("^[^:=%.?]*");
|
||||
if condition:find("%f[%w]NOT%f[^%w]") then
|
||||
local s, e = condition:match("%f[%w]()NOT()%f[^%w]");
|
||||
condition = (condition:sub(1,s-1)..condition:sub(e+1, -1)):match("^%s*(.-)%s*$");
|
||||
negated = true;
|
||||
end
|
||||
condition = condition:gsub(" ", "_");
|
||||
if not condition_handlers[condition] then
|
||||
return nil, ("Unknown condition on line %d: %s"):format(line_no, (condition:gsub("_", " ")));
|
||||
end
|
||||
-- Get the code for this condition
|
||||
local ok, condition_code, condition_deps = pcall(condition_handlers[condition], line:match(":%s?(.+)$"));
|
||||
if not ok then
|
||||
return nil, errmsg(condition_code);
|
||||
end
|
||||
if negated then condition_code = "not("..condition_code..")"; end
|
||||
table.insert(rule.conditions, condition_code);
|
||||
for _, dep in ipairs(condition_deps or {}) do
|
||||
table.insert(rule.deps, dep);
|
||||
end
|
||||
end
|
||||
end
|
||||
return ruleset;
|
||||
end
|
||||
|
||||
local function process_firewall_rules(ruleset)
|
||||
-- Compile ruleset and return complete code
|
||||
|
||||
local chain_handlers = {};
|
||||
|
||||
-- Loop through the chains in the parsed ruleset (e.g. incoming, outgoing)
|
||||
for chain_name, rules in pairs(ruleset) do
|
||||
local code = { included_deps = {}, global_header = {} };
|
||||
local condition_uses = {};
|
||||
-- This inner loop assumes chain is an event-based, not a filter-based
|
||||
-- chain (filter-based will be added later)
|
||||
for _, rule in ipairs(rules) do
|
||||
for _, condition in ipairs(rule.conditions) do
|
||||
if condition:find("^not%(.+%)$") then
|
||||
condition = condition:match("^not%((.+)%)$");
|
||||
end
|
||||
condition_uses[condition] = (condition_uses[condition] or 0) + 1;
|
||||
end
|
||||
end
|
||||
|
||||
local condition_cache, n_conditions = {}, 0;
|
||||
for _, rule in ipairs(rules) do
|
||||
for _, dep in ipairs(rule.deps) do
|
||||
include_dep(dep, code);
|
||||
end
|
||||
table.insert(code, "\n\t\t");
|
||||
local rule_code;
|
||||
if #rule.conditions > 0 then
|
||||
for i, condition in ipairs(rule.conditions) do
|
||||
local negated = condition:match("^not%(.+%)$");
|
||||
if negated then
|
||||
condition = condition:match("^not%((.+)%)$");
|
||||
end
|
||||
if condition_uses[condition] > 1 then
|
||||
local name = condition_cache[condition];
|
||||
if not name then
|
||||
n_conditions = n_conditions + 1;
|
||||
name = "condition"..n_conditions;
|
||||
condition_cache[condition] = name;
|
||||
table.insert(code, "local "..name.." = "..condition..";\n\t\t");
|
||||
end
|
||||
rule.conditions[i] = (negated and "not(" or "")..name..(negated and ")" or "");
|
||||
else
|
||||
rule.conditions[i] = (negated and "not(" or "(")..condition..")";
|
||||
end
|
||||
end
|
||||
|
||||
rule_code = "if "..table.concat(rule.conditions, " and ").." then\n\t\t\t"
|
||||
..table.concat(rule.actions, "\n\t\t\t")
|
||||
.."\n\t\tend\n";
|
||||
else
|
||||
rule_code = table.concat(rule.actions, "\n\t\t");
|
||||
end
|
||||
table.insert(code, rule_code);
|
||||
end
|
||||
|
||||
for name in pairs(definition_handlers) do
|
||||
table.insert(code.global_header, 1, "local "..name:lower().."s = definitions."..name..";");
|
||||
end
|
||||
|
||||
local code_string = "return function (definitions, fire_event, log, module, pass_return)\n\t"
|
||||
..table.concat(code.global_header, "\n\t")
|
||||
.."\n\tlocal db = require 'util.debug';\n\n\t"
|
||||
.."return function (event)\n\t\t"
|
||||
.."local stanza, session = event.stanza, event.origin;\n"
|
||||
..table.concat(code, "")
|
||||
.."\n\tend;\nend";
|
||||
|
||||
chain_handlers[chain_name] = code_string;
|
||||
end
|
||||
|
||||
return chain_handlers;
|
||||
end
|
||||
|
||||
local function compile_firewall_rules(filename)
|
||||
local ruleset, err = parse_firewall_rules(filename);
|
||||
if not ruleset then return nil, err; end
|
||||
local chain_handlers = process_firewall_rules(ruleset);
|
||||
return chain_handlers;
|
||||
end
|
||||
|
||||
-- Compile handler code into a factory that produces a valid event handler. Factory accepts
|
||||
-- a value to be returned on PASS
|
||||
local function compile_handler(code_string, filename)
|
||||
-- Prepare event handler function
|
||||
local chunk, err = envload(code_string, "="..filename, _G);
|
||||
if not chunk then
|
||||
return nil, "Error compiling (probably a compiler bug, please report): "..err;
|
||||
end
|
||||
local function fire_event(name, data)
|
||||
return module:fire_event(name, data);
|
||||
end
|
||||
local init_ok, initialized_chunk = pcall(chunk);
|
||||
if not init_ok then
|
||||
return nil, "Error initializing compiled rules: "..initialized_chunk;
|
||||
end
|
||||
return function (pass_return)
|
||||
return initialized_chunk(active_definitions, fire_event, logger(filename), module, pass_return); -- Returns event handler with upvalues
|
||||
end
|
||||
end
|
||||
|
||||
local function resolve_script_path(script_path)
|
||||
local relative_to = prosody.paths.config;
|
||||
if script_path:match("^module:") then
|
||||
relative_to = module.path:sub(1, -#("/mod_"..module.name..".lua"));
|
||||
script_path = script_path:match("^module:(.+)$");
|
||||
end
|
||||
return resolve_relative_path(relative_to, script_path);
|
||||
end
|
||||
|
||||
-- [filename] = { last_modified = ..., events_hooked = { [name] = handler } }
|
||||
local loaded_scripts = {};
|
||||
|
||||
function load_script(script)
|
||||
script = resolve_script_path(script);
|
||||
local last_modified = (lfs.attributes(script) or {}).modification or os.time();
|
||||
if loaded_scripts[script] then
|
||||
if loaded_scripts[script].last_modified == last_modified then
|
||||
return; -- Already loaded, and source file hasn't changed
|
||||
end
|
||||
module:log("debug", "Reloading %s", script);
|
||||
-- Already loaded, but the source file has changed
|
||||
-- unload it now, and we'll load the new version below
|
||||
unload_script(script, true);
|
||||
end
|
||||
local chain_functions, err = compile_firewall_rules(script);
|
||||
|
||||
if not chain_functions then
|
||||
module:log("error", "Error compiling %s: %s", script, err or "unknown error");
|
||||
return;
|
||||
end
|
||||
|
||||
-- Loop through the chains in the script, and for each chain attach the compiled code to the
|
||||
-- relevant events, keeping track in events_hooked so we can cleanly unload later
|
||||
local events_hooked = {};
|
||||
for chain, handler_code in pairs(chain_functions) do
|
||||
local new_handler, err = compile_handler(handler_code, "mod_firewall::"..chain);
|
||||
if not new_handler then
|
||||
module:log("error", "Compilation error for %s: %s", script, err);
|
||||
else
|
||||
local chain_definition = chains[chain];
|
||||
if chain_definition and chain_definition.type == "event" then
|
||||
local handler = new_handler(chain_definition.pass_return);
|
||||
for _, event_name in ipairs(chain_definition) do
|
||||
events_hooked[event_name] = handler;
|
||||
module:hook(event_name, handler, chain_definition.priority);
|
||||
end
|
||||
elseif not chain:sub(1, 5) == "user/" then
|
||||
module:log("warn", "Unknown chain %q", chain);
|
||||
end
|
||||
local event_name, handler = "firewall/chains/"..chain, new_handler(false);
|
||||
events_hooked[event_name] = handler;
|
||||
module:hook(event_name, handler);
|
||||
end
|
||||
end
|
||||
loaded_scripts[script] = { last_modified = last_modified, events_hooked = events_hooked };
|
||||
module:log("debug", "Loaded %s", script);
|
||||
end
|
||||
|
||||
--COMPAT w/0.9 (no module:unhook()!)
|
||||
local function module_unhook(event, handler)
|
||||
return module:unhook_object_event((hosts[module.host] or prosody).events, event, handler);
|
||||
end
|
||||
|
||||
function unload_script(script, is_reload)
|
||||
script = resolve_script_path(script);
|
||||
local script_info = loaded_scripts[script];
|
||||
if not script_info then
|
||||
return; -- Script not loaded
|
||||
end
|
||||
local events_hooked = script_info.events_hooked;
|
||||
for event_name, event_handler in pairs(events_hooked) do
|
||||
module_unhook(event_name, event_handler);
|
||||
events_hooked[event_name] = nil;
|
||||
end
|
||||
loaded_scripts[script] = nil;
|
||||
if not is_reload then
|
||||
module:log("debug", "Unloaded %s", script);
|
||||
end
|
||||
end
|
||||
|
||||
-- Given a set of scripts (e.g. from config) figure out which ones need to
|
||||
-- be loaded, which are already loaded but need unloading, and which to reload
|
||||
function load_unload_scripts(script_list)
|
||||
local wanted_scripts = script_list / resolve_script_path;
|
||||
local currently_loaded = set.new(it.to_array(it.keys(loaded_scripts)));
|
||||
local scripts_to_unload = currently_loaded - wanted_scripts;
|
||||
for script in wanted_scripts do
|
||||
-- If the script is already loaded, this is fine - it will
|
||||
-- reload the script for us if the file has changed
|
||||
load_script(script);
|
||||
end
|
||||
for script in scripts_to_unload do
|
||||
unload_script(script);
|
||||
end
|
||||
end
|
||||
|
||||
function module.load()
|
||||
if not prosody.arg then return end -- Don't run in prosodyctl
|
||||
local firewall_scripts = module:get_option_set("firewall_scripts", {});
|
||||
load_unload_scripts(firewall_scripts);
|
||||
-- Replace contents of definitions table (shared) with active definitions
|
||||
for k in it.keys(definitions) do definitions[k] = nil; end
|
||||
for k,v in pairs(active_definitions) do definitions[k] = v; end
|
||||
end
|
||||
|
||||
function module.save()
|
||||
return { active_definitions = active_definitions, loaded_scripts = loaded_scripts };
|
||||
end
|
||||
|
||||
function module.restore(state)
|
||||
active_definitions = state.active_definitions;
|
||||
loaded_scripts = state.loaded_scripts;
|
||||
end
|
||||
|
||||
module:hook_global("config-reloaded", function ()
|
||||
load_unload_scripts(module:get_option_set("firewall_scripts", {}));
|
||||
end);
|
||||
|
||||
function module.command(arg)
|
||||
if not arg[1] or arg[1] == "--help" then
|
||||
require"util.prosodyctl".show_usage([[mod_firewall <firewall.pfw>]], [[Compile files with firewall rules to Lua code]]);
|
||||
return 1;
|
||||
end
|
||||
local verbose = arg[1] == "-v";
|
||||
if verbose then table.remove(arg, 1); end
|
||||
|
||||
if arg[1] == "test" then
|
||||
table.remove(arg, 1);
|
||||
return module:require("test")(arg);
|
||||
end
|
||||
|
||||
local serialize = require "util.serialization".serialize;
|
||||
if verbose then
|
||||
print("local logger = require \"util.logger\".init;");
|
||||
print();
|
||||
print("local function fire_event(name, data)\n\tmodule:fire_event(name, data)\nend");
|
||||
print();
|
||||
end
|
||||
|
||||
for _, filename in ipairs(arg) do
|
||||
filename = resolve_script_path(filename);
|
||||
print("do -- File "..filename);
|
||||
local chain_functions = assert(compile_firewall_rules(filename));
|
||||
if verbose then
|
||||
print();
|
||||
print("local active_definitions = "..serialize(active_definitions)..";");
|
||||
print();
|
||||
end
|
||||
local c = 0;
|
||||
for chain, handler_code in pairs(chain_functions) do
|
||||
c = c + 1;
|
||||
print("---- Chain "..chain:gsub("_", " "));
|
||||
local chain_func_name = "chain_"..tostring(c).."_"..chain:gsub("%p", "_");
|
||||
if not verbose then
|
||||
print(("%s = %s;"):format(chain_func_name, handler_code:sub(8)));
|
||||
else
|
||||
|
||||
print(("local %s = (%s)(active_definitions, fire_event, logger(%q));"):format(chain_func_name, handler_code:sub(8), filename));
|
||||
print();
|
||||
|
||||
local chain_definition = chains[chain];
|
||||
if chain_definition and chain_definition.type == "event" then
|
||||
for _, event_name in ipairs(chain_definition) do
|
||||
print(("module:hook(%q, %s, %d);"):format(event_name, chain_func_name, chain_definition.priority or 0));
|
||||
end
|
||||
end
|
||||
print(("module:hook(%q, %s, %d);"):format("firewall/chains/"..chain, chain_func_name, chain_definition.priority or 0));
|
||||
end
|
||||
|
||||
print("---- End of chain "..chain);
|
||||
print();
|
||||
end
|
||||
print("end -- End of file "..filename);
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Console
|
||||
|
||||
local console_env = module:shared("/*/admin_shell/env");
|
||||
|
||||
console_env.firewall = {};
|
||||
|
||||
function console_env.firewall:mark(user_jid, mark_name)
|
||||
local username, host = jid.split(user_jid);
|
||||
if not username or not hosts[host] then
|
||||
return nil, "Invalid JID supplied";
|
||||
elseif not idsafe(mark_name) then
|
||||
return nil, "Invalid characters in mark name";
|
||||
end
|
||||
if not module:context(host):fire_event("firewall/marked/user", {
|
||||
username = session.username;
|
||||
mark = mark_name;
|
||||
timestamp = os.time();
|
||||
}) then
|
||||
return nil, "Mark not set - is mod_firewall loaded on that host?";
|
||||
end
|
||||
return true, "User marked";
|
||||
end
|
||||
|
||||
function console_env.firewall:unmark(jid, mark_name)
|
||||
local username, host = jid.split(user_jid);
|
||||
if not username or not hosts[host] then
|
||||
return nil, "Invalid JID supplied";
|
||||
elseif not idsafe(mark_name) then
|
||||
return nil, "Invalid characters in mark name";
|
||||
end
|
||||
if not module:context(host):fire_event("firewall/unmarked/user", {
|
||||
username = session.username;
|
||||
mark = mark_name;
|
||||
}) then
|
||||
return nil, "Mark not removed - is mod_firewall loaded on that host?";
|
||||
end
|
||||
return true, "User unmarked";
|
||||
end
|
||||
75
resources/prosody-plugins/mod_firewall/test.lib.lua
Normal file
75
resources/prosody-plugins/mod_firewall/test.lib.lua
Normal file
@@ -0,0 +1,75 @@
|
||||
-- luacheck: globals load_unload_scripts
|
||||
local set = require "util.set";
|
||||
local ltn12 = require "ltn12";
|
||||
|
||||
local xmppstream = require "util.xmppstream";
|
||||
|
||||
local function stderr(...)
|
||||
io.stderr:write("** ", table.concat({...}, "\t", 1, select("#", ...)), "\n");
|
||||
end
|
||||
|
||||
return function (arg)
|
||||
require "net.http".request = function (url, ex, cb)
|
||||
stderr("Making HTTP request to "..url);
|
||||
local body_table = {};
|
||||
local ok, response_status, response_headers = require "ssl.https".request({
|
||||
url = url;
|
||||
headers = ex.headers;
|
||||
method = ex.body and "POST" or "GET";
|
||||
sink = ltn12.sink.table(body_table);
|
||||
source = ex.body and ltn12.source.string(ex.body) or nil;
|
||||
});
|
||||
stderr("HTTP response "..response_status);
|
||||
cb(table.concat(body_table), response_status, { headers = response_headers });
|
||||
return true;
|
||||
end;
|
||||
|
||||
local stats_dropped, stats_passed = 0, 0;
|
||||
|
||||
load_unload_scripts(set.new(arg));
|
||||
local stream_callbacks = { default_ns = "jabber:client" };
|
||||
|
||||
function stream_callbacks.streamopened(session)
|
||||
session.notopen = nil;
|
||||
end
|
||||
function stream_callbacks.streamclosed()
|
||||
end
|
||||
function stream_callbacks.error(session, error_name, error_message) -- luacheck: ignore 212/session
|
||||
stderr("Fatal error parsing XML stream: "..error_name..": "..tostring(error_message))
|
||||
assert(false);
|
||||
end
|
||||
function stream_callbacks.handlestanza(session, stanza)
|
||||
if not module:fire_event("firewall/chains/deliver", { origin = session, stanza = stanza }) then
|
||||
stats_passed = stats_passed + 1;
|
||||
print(stanza);
|
||||
print("");
|
||||
else
|
||||
stats_dropped = stats_dropped + 1;
|
||||
end
|
||||
end
|
||||
|
||||
local session = { notopen = true };
|
||||
function session.send(stanza)
|
||||
stderr("Reply:", "\n"..tostring(stanza).."\n");
|
||||
end
|
||||
local stream = xmppstream.new(session, stream_callbacks);
|
||||
stream:feed("<stream:stream xmlns:stream='http://etherx.jabber.org/streams' xmlns='jabber:client'>");
|
||||
local line_count = 0;
|
||||
for line in io.lines() do
|
||||
line_count = line_count + 1;
|
||||
local ok, err = stream:feed(line.."\n");
|
||||
if not ok then
|
||||
stderr("Fatal XML parse error on line "..line_count..": "..err);
|
||||
return 1;
|
||||
end
|
||||
end
|
||||
|
||||
stderr("Summary");
|
||||
stderr("-------");
|
||||
stderr("");
|
||||
stderr(stats_dropped + stats_passed, "processed");
|
||||
stderr(stats_passed, "passed");
|
||||
stderr(stats_dropped, "dropped");
|
||||
stderr(line_count, "input lines");
|
||||
stderr("");
|
||||
end
|
||||
989
resources/prosody-plugins/mod_fmuc.lua
Normal file
989
resources/prosody-plugins/mod_fmuc.lua
Normal file
@@ -0,0 +1,989 @@
|
||||
--- activate under main muc component
|
||||
--- Add the following config under the main muc component
|
||||
--- muc_room_default_presence_broadcast = {
|
||||
--- visitor = false;
|
||||
--- participant = true;
|
||||
--- moderator = true;
|
||||
--- };
|
||||
--- Enable in global modules: 's2s_bidi'
|
||||
--- Make sure 's2s' is not in modules_disabled
|
||||
--- NOTE: Make sure all communication between prosodies is using the real jids ([foo]room1@muc.example.com), as there
|
||||
--- are certain configs for whitelisted domains and connections that are domain based
|
||||
--- TODO: filter presence from main occupants back to main prosody
|
||||
local jid = require 'util.jid';
|
||||
local st = require 'util.stanza';
|
||||
local new_id = require 'util.id'.medium;
|
||||
local filters = require 'util.filters';
|
||||
local array = require 'util.array';
|
||||
local set = require 'util.set';
|
||||
local json = require 'cjson.safe';
|
||||
local datetime = require 'util.datetime';
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
local ends_with = util.ends_with;
|
||||
local is_vpaas = util.is_vpaas;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local get_focus_occupant = util.get_focus_occupant;
|
||||
local is_focus_jid = util.is_focus_jid;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local presence_check_status = util.presence_check_status;
|
||||
local respond_iq_result = util.respond_iq_result;
|
||||
local table_compare = util.table_compare;
|
||||
|
||||
local PARTICIPANT_PROP_RAISE_HAND = 'jitsi_participant_raisedHand';
|
||||
local PARTICIPANT_PROP_REQUEST_TRANSCRIPTION = 'jitsi_participant_requestingTranscription';
|
||||
local PARTICIPANT_PROP_TRANSLATION_LANG = 'jitsi_participant_translation_language';
|
||||
local TRANSCRIPT_DEFAULT_LANG = module:get_option_string('transcriptions_default_language', 'en');
|
||||
|
||||
-- this is the main virtual host of this vnode
|
||||
local local_domain = module:get_option_string('muc_mapper_domain_base');
|
||||
if not local_domain then
|
||||
module:log('warn', "No 'muc_mapper_domain_base' option set, disabling fmuc plugin");
|
||||
return;
|
||||
end
|
||||
|
||||
-- this is the main virtual host of the main prosody that this vnode serves
|
||||
local main_domain = module:get_option_string('main_domain');
|
||||
if not main_domain then
|
||||
module:log('warn', "No 'main_domain' option set, disabling fmuc plugin");
|
||||
return;
|
||||
end
|
||||
|
||||
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
|
||||
local local_muc_domain = muc_domain_prefix..'.'..local_domain;
|
||||
|
||||
local NICK_NS = 'http://jabber.org/protocol/nick';
|
||||
local DISPLAY_NAME_NS = 'http://jitsi.org/protocol/display-name';
|
||||
|
||||
-- in certain cases we consider participants with token as moderators, this is the default behavior which can be turned off
|
||||
local auto_promoted_with_token = module:get_option_boolean('visitors_auto_promoted_with_token', true);
|
||||
|
||||
-- we send stats for the total number of rooms, total number of participants and total number of visitors
|
||||
local measure_rooms = module:measure('vnode-rooms', 'amount');
|
||||
local measure_participants = module:measure('vnode-participants', 'amount');
|
||||
local measure_visitors = module:measure('vnode-visitors', 'amount');
|
||||
|
||||
local sent_iq_cache = require 'util.cache'.new(200);
|
||||
|
||||
local sessions = prosody.full_sessions;
|
||||
|
||||
local function send_transcriptions_update(room)
|
||||
-- let's notify main prosody
|
||||
local lang_array = array();
|
||||
local count = 0;
|
||||
|
||||
for k, v in pairs(room._transcription_languages) do
|
||||
lang_array:push(v);
|
||||
count = count + 1;
|
||||
end
|
||||
|
||||
local iq_id = new_id();
|
||||
sent_iq_cache:set(iq_id, socket.gettime());
|
||||
module:send(st.iq({
|
||||
type = 'set',
|
||||
to = 'visitors.'..main_domain,
|
||||
from = local_domain,
|
||||
id = iq_id })
|
||||
:tag('visitors', { xmlns = 'jitsi:visitors',
|
||||
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
|
||||
:tag('transcription-languages', {
|
||||
xmlns = 'jitsi:visitors',
|
||||
langs = lang_array:unique():sort():concat(','),
|
||||
count = tostring(count)
|
||||
}):up());
|
||||
end
|
||||
|
||||
-- Forwards this vnode's local 'recording' metadata (isTranscribingEnabled, set by a visitor allowed via
|
||||
-- the transcription JWT feature -- see mod_filter_iq_rayo's 'jitsi-metadata-allow-moderation' hook) up to
|
||||
-- the main room, the same way send_transcriptions_update() forwards transcription-languages: as a
|
||||
-- 'visitors' IQ to the main prosody's visitors component, which applies it to the real room metadata.
|
||||
local function send_recording_metadata_update(room)
|
||||
local recording_metadata = room.jitsiMetadata and room.jitsiMetadata.recording;
|
||||
if not recording_metadata then
|
||||
return;
|
||||
end
|
||||
|
||||
local json_data, encode_error = json.encode(recording_metadata);
|
||||
if not json_data then
|
||||
module:log('error', 'Error encoding recording metadata for room:%s error:%s', room.jid, encode_error);
|
||||
return;
|
||||
end
|
||||
|
||||
local iq_id = new_id();
|
||||
sent_iq_cache:set(iq_id, socket.gettime());
|
||||
module:send(st.iq({
|
||||
type = 'set',
|
||||
to = 'visitors.'..main_domain,
|
||||
from = local_domain,
|
||||
id = iq_id })
|
||||
:tag('visitors', { xmlns = 'jitsi:visitors',
|
||||
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
|
||||
:tag('recording-metadata', { xmlns = 'jitsi:visitors' }):text(json_data):up());
|
||||
end
|
||||
|
||||
local function remove_transcription(room, occupant)
|
||||
local send_update = false;
|
||||
if room._transcription_languages then
|
||||
if room._transcription_languages[occupant.jid] then
|
||||
send_update = true;
|
||||
end
|
||||
room._transcription_languages[occupant.jid] = nil;
|
||||
end
|
||||
|
||||
if send_update then
|
||||
send_transcriptions_update(room);
|
||||
end
|
||||
end
|
||||
|
||||
-- if lang is nil we will remove it from the list
|
||||
local function add_transcription(room, occupant, lang)
|
||||
if not room._transcription_languages then
|
||||
room._transcription_languages = {};
|
||||
end
|
||||
|
||||
local old = room._transcription_languages[occupant.jid];
|
||||
room._transcription_languages[occupant.jid] = lang or TRANSCRIPT_DEFAULT_LANG;
|
||||
|
||||
if old ~= room._transcription_languages[occupant.jid] then
|
||||
send_transcriptions_update(room);
|
||||
end
|
||||
end
|
||||
|
||||
-- mark all occupants as visitors
|
||||
module:hook('muc-occupant-pre-join', function (event)
|
||||
local occupant, room, origin, stanza = event.occupant, event.room, event.origin, event.stanza;
|
||||
local node, host = jid.split(occupant.bare_jid);
|
||||
local resource = jid.resource(occupant.nick);
|
||||
|
||||
if is_admin(occupant.bare_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
if prosody.hosts[host] then
|
||||
-- local participants which host is defined in this prosody
|
||||
if room._main_room_lobby_enabled then
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Visitors not allowed while lobby is on!')
|
||||
:tag('no-visitors-lobby', { xmlns = 'jitsi:visitors' }));
|
||||
return true;
|
||||
else
|
||||
occupant.role = 'visitor';
|
||||
end
|
||||
elseif room.moderators_list and room.moderators_list:contains(resource) then
|
||||
-- remote participants, host is the main prosody
|
||||
occupant.role = 'moderator';
|
||||
end
|
||||
end, 3);
|
||||
|
||||
-- if a visitor leaves we want to lower its hand if it was still raised before leaving
|
||||
-- this is to clear indication for promotion on moderators visitors list
|
||||
module:hook('muc-occupant-pre-leave', function (event)
|
||||
local occupant = event.occupant;
|
||||
|
||||
---- we are interested only of visitors presence
|
||||
if occupant.role ~= 'visitor' then
|
||||
return;
|
||||
end
|
||||
|
||||
local room = event.room;
|
||||
|
||||
-- let's check if the visitor has a raised hand send a lower hand
|
||||
-- to main prosody
|
||||
local pr = occupant:get_presence();
|
||||
|
||||
local raiseHand = pr:get_child_text(PARTICIPANT_PROP_RAISE_HAND);
|
||||
|
||||
-- a promotion detected let's send it to main prosody
|
||||
if raiseHand and #raiseHand > 0 then
|
||||
local iq_id = new_id();
|
||||
sent_iq_cache:set(iq_id, socket.gettime());
|
||||
local promotion_request = st.iq({
|
||||
type = 'set',
|
||||
to = 'visitors.'..main_domain,
|
||||
from = local_domain,
|
||||
id = iq_id })
|
||||
:tag('visitors', { xmlns = 'jitsi:visitors',
|
||||
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
|
||||
:tag('promotion-request', {
|
||||
xmlns = 'jitsi:visitors',
|
||||
jid = occupant.jid,
|
||||
time = nil;
|
||||
}):up();
|
||||
|
||||
module:send(promotion_request);
|
||||
end
|
||||
|
||||
remove_transcription(room, occupant);
|
||||
end, 1); -- rate limit is 0
|
||||
|
||||
-- Returns the main participants count and the visitors count
|
||||
local function get_occupant_counts(room)
|
||||
local main_count = 0;
|
||||
local visitors_count = 0;
|
||||
|
||||
for _, o in room:each_occupant() do
|
||||
if o.role == 'visitor' then
|
||||
visitors_count = visitors_count + 1;
|
||||
elseif not is_admin(o.bare_jid) then
|
||||
main_count = main_count + 1;
|
||||
end
|
||||
end
|
||||
|
||||
return main_count, visitors_count;
|
||||
end
|
||||
|
||||
local function cancel_destroy_timer(room)
|
||||
if room.visitors_destroy_timer then
|
||||
room.visitors_destroy_timer:stop();
|
||||
room.visitors_destroy_timer = nil;
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy_with_conference_ended(room)
|
||||
-- if the room is being destroyed, ignore
|
||||
if room.destroying then
|
||||
return;
|
||||
end
|
||||
|
||||
cancel_destroy_timer(room);
|
||||
|
||||
local main_count, visitors_count = get_occupant_counts(room);
|
||||
module:log('info', 'Will destroy:%s main_occupants:%s visitors:%s', room.jid, main_count, visitors_count);
|
||||
room:destroy(nil, 'Conference ended.');
|
||||
return true;
|
||||
end
|
||||
|
||||
-- schedules a new destroy timer which will destroy the room if there are no visitors after the timeout
|
||||
local function schedule_destroy_timer(room)
|
||||
cancel_destroy_timer(room);
|
||||
|
||||
room.visitors_destroy_timer = module:add_timer(15, function()
|
||||
-- if the room is being destroyed, ignore
|
||||
if room.destroying then
|
||||
return;
|
||||
end
|
||||
|
||||
local main_count, visitors_count = get_occupant_counts(room);
|
||||
|
||||
if visitors_count == 0 then
|
||||
module:log('info', 'Will destroy:%s main_occupants:%s visitors:%s', room.jid, main_count, visitors_count);
|
||||
room:destroy(nil, 'No visitors.');
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
-- when occupant is leaving forward presences to jicofo for visitors
|
||||
-- do not check occupant.role as it maybe already reset
|
||||
-- if there are no main occupants or no visitors, destroy the room (give 15 seconds of grace period for reconnections)
|
||||
module:hook('muc-occupant-left', function (event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
local occupant_domain = jid.host(occupant.bare_jid);
|
||||
|
||||
if prosody.hosts[occupant_domain] and not is_admin(occupant.bare_jid) then
|
||||
local focus_occupant = get_focus_occupant(room);
|
||||
if not focus_occupant then
|
||||
if not room.destroying then
|
||||
module:log('warn', 'No focus found for %s', room.jid);
|
||||
end
|
||||
return;
|
||||
end
|
||||
-- Let's forward unavailable presence to the special jicofo
|
||||
room:route_stanza(st.presence({
|
||||
to = focus_occupant.jid,
|
||||
from = internal_room_jid_match_rewrite(occupant.nick),
|
||||
type = 'unavailable' })
|
||||
:tag('x', { xmlns = 'http://jabber.org/protocol/muc#user' })
|
||||
:tag('item', {
|
||||
affiliation = room:get_affiliation(occupant.bare_jid) or 'none';
|
||||
role = 'none';
|
||||
nick = event.nick;
|
||||
jid = occupant.bare_jid }):up():up());
|
||||
end
|
||||
|
||||
-- if the room is being destroyed, ignore
|
||||
if room.destroying then
|
||||
return;
|
||||
end
|
||||
|
||||
-- if there are no main participants, the main room will be destroyed and
|
||||
-- we can destroy and the visitor one as when jicofo leaves all visitors will reload
|
||||
-- if there are no visitors give them 15 secs to reconnect, if not destroy it
|
||||
local main_count, visitors_count = get_occupant_counts(room);
|
||||
|
||||
if visitors_count == 0 then
|
||||
schedule_destroy_timer(room);
|
||||
end
|
||||
|
||||
if main_count == 0 then
|
||||
destroy_with_conference_ended(room);
|
||||
end
|
||||
end);
|
||||
|
||||
-- forward visitor presences to jicofo
|
||||
-- detects raise hand in visitors presence, this is request for promotion
|
||||
-- detects the requested transcription and its language to send updates for it
|
||||
module:hook('muc-broadcast-presence', function (event)
|
||||
local occupant = event.occupant;
|
||||
|
||||
---- we are interested only of visitors presence to send it to jicofo
|
||||
if occupant.role ~= 'visitor' then
|
||||
return;
|
||||
end
|
||||
|
||||
local room = event.room;
|
||||
local focus_occupant = get_focus_occupant(room);
|
||||
|
||||
if not focus_occupant then
|
||||
return;
|
||||
end
|
||||
|
||||
local actor, base_presence, nick, reason, x = event.actor, event.stanza, event.nick, event.reason, event.x;
|
||||
local actor_nick;
|
||||
if actor then
|
||||
actor_nick = jid.resource(room:get_occupant_jid(actor));
|
||||
end
|
||||
|
||||
-- create a presence to send it to jicofo, as jicofo is special :)
|
||||
local full_x = st.clone(x.full or x);
|
||||
|
||||
room:build_item_list(occupant, full_x, false, nick, actor_nick, actor, reason);
|
||||
local full_p = st.clone(base_presence):add_child(full_x);
|
||||
full_p.attr.to = focus_occupant.jid;
|
||||
room:route_to_occupant(focus_occupant, full_p);
|
||||
|
||||
local raiseHand = full_p:get_child_text(PARTICIPANT_PROP_RAISE_HAND);
|
||||
-- a promotion detected let's send it to main prosody
|
||||
if raiseHand then
|
||||
local user_id;
|
||||
local group_id;
|
||||
local is_moderator;
|
||||
local session = sessions[occupant.jid];
|
||||
local identity = session and session.jitsi_meet_context_user;
|
||||
|
||||
if is_vpaas(room) and identity then
|
||||
-- in case of moderator in vpaas meeting we want to do auto-promotion
|
||||
local is_vpaas_moderator = identity.moderator;
|
||||
if is_vpaas_moderator == 'true' or is_vpaas_moderator == true then
|
||||
is_moderator = true;
|
||||
end
|
||||
else
|
||||
-- The case with single moderator in the room, we want to report our id
|
||||
-- so we can be auto promoted
|
||||
if identity and identity.id then
|
||||
user_id = session.jitsi_meet_context_user.id;
|
||||
group_id = session.jitsi_meet_context_group;
|
||||
|
||||
if session.auth_token and auto_promoted_with_token then
|
||||
if not session.jitsi_meet_tenant_mismatch or session.jitsi_web_query_prefix == '' then
|
||||
-- non-vpaas and having a token is considered a moderator, and if it is not in '/' tenant
|
||||
-- the tenant from url and token should match
|
||||
is_moderator = true;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local iq_id = new_id();
|
||||
sent_iq_cache:set(iq_id, socket.gettime());
|
||||
local promotion_request = st.iq({
|
||||
type = 'set',
|
||||
to = 'visitors.'..main_domain,
|
||||
from = local_domain,
|
||||
id = iq_id })
|
||||
:tag('visitors', { xmlns = 'jitsi:visitors',
|
||||
room = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain) })
|
||||
:tag('promotion-request', {
|
||||
xmlns = 'jitsi:visitors',
|
||||
jid = occupant.jid,
|
||||
time = raiseHand,
|
||||
userId = user_id,
|
||||
groupId = group_id,
|
||||
forcePromote = is_moderator and 'true' or 'false';
|
||||
}):up();
|
||||
|
||||
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
|
||||
if nick_element then
|
||||
promotion_request:add_child(nick_element);
|
||||
end
|
||||
|
||||
module:send(promotion_request);
|
||||
end
|
||||
|
||||
local requestTranscriptionValue = full_p:get_child_text(PARTICIPANT_PROP_REQUEST_TRANSCRIPTION);
|
||||
local hasTranscriptionEnabled = room._transcription_languages and room._transcription_languages[occupant.jid];
|
||||
|
||||
-- detect transcription
|
||||
if requestTranscriptionValue == 'true' then
|
||||
local lang = full_p:get_child_text(PARTICIPANT_PROP_TRANSLATION_LANG);
|
||||
|
||||
add_transcription(room, occupant, lang);
|
||||
elseif hasTranscriptionEnabled then
|
||||
remove_transcription(room, occupant, nil);
|
||||
end
|
||||
|
||||
return;
|
||||
end);
|
||||
|
||||
-- listens for responses to the iq sent for requesting promotion and forward it to the visitor
|
||||
local function stanza_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
if stanza.name ~= 'iq' then
|
||||
return;
|
||||
end
|
||||
|
||||
if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then
|
||||
sent_iq_cache:set(stanza.attr.id, nil);
|
||||
return true;
|
||||
end
|
||||
|
||||
if stanza.attr.type ~= 'set' then
|
||||
return;
|
||||
end
|
||||
|
||||
local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors');
|
||||
if not visitors_iq then
|
||||
return;
|
||||
end
|
||||
|
||||
if stanza.attr.from ~= 'visitors.'..main_domain then
|
||||
module:log('warn', 'not from visitors component, ignore! %s', stanza);
|
||||
return true;
|
||||
end
|
||||
|
||||
local room_jid = visitors_iq.attr.room;
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(room_jid));
|
||||
|
||||
if not room then
|
||||
return;
|
||||
end
|
||||
|
||||
local request_promotion = visitors_iq:get_child('promotion-response');
|
||||
if not request_promotion then
|
||||
return;
|
||||
end
|
||||
|
||||
-- respond with successful receiving the iq
|
||||
respond_iq_result(origin, stanza);
|
||||
|
||||
local req_jid = request_promotion.attr.jid;
|
||||
-- now let's find the occupant and forward the response
|
||||
local occupant = room:get_occupant_by_real_jid(req_jid);
|
||||
|
||||
if occupant then
|
||||
stanza.attr.to = occupant.jid;
|
||||
stanza.attr.from = room.jid;
|
||||
room:route_to_occupant(occupant, stanza);
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
--process a host module directly if loaded or hooks to wait for its load
|
||||
function process_host_module(name, callback)
|
||||
local function process_host(host)
|
||||
if host == name then
|
||||
callback(module:context(host), host);
|
||||
end
|
||||
end
|
||||
|
||||
if prosody.hosts[name] == nil then
|
||||
module:log('debug', 'No host/component found, will wait for it: %s', name)
|
||||
|
||||
-- when a host or component is added
|
||||
prosody.events.add_handler('host-activated', process_host);
|
||||
else
|
||||
process_host(name);
|
||||
end
|
||||
end
|
||||
-- if the message received ends with the main domain, these are system messages
|
||||
-- for visitors, let's correct the room name there
|
||||
local function message_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
if ends_with(stanza.attr.from, main_domain) then
|
||||
stanza.attr.from = stanza.attr.from:sub(1, -(main_domain:len() + 1))..local_domain;
|
||||
end
|
||||
end
|
||||
|
||||
-- Adds a stanza this node has just routed to its own occupants to the local
|
||||
-- history. Prosody writes room history from broadcast_message, and routing
|
||||
-- straight to the occupants bypasses it, so without this the history stays as the
|
||||
-- main prosody left it when this node connected and a visitor joining later sees
|
||||
-- nothing that was said since. Fire prosody's own events, so the entry is built,
|
||||
-- stamped and trimmed exactly as it is in the main room and the modules hooked on
|
||||
-- them apply here too.
|
||||
local function add_to_local_history(room, stanza)
|
||||
local event = { room = room; stanza = stanza; };
|
||||
|
||||
if not module:fire_event('muc-message-is-historic', event) then
|
||||
module:log('debug', 'Not storing %s in %s history', stanza.attr.id, room.jid);
|
||||
|
||||
return;
|
||||
end
|
||||
|
||||
module:fire_event('muc-add-history', event);
|
||||
module:log('debug', 'Stored %s in %s history, %s entries now',
|
||||
stanza.attr.id, room.jid, room._history and #room._history or 0);
|
||||
end
|
||||
|
||||
-- Receives history messages from the main prosody and adds them to the local history
|
||||
-- this happens before the first participant joins and that participant gets the history using the standard flow on join
|
||||
local function history_message_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
local delay_elem = stanza:get_child('delay', 'urn:xmpp:delay');
|
||||
if not delay_elem then
|
||||
return;
|
||||
end
|
||||
|
||||
-- now let's add it history, we do not use the event as we want to keep
|
||||
-- the delay element
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(
|
||||
jid.bare(stanza.attr.from):sub(1, -(main_domain:len() + 1))..local_domain));
|
||||
|
||||
if not room then
|
||||
return;
|
||||
end
|
||||
|
||||
if room:get_historylength() == 0 then
|
||||
return;
|
||||
end
|
||||
|
||||
local history = room._history;
|
||||
if not history then history = {}; room._history = history; end
|
||||
|
||||
local history_stanza = st.clone(stanza);
|
||||
history_stanza.attr.to = '';
|
||||
|
||||
local node, host, resource = jid.split(room_jid_match_rewrite(history_stanza.attr.from));
|
||||
history_stanza.attr.from = jid.join(node, local_muc_domain, resource);
|
||||
-- the from in the delay extension is still the main prosody jid, not used for now
|
||||
local entry = { stanza = history_stanza, timestamp = datetime.parse(delay_elem.attr.stamp) };
|
||||
table.insert(history, entry);
|
||||
while #history > room:get_historylength() do table.remove(history, 1) end
|
||||
|
||||
return false;
|
||||
end
|
||||
module:hook('message/host', history_message_handler);
|
||||
|
||||
process_host_module(local_domain, function(host_module, host)
|
||||
host_module:hook('iq/host', stanza_handler, 10);
|
||||
host_module:hook('message/full', message_handler);
|
||||
end);
|
||||
|
||||
-- add to message stanza display name for the visitor
|
||||
-- remove existing nick to avoid forgery
|
||||
local function process_display_name(stanza, occupant)
|
||||
-- TODO drop nick extension used in messages in favor of new display-name extension
|
||||
-- we want mobile client to update before dropping support for nick from messages
|
||||
stanza:remove_children('nick', NICK_NS);
|
||||
stanza:remove_children('display-name', DISPLAY_NAME_NS);
|
||||
local nick_element = occupant:get_presence():get_child('nick', NICK_NS);
|
||||
if nick_element then
|
||||
stanza:add_child(nick_element);
|
||||
stanza:tag('display-name', {
|
||||
xmlns = DISPLAY_NAME_NS,
|
||||
source = 'visitor'
|
||||
}):text(nick_element:get_text()):up();
|
||||
else
|
||||
stanza:tag('nick', { xmlns = NICK_NS })
|
||||
:text('anonymous'):up();
|
||||
stanza:tag('display-name', {
|
||||
xmlns = DISPLAY_NAME_NS,
|
||||
source = 'visitor'
|
||||
}):text('anonymous'):up();
|
||||
end
|
||||
end
|
||||
|
||||
-- only live chat is supported for visitors
|
||||
module:hook('muc-occupant-groupchat', function(event)
|
||||
local occupant, room, stanza = event.occupant, event.room, event.stanza;
|
||||
local from = stanza.attr.from;
|
||||
local occupant_host;
|
||||
|
||||
-- if there is no occupant this is a message from main, probably coming from other vnode
|
||||
if occupant then
|
||||
occupant_host = jid.host(occupant.bare_jid);
|
||||
|
||||
-- we manage nick only for visitors
|
||||
if occupant_host ~= main_domain then
|
||||
process_display_name(stanza, occupant);
|
||||
end
|
||||
|
||||
stanza.attr.from = occupant.nick;
|
||||
else
|
||||
stanza.attr.from = jid.join(jid.node(from), module.host);
|
||||
end
|
||||
|
||||
-- let's send it to main chat and rest of visitors here
|
||||
for _, o in room:each_occupant() do
|
||||
-- filter remote occupants
|
||||
if jid.host(o.bare_jid) == local_domain then
|
||||
room:route_to_occupant(o, stanza)
|
||||
end
|
||||
end
|
||||
|
||||
-- send to main participants only messages from local occupants (skip from remote vnodes)
|
||||
if occupant and occupant_host == local_domain then
|
||||
local main_message = st.clone(stanza);
|
||||
main_message.attr.to = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain);
|
||||
-- make sure we fix the from to be the real jid
|
||||
main_message.attr.from = room_jid_match_rewrite(stanza.attr.from);
|
||||
module:send(main_message);
|
||||
end
|
||||
|
||||
add_to_local_history(room, stanza);
|
||||
|
||||
stanza.attr.from = from; -- something prosody does internally
|
||||
|
||||
return true;
|
||||
end, 55); -- prosody check for visitor's chat is prio 50, we want to override it
|
||||
|
||||
-- Private messaging support for visitors
|
||||
module:hook('muc-private-message', function(event)
|
||||
local room, stanza = event.room, event.stanza;
|
||||
local from = stanza.attr.from;
|
||||
local to = stanza.attr.to;
|
||||
local recipient_occupant = room:get_occupant_by_nick(to);
|
||||
local recipient_domain = recipient_occupant and jid.host(recipient_occupant.bare_jid) or nil;
|
||||
local sender_occupant = room:get_occupant_by_nick(from);
|
||||
local sender_domain = sender_occupant and jid.host(sender_occupant.bare_jid) or nil;
|
||||
|
||||
if sender_domain == nil or recipient_domain == nil then
|
||||
return false;
|
||||
end
|
||||
|
||||
-- If both sender and recipient are local (on this vnode)
|
||||
if sender_domain == local_domain and recipient_domain == local_domain then
|
||||
event.origin.send(st.error_reply(event.stanza, 'auth', 'forbidden',
|
||||
'Private messaging between visitors is disabled on visitor nodes'));
|
||||
return false; -- Prevent sending the original message and stop further processing
|
||||
end
|
||||
|
||||
-- If sender is local (visitor node) and recipient is from main prosody, forward to main prosody
|
||||
if sender_domain == local_domain and recipient_domain == main_domain then
|
||||
local original_to = stanza.attr.to;
|
||||
local original_from = stanza.attr.from;
|
||||
|
||||
process_display_name(stanza, sender_occupant);
|
||||
|
||||
-- Forward to main prosody, preserving the resource and original from
|
||||
stanza.attr.to = jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain, jid.resource(to));
|
||||
module:send(stanza);
|
||||
return false; -- Prevent sending the original message and stop further processing
|
||||
end
|
||||
|
||||
-- For main->visitor messages, let the default MUC handler process it
|
||||
-- We don't need to do anything special
|
||||
if sender_domain == main_domain and recipient_domain == local_domain then
|
||||
return; -- Return nothing, let other handlers continue. The default MUC handler will process it.
|
||||
end
|
||||
|
||||
return false; -- Prevent sending the original message and stop further processing
|
||||
end, 100); -- Lower priority to run after other handlers
|
||||
|
||||
-- we calculate the stats on the configured interval (60 seconds by default)
|
||||
module:hook_global('stats-update', function ()
|
||||
local participants_count, rooms_count, visitors_count = 0, 0, 0;
|
||||
|
||||
-- iterate over all rooms
|
||||
for room in prosody.hosts[module.host].modules.muc.each_room() do
|
||||
rooms_count = rooms_count + 1;
|
||||
for _, o in room:each_occupant() do
|
||||
if not is_admin(o.bare_jid) then
|
||||
local _, host = jid.split(o.bare_jid);
|
||||
if prosody.hosts[host] then -- local hosts are visitors (including jigasi)
|
||||
visitors_count = visitors_count + 1;
|
||||
else
|
||||
participants_count = participants_count + 1;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
measure_rooms(rooms_count);
|
||||
measure_visitors(visitors_count);
|
||||
measure_participants(participants_count);
|
||||
end);
|
||||
|
||||
-- we skip it till the main participants are added from the main prosody
|
||||
module:hook('jicofo-unlock-room', function(e)
|
||||
-- we do not block events we fired
|
||||
if e.fmuc_fired then
|
||||
return;
|
||||
end
|
||||
|
||||
return true;
|
||||
end);
|
||||
|
||||
-- handles incoming iq visitors stanzas
|
||||
-- connect - sent after sending all main participant's presences
|
||||
-- disconnect - sent when main room is destroyed or when we receive a 'disconnect-vnode' iq from jicofo
|
||||
-- update - sent on:
|
||||
-- * room secret is changed
|
||||
-- * lobby enabled or disabled
|
||||
-- * initially before connect to report currently joined moderators
|
||||
-- * moderator participant joins main room
|
||||
-- * a participant has been granted moderator rights
|
||||
local function iq_from_main_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
if stanza.name ~= 'iq' then
|
||||
return;
|
||||
end
|
||||
|
||||
if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then
|
||||
sent_iq_cache:set(stanza.attr.id, nil);
|
||||
return true;
|
||||
end
|
||||
|
||||
if stanza.attr.type ~= 'set' then
|
||||
return;
|
||||
end
|
||||
|
||||
local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors');
|
||||
if not visitors_iq then
|
||||
return;
|
||||
end
|
||||
|
||||
if stanza.attr.from ~= main_domain then
|
||||
module:log('warn', 'not from main prosody, ignore! %s', stanza);
|
||||
return true;
|
||||
end
|
||||
|
||||
local room_jid = visitors_iq.attr.room;
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(room_jid));
|
||||
|
||||
if not room then
|
||||
if visitors_iq:get_child('disconnect') then
|
||||
-- maybe room was already destroyed, mark as processed so we can response with ok for the iq
|
||||
return true;
|
||||
end
|
||||
module:log('warn', 'No room found %s in iq_from_main_handler for:%s', room_jid, visitors_iq);
|
||||
return;
|
||||
end
|
||||
|
||||
local node = visitors_iq:get_child('connect');
|
||||
local fire_jicofo_unlock = true;
|
||||
local process_disconnect = false;
|
||||
|
||||
if not node then
|
||||
node = visitors_iq:get_child('update');
|
||||
fire_jicofo_unlock = false;
|
||||
end
|
||||
|
||||
if not node then
|
||||
node = visitors_iq:get_child('disconnect');
|
||||
process_disconnect = true;
|
||||
end
|
||||
|
||||
if not node then
|
||||
return;
|
||||
end
|
||||
|
||||
-- respond with successful receiving the iq
|
||||
respond_iq_result(origin, stanza);
|
||||
|
||||
if process_disconnect then
|
||||
return destroy_with_conference_ended(room);
|
||||
end
|
||||
|
||||
-- if there is password supplied use it
|
||||
-- if this is update it will either set or remove the password
|
||||
room:set_password(node.attr.password);
|
||||
room._data.meetingId = node.attr.meetingId;
|
||||
local createdTimestamp = node.attr.createdTimestamp;
|
||||
room.created_timestamp = createdTimestamp and tonumber(createdTimestamp) or nil;
|
||||
|
||||
if node.attr.allowUnauthenticatedAccess then
|
||||
room._data.allowUnauthenticatedAccess = node.attr.allowUnauthenticatedAccess == 'true';
|
||||
end
|
||||
|
||||
if node.attr.lobby == 'true' then
|
||||
room._main_room_lobby_enabled = true;
|
||||
elseif node.attr.lobby == 'false' then
|
||||
room._main_room_lobby_enabled = false;
|
||||
end
|
||||
|
||||
-- Mirror the main room's metadata (e.g. asyncTranscription) onto this vnode's room, so visitor
|
||||
-- clients connected here see the same metadata main-room clients do. Fired as 'room-metadata-changed'
|
||||
-- so a room_metadata_component enabled on this host (muc_component pointing at this muc) rebroadcasts
|
||||
-- it to visitor occupants, exactly like it does for the main room.
|
||||
local metadataEl = node:get_child('metadata', 'jitsi:visitors');
|
||||
if metadataEl then
|
||||
local metadataText = metadataEl:get_text();
|
||||
local metadata, metadata_error = json.decode(metadataText);
|
||||
if metadata then
|
||||
room.jitsiMetadata = metadata;
|
||||
module:fire_event('room-metadata-changed', { room = room; });
|
||||
else
|
||||
module:log('error', 'Failed to decode metadata for room:%s error:%s', room.jid, metadata_error);
|
||||
end
|
||||
end
|
||||
|
||||
-- read the moderators list
|
||||
room.moderators_list = room.moderators_list or set.new();
|
||||
local moderators = node:get_child('moderators');
|
||||
|
||||
if moderators then
|
||||
for _, child in ipairs(moderators.tags) do
|
||||
if child.name == 'item' then
|
||||
room.moderators_list:add(child.attr.epId);
|
||||
end
|
||||
end
|
||||
|
||||
-- let's check current occupants roles and promote them if needed
|
||||
-- we change only main participants which are not moderators, but participant
|
||||
for _, o in room:each_occupant() do
|
||||
if not is_admin(o.bare_jid)
|
||||
and o.role == 'participant'
|
||||
and room.moderators_list:contains(jid.resource(o.nick)) then
|
||||
room:set_affiliation(true, o.bare_jid, 'owner');
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local files = node:get_child('files');
|
||||
if files then
|
||||
local received_files = {};
|
||||
for _, child in ipairs(files.tags) do
|
||||
if child.name == 'file' then
|
||||
received_files[child.attr.id] = json.decode(child:get_text());
|
||||
end
|
||||
end
|
||||
|
||||
-- fire events so file sharing component will add/remove files and will notify clients
|
||||
local removed, added = table_compare(room.jitsi_shared_files or {}, received_files)
|
||||
for _, id in ipairs(removed) do
|
||||
module:context(local_domain):fire_event('jitsi-filesharing-remove', {
|
||||
room = room; id = id;
|
||||
});
|
||||
end
|
||||
for _, id in ipairs(added) do
|
||||
module:context(local_domain):fire_event('jitsi-filesharing-add', {
|
||||
room = room; file = received_files[id];
|
||||
});
|
||||
end
|
||||
end
|
||||
|
||||
local pollsEl = node:get_child('polls');
|
||||
if pollsEl then
|
||||
local polls = json.decode(pollsEl:get_text());
|
||||
-- let's find is there a new poll
|
||||
module:fire_event('jitsi-polls-update', { room = room; polls = polls; });
|
||||
end
|
||||
|
||||
if fire_jicofo_unlock then
|
||||
-- everything is connected allow participants to join
|
||||
module:fire_event('jicofo-unlock-room', { room = room; fmuc_fired = true; });
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
module:hook('iq/host', iq_from_main_handler, 10);
|
||||
|
||||
-- Fired locally (by the room_metadata_component enabled on this vnode) after a client successfully
|
||||
-- writes a metadata key on this vnode's mirrored room. When it's the 'recording' key (isTranscribingEnabled,
|
||||
-- set by a visitor via the transcription JWT feature), forward it up to the main room.
|
||||
module:hook('jitsi-metadata-updated', function(event)
|
||||
if event.key == 'recording' then
|
||||
send_recording_metadata_update(event.room);
|
||||
end
|
||||
end);
|
||||
|
||||
-- Filters presences (if detected) that are with destination the main prosody
|
||||
function filter_stanza(stanza, session)
|
||||
if (stanza.name == 'presence' or stanza.name == 'message') and session.type ~= 'c2s' then
|
||||
|
||||
-- do not send anything to the main prosody if the destination is the main domain
|
||||
if jid.host(stanza.attr.to) == main_domain then
|
||||
return nil; -- returning nil filters the stanza
|
||||
end
|
||||
|
||||
-- we clone it so we do not affect broadcast using same stanza, sending it to clients
|
||||
local f_st = st.clone(stanza);
|
||||
f_st.skipMapping = true;
|
||||
return f_st;
|
||||
elseif stanza.name == 'presence' and session.type == 'c2s' and is_focus_jid(stanza.attr.to) then
|
||||
local x = stanza:get_child('x', 'http://jabber.org/protocol/muc#user');
|
||||
if presence_check_status(x, '110') then
|
||||
return stanza; -- no filter
|
||||
end
|
||||
|
||||
-- we want to filter presences to jicofo for the main participants, skipping visitors
|
||||
-- no point of having them, but if it is the one of the first to be sent
|
||||
-- when first visitor is joining can produce the 'No hosts[from_host]' error as we
|
||||
-- rewrite the from, but we need to not do it to be able to filter it later for the s2s
|
||||
if jid.host(room_jid_match_rewrite(stanza.attr.from)) ~= local_muc_domain then
|
||||
return nil; -- returning nil filters the stanza
|
||||
end
|
||||
end
|
||||
return stanza; -- no filter
|
||||
end
|
||||
function filter_session(session)
|
||||
-- domain mapper is filtering on default priority 0, and we need it before that
|
||||
filters.add_filter(session, 'stanzas/out', filter_stanza, 2);
|
||||
end
|
||||
|
||||
filters.add_filter_hook(filter_session);
|
||||
|
||||
function route_s2s_stanza(event)
|
||||
local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
|
||||
|
||||
if to_host ~= main_domain then
|
||||
return; -- continue with hook listeners
|
||||
end
|
||||
|
||||
if stanza.name == 'message' then
|
||||
if jid.resource(stanza.attr.to) then
|
||||
-- there is no point of delivering messages to main participants individually
|
||||
return true; -- drop it
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if stanza.name == 'presence' then
|
||||
-- we want to leave only unavailable presences to go to main node
|
||||
-- all other presences from jicofo or the main participants there is no point to go to the main node
|
||||
-- they are anyway not handled
|
||||
if stanza.attr.type ~= 'unavailable' then
|
||||
return true; -- drop it
|
||||
end
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
module:hook('answer-poll', function(answerData)
|
||||
local room = answerData.room;
|
||||
local room_jid = room_jid_match_rewrite(jid.join(jid.node(room.jid), muc_domain_prefix..'.'..main_domain));
|
||||
|
||||
-- now send it to the main prosody
|
||||
local data = {
|
||||
answers = answerData.data.answers;
|
||||
command = 'answer-poll';
|
||||
pollId = answerData.pollId;
|
||||
roomJid = room_jid,
|
||||
senderId = answerData.voterId;
|
||||
senderName = answerData.voterName;
|
||||
type = 'polls';
|
||||
};
|
||||
|
||||
local data_str, error = json.encode(data);
|
||||
if not data_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
|
||||
local stanza = st.message({
|
||||
from = module.host,
|
||||
to = 'polls.'..main_domain
|
||||
})
|
||||
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet"; roomJid = room_jid; })
|
||||
:text(data_str)
|
||||
:up();
|
||||
room:route_stanza(stanza);
|
||||
end);
|
||||
|
||||
-- routing to sessions in mod_s2s is -1 and -10, we want to hook before that to make sure to is correct
|
||||
-- or if we want to filter that stanza
|
||||
module:hook("route/remote", route_s2s_stanza, 10);
|
||||
91
resources/prosody-plugins/mod_jibri_session.lua
Normal file
91
resources/prosody-plugins/mod_jibri_session.lua
Normal file
@@ -0,0 +1,91 @@
|
||||
-- mod_jibri_session
|
||||
--
|
||||
-- Enriches jibri `start` IQ stanzas with recording metadata before they reach
|
||||
-- jibri. It handles two passes:
|
||||
--
|
||||
-- 1. Client → jicofo (no `room` attr): reads the initiating user's identity
|
||||
-- from the XMPP session and writes it to
|
||||
-- app_data.file_recording_metadata.initiator {id, group}.
|
||||
--
|
||||
-- 2. Jicofo → jibri (`room` attr present): looks up the MUC room, reads its
|
||||
-- meetingId, and writes it to
|
||||
-- app_data.file_recording_metadata.conference_details.session_id.
|
||||
--
|
||||
-- Must be loaded on both the main virtual host and the jicofo virtual host.
|
||||
|
||||
local json = require 'cjson.safe';
|
||||
|
||||
local util = module:require 'util';
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
|
||||
-- This needs to be attached to the main virtual host and the virtual host where jicofo is connected and authenticated.
|
||||
-- The first pass is the iq coming from the client where we get the creator and attach it to the app_data.
|
||||
-- The second pass is jicofo approving that and inviting jibri where we attach the session_id information to app_data
|
||||
local function attachJibriSessionId(event)
|
||||
local stanza = event.stanza;
|
||||
if stanza.name == "iq" then
|
||||
local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri');
|
||||
if jibri then
|
||||
if jibri.attr.action and jibri.attr.action:lower() == 'start' then
|
||||
|
||||
local update_app_data = false;
|
||||
local app_data = jibri.attr.app_data;
|
||||
if app_data then
|
||||
local err;
|
||||
app_data, err = json.decode(app_data);
|
||||
if err then
|
||||
module:log('warn', 'Failed to decode jibri app_data from %s: %s', stanza.attr.from, err);
|
||||
return;
|
||||
end
|
||||
else
|
||||
app_data = {};
|
||||
end
|
||||
if app_data.file_recording_metadata == nil then
|
||||
app_data.file_recording_metadata = {};
|
||||
end
|
||||
|
||||
if jibri.attr.room then
|
||||
local jibri_room = jibri.attr.room;
|
||||
jibri_room = room_jid_match_rewrite(jibri_room)
|
||||
local room = get_room_from_jid(jibri_room);
|
||||
if room then
|
||||
local conference_details = {};
|
||||
conference_details["session_id"] = room._data.meetingId;
|
||||
app_data.file_recording_metadata.conference_details = conference_details;
|
||||
update_app_data = true;
|
||||
end
|
||||
else
|
||||
-- no room is because the iq received by the initiator in the room
|
||||
local session = event.origin;
|
||||
-- if a token is provided, add data to app_data
|
||||
if session ~= nil then
|
||||
local initiator = {};
|
||||
|
||||
if session.jitsi_meet_context_user ~= nil then
|
||||
initiator.id = session.jitsi_meet_context_user.id;
|
||||
else
|
||||
initiator.id = session.granted_jitsi_meet_context_user_id;
|
||||
end
|
||||
|
||||
initiator.group
|
||||
= session.jitsi_meet_context_group or session.granted_jitsi_meet_context_group_id;
|
||||
|
||||
app_data.file_recording_metadata.initiator = initiator
|
||||
update_app_data = true;
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
if update_app_data then
|
||||
app_data = json.encode(app_data);
|
||||
jibri.attr.app_data = app_data;
|
||||
jibri:up()
|
||||
stanza:up()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module:hook('pre-iq/full', attachJibriSessionId);
|
||||
72
resources/prosody-plugins/mod_jiconop.lua
Normal file
72
resources/prosody-plugins/mod_jiconop.lua
Normal file
@@ -0,0 +1,72 @@
|
||||
local st = require "util.stanza";
|
||||
local ext_services = module:depends("external_services");
|
||||
|
||||
-- Jitsi Connection Optimization
|
||||
-- gathers needed information and pushes it with a message to clients
|
||||
-- this way we skip 4 request responses during every client setup
|
||||
|
||||
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
|
||||
if not main_virtual_host then
|
||||
module:log('warn', 'No muc_mapper_domain_base option set.');
|
||||
return;
|
||||
end
|
||||
|
||||
local shard_name_config = module:get_option_string('shard_name');
|
||||
if shard_name_config then
|
||||
module:add_identity("server", "shard", shard_name_config);
|
||||
end
|
||||
|
||||
local region_name_config = module:get_option_string('region_name');
|
||||
if region_name_config then
|
||||
module:add_identity("server", "region", region_name_config);
|
||||
end
|
||||
|
||||
local release_number_config = module:get_option_string('release_number');
|
||||
if release_number_config then
|
||||
module:add_identity("server", "release", release_number_config);
|
||||
end
|
||||
|
||||
-- we cache the query as server identities will not change dynamically, amd use its clone every time
|
||||
local query_cache;
|
||||
|
||||
-- this is after xmpp-bind, the moment a client has resource and can be contacted
|
||||
module:hook("resource-bind", function (event)
|
||||
local session = event.session;
|
||||
|
||||
if query_cache == nil then
|
||||
-- disco info data / all identity and features
|
||||
local query = st.stanza("query", { xmlns = "http://jabber.org/protocol/disco#info" });
|
||||
local done = {};
|
||||
-- to lod this module in different virtual hosts than the main, make sure we query here for main
|
||||
for _,identity in ipairs(module:context(main_virtual_host):get_host_items("identity")) do
|
||||
local identity_s = identity.category.."\0"..identity.type;
|
||||
if not done[identity_s] then
|
||||
query:tag("identity", identity):up();
|
||||
done[identity_s] = true;
|
||||
end
|
||||
end
|
||||
|
||||
query_cache = query;
|
||||
end
|
||||
|
||||
local query = st.clone(query_cache);
|
||||
|
||||
-- check whether room has lobby enabled and display name is required for those trying to join
|
||||
local lobby_muc_component_config = module:get_option_string('lobby_muc');
|
||||
module:context(lobby_muc_component_config):fire_event('host-disco-info-node',
|
||||
{origin = session; reply = query; node = 'lobbyrooms';});
|
||||
|
||||
-- will add a rename feature for breakout rooms.
|
||||
local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc');
|
||||
if breakout_rooms_muc_component_config then
|
||||
module:context(breakout_rooms_muc_component_config):fire_event('host-disco-info-node',
|
||||
{origin = session; reply = query; node = 'breakout_rooms';});
|
||||
end
|
||||
|
||||
local stanza = st.message({
|
||||
from = module.host;
|
||||
to = session.full_jid; });
|
||||
stanza:add_child(query):up();
|
||||
|
||||
session.send(stanza);
|
||||
end);
|
||||
251
resources/prosody-plugins/mod_jitsi_permissions.lua
Normal file
251
resources/prosody-plugins/mod_jitsi_permissions.lua
Normal file
@@ -0,0 +1,251 @@
|
||||
-- Injects a <permissions> element into a participant's self-presence to
|
||||
-- communicate which features they are allowed to use.
|
||||
--
|
||||
-- The <permissions> element is only injected when the server is the authoritative
|
||||
-- source of that information. When a participant authenticated with a JWT that
|
||||
-- already contains context.features, the client reads those features directly
|
||||
-- from the token; no <permissions> element is sent. This means:
|
||||
--
|
||||
-- * Token user WITH context.features → no injection (client uses token features)
|
||||
-- * Token user WITHOUT context.features, becomes moderator → inject default_permissions
|
||||
-- * Anonymous/non-token user, becomes moderator → inject default_permissions
|
||||
-- * Non-moderator of any kind → no injection
|
||||
--
|
||||
-- There is no mechanism for a client to explicitly request its permissions;
|
||||
-- <permissions> is only ever pushed by the server, either on the initial
|
||||
-- self-presence when a moderator joins (if the conditions above are met), or
|
||||
-- when an affiliation change triggers force_permissions_update (e.g. another
|
||||
-- moderator grants owner to this participant).
|
||||
--
|
||||
-- auto loaded by meeting_id
|
||||
local filters = require 'util.filters';
|
||||
local jid = require 'util.jid';
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local is_focus = util.is_focus;
|
||||
local presence_check_status = util.presence_check_status;
|
||||
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
|
||||
|
||||
local MUC_NS = 'http://jabber.org/protocol/muc';
|
||||
|
||||
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
|
||||
|
||||
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
|
||||
if not muc_domain_base then
|
||||
module:log('warn', 'No "muc_domain_base" option set, disabling module.');
|
||||
return ;
|
||||
end
|
||||
|
||||
-- only the visitor prosody has main_domain setting
|
||||
local is_visitor_prosody = module:get_option_string('main_domain') ~= nil;
|
||||
|
||||
-- load it only on the main muc component as it is loaded by muc_meeting_id which is loaded and for the breakout room muc
|
||||
if muc_domain_prefix..'.'..muc_domain_base ~= module.host or is_visitor_prosody then
|
||||
return;
|
||||
end
|
||||
|
||||
local sessions = prosody.full_sessions;
|
||||
local default_permissions;
|
||||
|
||||
-- Returns a copy of a feature table. Sessions must not share one table, because
|
||||
-- a change to the features of one session must not change the features of
|
||||
-- another session.
|
||||
local function copy_features(features)
|
||||
local res = {};
|
||||
|
||||
for k, v in pairs(features) do
|
||||
res[k] = v;
|
||||
end
|
||||
|
||||
return res;
|
||||
end
|
||||
|
||||
local function load_config()
|
||||
default_permissions = module:get_option('jitsi_default_permissions', {
|
||||
livestreaming = true;
|
||||
recording = true;
|
||||
transcription = true;
|
||||
['outbound-call'] = true;
|
||||
['create-polls'] = true;
|
||||
['send-groupchat'] = true;
|
||||
flip = true;
|
||||
['live-translation'] = true;
|
||||
['live-translation-subscribe'] = true;
|
||||
});
|
||||
end
|
||||
load_config();
|
||||
|
||||
function process_set_affiliation(event)
|
||||
local actor, affiliation, jid, previous_affiliation, room
|
||||
= event.actor, event.affiliation, event.jid, event.previous_affiliation, event.room;
|
||||
local actor_session = sessions[actor];
|
||||
|
||||
if is_admin(jid) or is_healthcheck_room(room.jid) or not actor or not previous_affiliation
|
||||
or not actor_session or not actor_session.jitsi_meet_context_features then
|
||||
return;
|
||||
end
|
||||
|
||||
-- An affiliation belongs to a bare JID, and a bare JID can have more than one
|
||||
-- live session (multiple devices/tabs). Resolve them all, but only touch a
|
||||
-- session that is actually an occupant of this room or one of its own breakout
|
||||
-- rooms: an affiliation set on this room must not reach a session that happens
|
||||
-- to share the same bare JID while sitting in an unrelated conference.
|
||||
local bare_session = prosody.bare_sessions[jid];
|
||||
if not bare_session then
|
||||
return;
|
||||
end
|
||||
|
||||
-- Any transition into owner is a grant. Any transition out of owner is a revoke.
|
||||
-- This also covers the admin and outcast affiliations, which the client does not
|
||||
-- use but which a raw stanza can set.
|
||||
local granting = affiliation == 'owner' and previous_affiliation ~= 'owner';
|
||||
local revoking = previous_affiliation == 'owner' and affiliation ~= 'owner';
|
||||
|
||||
for _, occupant_session in pairs(bare_session.sessions) do
|
||||
if get_occupant_by_real_jid(room, occupant_session.full_jid) then
|
||||
if granting then
|
||||
-- Keep the features the session had before the grant, so that a later
|
||||
-- revoke can put them back. The value false marks a session that had no
|
||||
-- features, which is not the same as a session that had an empty table.
|
||||
if occupant_session.pre_grant_jitsi_meet_context_features == nil then
|
||||
occupant_session.pre_grant_jitsi_meet_context_features
|
||||
= occupant_session.jitsi_meet_context_features or false;
|
||||
end
|
||||
|
||||
occupant_session.jitsi_meet_context_features
|
||||
= copy_features(actor_session.jitsi_meet_context_features);
|
||||
if actor_session.jitsi_meet_context_user then
|
||||
occupant_session.granted_jitsi_meet_context_user_id = actor_session.jitsi_meet_context_user['id']
|
||||
or actor_session.granted_jitsi_meet_context_user_id;
|
||||
end
|
||||
occupant_session.granted_jitsi_meet_context_group_id = actor_session.jitsi_meet_context_group
|
||||
or actor_session.granted_jitsi_meet_context_group_id;
|
||||
-- even if token and features are set we may want to re-send permissions
|
||||
occupant_session.force_permissions_update = true;
|
||||
elseif revoking then
|
||||
occupant_session.granted_jitsi_meet_context_user_id = nil;
|
||||
occupant_session.granted_jitsi_meet_context_group_id = nil;
|
||||
|
||||
local pre_grant = occupant_session.pre_grant_jitsi_meet_context_features;
|
||||
|
||||
if pre_grant ~= nil then
|
||||
-- The session received a grant before. Put back the features it had
|
||||
-- then. A token session gets its token features again, and any other
|
||||
-- session gets no features and falls back to a check of its current role.
|
||||
occupant_session.jitsi_meet_context_features = pre_grant or nil;
|
||||
occupant_session.pre_grant_jitsi_meet_context_features = nil;
|
||||
elseif not occupant_session.auth_token then
|
||||
occupant_session.jitsi_meet_context_features = nil;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Detects when sending self-presence because of role change
|
||||
-- we can end up here because of the following cases:
|
||||
-- 1. user joins the room and is granted moderator by another moderator or jicofo
|
||||
-- 2. Some module changes the role of the user by using set_affiliation method
|
||||
-- In cases where authentication is 'anonymous', 'jitsi-anonymous', 'internal_hashed', 'internal_plain', 'cyrus' we
|
||||
-- want to send default permissions all to indicate UI that everything is allowed (to not relay on the UI to check
|
||||
-- is participant moderator or not), to allow finer control over the permissions.
|
||||
-- In case the authentication is 'token' based we want to send permissions only if the token of the user does not include
|
||||
-- features in the user.context.
|
||||
-- In case of allowners we want to send the permissions, no matter of the authentication method.
|
||||
-- In case permissions were granted we want to send the granted permissions in all cases except when the user is
|
||||
-- using token that has features pre-defined (authentication is 'token').
|
||||
function filter_stanza(stanza, session)
|
||||
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence'
|
||||
or stanza.attr.type == 'unavailable' or is_focus(stanza.attr.from) then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local bare_to = jid.bare(stanza.attr.to);
|
||||
if is_admin(bare_to) then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local muc_x = stanza:get_child('x', MUC_NS..'#user');
|
||||
if not muc_x or not presence_check_status(muc_x, '110') then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(jid.bare(stanza.attr.from)));
|
||||
|
||||
if not room or is_healthcheck_room(room.jid) then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
if not room.send_default_permissions_to then
|
||||
room.send_default_permissions_to = {};
|
||||
end
|
||||
|
||||
if not session.force_permissions_update then
|
||||
if session.auth_token and session.jitsi_meet_context_features then -- token and features are set so skip
|
||||
room.send_default_permissions_to[bare_to] = nil;
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- we are sending permissions only when becoming a member
|
||||
local is_moderator = false;
|
||||
for item in muc_x:childtags('item') do
|
||||
if item.attr.role == 'moderator' then
|
||||
is_moderator = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
|
||||
if not is_moderator then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
if not room.send_default_permissions_to[bare_to] then
|
||||
return stanza;
|
||||
end
|
||||
end
|
||||
|
||||
session.force_permissions_update = false;
|
||||
|
||||
if not session.jitsi_meet_context_features then
|
||||
session.jitsi_meet_context_features = copy_features(default_permissions);
|
||||
end
|
||||
|
||||
room.send_default_permissions_to[bare_to] = nil;
|
||||
|
||||
stanza:tag('permissions', { xmlns='http://jitsi.org/jitmeet' });
|
||||
for k, v in pairs(session.jitsi_meet_context_features) do
|
||||
local val = tostring(v);
|
||||
stanza:tag('p', { name = k, val = val }):up();
|
||||
end
|
||||
stanza:up();
|
||||
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- we need to indicate that we will send permissions if we need to
|
||||
-- we need to handle granted features and stuff in the pre-set hook so they are unavailable
|
||||
-- when the self presence is set, so we can update the client, the checks
|
||||
-- whether the actor is allowed to set the affiliation are done before pre-set hook is fired
|
||||
module:hook('muc-pre-set-affiliation', function(event)
|
||||
local jid, room = event.jid, event.room;
|
||||
|
||||
if not room.send_default_permissions_to then
|
||||
room.send_default_permissions_to = {};
|
||||
end
|
||||
room.send_default_permissions_to[jid] = true;
|
||||
|
||||
process_set_affiliation(event);
|
||||
end);
|
||||
|
||||
function filter_session(session)
|
||||
-- domain mapper is filtering on default priority 0
|
||||
-- allowners is -1 and we need it after that
|
||||
filters.add_filter(session, 'stanzas/out', filter_stanza, -2);
|
||||
end
|
||||
|
||||
-- enable filtering presences
|
||||
filters.add_filter_hook(filter_session);
|
||||
71
resources/prosody-plugins/mod_jitsi_session.lua
Normal file
71
resources/prosody-plugins/mod_jitsi_session.lua
Normal file
@@ -0,0 +1,71 @@
|
||||
-- Jitsi session information
|
||||
-- Copyright (C) 2021-present 8x8, Inc.
|
||||
--
|
||||
-- Hooks the BOSH and WebSocket session-init events to extract Jitsi-specific
|
||||
-- parameters from the URL query string and request headers, then stores them
|
||||
-- on the session object for use by other modules:
|
||||
--
|
||||
-- session.previd -- ?previd= for smacks session resumption; lets
|
||||
-- -- mod_auth_jitsi-anonymous re-use the same
|
||||
-- -- anonymous JID after a reconnect
|
||||
-- session.customusername -- ?customusername= pre-set JID, used with the
|
||||
-- -- "pre-jitsi-authentication" event
|
||||
-- session.jitsi_web_query_room -- ?room= Jitsi conference room name
|
||||
-- session.jitsi_web_query_prefix -- ?prefix= optional tenant prefix (defaults to "")
|
||||
-- session.auth_token -- ?token= or Authorization: Bearer <token> header;
|
||||
-- -- populated without validation — downstream
|
||||
-- -- modules (mod_auth_token, etc.) validate it
|
||||
-- session.user_region -- value of the configurable proxy header
|
||||
-- -- (region_header_name option, default x_proxy_region)
|
||||
-- session.user_agent_header -- User-Agent request header
|
||||
module:set_global();
|
||||
|
||||
local formdecode = require "util.http".formdecode;
|
||||
local region_header_name = module:get_option_string('region_header_name', 'x_proxy_region');
|
||||
|
||||
function init_session(event)
|
||||
local session, request = event.session, event.request;
|
||||
local query = request.url.query;
|
||||
|
||||
local token = nil;
|
||||
|
||||
-- extract token from Authorization header
|
||||
if request.headers["authorization"] then
|
||||
-- assumes the header value starts with "Bearer "
|
||||
token = request.headers["authorization"]:sub(8,#request.headers["authorization"])
|
||||
end
|
||||
|
||||
if query ~= nil then
|
||||
local params = formdecode(query);
|
||||
|
||||
-- previd is used together with https://modules.prosody.im/mod_smacks.html
|
||||
-- the param is used to find resumed session and re-use anonymous(random) user id
|
||||
session.previd = query and params.previd or nil;
|
||||
|
||||
-- customusername can be used with combination with "pre-jitsi-authentication" event to pre-set a known jid to a session
|
||||
session.customusername = query and params.customusername or nil;
|
||||
|
||||
-- The room name and optional prefix from the web query
|
||||
session.jitsi_web_query_room = params.room;
|
||||
session.jitsi_web_query_prefix = params.prefix or "";
|
||||
|
||||
-- The following fields are filled in the session, by extracting them
|
||||
-- from the query and no validation is being done.
|
||||
-- After validating auth_token will be cleaned in case of error and few
|
||||
-- other fields will be extracted from the token and set in the session
|
||||
|
||||
if params and params.token then
|
||||
token = params.token;
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
session.user_region = request.headers[region_header_name];
|
||||
|
||||
-- in either case set auth_token in the session
|
||||
session.auth_token = token;
|
||||
session.user_agent_header = request.headers['user_agent'];
|
||||
end
|
||||
|
||||
module:hook_global("bosh-session", init_session, 1);
|
||||
module:hook_global("websocket-session", init_session, 1);
|
||||
32
resources/prosody-plugins/mod_limits_exception.lua
Normal file
32
resources/prosody-plugins/mod_limits_exception.lua
Normal file
@@ -0,0 +1,32 @@
|
||||
if not pcall(require, 'util.async') then
|
||||
module:log('warn', 'util.async not available; mod_limits_exception requires Prosody 0.11+, not loading');
|
||||
return;
|
||||
end
|
||||
|
||||
local unlimited_jids = module:get_option_inherited_set("unlimited_jids", {});
|
||||
|
||||
-- rises the limit of the stanza size for the unlimited jids, default is 10MB
|
||||
local unlimited_stanza_size_limit = module:get_option_number("unlimited_size", 10*1024*1024);
|
||||
|
||||
if unlimited_jids:empty() then
|
||||
return;
|
||||
end
|
||||
|
||||
module:log('info', 'loaded; unlimited_jids=%s unlimited_size=%d', tostring(unlimited_jids), unlimited_stanza_size_limit);
|
||||
|
||||
module:hook("authentication-success", function (event)
|
||||
local session = event.session;
|
||||
local jid = session.username .. "@" .. session.host;
|
||||
if unlimited_jids:contains(jid) then
|
||||
if session.conn and session.conn.setlimit then
|
||||
session.conn:setlimit(0);
|
||||
elseif session.throttle then
|
||||
session.throttle = nil;
|
||||
end
|
||||
|
||||
if unlimited_stanza_size_limit and session.stream.set_stanza_size_limit then
|
||||
module:log('info', 'Setting stanza size limits for %s to %s', jid, unlimited_stanza_size_limit)
|
||||
session.stream:set_stanza_size_limit(unlimited_stanza_size_limit);
|
||||
end
|
||||
end
|
||||
end);
|
||||
124
resources/prosody-plugins/mod_log_ringbuffer.lua
Normal file
124
resources/prosody-plugins/mod_log_ringbuffer.lua
Normal file
@@ -0,0 +1,124 @@
|
||||
-- Registers a custom Prosody log sink that buffers recent log lines in memory
|
||||
-- using a ringbuffer (or a fixed-line queue) and dumps the buffer to a file
|
||||
-- when triggered by a configured OS signal or Prosody event.
|
||||
-- Source: https://hg.prosody.im/prosody-modules/file/tip/mod_log_ringbuffer/mod_log_ringbuffer.lua
|
||||
module:set_global();
|
||||
|
||||
local loggingmanager = require "core.loggingmanager";
|
||||
local format = require "util.format".format;
|
||||
local pposix = require "util.pposix";
|
||||
local rb = require "util.ringbuffer";
|
||||
local queue = require "util.queue";
|
||||
|
||||
local default_timestamp = "%b %d %H:%M:%S ";
|
||||
local max_chunk_size = module:get_option_number("log_ringbuffer_chunk_size", 16384);
|
||||
|
||||
local os_date = os.date;
|
||||
|
||||
local default_filename_template = "{paths.data}/ringbuffer-logs-{pid}-{count}.log";
|
||||
local render_filename = require "util.interpolation".new("%b{}", function (s) return s; end, {
|
||||
yyyymmdd = function (t)
|
||||
return os_date("%Y%m%d", t);
|
||||
end;
|
||||
hhmmss = function (t)
|
||||
return os_date("%H%M%S", t);
|
||||
end;
|
||||
});
|
||||
|
||||
local dump_count = 0;
|
||||
|
||||
local function dump_buffer(dump, filename)
|
||||
dump_count = dump_count + 1;
|
||||
local f, err = io.open(filename, "a+");
|
||||
if not f then
|
||||
module:log("error", "Unable to open output file: %s", err);
|
||||
return;
|
||||
end
|
||||
f:write(("-- Dumping log buffer at %s --\n"):format(os_date(default_timestamp)));
|
||||
dump(f);
|
||||
f:write("-- End of dump --\n\n");
|
||||
f:close();
|
||||
end
|
||||
|
||||
local function get_filename(filename_template)
|
||||
filename_template = filename_template or default_filename_template;
|
||||
return render_filename(filename_template, {
|
||||
paths = prosody.paths;
|
||||
pid = pposix.getpid();
|
||||
count = dump_count;
|
||||
time = os.time();
|
||||
});
|
||||
end
|
||||
|
||||
local function new_buffer(config)
|
||||
local write, dump;
|
||||
|
||||
if config.lines then
|
||||
local buffer = queue.new(config.lines, true);
|
||||
function write(line)
|
||||
buffer:push(line);
|
||||
end
|
||||
function dump(f)
|
||||
-- COMPAT w/0.11 - update to use :consume()
|
||||
for line in buffer.pop, buffer do
|
||||
f:write(line);
|
||||
end
|
||||
end
|
||||
else
|
||||
local buffer_size = config.size or 100*1024;
|
||||
local buffer = rb.new(buffer_size);
|
||||
function write(line)
|
||||
if not buffer:write(line) then
|
||||
if #line > buffer_size then
|
||||
buffer:discard(buffer_size);
|
||||
buffer:write(line:sub(-buffer_size));
|
||||
else
|
||||
buffer:discard(#line);
|
||||
buffer:write(line);
|
||||
end
|
||||
end
|
||||
end
|
||||
function dump(f)
|
||||
local bytes_remaining = buffer:length();
|
||||
while bytes_remaining > 0 do
|
||||
local chunk_size = math.min(bytes_remaining, max_chunk_size);
|
||||
local chunk = buffer:read(chunk_size);
|
||||
if not chunk then
|
||||
return;
|
||||
end
|
||||
f:write(chunk);
|
||||
bytes_remaining = bytes_remaining - chunk_size;
|
||||
end
|
||||
end
|
||||
end
|
||||
return write, dump;
|
||||
end
|
||||
|
||||
local function ringbuffer_log_sink_maker(sink_config)
|
||||
local write, dump = new_buffer(sink_config);
|
||||
|
||||
local timestamps = sink_config.timestamps;
|
||||
|
||||
if timestamps == true or timestamps == nil then
|
||||
timestamps = default_timestamp; -- Default format
|
||||
elseif timestamps then
|
||||
timestamps = timestamps .. " ";
|
||||
end
|
||||
|
||||
local function handler()
|
||||
dump_buffer(dump, sink_config.filename or get_filename(sink_config.filename_template));
|
||||
end
|
||||
|
||||
if sink_config.signal then
|
||||
require "util.signal".signal(sink_config.signal, handler);
|
||||
elseif sink_config.event then
|
||||
module:hook_global(sink_config.event, handler);
|
||||
end
|
||||
|
||||
return function (name, level, message, ...)
|
||||
local line = format("%s%s\t%s\t%s\n", timestamps and os_date(timestamps) or "", name, level, format(message, ...));
|
||||
write(line);
|
||||
end;
|
||||
end
|
||||
|
||||
loggingmanager.register_sink_type("ringbuffer", ringbuffer_log_sink_maker);
|
||||
171
resources/prosody-plugins/mod_measure_message_count.lua
Normal file
171
resources/prosody-plugins/mod_measure_message_count.lua
Normal file
@@ -0,0 +1,171 @@
|
||||
-- Counts messages and polls per conference and reports the totals to Amplitude
|
||||
-- when the main room is destroyed. Loaded on both the main MUC and breakout MUC
|
||||
-- components; in both cases messages are accumulated on the main room object
|
||||
-- (session.jitsi_web_query_room always refers to the main conference), so
|
||||
-- breakout messages are included in the main room's totals. The Amplitude event
|
||||
-- is only sent when the main room is destroyed (skipped for breakout rooms).
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local jid = require 'util.jid';
|
||||
local http = require 'net.http';
|
||||
local cjson_safe = require 'cjson.safe'
|
||||
|
||||
local amplitude_endpoint = module:get_option_string('amplitude_endpoint', 'https://api2.amplitude.com/2/httpapi');
|
||||
local amplitude_api_key = module:get_option_string('amplitude_api_key');
|
||||
|
||||
if not amplitude_api_key then
|
||||
module:log("warn", "No 'amplitude_api_key' option set, disabling amplitude reporting");
|
||||
return
|
||||
end
|
||||
|
||||
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
|
||||
local isBreakoutRoom = module.host == 'breakout.' .. muc_domain_base;
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local extract_subdomain = util.extract_subdomain;
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
|
||||
module:log('info', 'Loading measure message count');
|
||||
|
||||
local shard_name = module:context(muc_domain_base):get_option_string('shard_name');
|
||||
local region_name = module:context(muc_domain_base):get_option_string('region_name');
|
||||
local release_number = module:context(muc_domain_base):get_option_string('release_number');
|
||||
local http_headers = {
|
||||
['User-Agent'] = 'Prosody ('..prosody.version..'; '..prosody.platform..')',
|
||||
['Content-Type'] = 'application/json'
|
||||
};
|
||||
|
||||
local inspect = require "inspect"
|
||||
|
||||
function table.clone(t)
|
||||
return {table.unpack(t)}
|
||||
end
|
||||
|
||||
local function event_cb(content_, code_, response_, request_)
|
||||
if code_ == 200 or code_ == 204 then
|
||||
module:log('debug', 'URL Callback: Code %s, Content %s, Request (host %s, path %s, body %s), Response: %s',
|
||||
code_, content_, request_.host, request_.path, inspect(request_.body), inspect(response_));
|
||||
else
|
||||
module:log('warn', 'URL Callback non successful: Code %s, Content %s, Request (%s), Response: %s',
|
||||
code_, content_, inspect(request_), inspect(response_));
|
||||
end
|
||||
end
|
||||
|
||||
function send_event(room)
|
||||
local user_properties = {
|
||||
shard_name = shard_name;
|
||||
region_name = region_name;
|
||||
release_number = release_number;
|
||||
};
|
||||
|
||||
local node = jid.split(room.jid);
|
||||
local subdomain, room_name = extract_subdomain(node);
|
||||
user_properties.tenant = subdomain or '/';
|
||||
user_properties.conference_name = room_name or node;
|
||||
|
||||
local event_properties = {
|
||||
messages_count = room._muc_messages_count or 0;
|
||||
polls_count = room._muc_polls_count or 0;
|
||||
tenant_mismatch = room.jitsi_meet_tenant_mismatch or false;
|
||||
};
|
||||
|
||||
if room.created_timestamp then
|
||||
event_properties.duration = (os.time() * 1000 - room.created_timestamp) / 1000;
|
||||
end
|
||||
|
||||
local event = {
|
||||
api_key = amplitude_api_key;
|
||||
events = {
|
||||
{
|
||||
user_id = room._data.meetingId;
|
||||
device_id = room._data.meetingId;
|
||||
event_type = 'conference_ended';
|
||||
event_properties = event_properties;
|
||||
user_properties = user_properties;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
local request = http.request(amplitude_endpoint, {
|
||||
headers = http_headers,
|
||||
method = "POST",
|
||||
body = cjson_safe.encode(event)
|
||||
}, event_cb);
|
||||
end
|
||||
|
||||
function on_message(event)
|
||||
local stanza = event.stanza;
|
||||
local body = stanza:get_child('body');
|
||||
|
||||
if not body then
|
||||
-- we ignore messages without body - lobby, polls ...
|
||||
return;
|
||||
end
|
||||
|
||||
local session = event.origin;
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
return;
|
||||
end
|
||||
|
||||
-- get room name with tenant and find room.
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return;
|
||||
end
|
||||
|
||||
if not room._muc_messages_count then
|
||||
room._muc_messages_count = 0;
|
||||
end
|
||||
|
||||
room._muc_messages_count = room._muc_messages_count + 1;
|
||||
end
|
||||
|
||||
-- Conference ended, send stats
|
||||
function room_destroyed(event)
|
||||
local room, session = event.room, event.origin;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
if isBreakoutRoom then
|
||||
return;
|
||||
end
|
||||
send_event(room);
|
||||
end
|
||||
|
||||
function poll_created(event)
|
||||
local session = event.event.origin;
|
||||
|
||||
-- get room name with tenant and find room.
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return false;
|
||||
end
|
||||
|
||||
if not room._muc_polls_count then
|
||||
room._muc_polls_count = 0;
|
||||
end
|
||||
|
||||
room._muc_polls_count = room._muc_polls_count + 1;
|
||||
end
|
||||
|
||||
module:hook('message/full', on_message); -- private messages
|
||||
module:hook('message/bare', on_message); -- room messages
|
||||
|
||||
module:hook('muc-room-destroyed', room_destroyed, 1); -- prosody handles it at 0
|
||||
module:hook("muc-occupant-left", function(event)
|
||||
local occupant, room = event.occupant, event.room;
|
||||
local session = event.origin;
|
||||
|
||||
if session and session.jitsi_meet_tenant_mismatch then
|
||||
room.jitsi_meet_tenant_mismatch = true;
|
||||
end
|
||||
end);
|
||||
|
||||
module:hook('poll-created', poll_created);
|
||||
32
resources/prosody-plugins/mod_measure_stanza_counts.lua
Normal file
32
resources/prosody-plugins/mod_measure_stanza_counts.lua
Normal file
@@ -0,0 +1,32 @@
|
||||
module:set_global()
|
||||
|
||||
local filters = require"util.filters";
|
||||
|
||||
local stanzas_in = module:metric(
|
||||
"counter", "received", "",
|
||||
"Stanzas received by Prosody",
|
||||
{ "session_type", "stanza_kind" }
|
||||
)
|
||||
local stanzas_out = module:metric(
|
||||
"counter", "sent", "",
|
||||
"Stanzas sent by prosody",
|
||||
{ "session_type", "stanza_kind" }
|
||||
)
|
||||
|
||||
local stanza_kinds = { message = true, presence = true, iq = true };
|
||||
|
||||
local function rate(metric_family)
|
||||
return function (stanza, session)
|
||||
if stanza.attr and not stanza.attr.xmlns and stanza_kinds[stanza.name] then
|
||||
metric_family:with_labels(session.type, stanza.name):add(1);
|
||||
end
|
||||
return stanza;
|
||||
end
|
||||
end
|
||||
|
||||
local function measure_stanza_counts(session)
|
||||
filters.add_filter(session, "stanzas/in", rate(stanzas_in));
|
||||
filters.add_filter(session, "stanzas/out", rate(stanzas_out));
|
||||
end
|
||||
|
||||
filters.add_filter_hook(measure_stanza_counts);
|
||||
179
resources/prosody-plugins/mod_muc_allowners.lua
Normal file
179
resources/prosody-plugins/mod_muc_allowners.lua
Normal file
@@ -0,0 +1,179 @@
|
||||
--- activate under the main muc component
|
||||
local filters = require 'util.filters';
|
||||
local jid = require "util.jid";
|
||||
local jid_bare = require "util.jid".bare;
|
||||
local jid_host = require "util.jid".host;
|
||||
local st = require "util.stanza";
|
||||
local util = module:require "util";
|
||||
local is_admin = util.is_admin;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local is_moderated = util.is_moderated;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local presence_check_status = util.presence_check_status;
|
||||
local MUC_NS = 'http://jabber.org/protocol/muc';
|
||||
|
||||
local disable_revoke_owners;
|
||||
local allowner_issuers = module:get_option_set('allowner_issuers');
|
||||
|
||||
local function load_config()
|
||||
disable_revoke_owners = module:get_option_boolean("allowners_disable_revoke_owners", false);
|
||||
end
|
||||
load_config();
|
||||
|
||||
-- List of the bare_jids of all occupants that are currently joining (went through pre-join) and will be promoted
|
||||
-- as moderators. As pre-join (where added) and joined event (where removed) happen one after another this list should
|
||||
-- have length of 1
|
||||
local joining_moderator_participants = module:shared('moderators/joining_moderator_participants');
|
||||
|
||||
module:hook("muc-room-created", function(event)
|
||||
local room = event.room;
|
||||
|
||||
if room.jitsiMetadata then
|
||||
-- indicates whether all participants in the room will be moderators
|
||||
room.jitsiMetadata.allownersEnabled = not is_moderated(room.jid);
|
||||
end
|
||||
end, -2); -- room_metadata should run before this module on -1
|
||||
|
||||
module:hook("muc-occupant-pre-join", function (event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local moderated, room_name, subdomain = is_moderated(room.jid);
|
||||
if moderated then
|
||||
local session = event.origin;
|
||||
local token = session.auth_token;
|
||||
|
||||
if not token then
|
||||
module:log('debug', 'skip allowners for non-auth user subdomain:%s room_name:%s', subdomain, room_name);
|
||||
return;
|
||||
end
|
||||
|
||||
if not (room_name == session.jitsi_meet_room or session.jitsi_meet_room == '*') then
|
||||
module:log('debug', 'skip allowners for auth user and non matching room name: %s, jwt room name: %s',
|
||||
room_name, session.jitsi_meet_room);
|
||||
return;
|
||||
end
|
||||
|
||||
if session.jitsi_meet_domain ~= '*' and subdomain ~= session.jitsi_meet_domain then
|
||||
module:log('debug', 'skip allowners for auth user and non matching room subdomain: %s, jwt subdomain: %s',
|
||||
subdomain, session.jitsi_meet_domain);
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
-- mark this participant that it will be promoted and is currently joining
|
||||
joining_moderator_participants[occupant.bare_jid] = true;
|
||||
end, 2);
|
||||
|
||||
module:hook("muc-occupant-joined", function (event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
|
||||
local promote_to_moderator = joining_moderator_participants[occupant.bare_jid];
|
||||
-- clear it
|
||||
joining_moderator_participants[occupant.bare_jid] = nil;
|
||||
|
||||
if promote_to_moderator ~= nil then
|
||||
room:set_affiliation(true, occupant.bare_jid, "owner");
|
||||
end
|
||||
end, 2);
|
||||
|
||||
module:hook('room_has_host', function(event)
|
||||
local room, session = event.room, event.session;
|
||||
local moderated, _, tenant = is_moderated(room.jid);
|
||||
|
||||
if not moderated then
|
||||
return nil;
|
||||
end
|
||||
|
||||
if not tenant and allowner_issuers and not allowner_issuers:contains(session.jitsi_meet_auth_issuer) then
|
||||
-- this will stop listeners execution and will return false, if we require a specific issuer for
|
||||
-- a moderated room without a tenant and the issuer is not correct
|
||||
return false;
|
||||
end
|
||||
end, 1); -- we want it executed before the one in wait_for_host module
|
||||
|
||||
module:hook_global('config-reloaded', load_config);
|
||||
|
||||
-- Filters self-presences to a jid that exist in joining_participants array
|
||||
-- We want to filter those presences where we send first `participant` and just after it `moderator`
|
||||
function filter_stanza(stanza)
|
||||
-- when joining_moderator_participants is empty there is nothing to filter
|
||||
if next(joining_moderator_participants) == nil
|
||||
or not stanza.attr
|
||||
or not stanza.attr.to
|
||||
or stanza.name ~= "presence" then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- we want to filter presences only on this host for allowners and skip anything like lobby etc.
|
||||
local host_from = jid_host(room_jid_match_rewrite(stanza.attr.from));
|
||||
if host_from ~= module.host then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local bare_to = jid_bare(stanza.attr.to);
|
||||
if stanza:get_error() and joining_moderator_participants[bare_to] then
|
||||
-- pre-join succeeded but joined did not so we need to clear cache
|
||||
joining_moderator_participants[bare_to] = nil;
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local muc_x = stanza:get_child('x', MUC_NS..'#user');
|
||||
if not muc_x then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
if joining_moderator_participants[bare_to] and presence_check_status(muc_x, '110') then
|
||||
-- skip the local presence for participant
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- skip sending the 'participant' presences to all other people in the room
|
||||
for item in muc_x:childtags('item') do
|
||||
if joining_moderator_participants[jid_bare(item.attr.jid)] then
|
||||
return nil;
|
||||
end
|
||||
end
|
||||
|
||||
return stanza;
|
||||
end
|
||||
function filter_session(session)
|
||||
-- domain mapper is filtering on default priority 0, and we need it after that
|
||||
filters.add_filter(session, 'stanzas/out', filter_stanza, -1);
|
||||
end
|
||||
|
||||
-- enable filtering presences
|
||||
filters.add_filter_hook(filter_session);
|
||||
|
||||
-- filters any attempt to revoke owner rights on non moderated rooms
|
||||
function filter_admin_set_query(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
local room_jid = jid_bare(stanza.attr.to);
|
||||
local room = get_room_from_jid(room_jid);
|
||||
|
||||
-- if it is a moderated room we skip it
|
||||
if room and is_moderated(room.jid) then
|
||||
return nil;
|
||||
end
|
||||
|
||||
for i=1,#stanza.tags[1] do
|
||||
local item = stanza.tags[1].tags[i];
|
||||
local _aff = item.attr.affiliation;
|
||||
|
||||
-- any revoking is disabled, everyone should be owners
|
||||
if _aff == 'none' or _aff == 'outcast' or _aff == 'member' then
|
||||
origin.send(st.error_reply(stanza, "auth", "forbidden"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not disable_revoke_owners then
|
||||
-- default prosody priority for handling these is -2
|
||||
module:hook("iq-set/bare/http://jabber.org/protocol/muc#admin:query", filter_admin_set_query, 5);
|
||||
module:hook("iq-set/host/http://jabber.org/protocol/muc#admin:query", filter_admin_set_query, 5);
|
||||
end
|
||||
99
resources/prosody-plugins/mod_muc_auth_ban.lua
Normal file
99
resources/prosody-plugins/mod_muc_auth_ban.lua
Normal file
@@ -0,0 +1,99 @@
|
||||
-- Can be used to ban users based on external http service
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local ACCESS_MANAGER_URL = module:get_option_string("muc_prosody_jitsi_access_manager_url");
|
||||
if not ACCESS_MANAGER_URL then
|
||||
module:log("warn", "No 'muc_prosody_jitsi_access_manager_url' option set, disabling module");
|
||||
return
|
||||
end
|
||||
|
||||
local json = require "cjson.safe";
|
||||
local http = require "net.http";
|
||||
local inspect = require 'inspect';
|
||||
|
||||
local util = module:require 'util';
|
||||
local starts_with = util.starts_with;
|
||||
|
||||
local ban_check_count = module:measure("muc_auth_ban_check", "rate")
|
||||
local ban_check_users_banned_count = module:measure("muc_auth_ban_users_banned", "rate")
|
||||
local ban_check_error_count = module:measure("muc_auth_ban_check_error", "rate")
|
||||
|
||||
-- we will cache banned tokens to avoid extra requests
|
||||
-- on destroying session, websocket retries 2 more times before giving up
|
||||
local cache = require "util.cache".new(100);
|
||||
|
||||
local CACHE_DURATION = 5*60; -- 5 mins
|
||||
|
||||
local cache_timer = module:add_timer(CACHE_DURATION, function()
|
||||
for k, v in cache:items() do
|
||||
if socket.gettime() > v + CACHE_DURATION then
|
||||
cache:set(k, nil);
|
||||
end
|
||||
end
|
||||
|
||||
if cache:count() > 0 then
|
||||
-- rescheduling the timer
|
||||
return CACHE_DURATION;
|
||||
end
|
||||
|
||||
-- skipping return value stops the timer
|
||||
end);
|
||||
|
||||
local function shouldAllow(session)
|
||||
local token = session.auth_token;
|
||||
|
||||
if token ~= nil then
|
||||
-- cached tokens are banned
|
||||
if cache:get(token) then
|
||||
return false;
|
||||
end
|
||||
|
||||
if not starts_with(session.jitsi_web_query_prefix, 'vpaas-magic-cookie-') then
|
||||
return true;
|
||||
end
|
||||
|
||||
-- TODO: do this only for enabled customers
|
||||
ban_check_count();
|
||||
local function cb(content, code, response, request)
|
||||
if code == 200 then
|
||||
|
||||
local r = json.decode(content)
|
||||
if r ~= nil and r['access'] == false then
|
||||
module:log("info", "User is banned room:%s tenant:%s user_id:%s group:%s",
|
||||
session.jitsi_web_query_room, session.jitsi_web_query_prefix,
|
||||
inspect(session.jitsi_meet_context_user), session.jitsi_meet_context_group);
|
||||
|
||||
ban_check_users_banned_count();
|
||||
|
||||
session:close();
|
||||
|
||||
-- if the cache is empty and the timer is not running reschedule it
|
||||
if cache:count() == 0 then
|
||||
cache_timer:reschedule(CACHE_DURATION);
|
||||
end
|
||||
|
||||
cache:set(token, socket.gettime());
|
||||
end
|
||||
else
|
||||
ban_check_error_count();
|
||||
module:log("warn", "Error code:%s contacting url:%s content:%s room:%s tenant:%s response:%s request:%s",
|
||||
code, ACCESS_MANAGER_URL, session.jitsi_web_query_room, session.jitsi_web_query_prefix,
|
||||
inspect(response), inspect(request), content);
|
||||
end
|
||||
end
|
||||
|
||||
local request_headers = {}
|
||||
request_headers['Authorization'] = 'Bearer ' .. token;
|
||||
|
||||
http.request(ACCESS_MANAGER_URL, {
|
||||
headers = request_headers,
|
||||
method = "GET",
|
||||
}, cb);
|
||||
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
prosody.events.add_handler("jitsi-access-ban-check", function(session)
|
||||
return shouldAllow(session)
|
||||
end)
|
||||
813
resources/prosody-plugins/mod_muc_breakout_rooms.lua
Normal file
813
resources/prosody-plugins/mod_muc_breakout_rooms.lua
Normal file
@@ -0,0 +1,813 @@
|
||||
-- This module is added under the main virtual host domain
|
||||
-- It needs a breakout rooms muc component
|
||||
--
|
||||
-- VirtualHost "jitmeet.example.com"
|
||||
-- modules_enabled = {
|
||||
-- "muc_breakout_rooms"
|
||||
-- }
|
||||
-- breakout_rooms_muc = "breakout.jitmeet.example.com"
|
||||
-- main_muc = "muc.jitmeet.example.com"
|
||||
--
|
||||
-- Component "breakout.jitmeet.example.com" "muc"
|
||||
-- restrict_room_creation = true
|
||||
-- storage = "memory"
|
||||
-- admins = { "focusUser@auth.jitmeet.example.com" }
|
||||
-- muc_room_locking = false
|
||||
-- muc_room_default_public_jids = true
|
||||
--
|
||||
module:depends('room_destroy');
|
||||
|
||||
-- we use async to detect Prosody 0.10 and earlier
|
||||
local have_async = pcall(require, 'util.async');
|
||||
|
||||
if not have_async then
|
||||
module:log('warn', 'Breakout rooms will not work with Prosody version 0.10 or less.');
|
||||
return;
|
||||
end
|
||||
|
||||
local jid_node = require 'util.jid'.node;
|
||||
local jid_host = require 'util.jid'.host;
|
||||
local jid_split = require 'util.jid'.split;
|
||||
local jid_resource = require 'util.jid'.resource;
|
||||
local jid_bare = require 'util.jid'.bare;
|
||||
local json = require 'cjson.safe';
|
||||
local st = require 'util.stanza';
|
||||
local uuid_gen = require 'util.uuid'.generate;
|
||||
|
||||
local util = module:require 'util';
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local process_host_module = util.process_host_module;
|
||||
local is_admin = util.is_admin;
|
||||
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
|
||||
|
||||
local BREAKOUT_ROOMS_IDENTITY_TYPE = 'breakout_rooms';
|
||||
-- Available breakout room functionality
|
||||
local RENAME_FEATURE = 'http://jitsi.org/protocol/breakout_rooms#rename';
|
||||
-- only send at most this often updates on breakout rooms to avoid flooding.
|
||||
local BROADCAST_ROOMS_INTERVAL = .3;
|
||||
-- close conference after this amount of seconds if all leave.
|
||||
local ROOMS_TTL_IF_ALL_LEFT = 5;
|
||||
local JSON_TYPE_ADD_BREAKOUT_ROOM = 'features/breakout-rooms/add';
|
||||
local JSON_TYPE_MOVE_TO_ROOM_REQUEST = 'features/breakout-rooms/move-to-room';
|
||||
local JSON_TYPE_REMOVE_BREAKOUT_ROOM = 'features/breakout-rooms/remove';
|
||||
local JSON_TYPE_RENAME_BREAKOUT_ROOM = 'features/breakout-rooms/rename';
|
||||
local JSON_TYPE_UPDATE_BREAKOUT_ROOMS = 'features/breakout-rooms/update';
|
||||
|
||||
local main_muc_component_config = module:get_option_string('main_muc');
|
||||
if main_muc_component_config == nil then
|
||||
module:log('error', 'breakout rooms not enabled missing main_muc config');
|
||||
return ;
|
||||
end
|
||||
local breakout_rooms_muc_component_config = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);
|
||||
|
||||
module:depends('jitsi_session');
|
||||
|
||||
local breakout_rooms_muc_service;
|
||||
local main_muc_service;
|
||||
|
||||
-- Maps a breakout room jid to the main room jid
|
||||
local main_rooms_map = {};
|
||||
|
||||
-- Maps a full room JID to a bare connection jid for a participant that's changing rooms.
|
||||
local cache = require 'util.cache';
|
||||
local switching_room_cache = cache.new(1000);
|
||||
|
||||
-- Utility functions
|
||||
|
||||
function get_main_room_jid(room_jid)
|
||||
local _, host = jid_split(room_jid);
|
||||
|
||||
return
|
||||
host == main_muc_component_config
|
||||
and room_jid
|
||||
or main_rooms_map[room_jid];
|
||||
end
|
||||
|
||||
function get_main_room(room_jid)
|
||||
local main_room_jid = get_main_room_jid(room_jid);
|
||||
|
||||
return main_muc_service.get_room_from_jid(main_room_jid), main_room_jid;
|
||||
end
|
||||
|
||||
function get_room_from_jid(room_jid)
|
||||
local host = jid_host(room_jid);
|
||||
|
||||
return
|
||||
host == main_muc_component_config
|
||||
and main_muc_service.get_room_from_jid(room_jid)
|
||||
or breakout_rooms_muc_service.get_room_from_jid(room_jid);
|
||||
end
|
||||
|
||||
function send_json_msg(to_jid, json_msg)
|
||||
local stanza = st.message({ from = breakout_rooms_muc_component_config; to = to_jid; })
|
||||
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_msg):up();
|
||||
module:send(stanza);
|
||||
end
|
||||
|
||||
function get_participants(room)
|
||||
local participants = {};
|
||||
|
||||
if room then
|
||||
for room_nick, occupant in room:each_occupant() do
|
||||
-- Filter focus as we keep it as a hidden participant
|
||||
if not is_admin(occupant.bare_jid) then
|
||||
local display_name = occupant:get_presence():get_child_text(
|
||||
'nick', 'http://jabber.org/protocol/nick');
|
||||
local real_nick = internal_room_jid_match_rewrite(room_nick);
|
||||
participants[real_nick] = {
|
||||
jid = occupant.jid,
|
||||
role = occupant.role,
|
||||
displayName = display_name
|
||||
};
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return participants;
|
||||
end
|
||||
|
||||
function broadcast_breakout_rooms(room_jid)
|
||||
local main_room = get_main_room(room_jid);
|
||||
|
||||
if not main_room or main_room.broadcast_timer then
|
||||
return;
|
||||
end
|
||||
|
||||
-- Only send each BROADCAST_ROOMS_INTERVAL seconds to prevent flooding of messages.
|
||||
main_room.broadcast_timer = module:add_timer(BROADCAST_ROOMS_INTERVAL, function()
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
|
||||
if not main_room then
|
||||
return;
|
||||
end
|
||||
|
||||
main_room.broadcast_timer = nil;
|
||||
|
||||
local real_jid = internal_room_jid_match_rewrite(main_room_jid);
|
||||
local real_node = jid_node(real_jid);
|
||||
local rooms = {
|
||||
[real_node] = {
|
||||
isMainRoom = true,
|
||||
id = real_node,
|
||||
jid = real_jid,
|
||||
name = main_room._data.subject,
|
||||
participants = get_participants(main_room)
|
||||
};
|
||||
}
|
||||
|
||||
for breakout_room_jid, subject in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
local breakout_room_node = jid_node(breakout_room_jid)
|
||||
|
||||
rooms[breakout_room_node] = {
|
||||
id = breakout_room_node,
|
||||
jid = breakout_room_jid,
|
||||
name = subject,
|
||||
participants = {}
|
||||
}
|
||||
|
||||
-- The room may not physically exist yet.
|
||||
if breakout_room then
|
||||
rooms[breakout_room_node].participants = get_participants(breakout_room);
|
||||
end
|
||||
end
|
||||
|
||||
local json_msg, error = json.encode({
|
||||
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
|
||||
event = JSON_TYPE_UPDATE_BREAKOUT_ROOMS,
|
||||
roomCounter = main_room._data.breakout_rooms_counter,
|
||||
rooms = rooms
|
||||
});
|
||||
|
||||
if not json_msg then
|
||||
module:log('error', 'not broadcasting breakout room information room:%s error:%s', main_room_jid, error);
|
||||
return;
|
||||
end
|
||||
|
||||
for _, occupant in main_room:each_occupant() do
|
||||
if not is_admin(occupant.bare_jid) then
|
||||
send_json_msg(occupant.jid, json_msg)
|
||||
end
|
||||
end
|
||||
|
||||
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
if room then
|
||||
for _, occupant in room:each_occupant() do
|
||||
if not is_admin(occupant.bare_jid) then
|
||||
send_json_msg(occupant.jid, json_msg)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
|
||||
-- Managing breakout rooms
|
||||
|
||||
function create_breakout_room(orig_room, subject)
|
||||
local main_room, main_room_jid = get_main_room(orig_room.jid);
|
||||
|
||||
if orig_room ~= main_room then
|
||||
module:log('warn', 'Invalid create breakout room request for %s', orig_room.jid);
|
||||
return;
|
||||
end
|
||||
|
||||
local breakout_room_jid = uuid_gen() .. '@' .. breakout_rooms_muc_component_config;
|
||||
|
||||
if not main_room._data.breakout_rooms then
|
||||
main_room._data.breakout_rooms = {};
|
||||
main_room._data.breakout_rooms_counter = 0;
|
||||
end
|
||||
main_room._data.breakout_rooms_counter = main_room._data.breakout_rooms_counter + 1;
|
||||
main_room._data.breakout_rooms[breakout_room_jid] = subject;
|
||||
main_room._data.breakout_rooms_active = true;
|
||||
-- Make room persistent - not to be destroyed - if all participants join breakout rooms.
|
||||
main_room:set_persistent(true);
|
||||
main_room:save(true);
|
||||
|
||||
main_rooms_map[breakout_room_jid] = main_room_jid;
|
||||
broadcast_breakout_rooms(main_room_jid);
|
||||
end
|
||||
|
||||
function destroy_breakout_room(orig_room, room_jid, message)
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
|
||||
if room_jid == main_room_jid then
|
||||
return;
|
||||
end
|
||||
|
||||
if orig_room ~= main_room then
|
||||
module:log('warn', 'Invalid destroy breakout room request for %s', orig_room.jid);
|
||||
return;
|
||||
end
|
||||
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
|
||||
|
||||
if breakout_room then
|
||||
message = message or 'Breakout room removed.';
|
||||
breakout_room:destroy(main_room and main_room_jid or nil, message);
|
||||
end
|
||||
if main_room then
|
||||
if main_room._data.breakout_rooms then
|
||||
main_room._data.breakout_rooms[room_jid] = nil;
|
||||
end
|
||||
main_room:save(true);
|
||||
|
||||
main_rooms_map[room_jid] = nil;
|
||||
broadcast_breakout_rooms(main_room_jid);
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
function rename_breakout_room(orig_room, room_jid, name)
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
|
||||
if room_jid == main_room_jid then
|
||||
return;
|
||||
end
|
||||
|
||||
if orig_room ~= main_room then
|
||||
module:log('warn', 'Invalid rename breakout room request for %s', orig_room.jid);
|
||||
return;
|
||||
end
|
||||
|
||||
if main_room then
|
||||
if main_room._data.breakout_rooms then
|
||||
main_room._data.breakout_rooms[room_jid] = name;
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(room_jid);
|
||||
|
||||
if breakout_room then
|
||||
breakout_room:set_subject(breakout_room.jid, name);
|
||||
end
|
||||
|
||||
end
|
||||
main_room:save(true);
|
||||
broadcast_breakout_rooms(main_room_jid);
|
||||
end
|
||||
end
|
||||
|
||||
-- Handling events
|
||||
|
||||
function on_message(event)
|
||||
local session = event.origin;
|
||||
|
||||
-- Check the type of the incoming stanza to avoid loops:
|
||||
if event.stanza.attr.type == 'error' then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
return false;
|
||||
end
|
||||
|
||||
local message = event.stanza:get_child(BREAKOUT_ROOMS_IDENTITY_TYPE);
|
||||
|
||||
if not message then
|
||||
return false;
|
||||
end
|
||||
|
||||
-- get room name with tenant and find room
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- check that the participant requesting is a moderator and is an occupant in the room
|
||||
local from = event.stanza.attr.from;
|
||||
local occupant = get_occupant_by_real_jid(room, from);
|
||||
|
||||
if not occupant then
|
||||
module:log('warn', 'No occupant %s found for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if occupant.role ~= 'moderator' then
|
||||
module:log('warn', 'Occupant %s is not moderator and not allowed this operation for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if message.attr.type == JSON_TYPE_ADD_BREAKOUT_ROOM then
|
||||
create_breakout_room(room, message.attr.subject);
|
||||
return true;
|
||||
elseif message.attr.type == JSON_TYPE_REMOVE_BREAKOUT_ROOM then
|
||||
destroy_breakout_room(room, message.attr.breakoutRoomJid);
|
||||
return true;
|
||||
elseif message.attr.type == JSON_TYPE_RENAME_BREAKOUT_ROOM then
|
||||
rename_breakout_room(room, message.attr.breakoutRoomJid, message.attr.subject);
|
||||
return true;
|
||||
elseif message.attr.type == JSON_TYPE_MOVE_TO_ROOM_REQUEST then
|
||||
local participant_jid = message.attr.participantJid;
|
||||
local target_room_jid = message.attr.roomJid;
|
||||
|
||||
if not room._data.breakout_rooms or not (
|
||||
room._data.breakout_rooms[target_room_jid] or target_room_jid == internal_room_jid_match_rewrite(room.jid))
|
||||
then
|
||||
module:log('warn', 'Invalid breakout room %s for %s', target_room_jid, room.jid);
|
||||
return false
|
||||
end
|
||||
|
||||
local json_msg, error = json.encode({
|
||||
type = BREAKOUT_ROOMS_IDENTITY_TYPE,
|
||||
event = JSON_TYPE_MOVE_TO_ROOM_REQUEST,
|
||||
roomJid = target_room_jid
|
||||
});
|
||||
|
||||
if not json_msg then
|
||||
module:log('error', 'skip sending request room:%s error:%s', room.jid, error);
|
||||
return false
|
||||
end
|
||||
|
||||
send_json_msg(participant_jid, json_msg)
|
||||
return true;
|
||||
end
|
||||
|
||||
-- return error.
|
||||
return false;
|
||||
end
|
||||
|
||||
function on_breakout_room_pre_create(event)
|
||||
local breakout_room = event.room;
|
||||
local main_room, main_room_jid = get_main_room(breakout_room.jid);
|
||||
|
||||
-- Only allow existent breakout rooms to be started.
|
||||
-- Authorisation of breakout rooms is done by their random uuid name
|
||||
if main_room and main_room._data.breakout_rooms and main_room._data.breakout_rooms[breakout_room.jid] then
|
||||
breakout_room:set_subject(breakout_room.jid, main_room._data.breakout_rooms[breakout_room.jid]);
|
||||
else
|
||||
module:log('debug', 'Invalid breakout room %s will not be created.', breakout_room.jid);
|
||||
|
||||
-- Tell the client that the join failed. Without this reply the join presence is
|
||||
-- dropped without an answer, and the client waits until its own timeout.
|
||||
-- Prosody sends an error for restrict_room_creation, but that handler and this one
|
||||
-- both use the default priority, so the order between the two is not defined.
|
||||
if event.origin and event.stanza then
|
||||
event.origin.send(st.error_reply(
|
||||
event.stanza, 'cancel', 'item-not-found', 'Breakout room is invalid.'));
|
||||
end
|
||||
|
||||
breakout_room:destroy(main_room_jid, 'Breakout room is invalid.');
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
function on_occupant_joined(event)
|
||||
local occupant, room, session = event.occupant, event.room, event.origin;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local main_room, main_room_jid = get_main_room(room.jid);
|
||||
local isFocus = is_admin(occupant.bare_jid);
|
||||
|
||||
if main_room and main_room._data.breakout_rooms_active then
|
||||
if not isFocus then
|
||||
broadcast_breakout_rooms(main_room_jid);
|
||||
end
|
||||
|
||||
-- Prevent closing all rooms if a participant has joined (see on_occupant_left).
|
||||
if main_room.close_timer then
|
||||
main_room.close_timer:stop();
|
||||
main_room.close_timer = nil;
|
||||
end
|
||||
|
||||
-- clear any switching state for this occupant, we always store main room / resource
|
||||
switching_room_cache:set(main_room_jid..'/'..jid_resource(occupant.nick), nil);
|
||||
end
|
||||
|
||||
-- check if this is joining main room or breakout room
|
||||
if main_room == room and not isFocus then
|
||||
-- mark that this session was in the main room
|
||||
session.jitsi_breakout_main_jid = main_room_jid;
|
||||
end
|
||||
end
|
||||
|
||||
function exist_occupants_in_room(room)
|
||||
if not room then
|
||||
return false;
|
||||
end
|
||||
for _, occupant in room:each_occupant() do
|
||||
if not is_admin(occupant.bare_jid) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
function exist_occupants_in_rooms(main_room)
|
||||
if exist_occupants_in_room(main_room) then
|
||||
return true;
|
||||
end
|
||||
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
if exist_occupants_in_room(room) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
function on_occupant_pre_leave(event)
|
||||
local room, occupant, session, stanza = event.room, event.occupant, event.origin, event.stanza;
|
||||
|
||||
local main_room = get_main_room(room.jid);
|
||||
|
||||
prosody.events.fire_event('jitsi-breakout-occupant-leaving', {
|
||||
room = room; main_room = main_room; occupant = occupant; stanza = stanza; session = session;
|
||||
});
|
||||
|
||||
local presence_status = stanza:get_child_text('status');
|
||||
if presence_status == 'switch_room' then
|
||||
switching_room_cache:set(main_room.jid..'/'..jid_resource(occupant.nick), jid_bare(occupant.jid));
|
||||
end
|
||||
end
|
||||
|
||||
function on_occupant_left(event)
|
||||
local room_jid = event.room.jid;
|
||||
|
||||
if is_healthcheck_room(room_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
|
||||
if not main_room then
|
||||
return;
|
||||
end
|
||||
|
||||
if main_room._data.breakout_rooms_active and not is_admin(event.occupant.bare_jid) then
|
||||
broadcast_breakout_rooms(main_room_jid);
|
||||
end
|
||||
|
||||
-- Close the conference if all left for good.
|
||||
if main_room._data.breakout_rooms_active and not main_room.close_timer and not exist_occupants_in_rooms(main_room) then
|
||||
main_room.close_timer = module:add_timer(ROOMS_TTL_IF_ALL_LEFT, function()
|
||||
-- we need to look up again the room as till the timer is fired, the room maybe already destroyed/recreated
|
||||
-- and we will have the old instance
|
||||
local main_room, main_room_jid = get_main_room(room_jid);
|
||||
if main_room and main_room.close_timer then
|
||||
prosody.events.fire_event("maybe-destroy-room", {
|
||||
room = main_room;
|
||||
reason = 'All occupants left.';
|
||||
caller = module:get_name();
|
||||
});
|
||||
end
|
||||
end);
|
||||
end
|
||||
end
|
||||
|
||||
-- Stop other modules from destroying room if breakout rooms not empty
|
||||
function handle_maybe_destroy_main_room(event)
|
||||
local main_room = event.room;
|
||||
local caller = event.caller;
|
||||
|
||||
if caller == module:get_name() then
|
||||
-- we were the one that requested the deletion. Do not override.
|
||||
return nil; -- stop room destruction
|
||||
end
|
||||
|
||||
-- deletion was requested by another module. Check for break room occupants.
|
||||
for breakout_room_jid, _ in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
if breakout_room and breakout_room:has_occupant() then
|
||||
module:log('info', 'Suppressing room destroy. Breakout room still occupied %s', breakout_room_jid);
|
||||
return true; -- stop room destruction
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module:hook_global("maybe-destroy-room", handle_maybe_destroy_main_room)
|
||||
|
||||
|
||||
function on_main_room_destroyed(event)
|
||||
local main_room = event.room;
|
||||
|
||||
if is_healthcheck_room(main_room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
|
||||
destroy_breakout_room(main_room, breakout_room_jid, event.reason)
|
||||
end
|
||||
end
|
||||
|
||||
-- Checks for a conflict with a JID in the switching_room_cache. In case of a conflict sends an error and returns true (the join is not allowed).
|
||||
-- in switching_room_cache is the same as the jid that is sending the stanza, if that is the case we can allow
|
||||
-- the join to proceed by returning false. If there is no match we send an error and return true
|
||||
-- which should halt the join.
|
||||
-- @param jid - The jid to check, this is the jid requested to join breakout or main room
|
||||
-- @param from_bare_jid - The real jid of the occupant trying to join
|
||||
-- @param room - The room being joined
|
||||
-- @param stanza - The presence stanza
|
||||
-- @param origin - The session origin to send error if needed
|
||||
function check_switching_state(jid, from_bare_jid, room, stanza, origin)
|
||||
local switching_session_jid = switching_room_cache:get(jid);
|
||||
|
||||
if switching_session_jid and switching_session_jid ~= from_bare_jid then
|
||||
local reply = st.error_reply(stanza, "cancel", "conflict", nil, room.jid):up();
|
||||
origin.send(reply);
|
||||
return true;
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
function check_for_existing_occupant_in_room(room, requested_resource, bare_jid, stanza, origin)
|
||||
local dest_occupant = room:get_occupant_by_nick(room.jid..'/'..requested_resource);
|
||||
if dest_occupant ~= nil and bare_jid ~= jid_bare(dest_occupant.bare_jid) then
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'conflict', nil, room.jid):up());
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- This is a request to join or change jid in main or breakout room. We need to check whether the requested jid does not
|
||||
-- conflict with a jid which is currently in switching state or already in another room.
|
||||
function on_occupant_pre_join_or_change(e)
|
||||
local room, stanza, origin = e.room, e.stanza, e.origin;
|
||||
local occupant = e.dest_occupant or e.occupant;
|
||||
local requested_jid = stanza.attr.to;
|
||||
|
||||
local main_room = get_main_room(room.jid);
|
||||
|
||||
-- case where the room can be destroyed while someone is switching to it
|
||||
if not main_room then
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
|
||||
return true;
|
||||
end
|
||||
|
||||
if is_admin(occupant.bare_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local main_room_requested_jid = main_room.jid..'/'..jid_resource(requested_jid);
|
||||
local bare_jid = jid_bare(stanza.attr.from);
|
||||
|
||||
-- we always store main room jid with resource in switching cache
|
||||
if check_switching_state(main_room_requested_jid, bare_jid, room, stanza, origin) then
|
||||
return true;
|
||||
end
|
||||
|
||||
if main_room == room then
|
||||
-- this is the main room we need to check all its breakout rooms
|
||||
for breakout_room_jid in pairs(room._data.breakout_rooms or {}) do
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
if breakout_room then
|
||||
if check_for_existing_occupant_in_room(
|
||||
breakout_room, jid_resource(requested_jid), bare_jid, stanza, origin) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
if origin.jitsi_breakout_main_jid ~= main_room.jid then
|
||||
-- this session did not come from the main room
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
|
||||
return true;
|
||||
end
|
||||
|
||||
-- this is a breakout room let's check the main room
|
||||
if check_for_existing_occupant_in_room(main_room, jid_resource(requested_jid), bare_jid, stanza, origin) then
|
||||
return true;
|
||||
end
|
||||
|
||||
-- now let's check the rest of the breakout rooms
|
||||
for breakout_room_jid in pairs(main_room._data.breakout_rooms or {}) do
|
||||
local breakout_room = breakout_rooms_muc_service.get_room_from_jid(breakout_room_jid);
|
||||
if breakout_room then
|
||||
if check_for_existing_occupant_in_room(
|
||||
breakout_room, jid_resource(requested_jid), bare_jid, stanza, origin) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Module operations
|
||||
|
||||
-- operates on already loaded breakout rooms muc module
|
||||
function process_breakout_rooms_muc_loaded(breakout_rooms_muc, host_module)
|
||||
module:log('debug', 'Breakout rooms muc loaded');
|
||||
|
||||
-- Advertise the breakout rooms component so clients can pick up the address and use it
|
||||
module:add_identity('component', BREAKOUT_ROOMS_IDENTITY_TYPE, breakout_rooms_muc_component_config);
|
||||
|
||||
-- Tag the disco#info response with available features of breakout rooms.
|
||||
host_module:hook('host-disco-info-node', function (event)
|
||||
local session, reply, node = event.origin, event.reply, event.node;
|
||||
if node == BREAKOUT_ROOMS_IDENTITY_TYPE and session.jitsi_web_query_room then
|
||||
reply:tag('feature', { var = RENAME_FEATURE }):up();
|
||||
end
|
||||
event.exists = true;
|
||||
end);
|
||||
|
||||
breakout_rooms_muc_service = breakout_rooms_muc;
|
||||
module:log("info", "Hook to muc events on %s", breakout_rooms_muc_component_config);
|
||||
host_module:hook('message/host', on_message);
|
||||
host_module:hook('muc-occupant-joined', on_occupant_joined);
|
||||
host_module:hook('muc-occupant-left', on_occupant_left);
|
||||
host_module:hook('muc-room-pre-create', on_breakout_room_pre_create);
|
||||
host_module:hook('muc-occupant-pre-leave', on_occupant_pre_leave);
|
||||
host_module:hook('muc-occupant-pre-join', on_occupant_pre_join_or_change);
|
||||
host_module:hook('muc-occupant-pre-change', on_occupant_pre_join_or_change);
|
||||
|
||||
host_module:hook('muc-disco#info', function (event)
|
||||
local room = event.room;
|
||||
local main_room, main_room_jid = get_main_room(room.jid);
|
||||
|
||||
-- Breakout room metadata.
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_isbreakout';
|
||||
label = 'Is this a breakout room?';
|
||||
type = "boolean";
|
||||
});
|
||||
event.formdata['muc#roominfo_isbreakout'] = true;
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_breakout_main_room';
|
||||
label = 'The main room associated with this breakout room';
|
||||
});
|
||||
event.formdata['muc#roominfo_breakout_main_room'] = internal_room_jid_match_rewrite(main_room_jid);
|
||||
|
||||
-- If the main room has a lobby, make it so this breakout room also uses it.
|
||||
if (main_room and main_room._data.lobbyroom and main_room:get_members_only()) then
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_lobbyroom';
|
||||
label = 'Lobby room jid';
|
||||
});
|
||||
event.formdata['muc#roominfo_lobbyroom'] = main_room._data.lobbyroom;
|
||||
end
|
||||
end);
|
||||
|
||||
host_module:hook("muc-config-form", function(event)
|
||||
local room = event.room;
|
||||
local _, main_room_jid = get_main_room(room.jid);
|
||||
|
||||
-- Breakout room metadata.
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_isbreakout';
|
||||
label = 'Is this a breakout room?';
|
||||
type = "boolean";
|
||||
value = true;
|
||||
});
|
||||
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_breakout_main_room';
|
||||
label = 'The main room associated with this breakout room';
|
||||
value = internal_room_jid_match_rewrite(main_room_jid);
|
||||
});
|
||||
end);
|
||||
|
||||
local room_mt = breakout_rooms_muc_service.room_mt;
|
||||
|
||||
room_mt.get_members_only = function(room)
|
||||
local main_room = get_main_room(room.jid);
|
||||
|
||||
if not main_room then
|
||||
module:log('error', 'No main room (%s)!', room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
return main_room.get_members_only(main_room)
|
||||
end
|
||||
|
||||
-- we base affiliations (roles) in breakout rooms muc component to be based on the roles in the main muc
|
||||
room_mt.get_affiliation = function(room, jid)
|
||||
local main_room, _ = get_main_room(room.jid);
|
||||
|
||||
if not main_room then
|
||||
module:log('error', 'No main room(%s) for %s!', room.jid, jid);
|
||||
return 'none';
|
||||
end
|
||||
|
||||
-- moderators in main room are moderators here
|
||||
local role = main_room.get_affiliation(main_room, jid);
|
||||
if role then
|
||||
return role;
|
||||
end
|
||||
|
||||
return 'none';
|
||||
end
|
||||
end
|
||||
|
||||
-- process or waits to process the breakout rooms muc component
|
||||
process_host_module(breakout_rooms_muc_component_config, function(host_module, host)
|
||||
module:log('info', 'Breakout rooms component created %s', host);
|
||||
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_breakout_rooms_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_breakout_rooms_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
-- clears switching_room_cache on resource unbind (disconnect one way or another)
|
||||
local function handle_pre_resource_unbind(event)
|
||||
local participant_bare_jid = jid_bare(event.jid);
|
||||
|
||||
-- check switching_room_cache for anyone that is switching rooms but got disconnected so we can clean up the map
|
||||
local keysToRemove = {};
|
||||
for key, value in switching_room_cache:items() do
|
||||
if value == participant_bare_jid then
|
||||
table.insert(keysToRemove, key)
|
||||
end
|
||||
end
|
||||
|
||||
for _, key in ipairs(keysToRemove) do
|
||||
switching_room_cache:set(key, nil);
|
||||
end
|
||||
end
|
||||
module:hook_global('jitsi-pre-session-unbind', handle_pre_resource_unbind);
|
||||
|
||||
-- operates on already loaded main muc module
|
||||
function process_main_muc_loaded(main_muc, host_module)
|
||||
module:log('debug', 'Main muc loaded');
|
||||
|
||||
main_muc_service = main_muc;
|
||||
module:log("info", "Hook to muc events on %s", main_muc_component_config);
|
||||
host_module:hook('muc-occupant-joined', on_occupant_joined);
|
||||
host_module:hook('muc-occupant-left', on_occupant_left);
|
||||
host_module:hook('muc-room-destroyed', on_main_room_destroyed, 1); -- prosody handles it at 0
|
||||
|
||||
host_module:hook('muc-occupant-pre-leave', function(event)
|
||||
local room, occupant, session, stanza = event.room, event.occupant, event.origin, event.stanza;
|
||||
|
||||
local presence_status = stanza:get_child_text('status');
|
||||
if presence_status == 'switch_room' then
|
||||
switching_room_cache:set(occupant.nick, jid_bare(occupant.jid));
|
||||
end
|
||||
end);
|
||||
|
||||
host_module:hook('muc-occupant-pre-join', on_occupant_pre_join_or_change);
|
||||
host_module:hook('muc-occupant-pre-change', on_occupant_pre_join_or_change);
|
||||
end
|
||||
|
||||
-- process or waits to process the main muc component
|
||||
process_host_module(main_muc_component_config, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_main_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
106
resources/prosody-plugins/mod_muc_census.lua
Normal file
106
resources/prosody-plugins/mod_muc_census.lua
Normal file
@@ -0,0 +1,106 @@
|
||||
-- provides an http endpoint at /room-census that reports list of rooms with the
|
||||
-- number of members and created date in this JSON format:
|
||||
--
|
||||
-- {
|
||||
-- "room_census": [
|
||||
-- {
|
||||
-- "room_name": "<muc name>",
|
||||
-- "participants": <# participants>,
|
||||
-- "created_time": <unix timestamp>,
|
||||
-- },
|
||||
-- ...
|
||||
-- ]
|
||||
-- }
|
||||
--
|
||||
-- to activate, add "muc_census" to the modules_enabled table in prosody.cfg.lua
|
||||
--
|
||||
-- warning: this module is unprotected and intended for server admin use only.
|
||||
-- when enabled, make sure to secure the endpoint at the web server or via
|
||||
-- network filters
|
||||
|
||||
local jid = require "util.jid";
|
||||
local json = require 'cjson.safe';
|
||||
local iterators = require "util.iterators";
|
||||
local util = module:require "util";
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
|
||||
local have_async = pcall(require, "util.async");
|
||||
if not have_async then
|
||||
module:log("error", "requires a version of Prosody with util.async");
|
||||
return;
|
||||
end
|
||||
|
||||
local async_handler_wrapper = module:require "util".async_handler_wrapper;
|
||||
|
||||
local tostring = tostring;
|
||||
|
||||
-- required parameter for custom muc component prefix, defaults to "conference"
|
||||
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
|
||||
|
||||
local leaked_rooms = 0;
|
||||
|
||||
--- handles request to get number of participants in all rooms
|
||||
-- @return GET response
|
||||
function handle_get_room_census(event)
|
||||
local host_session = prosody.hosts[muc_domain_prefix .. "." .. tostring(module.host)]
|
||||
if not host_session or not host_session.modules.muc then
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
room_data = {}
|
||||
leaked_rooms = 0;
|
||||
for room in host_session.modules.muc.each_room() do
|
||||
if not is_healthcheck_room(room.jid) then
|
||||
local occupants = room._occupants;
|
||||
local participant_count = 0;
|
||||
local missing_connections_count = 0;
|
||||
|
||||
if occupants then
|
||||
for _, o in room:each_occupant() do
|
||||
participant_count = participant_count + 1;
|
||||
|
||||
-- let's check whether that occupant has connection in the full_sessions of prosody
|
||||
-- attempt to detect leaked occupants/rooms.
|
||||
if prosody.full_sessions[o.jid] == nil then
|
||||
missing_connections_count = missing_connections_count + 1;
|
||||
end
|
||||
end
|
||||
participant_count = participant_count - 1; -- subtract focus
|
||||
end
|
||||
|
||||
local leaked = false;
|
||||
if participant_count > 0 and missing_connections_count == participant_count then
|
||||
leaked = true;
|
||||
leaked_rooms = leaked_rooms + 1;
|
||||
end
|
||||
|
||||
table.insert(room_data, {
|
||||
room_name = room.jid;
|
||||
participants = participant_count;
|
||||
created_time = room.created_timestamp;
|
||||
leaked = leaked;
|
||||
});
|
||||
end
|
||||
end
|
||||
|
||||
-- cjson encodes an empty Lua table as {} (object); force array literal.
|
||||
local rc_json = #room_data == 0 and "[]" or json.encode(room_data);
|
||||
census_resp = '{"room_census":' .. rc_json .. '}';
|
||||
return { status_code = 200; body = census_resp }
|
||||
end
|
||||
|
||||
function module.load()
|
||||
module:depends("http");
|
||||
module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["GET room-census"] = function (event) return async_handler_wrapper(event,handle_get_room_census) end;
|
||||
};
|
||||
});
|
||||
end
|
||||
|
||||
-- we calculate the stats on the configured interval (60 seconds by default)
|
||||
local measure_leaked_rooms = module:measure('leaked_rooms', 'amount');
|
||||
module:hook_global('stats-update', function ()
|
||||
measure_leaked_rooms(leaked_rooms);
|
||||
end);
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Module to be enabled under the main MUC component.
|
||||
-- Destroys a conference room after a configurable timeout (default 20 s,
|
||||
-- overridable via services_empty_meeting_timeout) when the room is left
|
||||
-- occupied only by backend services — Jibri recorders or transcribers —
|
||||
-- with no regular participants remaining.
|
||||
--
|
||||
-- Identity is determined by JID prefix (util.lib.lua):
|
||||
-- is_jibri(): JID starts with 'recorder@recorder.', 'jibria@recorder.',
|
||||
-- or 'jibrib@recorder.'
|
||||
-- is_transcriber(): JID starts with 'transcriber@recorder.',
|
||||
-- 'transcribera@recorder.', or 'transcriberb@recorder.'
|
||||
-- is_admin(): bare JID is in the Prosody admins list
|
||||
--
|
||||
-- Hook summary:
|
||||
-- muc-occupant-joined (-100) — cancel the destroy timer when a regular user
|
||||
-- (not jibri, not transcriber) joins.
|
||||
-- muc-occupant-left (-100) — start the destroy timer when the leaver is a
|
||||
-- regular user and only backend services remain;
|
||||
-- skip entirely if the leaver is admin, jibri,
|
||||
-- or transcriber, or if breakout_rooms_active.
|
||||
-- muc-room-destroyed (1) — cancel and clear the timer reference.
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
local is_transcriber = util.is_transcriber;
|
||||
local is_jibri = util.is_jibri;
|
||||
|
||||
local EMPTY_TIMEOUT = module:get_option_number('services_empty_meeting_timeout', 20);
|
||||
|
||||
module:hook('muc-occupant-joined', function (event)
|
||||
local room = event.room;
|
||||
local occupant = event.occupant;
|
||||
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
-- clear the timer when someone joins
|
||||
if not is_jibri(occupant.jid) and not is_transcriber(occupant.jid) and room.empty_destroy_timer then
|
||||
room.empty_destroy_timer:stop();
|
||||
room.empty_destroy_timer = nil;
|
||||
end
|
||||
end, -100); -- make sure we are last in the chain
|
||||
|
||||
module:hook('muc-occupant-left', function (event)
|
||||
local occupant, room = event.occupant, event.room;
|
||||
|
||||
if is_admin(occupant.bare_jid) or is_jibri(occupant.jid) or is_transcriber(occupant.jid)
|
||||
or room._data.breakout_rooms_active then
|
||||
return;
|
||||
end
|
||||
|
||||
for _, o in room:each_occupant() do
|
||||
if not is_jibri(o.jid) and not is_transcriber(o.jid)
|
||||
and not is_admin(o.bare_jid) then
|
||||
-- not empty
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
-- seems the room only has jibri and transcriber, add a timeout to destroy the room
|
||||
if room.empty_destroy_timer then
|
||||
room.empty_destroy_timer:stop();
|
||||
end
|
||||
room.empty_destroy_timer = module:add_timer(EMPTY_TIMEOUT, function()
|
||||
if room.destroying then return end
|
||||
room:destroy(nil, 'Empty room with recording and/or transcribing.');
|
||||
|
||||
module:log('info',
|
||||
'the conference terminated %s as being empty for %s seconds with recording/transcribing enabled. By %s',
|
||||
room.jid, EMPTY_TIMEOUT, room.empty_destroy_timer);
|
||||
end)
|
||||
module:log('info', 'Added room destroy timer %s for %s', room.empty_destroy_timer, room.jid);
|
||||
end, -100); -- the last thing to execute
|
||||
|
||||
module:hook('muc-room-destroyed', function (event)
|
||||
local room = event.room;
|
||||
if room.empty_destroy_timer then
|
||||
room.empty_destroy_timer:stop();
|
||||
room.empty_destroy_timer = nil;
|
||||
end
|
||||
end, 1); -- prosody handles it at 0
|
||||
168
resources/prosody-plugins/mod_muc_displayname.lua
Normal file
168
resources/prosody-plugins/mod_muc_displayname.lua
Normal file
@@ -0,0 +1,168 @@
|
||||
-- Manages display names and identity visibility in MUC rooms.
|
||||
--
|
||||
-- Three behaviours:
|
||||
--
|
||||
-- 1. hideDisplayNameForGuests (outbound filter on stanzas/out, priority -100):
|
||||
-- When room._data.hideDisplayNameForGuests is true, strips identity elements
|
||||
-- (<nick>, <email>, <stats-id>, and name/email inside <identity>) from
|
||||
-- presence stanzas before they are delivered to non-moderator occupants.
|
||||
-- Moderator recipients and self-presence are always passed through unchanged.
|
||||
-- Uses session.jitsi_web_query_room / jitsi_web_query_prefix to locate the room.
|
||||
--
|
||||
-- 2. name-readonly (inbound filter on stanzas/in, priority -100):
|
||||
-- When session.jitsi_meet_context_features['name-readonly'] is true, enforces
|
||||
-- the display name from the JWT token on every outgoing presence from that session:
|
||||
-- * Token has context.user.name → replaces the <nick> element with the token name
|
||||
-- * Token has no name → removes the <nick> element entirely
|
||||
-- This prevents clients from changing their display name when the token marks
|
||||
-- it as read-only.
|
||||
--
|
||||
-- 3. Message display-name handling (MUC hooks):
|
||||
-- muc-broadcast-message (priority -1): strips <nick> and <display-name> from
|
||||
-- every message before it is broadcast to occupants, so message payloads
|
||||
-- never carry identity information to live receivers.
|
||||
-- muc-add-history: injects <display-name source="token|guest"> into the
|
||||
-- history copy of each message (cloned from the broadcast stanza), sourced
|
||||
-- from session.jitsi_meet_context_user.name (token users) or the <nick>
|
||||
-- element (guests). Late-joining clients receive this annotated history.
|
||||
--
|
||||
-- Load on the MUC component.
|
||||
local filters = require 'util.filters';
|
||||
local st = require 'util.stanza';
|
||||
|
||||
local util = module:require 'util';
|
||||
local filter_identity_from_presence = util.filter_identity_from_presence;
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local is_admin = util.is_admin;
|
||||
local is_focus = util.is_focus;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local NICK_NS = 'http://jabber.org/protocol/nick';
|
||||
local DISPLAY_NAME_NS = 'http://jitsi.org/protocol/display-name';
|
||||
|
||||
local sessions = prosody.full_sessions;
|
||||
|
||||
local ignore_jwt_name = module:get_option_boolean('ignore_jwt_name', false);
|
||||
|
||||
-- we need to get the shared resource for joining moderators, as participants are marked as moderators
|
||||
-- after joining which is after the filter for stanza/out, but we need to know will this participant be a moderator
|
||||
local joining_moderator_participants = module:shared('moderators/joining_moderator_participants');
|
||||
|
||||
--- Filter presence sent to non-moderator members of a room when the hideDisplayNameForGuests option is set.
|
||||
function filter_stanza_out(stanza, session)
|
||||
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
|
||||
or stanza.attr.type == 'unavailable' or is_focus(stanza.attr.from) then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
local shouldFilter = false;
|
||||
|
||||
if room and room._data.hideDisplayNameForGuests == true then
|
||||
local occupant = room:get_occupant_by_real_jid(stanza.attr.to);
|
||||
-- don't touch self-presence
|
||||
if occupant and stanza.attr.from ~= internal_room_jid_match_rewrite(occupant.nick) then
|
||||
shouldFilter = occupant.role ~= 'moderator' and not joining_moderator_participants[occupant.bare_jid];
|
||||
end
|
||||
end
|
||||
|
||||
if shouldFilter then
|
||||
return filter_identity_from_presence(stanza);
|
||||
else
|
||||
return stanza;
|
||||
end
|
||||
end
|
||||
|
||||
-- When a participant has a name-readonly feature enabled, we need to ensure that the
|
||||
-- nick element in the presence stanza is set to the user's name, if it exists.
|
||||
-- If the user does not have a name, we remove the nick element.
|
||||
-- This is to ensure that the name is not changed by the user, as it is read-only.
|
||||
function filter_stanza_in(stanza, session)
|
||||
if not session or not session.jitsi_meet_context_features
|
||||
or session.jitsi_meet_context_features['name-readonly'] ~= true then
|
||||
-- if the name-readonly feature is not set, there is nothing we need to do
|
||||
return stanza;
|
||||
end
|
||||
|
||||
if stanza.name ~= 'presence' or stanza.attr.type == 'error'
|
||||
or stanza.attr.type == 'unavailable' or is_focus(stanza.attr.from or '') then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- if the user does not have a name in token and name is readonly, remove any nick element
|
||||
if not session.jitsi_meet_context_user or not session.jitsi_meet_context_user.name then
|
||||
stanza:remove_children('nick', NICK_NS);
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local nick_element = stanza:get_child('nick', NICK_NS);
|
||||
|
||||
if nick_element and nick_element:get_text() ~= session.jitsi_meet_context_user.name then
|
||||
stanza:remove_children('nick', NICK_NS);
|
||||
stanza:tag('nick', { xmlns = NICK_NS }):text(session.jitsi_meet_context_user.name):up();
|
||||
end
|
||||
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- 'muc-add-history' is called in 'muc-broadcast-message' with priority 0
|
||||
-- we want to clean up message after we have processed it for history, but do it for all messages
|
||||
-- not only those that are added to history (as someone can forge a message and add 'no-store')
|
||||
module:hook('muc-broadcast-message', function(event)
|
||||
-- any message that is about to be delivered, clear up nick and display-name elements
|
||||
event.stanza:remove_children('nick', NICK_NS);
|
||||
event.stanza:remove_children('display-name', DISPLAY_NAME_NS);
|
||||
end, -1);
|
||||
|
||||
module:hook('muc-add-history', function(event)
|
||||
local room, stanza = event.room, event.stanza;
|
||||
local name;
|
||||
local source;
|
||||
|
||||
local occupant = room:get_occupant_by_nick(stanza.attr.from);
|
||||
|
||||
if occupant then
|
||||
local session = sessions[occupant.jid];
|
||||
if session and session.jitsi_meet_context_user then
|
||||
if ignore_jwt_name then
|
||||
name = occupant:get_presence():get_child_text('nick', NICK_NS);
|
||||
else
|
||||
name = session.jitsi_meet_context_user.name;
|
||||
end
|
||||
|
||||
source = 'token';
|
||||
else
|
||||
name = occupant:get_presence():get_child_text('nick', NICK_NS);
|
||||
source = 'guest';
|
||||
end
|
||||
else
|
||||
-- if no occupant found it is a message from visitor let's check display-name source
|
||||
local display_name_element = stanza:get_child('display-name', DISPLAY_NAME_NS);
|
||||
if display_name_element and display_name_element.attr.source == 'visitor' then
|
||||
name = display_name_element:get_text();
|
||||
source = display_name_element.attr.source;
|
||||
end
|
||||
end
|
||||
|
||||
if name then
|
||||
-- clone the stanza, so only history has the display_name extension
|
||||
event.stanza = st.clone(stanza);
|
||||
|
||||
event.stanza:tag('display-name', {
|
||||
xmlns = DISPLAY_NAME_NS,
|
||||
source = source
|
||||
}):text(name):up();
|
||||
end
|
||||
end);
|
||||
|
||||
function filter_session(session)
|
||||
filters.add_filter(session, 'stanzas/out', filter_stanza_out, -100);
|
||||
filters.add_filter(session, 'stanzas/in', filter_stanza_in, -100);
|
||||
end
|
||||
|
||||
function module.load()
|
||||
filters.add_filter_hook(filter_session);
|
||||
end
|
||||
|
||||
function module.unload()
|
||||
filters.remove_filter_hook(filter_session);
|
||||
end
|
||||
107
resources/prosody-plugins/mod_muc_domain_mapper.lua
Normal file
107
resources/prosody-plugins/mod_muc_domain_mapper.lua
Normal file
@@ -0,0 +1,107 @@
|
||||
-- Maps MUC JIDs like room1@muc.foo.example.com to JIDs like [foo]room1@muc.example.com
|
||||
-- Must be loaded on the client host in Prosody
|
||||
|
||||
-- It is recommended to set muc_mapper_domain_base to the main domain being served (example.com)
|
||||
|
||||
local filters = require "util.filters";
|
||||
|
||||
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
if not muc_domain_base then
|
||||
module:log("warn", "No 'muc_mapper_domain_base' option set, disabling muc_mapper plugin inactive");
|
||||
return
|
||||
end
|
||||
|
||||
local log_not_allowed_errors = module:get_option_boolean('muc_mapper_log_not_allowed_errors', false);
|
||||
|
||||
local util = module:require "util";
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
|
||||
-- We must filter stanzas in order to hook in to all incoming and outgoing messaging which skips the stanza routers
|
||||
function filter_stanza(stanza, session)
|
||||
if stanza.skipMapping or session.type == 's2sout' then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
if stanza.name == "message" or stanza.name == "iq" or stanza.name == "presence" then
|
||||
-- module:log("debug", "Filtering stanza type %s to %s from %s",stanza.name,stanza.attr.to,stanza.attr.from);
|
||||
if stanza.name == "iq" then
|
||||
local conf = stanza:get_child('conference')
|
||||
if conf then
|
||||
-- module:log("debug", "Filtering stanza conference %s to %s from %s",conf.attr.room,stanza.attr.to,stanza.attr.from);
|
||||
conf.attr.room = room_jid_match_rewrite(conf.attr.room, stanza)
|
||||
end
|
||||
end
|
||||
if stanza.attr.to then
|
||||
stanza.attr.to = room_jid_match_rewrite(stanza.attr.to, stanza)
|
||||
end
|
||||
if stanza.attr.from then
|
||||
stanza.attr.from = internal_room_jid_match_rewrite(stanza.attr.from, stanza)
|
||||
end
|
||||
|
||||
if log_not_allowed_errors and stanza.name == 'presence' and stanza.attr.type == 'error' then
|
||||
local error = stanza:get_child('error');
|
||||
if error and error.attr.type == 'cancel'
|
||||
and error:get_child('not-allowed', 'urn:ietf:params:xml:ns:xmpp-stanzas')
|
||||
and not session.jitsi_not_allowed_logged then
|
||||
session.jitsi_not_allowed_logged = true;
|
||||
session.log('error', 'Not allowed presence %s', stanza);
|
||||
end
|
||||
end
|
||||
end
|
||||
return stanza;
|
||||
end
|
||||
|
||||
function filter_session(session)
|
||||
-- module:log("warn", "Session filters applied");
|
||||
filters.add_filter(session, "stanzas/out", filter_stanza);
|
||||
end
|
||||
|
||||
function module.load()
|
||||
if module.reloading then
|
||||
module:log("debug", "Reloading MUC mapper!");
|
||||
else
|
||||
module:log("debug", "First load of MUC mapper!");
|
||||
end
|
||||
filters.add_filter_hook(filter_session);
|
||||
end
|
||||
|
||||
function module.unload()
|
||||
filters.remove_filter_hook(filter_session);
|
||||
end
|
||||
|
||||
|
||||
local function outgoing_stanza_rewriter(event)
|
||||
local stanza = event.stanza;
|
||||
if stanza.attr.to then
|
||||
stanza.attr.to = room_jid_match_rewrite(stanza.attr.to, stanza)
|
||||
end
|
||||
end
|
||||
|
||||
local function incoming_stanza_rewriter(event)
|
||||
local stanza = event.stanza;
|
||||
if stanza.attr.from then
|
||||
stanza.attr.from = internal_room_jid_match_rewrite(stanza.attr.from, stanza)
|
||||
end
|
||||
end
|
||||
|
||||
-- The stanza rewriters helper functions are attached for all stanza router hooks
|
||||
local function hook_all_stanzas(handler, host_module, event_prefix)
|
||||
for _, stanza_type in ipairs({ "message", "presence", "iq" }) do
|
||||
for _, jid_type in ipairs({ "host", "bare", "full" }) do
|
||||
host_module:hook((event_prefix or "")..stanza_type.."/"..jid_type, handler);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function add_host(host)
|
||||
module:log("info", "Loading mod_muc_domain_mapper for host %s!", host);
|
||||
local host_module = module:context(host);
|
||||
hook_all_stanzas(incoming_stanza_rewriter, host_module);
|
||||
hook_all_stanzas(outgoing_stanza_rewriter, host_module, "pre-");
|
||||
end
|
||||
|
||||
prosody.events.add_handler("host-activated", add_host);
|
||||
for host in pairs(prosody.hosts) do
|
||||
add_host(host);
|
||||
end
|
||||
141
resources/prosody-plugins/mod_muc_end_meeting.lua
Normal file
141
resources/prosody-plugins/mod_muc_end_meeting.lua
Normal file
@@ -0,0 +1,141 @@
|
||||
-- Global HTTP module that exposes a POST /end-meeting endpoint for terminating
|
||||
-- MUC rooms via an authenticated API call. Intended for internal system use
|
||||
-- (e.g. by a backend service), not for end-user clients.
|
||||
--
|
||||
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
|
||||
-- tokens (mod_auth_token). The key server URL is read from
|
||||
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
|
||||
-- are not accepted.
|
||||
--
|
||||
-- Request format:
|
||||
-- POST /end-meeting?conference=<room-jid>[&silent-reconnect=true]
|
||||
-- Authorization: Bearer <system-token>
|
||||
--
|
||||
-- Responses:
|
||||
-- 200 Room destroyed.
|
||||
-- 400 Missing or invalid query parameters.
|
||||
-- 401 Missing, malformed, or unverifiable token.
|
||||
-- 404 Room not found.
|
||||
--
|
||||
-- When silent-reconnect=true the room is destroyed with an alternate-venue JID
|
||||
-- so clients silently reconnect rather than showing a "meeting ended" screen.
|
||||
--
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
module:set_global();
|
||||
|
||||
local util = module:require "util";
|
||||
local async_handler_wrapper = util.async_handler_wrapper;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local starts_with = util.starts_with;
|
||||
|
||||
local parse = require "util.http".formdecode;
|
||||
|
||||
-- will be initialized once the main virtual host module is initialized
|
||||
local token_util;
|
||||
|
||||
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
|
||||
local muc_domain = muc_domain_prefix..'.'..muc_domain_base;
|
||||
|
||||
local asapKeyServer = module:get_option_string("prosody_password_public_key_repo_url", "");
|
||||
|
||||
local event_count = module:measure("muc_end_meeting_rate", "rate")
|
||||
local event_count_success = module:measure("muc_end_meeting_success", "rate")
|
||||
|
||||
function verify_token(token)
|
||||
if token == nil then
|
||||
module:log("warn", "no token provided");
|
||||
return false;
|
||||
end
|
||||
|
||||
local session = {};
|
||||
session.auth_token = token;
|
||||
local verified, reason, msg = token_util:process_and_verify_token(session);
|
||||
if not verified then
|
||||
module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg));
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
function handle_terminate_meeting (event)
|
||||
module:log("info", "Request for terminate meeting received: reqid %s", event.request.headers["request_id"])
|
||||
event_count()
|
||||
if not event.request.url.query then
|
||||
return { status_code = 400 };
|
||||
end
|
||||
local params = parse(event.request.url.query);
|
||||
local conference = params["conference"];
|
||||
local silent_reconnect = params['silent-reconnect'];
|
||||
local room_jid;
|
||||
|
||||
if conference then
|
||||
room_jid = room_jid_match_rewrite(conference)
|
||||
else
|
||||
module:log('warn', "conference param was not provided")
|
||||
return { status_code = 400 };
|
||||
end
|
||||
|
||||
-- verify access
|
||||
local token = event.request.headers["authorization"]
|
||||
if not token then
|
||||
module:log("error", "Authorization header was not provided for conference %s", conference)
|
||||
return { status_code = 401 };
|
||||
end
|
||||
if starts_with(token, 'Bearer ') then
|
||||
token = token:sub(8, #token)
|
||||
else
|
||||
module:log("error", "Authorization header is invalid")
|
||||
return { status_code = 401 };
|
||||
end
|
||||
|
||||
if not verify_token(token, room_jid) then
|
||||
return { status_code = 401 };
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_jid);
|
||||
if not room then
|
||||
module:log("warn", "Room not found")
|
||||
return { status_code = 404 };
|
||||
else
|
||||
if silent_reconnect == 'true' then
|
||||
module:log('info', 'Setting silent_reconnect on room %s', room.jid);
|
||||
room:destroy(internal_room_jid_match_rewrite(room.jid), 'The meeting has been terminated silently')
|
||||
else
|
||||
module:log("info", "Destroy room jid %s", room.jid)
|
||||
room:destroy(nil, "The meeting has been terminated")
|
||||
end
|
||||
end
|
||||
event_count_success()
|
||||
return { status_code = 200 };
|
||||
end
|
||||
|
||||
|
||||
-- module API called on virtual host added, passing the host module
|
||||
function module.add_host(host_module)
|
||||
if host_module.host == muc_domain_base then
|
||||
-- the main virtual host
|
||||
module:log("info", "Initialize token_util using %s", host_module.host)
|
||||
|
||||
token_util = module:require "token/util".new(host_module);
|
||||
|
||||
if asapKeyServer ~= "" then
|
||||
-- init token util with our asap keyserver
|
||||
token_util:set_asap_key_server(asapKeyServer)
|
||||
end
|
||||
|
||||
module:log("info", "Adding http handler for /end-meeting on %s", host_module.host);
|
||||
host_module:depends("http");
|
||||
host_module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["POST end-meeting"] = function(event)
|
||||
return async_handler_wrapper(event, handle_terminate_meeting)
|
||||
end;
|
||||
};
|
||||
});
|
||||
end
|
||||
end
|
||||
27
resources/prosody-plugins/mod_muc_filter_access.lua
Normal file
27
resources/prosody-plugins/mod_muc_filter_access.lua
Normal file
@@ -0,0 +1,27 @@
|
||||
-- Restricts access to a muc component to certain domains
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
-- a list of (authenticated)domains that can access rooms(send presence)
|
||||
local whitelist = module:get_option_set("muc_filter_whitelist");
|
||||
|
||||
if not whitelist then
|
||||
module:log("warn", "No 'muc_filter_whitelist' option set, disabling muc_filter_access, plugin inactive");
|
||||
return
|
||||
end
|
||||
|
||||
local jid_split = require "util.jid".split;
|
||||
|
||||
local function incoming_presence_filter(event)
|
||||
local stanza = event.stanza;
|
||||
local _, domain, _ = jid_split(stanza.attr.from);
|
||||
|
||||
if not stanza.attr.from or not whitelist:contains(domain) then
|
||||
-- Filter presence
|
||||
module:log("error", "Filtering unauthorized presence: %s", stanza:top_tag());
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
for _, jid_type in ipairs({ "host", "bare", "full" }) do
|
||||
module:hook("presence/"..jid_type, incoming_presence_filter, 2000);
|
||||
end
|
||||
195
resources/prosody-plugins/mod_muc_flip.lua
Normal file
195
resources/prosody-plugins/mod_muc_flip.lua
Normal file
@@ -0,0 +1,195 @@
|
||||
-- Allows flipping device. When a presence contains flip_device tag
|
||||
-- and the used jwt matches the id(session.jitsi_meet_context_user.id) of another user this is indication that the user
|
||||
-- is moving from one device to another. The flip feature should be present and enabled in the token features.
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local oss_util = module:require "util";
|
||||
local is_admin = oss_util.is_admin;
|
||||
local is_healthcheck_room = oss_util.is_healthcheck_room;
|
||||
local process_host_module = oss_util.process_host_module;
|
||||
local jid_bare = require "util.jid".bare;
|
||||
local jid = require "util.jid";
|
||||
local MUC_NS = "http://jabber.org/protocol/muc";
|
||||
|
||||
local lobby_host;
|
||||
local lobby_muc_service;
|
||||
|
||||
local lobby_muc_component_config = 'lobby.' .. module:get_option_string("muc_mapper_domain_base");
|
||||
if lobby_muc_component_config == nil then
|
||||
module:log('error', 'lobby not enabled missing lobby_muc config');
|
||||
return ;
|
||||
end
|
||||
|
||||
local function find_occupant_nick_by_user_id(room, user_id)
|
||||
for _, occ in room:each_occupant() do
|
||||
for full_jid in pairs(occ.sessions) do
|
||||
local session = prosody.full_sessions[full_jid];
|
||||
if session and session.jitsi_meet_context_user
|
||||
and session.jitsi_meet_context_user.id == user_id then
|
||||
return occ.nick;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function remove_flip_tag(stanza)
|
||||
stanza:maptags(function(tag)
|
||||
if tag and tag.name == "flip_device" then
|
||||
-- module:log("debug", "Removing %s tag from presence stanza!", tag.name);
|
||||
return nil;
|
||||
else
|
||||
return tag;
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- Make user that switch devices bypass lobby or password.
|
||||
-- A user is considered to join from another device if the
|
||||
-- id from jwt is the same as another occupant and the presence
|
||||
-- stanza has flip_device tag
|
||||
module:hook("muc-occupant-pre-join", function(event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
local session = event.origin;
|
||||
local stanza = event.stanza;
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
|
||||
return ;
|
||||
end
|
||||
local flip_device_tag = stanza:get_child("flip_device");
|
||||
if session.jitsi_meet_context_user and session.jitsi_meet_context_user.id then
|
||||
local id = session.jitsi_meet_context_user.id;
|
||||
local first_device_occ_nick = find_occupant_nick_by_user_id(room, id);
|
||||
if flip_device_tag then
|
||||
if first_device_occ_nick and session.jitsi_meet_context_features.flip and (session.jitsi_meet_context_features.flip == true or session.jitsi_meet_context_features.flip == "true") then
|
||||
room._data.kicked_participant_nick = first_device_occ_nick;
|
||||
room._data.flip_participant_nick = occupant.nick;
|
||||
-- allow participant from flip device to bypass Lobby
|
||||
local occupant_jid = stanza.attr.from;
|
||||
local affiliation = room:get_affiliation(occupant_jid);
|
||||
if not affiliation or affiliation == 'none' or affiliation == 'member' then
|
||||
-- module:log("debug", "Bypass lobby invitee %s", occupant_jid)
|
||||
occupant.role = "participant";
|
||||
room:set_affiliation(true, jid_bare(occupant_jid), "member")
|
||||
room:save_occupant(occupant);
|
||||
end
|
||||
|
||||
if room:get_password() then
|
||||
-- bypass password on the flip device
|
||||
local join = stanza:get_child("x", MUC_NS);
|
||||
if not join then
|
||||
join = stanza:tag("x", { xmlns = MUC_NS });
|
||||
end
|
||||
local password = join:get_child("password", MUC_NS);
|
||||
if password then
|
||||
join:maptags(
|
||||
function(tag)
|
||||
for k, v in pairs(tag) do
|
||||
if k == "name" and v == "password" then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return tag
|
||||
end);
|
||||
end
|
||||
join:tag("password", { xmlns = MUC_NS }):text(room:get_password());
|
||||
end
|
||||
elseif not session.jitsi_meet_context_features.flip or session.jitsi_meet_context_features.flip == false or session.jitsi_meet_context_features.flip == "false" then
|
||||
module:log("warn", "Flip device tag present without jwt permission")
|
||||
--remove flip_device tag if somebody wants to abuse this feature
|
||||
remove_flip_tag(stanza)
|
||||
else
|
||||
module:log("warn", "Flip device tag present without user from different device")
|
||||
--remove flip_device tag if somebody wants to abuse this feature
|
||||
remove_flip_tag(stanza)
|
||||
end
|
||||
end
|
||||
else
|
||||
if flip_device_tag then
|
||||
module:log("warn", "Flip device tag present for a guest user")
|
||||
-- remove flip_device tag because a guest want to do a sneaky join
|
||||
remove_flip_tag(stanza)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- Kick participant from the the first device from the main room and lobby if applies
|
||||
-- and transfer role from the previous participant, this will take care of the grant
|
||||
-- moderation case
|
||||
module:hook("muc-occupant-joined", function(event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
if room._data.flip_participant_nick and occupant.nick == room._data.flip_participant_nick then
|
||||
-- make joining participant from flip device have the same role and affiliation as for the previous device
|
||||
local kicked_occupant = room:get_occupant_by_nick(room._data.kicked_participant_nick);
|
||||
|
||||
if not kicked_occupant then
|
||||
module:log("info", "Kick participant not found, nick %s from main room jid %s",
|
||||
room._data.kicked_participant_nick, room.jid)
|
||||
return;
|
||||
end
|
||||
|
||||
local initial_affiliation = room:get_affiliation(kicked_occupant.jid) or "member";
|
||||
-- module:log("debug", "Transfer affiliation %s to occupant jid %s", initial_affiliation, occupant.jid)
|
||||
room:set_affiliation(true, occupant.bare_jid, initial_affiliation)
|
||||
if initial_affiliation == "owner" then
|
||||
event.occupant.role = "moderator";
|
||||
elseif initial_affiliation == "member" then
|
||||
event.occupant.role = "participant";
|
||||
end
|
||||
-- Kick participant from the first device from the main room
|
||||
local kicked_participant_node_jid = jid.split(kicked_occupant.jid);
|
||||
module:log("info", "Kick participant jid %s nick %s from main room jid %s", kicked_occupant.jid, room._data.kicked_participant_nick, room.jid)
|
||||
room:set_role(true, room._data.kicked_participant_nick, 'none')
|
||||
room:save_occupant(occupant);
|
||||
-- Kick participant from the first device from the lobby room
|
||||
if room._data.lobbyroom then
|
||||
local lobby_room_jid = room._data.lobbyroom;
|
||||
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid)
|
||||
for _, occupant in lobby_room:each_occupant() do
|
||||
local node = jid.split(occupant.jid);
|
||||
if kicked_participant_node_jid == node then
|
||||
module:log("info", "Kick participant from lobby %s", occupant.jid)
|
||||
lobby_room:set_role(true, occupant.nick, 'none')
|
||||
end
|
||||
end
|
||||
end
|
||||
event.room._data.flip_participant_nick = nil
|
||||
event.room._data.kicked_participant_nick = nil;
|
||||
end
|
||||
end,-2)
|
||||
|
||||
-- Add a flip_device tag on the unavailable presence from the kicked participant in order to silent the notifications
|
||||
module:hook('muc-broadcast-presence', function(event)
|
||||
local kicked_participant_nick = event.room._data.kicked_participant_nick
|
||||
local stanza = event.stanza;
|
||||
if kicked_participant_nick and stanza.attr.from == kicked_participant_nick and stanza.attr.type == 'unavailable' then
|
||||
-- module:log("debug", "Add flip_device tag for presence unavailable from occupant nick %s", kicked_participant_nick)
|
||||
stanza:tag("flip_device"):up();
|
||||
end
|
||||
end)
|
||||
|
||||
function process_lobby_muc_loaded(lobby_muc, host_module)
|
||||
module:log('info', 'Lobby muc loaded');
|
||||
lobby_muc_service = lobby_muc;
|
||||
lobby_host = module:context(host_module);
|
||||
end
|
||||
|
||||
-- process or waits to process the lobby muc component
|
||||
process_host_module(lobby_muc_component_config, function(host_module, host)
|
||||
-- lobby muc component created
|
||||
module:log('info', 'Lobby component loaded %s', host);
|
||||
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
if muc_module then
|
||||
process_lobby_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_lobby_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
33
resources/prosody-plugins/mod_muc_hide_all.lua
Normal file
33
resources/prosody-plugins/mod_muc_hide_all.lua
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Hides all MUC rooms from disco#items queries on the MUC component, so that
|
||||
-- room enumeration by external clients is prevented. Rooms are still joinable
|
||||
-- by full JID; they simply do not appear in the public room list.
|
||||
-- This module is enabled under the MUC component.
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
local jid = require 'util.jid';
|
||||
local st = require 'util.stanza';
|
||||
|
||||
local util = module:require 'util';
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
|
||||
module:hook('muc-room-pre-create', function(event)
|
||||
event.room:set_hidden(true);
|
||||
end, -1);
|
||||
|
||||
for _, event_name in pairs {
|
||||
'iq-get/bare/http://jabber.org/protocol/disco#info:query';
|
||||
'iq-get/host/http://jabber.org/protocol/disco#info:query';
|
||||
} do
|
||||
module:hook(event_name, function (event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
local room_jid = jid.bare(stanza.attr.to);
|
||||
local room = get_room_from_jid(room_jid);
|
||||
|
||||
if room then
|
||||
if not room:get_occupant_by_real_jid(stanza.attr.from) then
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
-- prosody will send item-not-found
|
||||
end, 1) -- make sure we handle it before prosody that uses priority -2 for this
|
||||
end
|
||||
218
resources/prosody-plugins/mod_muc_jigasi_invite.lua
Normal file
218
resources/prosody-plugins/mod_muc_jigasi_invite.lua
Normal file
@@ -0,0 +1,218 @@
|
||||
-- HTTP module that exposes a POST /invite-jigasi endpoint for inviting a Jigasi
|
||||
-- SIP gateway instance to dial out to a phone number and connect it to a
|
||||
-- conference. Intended for internal system use (e.g. by a backend service),
|
||||
-- not for end-user clients.
|
||||
--
|
||||
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
|
||||
-- tokens (mod_auth_token). The key server URL is read from
|
||||
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
|
||||
-- are not accepted.
|
||||
--
|
||||
-- Request format:
|
||||
-- POST /invite-jigasi
|
||||
-- Content-Type: application/json
|
||||
-- Authorization: Bearer <system-token>
|
||||
-- Body: { "conference": "<room-jid>", "phoneNo": "<dial-target>" }
|
||||
--
|
||||
-- The module selects a Jigasi instance from the brewery room
|
||||
-- (muc_jigasi_brewery_jid, default: jigasibrewery@internal.auth.<base>):
|
||||
-- 1. Iterates brewery occupants, skips focus.
|
||||
-- 2. Filters for occupants with supports_sip=true in their colibri stats.
|
||||
-- 3. Among those, picks the one with the lowest stress_level.
|
||||
-- 4. Sends a Rayo <dial> IQ to that occupant (impersonating focus) with
|
||||
-- phoneNo as the dial target and conference as JvbRoomName.
|
||||
--
|
||||
-- Responses:
|
||||
-- 200 Jigasi invited successfully.
|
||||
-- 400 Missing or invalid parameters / wrong Content-Type.
|
||||
-- 401 Missing, malformed, or unverifiable token.
|
||||
-- 404 Brewery room not found, or no SIP-capable Jigasi available.
|
||||
--
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local hashes = require "util.hashes";
|
||||
local random = require "util.random";
|
||||
local st = require("util.stanza");
|
||||
local json = require 'cjson.safe';
|
||||
local util = module:require "util";
|
||||
local async_handler_wrapper = util.async_handler_wrapper;
|
||||
local is_focus = util.is_focus;
|
||||
local process_host_module = util.process_host_module;
|
||||
|
||||
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
|
||||
-- This module chooses jigasi from the brewery room, so it needs information for the configured brewery
|
||||
local muc_domain = module:get_option_string("muc_internal_domain_base", 'internal.auth.' .. muc_domain_base);
|
||||
|
||||
local jigasi_brewery_room_jid = module:get_option_string("muc_jigasi_brewery_jid", 'jigasibrewery@' .. muc_domain);
|
||||
|
||||
local focus_jid = module:get_option_string("muc_jicofo_brewery_jid", jigasi_brewery_room_jid .. "/focus");
|
||||
|
||||
local main_muc_service;
|
||||
local JSON_CONTENT_TYPE = "application/json";
|
||||
|
||||
local event_count = module:measure("muc_invite_jigasi_rate", "rate")
|
||||
local event_count_success = module:measure("muc_invite_jigasi_success", "rate")
|
||||
local ASAP_KEY_SERVER = module:get_option_string("prosody_password_public_key_repo_url", "");
|
||||
local token_util = module:require "token/util".new(module);
|
||||
if ASAP_KEY_SERVER then
|
||||
-- init token util with our asap keyserver
|
||||
token_util:set_asap_key_server(ASAP_KEY_SERVER)
|
||||
end
|
||||
|
||||
local function invite_jigasi(conference, phone_no)
|
||||
local jigasi_brewery_room = main_muc_service.get_room_from_jid(jigasi_brewery_room_jid);
|
||||
if not jigasi_brewery_room then
|
||||
module:log("error", "Jigasi brewery room not found")
|
||||
return 404, 'Brewery room was not found'
|
||||
end
|
||||
module:log("info", "Invite jigasi from %s to join conference %s and outbound phone_no %s", jigasi_brewery_room.jid, conference, phone_no)
|
||||
|
||||
--select least stressed Jigasi
|
||||
local least_stressed_value = math.huge;
|
||||
local least_stressed_jigasi_occupant;
|
||||
for occupant_jid, occupant in jigasi_brewery_room:each_occupant() do
|
||||
if not is_focus(occupant_jid) then
|
||||
local occ = occupant:get_presence();
|
||||
local stats_child = occ:get_child("stats", "http://jitsi.org/protocol/colibri")
|
||||
|
||||
if not stats_child then
|
||||
module:log("warn", "Jigasi occupant %s has no stats element, skipping", occupant_jid);
|
||||
else
|
||||
local is_sip_jigasi = true;
|
||||
for stats_tag in stats_child:children() do
|
||||
if stats_tag.attr.name == 'supports_sip' and stats_tag.attr.value == 'false' then
|
||||
is_sip_jigasi = false;
|
||||
end
|
||||
end
|
||||
|
||||
if is_sip_jigasi then
|
||||
for stats_tag in stats_child:children() do
|
||||
if stats_tag.attr.name == 'stress_level' then
|
||||
local stress_level = tonumber(stats_tag.attr.value);
|
||||
module:log("debug", "Stressed level %s %s ", stress_level, occupant_jid)
|
||||
if stress_level < least_stressed_value then
|
||||
least_stressed_jigasi_occupant = occupant;
|
||||
least_stressed_value = stress_level
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not least_stressed_jigasi_occupant then
|
||||
module:log("error", "Cannot invite jigasi from room %s", jigasi_brewery_room.jid)
|
||||
return 404, 'Jigasi not found'
|
||||
end
|
||||
module:log("debug", "Least stressed jigasi selected jid %s value %s", least_stressed_jigasi_occupant.jid, least_stressed_value)
|
||||
|
||||
-- invite Jigasi to join the conference
|
||||
local stanza_id = hashes.sha256(random.bytes(8), true);
|
||||
local invite_jigasi_stanza = st.iq({ xmlns = "jabber:client", type = "set", to = least_stressed_jigasi_occupant.jid, from = focus_jid, id = stanza_id })
|
||||
:tag("dial", { xmlns = "urn:xmpp:rayo:1", from = "fromnumber", to = phone_no })
|
||||
:tag("header", { xmlns = "urn:xmpp:rayo:1", name = "JvbRoomName", value = conference })
|
||||
|
||||
module:log("debug", "Invite jigasi stanza %s", invite_jigasi_stanza)
|
||||
jigasi_brewery_room:route_stanza(invite_jigasi_stanza);
|
||||
return 200
|
||||
end
|
||||
|
||||
local function is_token_valid(token)
|
||||
if token == nil then
|
||||
module:log("warn", "no token provided");
|
||||
return false;
|
||||
end
|
||||
|
||||
local session = {};
|
||||
session.auth_token = token;
|
||||
local verified, reason, msg = token_util:process_and_verify_token(session);
|
||||
if not verified then
|
||||
module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg));
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
local function handle_jigasi_invite(event)
|
||||
module:log("debug", "Request for invite jigasi received: reqId %s", event.request.headers["request_id"])
|
||||
event_count()
|
||||
local request = event.request;
|
||||
-- verify access
|
||||
local token = event.request.headers["authorization"]
|
||||
if not token then
|
||||
module:log("error", "Authorization header was not provided for conference %s", conference)
|
||||
return { status_code = 401 };
|
||||
end
|
||||
if util.starts_with(token, 'Bearer ') then
|
||||
token = token:sub(8, #token)
|
||||
else
|
||||
module:log("error", "Authorization header is invalid")
|
||||
return { status_code = 401 };
|
||||
end
|
||||
if not is_token_valid(token) then
|
||||
return { status_code = 401 };
|
||||
end
|
||||
|
||||
-- verify payload
|
||||
if request.headers.content_type ~= JSON_CONTENT_TYPE
|
||||
or (not request.body or #request.body == 0) then
|
||||
module:log("warn", "Wrong content type: %s or missing payload", request.headers.content_type);
|
||||
return { status_code = 400; }
|
||||
end
|
||||
local payload, error = json.decode(request.body);
|
||||
|
||||
if not payload then
|
||||
module:log('error', 'Cannot decode json error:%s', error);
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local conference = payload["conference"];
|
||||
local phone_no = payload["phoneNo"];
|
||||
if not conference then
|
||||
module:log("warn", "Missing conference param")
|
||||
return { status_code = 400; }
|
||||
end
|
||||
if not phone_no then
|
||||
module:log("warn", "Missing phone no param")
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
--invite jigasi
|
||||
local status_code, error_msg = invite_jigasi(conference, phone_no)
|
||||
|
||||
if not error_msg then
|
||||
event_count_success()
|
||||
return { status_code = 200 }
|
||||
else
|
||||
return { status_code = status_code, body = json.encode({ error = error_msg }) }
|
||||
end
|
||||
end
|
||||
|
||||
module:log("info", "Adding http handler for /invite-jigasi on %s", module.host);
|
||||
module:depends("http");
|
||||
module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["POST invite-jigasi"] = function(event)
|
||||
return async_handler_wrapper(event, handle_jigasi_invite)
|
||||
end;
|
||||
};
|
||||
});
|
||||
|
||||
process_host_module(muc_domain, function(_, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
if muc_module then
|
||||
main_muc_service = muc_module;
|
||||
module:log('info', 'Found main_muc_service: %s', main_muc_service);
|
||||
else
|
||||
module:log('info', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
main_muc_service = prosody.hosts[host].modules.muc;
|
||||
module:log('info', 'Found(on loaded) main_muc_service: %s', main_muc_service);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
194
resources/prosody-plugins/mod_muc_kick_participant.lua
Normal file
194
resources/prosody-plugins/mod_muc_kick_participant.lua
Normal file
@@ -0,0 +1,194 @@
|
||||
-- HTTP module that exposes a PUT /kick-participant endpoint for removing a
|
||||
-- specific participant from a MUC room via an authenticated API call.
|
||||
-- Intended for internal system use (e.g. by a backend service), not for
|
||||
-- end-user clients.
|
||||
--
|
||||
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
|
||||
-- tokens (mod_auth_token). The key server URL is read from
|
||||
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
|
||||
-- are not accepted.
|
||||
--
|
||||
-- Request format:
|
||||
-- PUT /kick-participant?room=<room-name>[&prefix=<subdomain>]
|
||||
-- Content-Type: application/json
|
||||
-- Authorization: Bearer <system-token>
|
||||
-- Body: { "participantId": "<occupant-nick>" }
|
||||
-- OR: { "number": "<phone-prefix>" } (matches SIP Jigasi by display name)
|
||||
-- Exactly one of participantId or number must be provided.
|
||||
--
|
||||
-- Responses:
|
||||
-- 200 Participant kicked.
|
||||
-- 400 Missing or invalid parameters / wrong Content-Type.
|
||||
-- 403 Missing, malformed, or unverifiable token.
|
||||
-- 404 Room not found, or no matching participant found.
|
||||
--
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local util = module:require "util";
|
||||
local async_handler_wrapper = util.async_handler_wrapper;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local is_sip_jigasi = util.is_sip_jigasi;
|
||||
local starts_with = util.starts_with;
|
||||
local formdecode = require "util.http".formdecode;
|
||||
local urlencode = require "util.http".urlencode;
|
||||
local jid = require "util.jid";
|
||||
local json = require 'cjson.safe';
|
||||
|
||||
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
|
||||
|
||||
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
if not muc_domain_base then
|
||||
module:log("warn", "No 'muc_domain_base' option set, disabling kick check endpoint.");
|
||||
return ;
|
||||
end
|
||||
|
||||
local json_content_type = "application/json";
|
||||
|
||||
local token_util = module:require "token/util".new(module);
|
||||
|
||||
local asapKeyServer = module:get_option_string('prosody_password_public_key_repo_url', '');
|
||||
if asapKeyServer == '' then
|
||||
module:log('warn', 'No "prosody_password_public_key_repo_url" option set, disabling kick endpoint.');
|
||||
return ;
|
||||
end
|
||||
|
||||
token_util:set_asap_key_server(asapKeyServer);
|
||||
|
||||
--- Verifies the token
|
||||
-- @param token the token we received
|
||||
-- @param room_address the full room address jid
|
||||
-- @return true if values are ok or false otherwise
|
||||
function verify_token(token, room_address)
|
||||
|
||||
if token == nil then
|
||||
module:log("warn", "no token provided for %s", room_address);
|
||||
return false;
|
||||
end
|
||||
|
||||
local session = {};
|
||||
session.auth_token = token;
|
||||
local verified, reason, msg = token_util:process_and_verify_token(session);
|
||||
if not verified then
|
||||
module:log("warn", "not a valid token %s %s for %s", tostring(reason), tostring(msg), room_address);
|
||||
return false;
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
|
||||
-- Validates the request by checking for required url param room and
|
||||
-- validates the token provided with the request
|
||||
-- @param request - The request to validate.
|
||||
-- @return [error_code, room]
|
||||
local function validate_and_get_room(request)
|
||||
if not request.url.query then
|
||||
module:log("warn", "No query");
|
||||
return 400, nil;
|
||||
end
|
||||
|
||||
local params = formdecode(request.url.query);
|
||||
local room_name = urlencode(params.room) or "";
|
||||
local subdomain = urlencode(params.prefix) or "";
|
||||
|
||||
if not room_name then
|
||||
module:log("warn", "Missing room param for %s", room_name);
|
||||
return 400, nil;
|
||||
end
|
||||
|
||||
local room_address = jid.join(room_name, muc_domain_prefix.."."..muc_domain_base);
|
||||
|
||||
if subdomain and subdomain ~= "" then
|
||||
room_address = "["..subdomain.."]"..room_address;
|
||||
end
|
||||
|
||||
-- verify access
|
||||
local token = request.headers["authorization"]
|
||||
|
||||
if token and starts_with(token,'Bearer ') then
|
||||
token = token:sub(8,#token)
|
||||
end
|
||||
|
||||
if not verify_token(token, room_address) then
|
||||
return 403, nil;
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_address);
|
||||
|
||||
if not room then
|
||||
module:log("warn", "No room found for %s", room_address);
|
||||
return 404, nil;
|
||||
else
|
||||
return 200, room;
|
||||
end
|
||||
end
|
||||
|
||||
function handle_kick_participant (event)
|
||||
local request = event.request;
|
||||
if request.headers.content_type ~= json_content_type
|
||||
or (not request.body or #request.body == 0) then
|
||||
module:log("warn", "Wrong content type: %s", request.headers.content_type);
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local params, error = json.decode(request.body);
|
||||
if not params then
|
||||
module:log("warn", "Missing params error:%s", error);
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local number = params["number"];
|
||||
local participantId = params["participantId"];
|
||||
|
||||
if (not number and not participantId) or (number and participantId) then
|
||||
module:log("warn", "Invalid parameters: exactly one of 'number' or 'participantId' must be provided.");
|
||||
return { status_code = 400; };
|
||||
end
|
||||
|
||||
local error_code, room = validate_and_get_room(request);
|
||||
|
||||
if error_code and error_code ~= 200 then
|
||||
module:log("error", "Error validating %s", error_code);
|
||||
return { status_code = error_code; }
|
||||
end
|
||||
|
||||
if not room then
|
||||
return { status_code = 404; }
|
||||
end
|
||||
|
||||
for _, occupant in room:each_occupant() do
|
||||
local pr = occupant:get_presence();
|
||||
|
||||
if is_participant_match(pr, number, participantId) then
|
||||
room:set_role(true, occupant.nick, nil);
|
||||
module:log('info', 'Occupant kicked %s from %s', occupant.nick, room.jid);
|
||||
return { status_code = 200; }
|
||||
end
|
||||
end
|
||||
|
||||
-- not found participant to kick
|
||||
return { status_code = 404; };
|
||||
end
|
||||
|
||||
function is_participant_match(pr, number, participantId)
|
||||
if number then
|
||||
local displayName = pr:get_child_text('nick', 'http://jabber.org/protocol/nick');
|
||||
return is_sip_jigasi(pr) and displayName and starts_with(displayName, number);
|
||||
elseif participantId then
|
||||
local from = pr.attr.from;
|
||||
local _, _, from_resource = jid.split(from);
|
||||
if from_resource then
|
||||
return from_resource == participantId;
|
||||
end
|
||||
end
|
||||
return false;
|
||||
end
|
||||
|
||||
module:log("info","Adding http handler for /kick-participant on %s", module.host);
|
||||
module:depends("http");
|
||||
module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["PUT kick-participant"] = function (event) return async_handler_wrapper(event, handle_kick_participant) end;
|
||||
};
|
||||
});
|
||||
109
resources/prosody-plugins/mod_muc_limit_messages.lua
Normal file
109
resources/prosody-plugins/mod_muc_limit_messages.lua
Normal file
@@ -0,0 +1,109 @@
|
||||
-- Enforces a per-room message count cap (muc_limit_messages_count). Once the cap
|
||||
-- is reached the triggering message is rejected with <not-allowed/>, a broadcast
|
||||
-- is sent to the room, and all further messages are rejected. Messages from
|
||||
-- sessions without room context (no jitsi_web_query_room) are passed through.
|
||||
-- Body-less non-groupchat messages and answer-poll json-messages are exempt from
|
||||
-- counting. With muc_limit_messages_check_token = true an authenticated sender
|
||||
-- permanently lifts the cap for that room.
|
||||
-- Needs to be activated under the muc component where the limit needs to be applied
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local id = require 'util.id';
|
||||
local st = require 'util.stanza';
|
||||
|
||||
local get_room_by_name_and_subdomain = module:require 'util'.get_room_by_name_and_subdomain;
|
||||
|
||||
local count;
|
||||
local check_token;
|
||||
|
||||
local function load_config()
|
||||
count = module:get_option_number('muc_limit_messages_count');
|
||||
check_token = module:get_option_boolean('muc_limit_messages_check_token', false);
|
||||
end
|
||||
load_config();
|
||||
|
||||
if not count then
|
||||
module:log('warn', "No 'muc_limit_messages_count' option set, disabling module");
|
||||
return
|
||||
end
|
||||
|
||||
module:log('info', 'Loaded muc limits for %s, limit:%s, will check for authenticated users:%s',
|
||||
module.host, count, check_token);
|
||||
|
||||
local error_text = 'The message limit for the room has been reached. Messaging is now disabled.';
|
||||
|
||||
function on_message(event)
|
||||
local stanza = event.stanza;
|
||||
local body = stanza:get_child('body');
|
||||
-- we ignore any non groupchat message without a body
|
||||
if not body then
|
||||
if stanza.attr.type ~= 'groupchat' then -- lobby messages
|
||||
return;
|
||||
else
|
||||
-- we want to pass through only polls answers
|
||||
local json_data = stanza:get_child_text('json-message', 'http://jitsi.org/jitmeet');
|
||||
if json_data and string.find(json_data, 'answer-poll', 1, true) then
|
||||
return;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local session = event.origin;
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
-- No room context: cannot apply limits, pass through.
|
||||
-- (Visitor messages from s2sin are also passed through — limits enforced at visitor node.)
|
||||
return;
|
||||
end
|
||||
|
||||
-- get room name with tenant and find room
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return;
|
||||
end
|
||||
|
||||
if check_token and session.auth_token then
|
||||
-- there is an authenticated participant drop all limits
|
||||
room._muc_messages_limit = false;
|
||||
end
|
||||
|
||||
if room._muc_messages_limit == false then
|
||||
-- no limits for this room, just skip
|
||||
return;
|
||||
end
|
||||
|
||||
if not room._muc_messages_limit_count then
|
||||
room._muc_messages_limit_count = 0;
|
||||
end
|
||||
|
||||
room._muc_messages_limit_count = room._muc_messages_limit_count + 1;
|
||||
|
||||
-- on the first message above the limit we set the limit and we send an announcement to the room
|
||||
if room._muc_messages_limit_count == count + 1 then
|
||||
module:log('warn', 'Room message limit reached: %s', room.jid);
|
||||
|
||||
-- send a message to the room
|
||||
local announcement = st.message({ from = room.jid, type = 'groupchat', id = id.medium(), })
|
||||
:tag('body'):text(error_text);
|
||||
room:broadcast_message(announcement);
|
||||
|
||||
room._muc_messages_limit = true;
|
||||
end
|
||||
|
||||
if room._muc_messages_limit == true then
|
||||
-- return error to the sender of this message
|
||||
event.origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', error_text));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- handle messages sent in the component
|
||||
-- 'message/host' is used for breakout rooms
|
||||
-- Priority -1 ensures modules at the default priority (0) — notably
|
||||
-- filter_messages — run first. Messages they block (return true) never reach
|
||||
-- this handler and do not consume cap slots.
|
||||
module:hook('message/full', on_message, -1); -- private messages
|
||||
module:hook('message/bare', on_message, -1); -- room messages
|
||||
|
||||
module:hook_global('config-reloaded', load_config);
|
||||
754
resources/prosody-plugins/mod_muc_lobby_rooms.lua
Normal file
754
resources/prosody-plugins/mod_muc_lobby_rooms.lua
Normal file
@@ -0,0 +1,754 @@
|
||||
-- mod_muc_lobby_rooms
|
||||
--
|
||||
-- Implements the Jitsi "lobby" feature: a waiting room that holds participants
|
||||
-- until a moderator admits or rejects them.
|
||||
--
|
||||
-- How it works:
|
||||
-- When a moderator sets a room to members-only (muc#roomconfig_membersonly),
|
||||
-- a paired persistent lobby MUC room is created on the lobby component.
|
||||
-- Subsequent join attempts by non-members are rejected with a 407 error that
|
||||
-- includes the lobby room JID, redirecting the client to wait there.
|
||||
-- Moderators (owners in the main room) are also owners in the lobby room and
|
||||
-- can admit participants by inviting them or deny them by kicking.
|
||||
--
|
||||
-- Join-gate logic (muc-occupant-pre-join, priority -4):
|
||||
-- 1. Healthcheck rooms, focus nick (/focus suffix) -> pass through.
|
||||
-- 2. Whitelisted JID/domain (muc_lobby_whitelist) or correct password -> grant
|
||||
-- member affiliation and pass through.
|
||||
-- 3. Missing display name -> 406 not-acceptable with <displayname-required>.
|
||||
-- 4. No affiliation -> 407 registration-required with <lobbyroom> JID.
|
||||
-- 5. Existing member/owner affiliation -> pass through to the MUC layer.
|
||||
--
|
||||
-- Presence/message filtering:
|
||||
-- Stanzas from the lobby component are filtered so lobby participants can only
|
||||
-- see and message moderators, not each other.
|
||||
--
|
||||
-- Notifications:
|
||||
-- Broadcasts JSON groupchat messages (type "lobby-notify") to the main room
|
||||
-- when lobby is enabled/disabled (LOBBY-ENABLED) or when a participant is
|
||||
-- admitted (LOBBY-ACCESS-GRANTED) or denied (LOBBY-ACCESS-DENIED).
|
||||
--
|
||||
-- Backend API:
|
||||
-- Handles global events "create-lobby-room" and "destroy-lobby-room" so that
|
||||
-- other Prosody modules (e.g. jicofo bridge) can manage the lobby
|
||||
-- programmatically without a moderator submitting a config form.
|
||||
--
|
||||
-- Required configuration (on the main VirtualHost):
|
||||
-- lobby_muc = "lobby.jitmeet.example.com"
|
||||
-- main_muc = "conference.jitmeet.example.com"
|
||||
--
|
||||
-- This module added under the main virtual host domain
|
||||
-- It needs a lobby muc component
|
||||
--
|
||||
-- VirtualHost "jitmeet.example.com"
|
||||
-- modules_enabled = {
|
||||
-- "muc_lobby_rooms"
|
||||
-- }
|
||||
-- lobby_muc = "lobby.jitmeet.example.com"
|
||||
-- main_muc = "conference.jitmeet.example.com"
|
||||
--
|
||||
-- Component "lobby.jitmeet.example.com" "muc"
|
||||
-- storage = "memory"
|
||||
-- muc_room_cache_size = 1000
|
||||
-- restrict_room_creation = true
|
||||
-- muc_room_locking = false
|
||||
-- muc_room_default_public_jids = true
|
||||
--
|
||||
-- we use async to detect Prosody 0.10 and earlier
|
||||
local have_async = pcall(require, 'util.async');
|
||||
|
||||
if not have_async then
|
||||
module:log('warn', 'Lobby rooms will not work with Prosody version 0.10 or less.');
|
||||
return;
|
||||
end
|
||||
|
||||
module:depends("jitsi_session");
|
||||
|
||||
local jid_split = require 'util.jid'.split;
|
||||
local jid_bare = require 'util.jid'.bare;
|
||||
local jid_prep = require "util.jid".prep;
|
||||
local jid_resource = require "util.jid".resource;
|
||||
local resourceprep = require "util.encodings".stringprep.resourceprep;
|
||||
local json = require 'cjson.safe';
|
||||
local filters = require 'util.filters';
|
||||
local st = require 'util.stanza';
|
||||
local muc_util = module:require "muc/util";
|
||||
local valid_affiliations = muc_util.valid_affiliations;
|
||||
local MUC_NS = 'http://jabber.org/protocol/muc';
|
||||
local MUC_USER_NS = 'http://jabber.org/protocol/muc#user';
|
||||
local DISCO_INFO_NS = 'http://jabber.org/protocol/disco#info';
|
||||
local DISPLAY_NAME_REQUIRED_FEATURE = 'http://jitsi.org/protocol/lobbyrooms#displayname_required';
|
||||
local LOBBY_IDENTITY_TYPE = 'lobbyrooms';
|
||||
local NOTIFY_JSON_MESSAGE_TYPE = 'lobby-notify';
|
||||
local NOTIFY_LOBBY_ENABLED = 'LOBBY-ENABLED';
|
||||
local NOTIFY_LOBBY_ACCESS_GRANTED = 'LOBBY-ACCESS-GRANTED';
|
||||
local NOTIFY_LOBBY_ACCESS_DENIED = 'LOBBY-ACCESS-DENIED';
|
||||
|
||||
local util = module:require "util";
|
||||
local is_focus = util.is_focus;
|
||||
local is_focus_jid = util.is_focus_jid;
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local presence_check_status = util.presence_check_status;
|
||||
local process_host_module = util.process_host_module;
|
||||
|
||||
local main_muc_component_config = module:get_option_string('main_muc');
|
||||
if main_muc_component_config == nil then
|
||||
module:log('error', 'lobby not enabled missing main_muc config');
|
||||
return ;
|
||||
end
|
||||
local lobby_muc_component_config = module:get_option_string('lobby_muc');
|
||||
if lobby_muc_component_config == nil then
|
||||
module:log('error', 'lobby not enabled missing lobby_muc config');
|
||||
return ;
|
||||
end
|
||||
|
||||
local whitelist;
|
||||
local check_display_name_required;
|
||||
local function load_config()
|
||||
whitelist = module:get_option_set('muc_lobby_whitelist', {});
|
||||
check_display_name_required
|
||||
= module:get_option_boolean('muc_lobby_check_display_name_required', true);
|
||||
end
|
||||
load_config();
|
||||
|
||||
local lobby_muc_service;
|
||||
local main_muc_service;
|
||||
|
||||
function broadcast_json_msg(room, from, json_msg)
|
||||
json_msg.type = NOTIFY_JSON_MESSAGE_TYPE;
|
||||
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
if occupant then
|
||||
local json_msg_str, error = json.encode(json_msg);
|
||||
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error broadcasting message room:%s', room.jid, error);
|
||||
return;
|
||||
end
|
||||
|
||||
room:broadcast_message(
|
||||
st.message({ type = 'groupchat', from = occupant.nick })
|
||||
:tag('json-message', {xmlns='http://jitsi.org/jitmeet'})
|
||||
:text(json_msg_str):up());
|
||||
end
|
||||
end
|
||||
|
||||
-- Sends a json message notifying for lobby enabled/disable
|
||||
-- the message from is the actor that did the operation
|
||||
function notify_lobby_enabled(room, actor, value)
|
||||
broadcast_json_msg(room, actor, {
|
||||
event = NOTIFY_LOBBY_ENABLED,
|
||||
value = value
|
||||
});
|
||||
end
|
||||
|
||||
-- Sends a json message notifying that the jid was granted/denied access in lobby
|
||||
-- the message from is the actor that did the operation
|
||||
function notify_lobby_access(room_jid, actor, jid, display_name, granted)
|
||||
local notify_json = {
|
||||
value = jid,
|
||||
name = display_name
|
||||
};
|
||||
if granted then
|
||||
notify_json.event = NOTIFY_LOBBY_ACCESS_GRANTED;
|
||||
else
|
||||
notify_json.event = NOTIFY_LOBBY_ACCESS_DENIED;
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_jid);
|
||||
if not room then
|
||||
module:log('error', 'Room not found for %s', room_jid)
|
||||
return;
|
||||
end
|
||||
|
||||
broadcast_json_msg(room, actor, notify_json);
|
||||
end
|
||||
|
||||
function filter_stanza(stanza)
|
||||
if not stanza.attr or not stanza.attr.from or not main_muc_service or not lobby_muc_service then
|
||||
return stanza;
|
||||
end
|
||||
-- Allow self-presence (code=110)
|
||||
local node, from_domain = jid_split(stanza.attr.from);
|
||||
|
||||
if from_domain == lobby_muc_component_config then
|
||||
if stanza.name == 'presence' then
|
||||
local muc_x = stanza:get_child('x', MUC_NS..'#user');
|
||||
if not muc_x or presence_check_status(muc_x, '110') then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local lobby_room_jid = jid_bare(stanza.attr.from);
|
||||
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
|
||||
if not lobby_room then
|
||||
module:log('warn', 'No lobby room found %s', lobby_room_jid);
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- check is an owner, only owners can receive the presence
|
||||
-- do not forward presence of owners (other than unavailable)
|
||||
local room = main_muc_service.get_room_from_jid(jid_bare(node .. '@' .. main_muc_component_config));
|
||||
local item = muc_x:get_child('item');
|
||||
if not room
|
||||
or stanza.attr.type == 'unavailable'
|
||||
or (room.get_affiliation(room, stanza.attr.to) == 'owner'
|
||||
and room.get_affiliation(room, item.attr.jid) ~= 'owner') then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
|
||||
local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
|
||||
if not from_occupant then
|
||||
if is_to_moderator then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
module:log('warn', 'No lobby occupant found %s', stanza.attr.from);
|
||||
return nil;
|
||||
end
|
||||
|
||||
local from_real_jid;
|
||||
for real_jid in from_occupant:each_session() do
|
||||
from_real_jid = real_jid;
|
||||
end
|
||||
|
||||
if is_to_moderator and lobby_room:get_affiliation(from_real_jid) ~= 'owner' then
|
||||
return stanza;
|
||||
end
|
||||
elseif stanza.name == 'iq' and stanza:get_child('query', DISCO_INFO_NS) then
|
||||
-- allow disco info from the lobby component
|
||||
return stanza;
|
||||
elseif stanza.name == 'message' then
|
||||
-- allow messages to or from moderator
|
||||
local lobby_room_jid = jid_bare(stanza.attr.from);
|
||||
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
|
||||
|
||||
if not lobby_room then
|
||||
module:log('warn', 'No lobby room found %s', stanza.attr.from);
|
||||
return nil;
|
||||
end
|
||||
|
||||
local is_to_moderator = lobby_room:get_affiliation(stanza.attr.to) == 'owner';
|
||||
local from_occupant = lobby_room:get_occupant_by_nick(stanza.attr.from);
|
||||
|
||||
local from_real_jid;
|
||||
if from_occupant then
|
||||
for real_jid in from_occupant:each_session() do
|
||||
from_real_jid = real_jid;
|
||||
end
|
||||
end
|
||||
|
||||
if not from_real_jid then
|
||||
return nil;
|
||||
end
|
||||
|
||||
local is_from_moderator = lobby_room:get_affiliation(from_real_jid) == 'owner';
|
||||
|
||||
if is_to_moderator or is_from_moderator then
|
||||
return stanza;
|
||||
end
|
||||
return nil;
|
||||
end
|
||||
|
||||
return nil;
|
||||
else
|
||||
return stanza;
|
||||
end
|
||||
end
|
||||
function filter_session(session)
|
||||
-- domain mapper is filtering on default priority 0, and we need it after that
|
||||
filters.add_filter(session, 'stanzas/out', filter_stanza, -1);
|
||||
end
|
||||
|
||||
-- actor can be null if called from backend (another module using hook create-lobby-room)
|
||||
function attach_lobby_room(room, actor)
|
||||
local node = jid_split(room.jid);
|
||||
local lobby_room_jid = node .. '@' .. lobby_muc_component_config;
|
||||
if not lobby_muc_service.get_room_from_jid(lobby_room_jid) then
|
||||
local new_room = lobby_muc_service.create_room(lobby_room_jid);
|
||||
-- set persistent the lobby room to avoid it to be destroyed
|
||||
-- there are cases like when selecting new moderator after the current one leaves
|
||||
-- which can leave the room with no occupants and it will be destroyed and we want to
|
||||
-- avoid lobby destroy while it is enabled
|
||||
new_room:set_persistent(true);
|
||||
module:log("info","Lobby room jid = %s created from:%s", lobby_room_jid, actor);
|
||||
new_room.main_room_jid = room.jid;
|
||||
room._data.lobbyroom = new_room.jid;
|
||||
room:save(true);
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- destroys lobby room for the supplied main room
|
||||
function destroy_lobby_room(room, newjid, message)
|
||||
if not message then
|
||||
message = 'Lobby room closed.';
|
||||
end
|
||||
if lobby_muc_service and room and room._data.lobbyroom then
|
||||
local lobby_room_obj = lobby_muc_service.get_room_from_jid(room._data.lobbyroom);
|
||||
if lobby_room_obj then
|
||||
lobby_room_obj:set_persistent(false);
|
||||
lobby_room_obj:destroy(newjid, message);
|
||||
|
||||
module:log('info', 'Lobby room destroyed %s', lobby_room_obj.jid)
|
||||
|
||||
if room.jitsiMetadata then
|
||||
room.jitsiMetadata.lobbyEnabled = false;
|
||||
module:context(main_muc_component_config):fire_event('room-metadata-changed', { room = room; });
|
||||
end
|
||||
end
|
||||
room._data.lobbyroom = nil;
|
||||
room._data.lobby_extra_reason = nil;
|
||||
room._data.lobby_skip_display_name_check = nil;
|
||||
end
|
||||
end
|
||||
|
||||
-- This is a copy of the function(handle_admin_query_set_command) from prosody 12 (d7857ef7843a)
|
||||
function handle_admin_query_set_command_item(self, origin, stanza, item)
|
||||
if not item then
|
||||
origin.send(st.error_reply(stanza, "cancel", "bad-request"));
|
||||
return true;
|
||||
end
|
||||
if item.attr.jid then -- Validate provided JID
|
||||
item.attr.jid = jid_prep(item.attr.jid);
|
||||
if not item.attr.jid then
|
||||
origin.send(st.error_reply(stanza, "modify", "jid-malformed"));
|
||||
return true;
|
||||
elseif jid_resource(item.attr.jid) then
|
||||
origin.send(st.error_reply(stanza, "modify", "jid-malformed", "Bare JID expected, got full JID"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
if item.attr.nick then -- Validate provided nick
|
||||
item.attr.nick = resourceprep(item.attr.nick);
|
||||
if not item.attr.nick then
|
||||
origin.send(st.error_reply(stanza, "modify", "jid-malformed", "invalid nickname"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
if not item.attr.jid and item.attr.nick then
|
||||
-- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation
|
||||
local occupant = self:get_occupant_by_nick(self.jid.."/"..item.attr.nick);
|
||||
if occupant then item.attr.jid = occupant.bare_jid; end
|
||||
elseif item.attr.role and not item.attr.nick and item.attr.jid then
|
||||
-- Role changes should use nick, but we have a JID so pull the nick from that
|
||||
local nick = self:get_occupant_jid(item.attr.jid);
|
||||
if nick then item.attr.nick = jid_resource(nick); end
|
||||
end
|
||||
local actor = stanza.attr.from;
|
||||
local reason = item:get_child_text("reason");
|
||||
local success, errtype, err
|
||||
if item.attr.affiliation and item.attr.jid and not item.attr.role then
|
||||
local registration_data;
|
||||
if item.attr.nick then
|
||||
local room_nick = self.jid.."/"..item.attr.nick;
|
||||
local existing_occupant = self:get_occupant_by_nick(room_nick);
|
||||
if existing_occupant and existing_occupant.bare_jid ~= item.attr.jid then
|
||||
module:log("debug", "Existing occupant for %s: %s does not match %s - need to kick", room_nick, existing_occupant.bare_jid, item.attr.jid);
|
||||
success, errtype, err = self:set_role(actor, room_nick, nil, "This nickname is reserved");
|
||||
if not success then
|
||||
origin.send(st.error_reply(stanza, errtype, err));
|
||||
return true;
|
||||
end end
|
||||
module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation);
|
||||
registration_data = { reserved_nickname = item.attr.nick };
|
||||
end
|
||||
success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data);
|
||||
elseif item.attr.role and item.attr.nick and not item.attr.affiliation then
|
||||
success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, reason);
|
||||
else
|
||||
success, errtype, err = nil, "cancel", "bad-request";
|
||||
end
|
||||
self:save(true);
|
||||
if not success then
|
||||
origin.send(st.error_reply(stanza, errtype, err));
|
||||
else
|
||||
origin.send(st.reply(stanza));
|
||||
end
|
||||
end
|
||||
|
||||
-- this is extracted from prosody to handle multiple invites
|
||||
function handle_mediated_invite(room, origin, stanza, payload, host_module)
|
||||
local invitee = jid_prep(payload.attr.to);
|
||||
if not invitee then
|
||||
origin.send(st.error_reply(stanza, "cancel", "jid-malformed"));
|
||||
return true;
|
||||
elseif host_module:fire_event("muc-pre-invite", {room = room, origin = origin, stanza = stanza}) then
|
||||
return true;
|
||||
end
|
||||
local invite = muc_util.filter_muc_x(st.clone(stanza));
|
||||
invite.attr.from = room.jid;
|
||||
invite.attr.to = invitee;
|
||||
invite:tag('x', { xmlns = MUC_USER_NS })
|
||||
:tag('invite', {from = stanza.attr.from;})
|
||||
:tag('reason'):text(payload:get_child_text("reason")):up()
|
||||
:up()
|
||||
:up();
|
||||
if not host_module:fire_event("muc-invite", {room = room, stanza = invite, origin = origin, incoming = stanza}) then
|
||||
local join = invite:get_child('x', MUC_USER_NS);
|
||||
-- make sure we filter password added by any module
|
||||
if join then
|
||||
local password = join:get_child('password');
|
||||
if password then
|
||||
join:maptags(
|
||||
function(tag)
|
||||
for k, v in pairs(tag) do
|
||||
if k == 'name' and v == 'password' then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return tag
|
||||
end
|
||||
);
|
||||
end
|
||||
end
|
||||
room:route_stanza(invite);
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
local prosody_overrides = {
|
||||
-- handle multiple items at once
|
||||
handle_admin_query_set_command = function(self, origin, stanza)
|
||||
for i=1,#stanza.tags[1] do
|
||||
if handle_admin_query_set_command_item(self, origin, stanza, stanza.tags[1].tags[i]) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
return true;
|
||||
end,
|
||||
-- this is extracted from prosody to handle multiple invites
|
||||
handle_message_to_room = function(room, origin, stanza, host_module)
|
||||
local type = stanza.attr.type;
|
||||
if type == nil or type == "normal" then
|
||||
local x = stanza:get_child("x", MUC_USER_NS);
|
||||
if x then
|
||||
local handled = false;
|
||||
for _, payload in pairs(x.tags) do
|
||||
if payload ~= nil and payload.name == "invite" and payload.attr.to then
|
||||
handled = true;
|
||||
handle_mediated_invite(room, origin, stanza, payload, host_module)
|
||||
end
|
||||
end
|
||||
return handled;
|
||||
end
|
||||
end
|
||||
end
|
||||
};
|
||||
|
||||
-- operates on already loaded lobby muc module
|
||||
function process_lobby_muc_loaded(lobby_muc, host_module)
|
||||
module:log('debug', 'Lobby muc loaded');
|
||||
lobby_muc_service = lobby_muc;
|
||||
|
||||
-- enable filtering presences in the lobby muc rooms
|
||||
filters.add_filter_hook(filter_session);
|
||||
|
||||
-- Advertise lobbyrooms support on main domain so client can pick up the address and use it
|
||||
module:add_identity('component', LOBBY_IDENTITY_TYPE, lobby_muc_component_config);
|
||||
|
||||
-- Tag the disco#info response with a feature that display name is required
|
||||
-- when the conference name from the web request has a lobby enabled.
|
||||
host_module:hook('host-disco-info-node', function (event)
|
||||
local session, reply, node = event.origin, event.reply, event.node;
|
||||
if node == LOBBY_IDENTITY_TYPE
|
||||
and session.jitsi_web_query_room then
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
|
||||
if room and room._data.lobby_disabled then
|
||||
-- we cannot remove the child from the stanza so let's just change the type
|
||||
local lobby_identity = reply:get_child_with_attr('identity', nil, 'type', LOBBY_IDENTITY_TYPE);
|
||||
if lobby_identity then
|
||||
lobby_identity.attr.type = 'DISABLED_'..LOBBY_IDENTITY_TYPE;
|
||||
end
|
||||
end
|
||||
|
||||
if check_display_name_required and room and room._data.lobbyroom then
|
||||
reply:tag('feature', { var = DISPLAY_NAME_REQUIRED_FEATURE }):up();
|
||||
end
|
||||
end
|
||||
event.exists = true;
|
||||
end);
|
||||
|
||||
local room_mt = lobby_muc_service.room_mt;
|
||||
-- we base affiliations (roles) in lobby muc component to be based on the roles in the main muc
|
||||
room_mt.get_affiliation = function(room, jid)
|
||||
if not room.main_room_jid then
|
||||
module:log('error', 'No main room(%s) for %s!', room.jid, jid);
|
||||
return 'none';
|
||||
end
|
||||
|
||||
-- moderators in main room are moderators here
|
||||
local main_room = get_room_from_jid(room.main_room_jid);
|
||||
if not main_room then
|
||||
module:log('error', 'Main room not found for %s!', room.main_room_jid);
|
||||
return 'none';
|
||||
end
|
||||
local role = main_room.get_affiliation(main_room, jid);
|
||||
if role then
|
||||
return role;
|
||||
end
|
||||
|
||||
return 'none';
|
||||
end
|
||||
|
||||
-- listens for kicks in lobby room, 307 is the status for kick according to xep-0045
|
||||
host_module:hook('muc-broadcast-presence', function (event)
|
||||
local actor, occupant, room, x = event.actor, event.occupant, event.room, event.x;
|
||||
if presence_check_status(x, '307') then
|
||||
local display_name = occupant:get_presence():get_child_text(
|
||||
'nick', 'http://jabber.org/protocol/nick');
|
||||
-- we need to notify in the main room
|
||||
notify_lobby_access(room.main_room_jid, actor, occupant.nick, display_name, false);
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
-- process or waits to process the lobby muc component
|
||||
process_host_module(lobby_muc_component_config, function(host_module, host)
|
||||
-- lobby muc component created
|
||||
module:log('info', 'Lobby component loaded %s', host);
|
||||
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
if muc_module then
|
||||
process_lobby_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_lobby_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
-- process or waits to process the main muc component
|
||||
process_host_module(main_muc_component_config, function(host_module, host)
|
||||
main_muc_service = prosody.hosts[host].modules.muc;
|
||||
|
||||
-- hooks when lobby is enabled to create its room, only done here or by admin
|
||||
host_module:hook('muc-config-submitted', function(event)
|
||||
local actor, room = event.actor, event.room;
|
||||
if is_focus_jid(actor) then
|
||||
return;
|
||||
end
|
||||
local members_only = event.fields['muc#roomconfig_membersonly'] and true or nil;
|
||||
if members_only then
|
||||
-- if lobby disabled just ignore and return
|
||||
if room._data.lobby_disabled then
|
||||
module:log('warn', 'Lobby is disabled for room %s, cannot enable members only', room.jid);
|
||||
return;
|
||||
end
|
||||
local lobby_created = attach_lobby_room(room, actor);
|
||||
if lobby_created then
|
||||
module:fire_event('jitsi-lobby-enabled', { room = room; });
|
||||
event.status_codes['104'] = true;
|
||||
notify_lobby_enabled(room, actor, true);
|
||||
|
||||
-- let's set it in the metadata and fire the event
|
||||
if not room.jitsiMetadata then
|
||||
room.jitsiMetadata = {};
|
||||
end
|
||||
room.jitsiMetadata.lobbyEnabled = true;
|
||||
host_module:fire_event('room-metadata-changed', { room = room; });
|
||||
end
|
||||
elseif room._data.lobbyroom then
|
||||
destroy_lobby_room(room, internal_room_jid_match_rewrite(room.jid), nil); --
|
||||
module:fire_event('jitsi-lobby-disabled', { room = room; });
|
||||
notify_lobby_enabled(room, actor, false);
|
||||
end
|
||||
end);
|
||||
host_module:hook('muc-room-destroyed',function(event)
|
||||
local room = event.room;
|
||||
if room._data.lobbyroom then
|
||||
destroy_lobby_room(room, nil);
|
||||
end
|
||||
end, 1); -- prosody handles it at 0
|
||||
host_module:hook('muc-disco#info', function (event)
|
||||
local room = event.room;
|
||||
if (room._data.lobbyroom and room:get_members_only()) then
|
||||
table.insert(event.form, {
|
||||
name = 'muc#roominfo_lobbyroom';
|
||||
label = 'Lobby room jid';
|
||||
value = '';
|
||||
});
|
||||
event.formdata['muc#roominfo_lobbyroom'] = room._data.lobbyroom;
|
||||
end
|
||||
end);
|
||||
|
||||
host_module:hook('muc-occupant-pre-join', function (event)
|
||||
local occupant, room, stanza = event.occupant, event.room, event.stanza;
|
||||
|
||||
if is_healthcheck_room(room.jid) or not room:get_members_only() or is_focus(occupant.nick) then
|
||||
return;
|
||||
end
|
||||
|
||||
local join = stanza:get_child('x', MUC_NS);
|
||||
if not join then
|
||||
return;
|
||||
end
|
||||
|
||||
local invitee = event.stanza.attr.from;
|
||||
local invitee_bare_jid = jid_bare(invitee);
|
||||
local _, invitee_domain = jid_split(invitee);
|
||||
local whitelistJoin = false;
|
||||
|
||||
-- whitelist participants
|
||||
if whitelist:contains(invitee_domain) or whitelist:contains(invitee_bare_jid) then
|
||||
whitelistJoin = true;
|
||||
end
|
||||
|
||||
local password = join:get_child_text('password', MUC_NS);
|
||||
if password and room:get_password() and password == room:get_password() then
|
||||
whitelistJoin = true;
|
||||
end
|
||||
|
||||
if whitelistJoin then
|
||||
local affiliation = room:get_affiliation(invitee);
|
||||
-- if it was already set to be whitelisted member
|
||||
if not affiliation or affiliation == 'none' or affiliation == 'member' then
|
||||
occupant.role = 'participant';
|
||||
room:set_affiliation(true, invitee_bare_jid, 'member');
|
||||
room:save_occupant(occupant);
|
||||
|
||||
return;
|
||||
end
|
||||
elseif room:get_password() then
|
||||
local affiliation = room:get_affiliation(invitee);
|
||||
-- if pre-approved and password is set for the room, add the password to allow joining
|
||||
if valid_affiliations[affiliation or 'none'] >= valid_affiliations.member and not password then
|
||||
join:tag('password', { xmlns = MUC_NS }):text(room:get_password());
|
||||
end
|
||||
end
|
||||
|
||||
-- Check for display name if missing return an error
|
||||
local displayName = stanza:get_child_text('nick', 'http://jabber.org/protocol/nick');
|
||||
if (not displayName or #displayName == 0) and not room._data.lobby_skip_display_name_check then
|
||||
local reply = st.error_reply(stanza, 'modify', 'not-acceptable');
|
||||
reply.tags[1].attr.code = '406';
|
||||
reply:tag('displayname-required', { xmlns = 'http://jitsi.org/jitmeet', lobby = 'true' }):up():up();
|
||||
|
||||
event.origin.send(reply:tag('x', {xmlns = MUC_NS}));
|
||||
return true;
|
||||
end
|
||||
|
||||
-- we want to add the custom lobbyroom field to fill in the lobby room jid
|
||||
local invitee = event.stanza.attr.from;
|
||||
local affiliation = room:get_affiliation(invitee);
|
||||
if not affiliation or affiliation == 'none' then
|
||||
local reply = st.error_reply(stanza, 'auth', 'registration-required');
|
||||
reply.tags[1].attr.code = '407';
|
||||
if room._data.lobby_extra_reason then
|
||||
reply:tag(room._data.lobby_extra_reason, { xmlns = 'http://jitsi.org/jitmeet' }):up();
|
||||
end
|
||||
reply:tag('lobbyroom', { xmlns = 'http://jitsi.org/jitmeet' }):text(room._data.lobbyroom):up():up();
|
||||
|
||||
-- TODO: Drop this tag at some point (when all mobile clients and jigasi are updated), as this violates the rfc
|
||||
reply:tag('lobbyroom'):text(room._data.lobbyroom):up();
|
||||
|
||||
event.origin.send(reply:tag('x', {xmlns = MUC_NS}));
|
||||
return true;
|
||||
end
|
||||
end, -4); -- the default hook on members_only module is on -5
|
||||
|
||||
-- listens for invites for participants to join the main room
|
||||
host_module:hook('muc-invite', function(event)
|
||||
local room, stanza = event.room, event.stanza;
|
||||
local invitee = stanza.attr.to;
|
||||
local from = stanza:get_child('x', MUC_USER_NS)
|
||||
:get_child('invite').attr.from;
|
||||
|
||||
if lobby_muc_service and room._data.lobbyroom then
|
||||
local lobby_room_obj = lobby_muc_service.get_room_from_jid(room._data.lobbyroom);
|
||||
if lobby_room_obj then
|
||||
local occupant = lobby_room_obj:get_occupant_by_real_jid(invitee);
|
||||
if occupant then
|
||||
local display_name = occupant:get_presence():get_child_text(
|
||||
'nick', 'http://jabber.org/protocol/nick');
|
||||
|
||||
notify_lobby_access(room.jid, from, occupant.nick, display_name, true);
|
||||
end
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
-- listen for admin set
|
||||
for event_name, method in pairs {
|
||||
-- Normal room interactions
|
||||
["iq-set/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ;
|
||||
["message/bare"] = "handle_message_to_room" ;
|
||||
-- Host room
|
||||
["iq-set/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ;
|
||||
["message/host"] = "handle_message_to_room" ;
|
||||
} do
|
||||
host_module:hook(event_name, function (event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
local room_jid = jid_bare(stanza.attr.to);
|
||||
local room = get_room_from_jid(room_jid);
|
||||
|
||||
if room then
|
||||
return prosody_overrides[method](room, origin, stanza, host_module);
|
||||
end
|
||||
end, 1) -- make sure we handle it before prosody that uses priority -2 for this
|
||||
end
|
||||
end);
|
||||
|
||||
function handle_create_lobby(event)
|
||||
local room = event.room;
|
||||
|
||||
-- since this is called by backend rather than triggered by UI, we need to handle a few additional things:
|
||||
-- 1. Make sure existing participants are already members or they will get kicked out when set_members_only(true)
|
||||
-- 2. Trigger a 104 (config change) status message so UI state is properly updated for existing users
|
||||
|
||||
-- make sure all existing occupants are members
|
||||
for _, occupant in room:each_occupant() do
|
||||
local affiliation = room:get_affiliation(occupant.bare_jid);
|
||||
if valid_affiliations[affiliation or "none"] < valid_affiliations.member then
|
||||
room:set_affiliation(true, occupant.bare_jid, 'member');
|
||||
end
|
||||
end
|
||||
-- Now it is safe to set the room to members only
|
||||
room:set_members_only(true);
|
||||
room._data.lobby_extra_reason = event.reason;
|
||||
room._data.lobby_skip_display_name_check = event.skip_display_name_check;
|
||||
|
||||
-- set in metadata without firing room-metadata-changed,
|
||||
-- as this is a backend call and the caller will take care of that
|
||||
if not room.jitsiMetadata then
|
||||
room.jitsiMetadata = {};
|
||||
end
|
||||
room.jitsiMetadata.lobbyEnabled = true;
|
||||
|
||||
-- Trigger a presence with 104 so existing participants retrieves new muc#roomconfig
|
||||
room:broadcast_message(
|
||||
st.message({ type='groupchat', from=room.jid })
|
||||
:tag('x', { xmlns = MUC_USER_NS })
|
||||
:tag('status', { code='104' })
|
||||
);
|
||||
|
||||
-- Attach the lobby room.
|
||||
attach_lobby_room(room);
|
||||
end
|
||||
|
||||
function handle_destroy_lobby(event)
|
||||
local room = event.room;
|
||||
|
||||
-- since this is called by backend rather than triggered by UI, we need to
|
||||
-- trigger a 104 (config change) status message so UI state is properly updated for existing users (and jicofo)
|
||||
destroy_lobby_room(room, event.newjid, event.message);
|
||||
|
||||
-- Trigger a presence with 104 so existing participants retrieves new muc#roomconfig
|
||||
room:broadcast_message(
|
||||
st.message({ type='groupchat', from=room.jid })
|
||||
:tag('x', { xmlns = MUC_USER_NS })
|
||||
:tag('status', { code='104' })
|
||||
);
|
||||
end
|
||||
|
||||
module:hook_global('config-reloaded', load_config);
|
||||
module:hook_global('create-lobby-room', handle_create_lobby);
|
||||
module:hook_global('destroy-lobby-room', handle_destroy_lobby);
|
||||
72
resources/prosody-plugins/mod_muc_max_occupants.lua
Normal file
72
resources/prosody-plugins/mod_muc_max_occupants.lua
Normal file
@@ -0,0 +1,72 @@
|
||||
-- MUC Max Occupants
|
||||
-- Configuring muc_max_occupants will set a limit of the maximum number
|
||||
-- of participants that will be able to join in a room.
|
||||
-- Participants in muc_access_whitelist will not be counted for the
|
||||
-- max occupants value (values are jids like recorder@jitsi.meeet.example.com).
|
||||
-- This module is configured under the muc component that is used for jitsi-meet
|
||||
local split_jid = require "util.jid".split;
|
||||
local st = require "util.stanza";
|
||||
local it = require "util.iterators";
|
||||
local is_healthcheck_room = module:require "util".is_healthcheck_room;
|
||||
|
||||
local whitelist = module:get_option_set("muc_access_whitelist");
|
||||
local MAX_OCCUPANTS = module:get_option_number("muc_max_occupants", -1);
|
||||
|
||||
local function count_keys(t)
|
||||
return it.count(it.keys(t));
|
||||
end
|
||||
|
||||
local function check_for_max_occupants(event)
|
||||
local room, origin, stanza = event.room, event.origin, event.stanza;
|
||||
local user, domain, res = split_jid(stanza.attr.from);
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
--no user object means no way to check for max occupants
|
||||
if user == nil then
|
||||
return
|
||||
end
|
||||
-- If we're a whitelisted user joining the room, don't bother checking the max
|
||||
-- occupants.
|
||||
if whitelist and (whitelist:contains(domain) or whitelist:contains(user..'@'..domain)) then
|
||||
return;
|
||||
end
|
||||
|
||||
if room and not room._jid_nick[stanza.attr.from] then
|
||||
local max_occupants_by_room = event.room._data.max_occupants;
|
||||
local count = count_keys(room._occupants);
|
||||
-- if no of occupants limit is set per room basis use
|
||||
-- that settings otherwise use the global one
|
||||
local slots = max_occupants_by_room or MAX_OCCUPANTS;
|
||||
|
||||
-- If there is no whitelist, just check the count.
|
||||
if not whitelist and count >= slots then
|
||||
module:log("info", "Attempt to enter a maxed out room: %s", room.jid);
|
||||
origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
|
||||
return true;
|
||||
end
|
||||
|
||||
-- TODO: Are Prosody hooks atomic, or is this a race condition?
|
||||
-- For each person in the room that's not on the whitelist, subtract one
|
||||
-- from the count.
|
||||
for _, occupant in room:each_occupant() do
|
||||
user, domain, res = split_jid(occupant.bare_jid);
|
||||
if not whitelist or (not whitelist:contains(domain) and not whitelist:contains(user..'@'..domain)) then
|
||||
slots = slots - 1
|
||||
end
|
||||
end
|
||||
|
||||
-- If the room is full (<0 slots left), error out.
|
||||
if slots <= 0 then
|
||||
module:log("info", "Attempt to enter a maxed out room:%s", room.jid);
|
||||
origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if MAX_OCCUPANTS > 0 then
|
||||
module:hook("muc-occupant-pre-join", check_for_max_occupants, 10);
|
||||
end
|
||||
339
resources/prosody-plugins/mod_muc_meeting_id.lua
Normal file
339
resources/prosody-plugins/mod_muc_meeting_id.lua
Normal file
@@ -0,0 +1,339 @@
|
||||
-- Assigns a unique UUID meeting ID to each MUC room at creation time and
|
||||
-- advertises it in the room's config IQ response so that all participants share
|
||||
-- a common conference identifier. Also enforces a jicofo lock: non-focus clients
|
||||
-- that try to join before jicofo (identified by nick suffix '/focus') are queued
|
||||
-- and admitted only after jicofo joins and fires the jicofo-unlock-room event.
|
||||
-- This module is enabled under the MUC component.
|
||||
local jid = require 'util.jid';
|
||||
local json = require 'cjson.safe';
|
||||
local st = require "util.stanza";
|
||||
local queue = require "util.queue";
|
||||
local uuid_gen = require "util.uuid".generate;
|
||||
local main_util = module:require "util";
|
||||
local is_admin = main_util.is_admin;
|
||||
local is_focus = main_util.is_focus;
|
||||
local is_focus_jid = main_util.is_focus_jid;
|
||||
local get_room_from_jid = main_util.get_room_from_jid;
|
||||
local is_healthcheck_room = main_util.is_healthcheck_room;
|
||||
local internal_room_jid_match_rewrite = main_util.internal_room_jid_match_rewrite;
|
||||
local presence_check_status = main_util.presence_check_status;
|
||||
local extract_subdomain = main_util.extract_subdomain;
|
||||
local util = module:require 'util';
|
||||
local is_transcriber = util.is_transcriber;
|
||||
|
||||
local QUEUE_MAX_SIZE = 500;
|
||||
|
||||
module:depends("jitsi_permissions");
|
||||
module:depends("jitsi_session");
|
||||
|
||||
-- Common module for all logic that can be loaded under the conference muc component.
|
||||
--
|
||||
-- This module:
|
||||
-- a) Generates a unique meetingId, attaches it to the room and adds it to all disco info form data
|
||||
-- (when room is queried or in the initial room owner config).
|
||||
-- b) Updates user region (obtain it from the incoming http headers) in the occupant's presence on pre-join.
|
||||
-- c) Avoids any participant joining the room in the interval between creating the room and jicofo entering the room.
|
||||
-- d) Removes any nick that maybe set to messages being sent to the room.
|
||||
-- e) Fires event for received endpoint messages (optimization to decode them once).
|
||||
|
||||
-- Block non-focus participants from creating health-check rooms. This hook
|
||||
-- runs at priority 100, before mod_token_verification (priority 99), so that
|
||||
-- the error is service-unavailable (not the token verification not-allowed that
|
||||
-- would fire because the room does not yet exist at pre-create time).
|
||||
module:hook('muc-room-pre-create', function (event)
|
||||
local stanza = event.stanza;
|
||||
if is_healthcheck_room(jid.bare(stanza.attr.to)) then
|
||||
if not is_focus(stanza.attr.to) then
|
||||
module:log('info', 'Blocking non-focus from creating health-check room');
|
||||
event.origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end, 100);
|
||||
|
||||
-- Hook to assign meetingId for new rooms
|
||||
module:hook("muc-room-created", function(event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
room._data.meetingId = uuid_gen();
|
||||
|
||||
module:log("debug", "Created meetingId:%s for %s",
|
||||
room._data.meetingId, room.jid);
|
||||
end);
|
||||
|
||||
-- Returns the meeting config Id form data.
|
||||
function getMeetingIdConfig(room)
|
||||
return {
|
||||
name = "muc#roominfo_meetingId";
|
||||
type = "text-single";
|
||||
label = "The meeting unique id.";
|
||||
value = room._data.meetingId or "";
|
||||
};
|
||||
end
|
||||
|
||||
-- add meeting Id to the disco info requests to the room
|
||||
module:hook("muc-disco#info", function(event)
|
||||
table.insert(event.form, getMeetingIdConfig(event.room));
|
||||
end);
|
||||
|
||||
-- add the meeting Id in the default config we return to jicofo
|
||||
module:hook("muc-config-form", function(event)
|
||||
table.insert(event.form, getMeetingIdConfig(event.room));
|
||||
end, 90-3);
|
||||
|
||||
-- disabled few options for room config, to not mess with visitor logic
|
||||
module:hook("muc-config-submitted/muc#roomconfig_moderatedroom", function()
|
||||
return true;
|
||||
end, 99);
|
||||
module:hook("muc-config-submitted/muc#roomconfig_presencebroadcast", function()
|
||||
return true;
|
||||
end, 99);
|
||||
module:hook("muc-config-submitted/muc#roominfo_meetingId", function(event)
|
||||
-- we allow jicofo to overwrite the meetingId
|
||||
if is_admin(event.actor) then
|
||||
event.room._data.meetingId = event.value;
|
||||
return;
|
||||
end
|
||||
|
||||
return true;
|
||||
end, 99);
|
||||
module:hook('muc-broadcast-presence', function (event)
|
||||
local actor, occupant, room, x = event.actor, event.occupant, event.room, event.x;
|
||||
if presence_check_status(x, '307') then
|
||||
-- make sure we update and affiliation for kicked users
|
||||
room:set_affiliation(actor, occupant.bare_jid, 'none');
|
||||
end
|
||||
end);
|
||||
|
||||
local function process_region(session, stanza)
|
||||
if not session.user_region then
|
||||
return;
|
||||
end
|
||||
|
||||
local region = stanza:get_child_text('jitsi_participant_region');
|
||||
if region then
|
||||
return;
|
||||
end
|
||||
|
||||
stanza:tag('jitsi_participant_region'):text(session.user_region):up();
|
||||
end
|
||||
|
||||
--- Avoids any participant joining the room in the interval between creating the room
|
||||
--- and jicofo entering the room
|
||||
module:hook('muc-occupant-pre-join', function (event)
|
||||
local occupant, room, stanza = event.occupant, event.room, event.stanza;
|
||||
|
||||
local is_health_room = is_healthcheck_room(room.jid);
|
||||
-- check for region
|
||||
if not is_admin(occupant.bare_jid) and not is_health_room then
|
||||
process_region(event.origin, stanza);
|
||||
end
|
||||
|
||||
if is_health_room then
|
||||
-- Only jicofo (focus) may join health-check rooms.
|
||||
if not is_focus(occupant.nick) then
|
||||
module:log('info', 'Blocking non-focus participant from health-check room: %s', room.jid);
|
||||
event.origin.send(st.error_reply(stanza, 'cancel', 'service-unavailable'));
|
||||
return true;
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
-- we skip processing only if jicofo_lock is set to false
|
||||
if room._data.jicofo_lock == false then
|
||||
return;
|
||||
end
|
||||
|
||||
if is_focus(occupant.nick) then
|
||||
module:fire_event('jicofo-unlock-room', { room = room; });
|
||||
else
|
||||
room._data.jicofo_lock = true;
|
||||
if not room.pre_join_queue then
|
||||
room.pre_join_queue = queue.new(QUEUE_MAX_SIZE);
|
||||
end
|
||||
|
||||
if not room.pre_join_queue:push(event) then
|
||||
module:log('error', 'Error enqueuing occupant event for: %s', occupant.nick);
|
||||
return true;
|
||||
end
|
||||
module:log('debug', 'Occupant pushed to prejoin queue %s', occupant.nick);
|
||||
|
||||
-- stop processing
|
||||
return true;
|
||||
end
|
||||
end, 8); -- just after the rate limit
|
||||
|
||||
function handle_jicofo_unlock(event)
|
||||
local room = event.room;
|
||||
|
||||
room._data.jicofo_lock = false;
|
||||
if not room.pre_join_queue then
|
||||
return;
|
||||
end
|
||||
|
||||
-- and now let's handle all pre_join_queue events
|
||||
for _, ev in room.pre_join_queue:items() do
|
||||
-- if the connection was closed while waiting in the queue, ignore
|
||||
if ev.origin.conn then
|
||||
module:log('debug', 'Occupant processed from queue %s', ev.occupant.nick);
|
||||
room:handle_normal_presence(ev.origin, ev.stanza);
|
||||
end
|
||||
end
|
||||
room.pre_join_queue = nil;
|
||||
end
|
||||
|
||||
module:hook('jicofo-unlock-room', handle_jicofo_unlock);
|
||||
|
||||
-- Prevents some participant from being modified in any way through a
|
||||
-- muc#admin set IQ (kick, ban, role/affiliation change, ...). This runs at a high
|
||||
-- priority (10) so it precedes prosody's own admin-query handler (default priority
|
||||
-- -2) and any other module hooks (e.g. allowners at 5). There is no legitimate
|
||||
-- reason for a client/moderator to target certain participants, so any such item is rejected.
|
||||
local function filter_admin_set(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
local room = get_room_from_jid(jid.bare(stanza.attr.to));
|
||||
|
||||
if not room then
|
||||
return;
|
||||
end
|
||||
|
||||
local query = stanza.tags[1];
|
||||
if not query then
|
||||
return;
|
||||
end
|
||||
|
||||
for item in query:childtags('item') do
|
||||
-- An item may carry a nick (role changes) and/or a jid (affiliation
|
||||
-- changes). Prosody's own handler applies a role change by nick and an
|
||||
-- affiliation change by jid, so both must be checked independently: a
|
||||
-- decoy nick must not mask a jid that targets focus (and vice versa).
|
||||
if item.attr.nick and is_focus(room.jid .. '/' .. item.attr.nick) then
|
||||
module:log('warn', 'Blocking attempt to modify %s in room %s', item.attr.nick, room.jid);
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
|
||||
return true;
|
||||
end
|
||||
|
||||
if item.attr.jid and is_focus_jid(item.attr.jid) then
|
||||
module:log('warn', 'Blocking attempt to modify %s in room %s', item.attr.jid, room.jid);
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module:hook('iq-set/bare/http://jabber.org/protocol/muc#admin:query', filter_admin_set, 10);
|
||||
module:hook('iq-set/host/http://jabber.org/protocol/muc#admin:query', filter_admin_set, 10);
|
||||
|
||||
-- make sure we remove nick if someone is sending it with a message to protect
|
||||
-- forgery of display name
|
||||
module:hook("muc-occupant-groupchat", function(event)
|
||||
event.stanza:remove_children('nick', 'http://jabber.org/protocol/nick');
|
||||
end, 45); -- prosody check is prio 50, we want to run after it
|
||||
|
||||
local function filterTranscriptionResult(event)
|
||||
local stanza = event.stanza;
|
||||
|
||||
if stanza.attr.type ~= 'groupchat' then
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- we are interested in all messages without a body
|
||||
local body = stanza:get_child('body')
|
||||
if body then
|
||||
return;
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(stanza.attr.to);
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s', stanza.attr.to);
|
||||
return;
|
||||
end
|
||||
|
||||
local occupant_jid = stanza.attr.from;
|
||||
local occupant = room:get_occupant_by_real_jid(occupant_jid);
|
||||
if not occupant then
|
||||
-- skip logs for messages coming from s2s
|
||||
if event.origin.type == 'c2s' then
|
||||
module:log("error", "Occupant sending msg %s was not found in room %s", occupant_jid, room.jid);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
local json_message = stanza:get_child_text('json-message', 'http://jitsi.org/jitmeet')
|
||||
or stanza:get_child_text('json-message');
|
||||
if not json_message then
|
||||
return;
|
||||
end
|
||||
|
||||
local msg_obj, error = json.decode(json_message);
|
||||
|
||||
if error then
|
||||
module:log('error', 'Error decoding data error:%s Sender: %s to:%s', error, stanza.attr.from, stanza.attr.to);
|
||||
return true;
|
||||
end
|
||||
|
||||
if msg_obj.type == 'transcription-result' then
|
||||
if not is_transcriber(stanza.attr.from) then
|
||||
module:log('warn', 'Filtering transcription-result message from non-transcriber: %s', stanza.attr.from);
|
||||
-- Do not fire the event, and do not forward the message
|
||||
return true
|
||||
end
|
||||
if msg_obj.is_interim then
|
||||
-- Do not fire the event, but forward the message
|
||||
return
|
||||
end
|
||||
|
||||
if msg_obj.transcript ~= nil then
|
||||
local transcription = msg_obj;
|
||||
|
||||
-- in case of the string matching optimization above failed
|
||||
if transcription.is_interim then
|
||||
return;
|
||||
end
|
||||
|
||||
-- TODO what if we have multiple alternative transcriptions not just 1
|
||||
if not transcription.transcript[1] then
|
||||
module:log('warn', 'Empty transcript array in transcription-result from %s', stanza.attr.from);
|
||||
return true;
|
||||
end
|
||||
local text_message = transcription.transcript[1].text;
|
||||
--do not send empty messages
|
||||
if text_message == '' then
|
||||
return;
|
||||
end
|
||||
|
||||
if not transcription.participant then
|
||||
module:log('warn', 'Missing participant field in transcription-result from %s', stanza.attr.from);
|
||||
return true;
|
||||
end
|
||||
local user_id = transcription.participant.id;
|
||||
local who = room:get_occupant_by_nick(jid.bare(room.jid)..'/'..user_id);
|
||||
|
||||
transcription.jid = who and who.jid;
|
||||
transcription.session_id = room._data.meetingId;
|
||||
|
||||
local tenant, conference_name, id = extract_subdomain(jid.node(room.jid));
|
||||
if tenant then
|
||||
transcription.fqn = tenant..'/'..conference_name;
|
||||
else
|
||||
transcription.fqn = conference_name;
|
||||
end
|
||||
transcription.customer_id = id;
|
||||
|
||||
return module:fire_event('jitsi-transcript-received', {
|
||||
room = room, occupant = occupant, transcription = transcription, stanza = stanza });
|
||||
end
|
||||
end
|
||||
|
||||
return module:fire_event('jitsi-endpoint-message-received', {
|
||||
room = room, occupant = occupant, message = msg_obj,
|
||||
origin = event.origin,
|
||||
stanza = stanza, raw_message = json_message });
|
||||
end
|
||||
|
||||
module:hook('message/bare', filterTranscriptionResult);
|
||||
module:hook('jitsi-visitor-groupchat-pre-route', filterTranscriptionResult);
|
||||
460
resources/prosody-plugins/mod_muc_message_moderation.lua
Normal file
460
resources/prosody-plugins/mod_muc_message_moderation.lua
Normal file
@@ -0,0 +1,460 @@
|
||||
-- Server side handling of chat message moderation (XEP-0425) and message
|
||||
-- correction (XEP-0308) for Jitsi MUC rooms.
|
||||
--
|
||||
-- Both operations used to be synchronised client to client over json messages,
|
||||
-- and late joiners were brought up to date by every participant replaying the
|
||||
-- current state to them on join. Handling both here keeps the room history
|
||||
-- itself correct, so a joining client needs no follow up at all.
|
||||
--
|
||||
-- 1. Moderation (XEP-0425). An occupant sends:
|
||||
--
|
||||
-- <message type='groupchat'>
|
||||
-- <apply-to xmlns='urn:xmpp:fasten:0' id='ORIG'>
|
||||
-- <moderated xmlns='urn:xmpp:message-moderate:1'>
|
||||
-- <retract xmlns='urn:xmpp:message-retract:1'/>
|
||||
-- <reason>spam</reason>
|
||||
-- </moderated>
|
||||
-- </apply-to>
|
||||
-- </message>
|
||||
--
|
||||
-- The request is honoured only when the sending occupant's MUC role is
|
||||
-- 'moderator'. The room history entry for ORIG is replaced with a tombstone
|
||||
-- (body stripped, <moderated by='...'><retracted/></moderated> added) so late
|
||||
-- joiners never receive the moderated text at all, and the moderation is
|
||||
-- broadcast from the room JID. The occupant's own request is not relayed, the
|
||||
-- room emits its own stanza and takes 'by' from the sender's occupant JID.
|
||||
-- A message that has aged out of the history window is still moderated for
|
||||
-- everyone in the room, there is just no entry left to turn into a tombstone.
|
||||
-- 'jitsi-message-moderated' is fired alongside the broadcast so mod_visitors
|
||||
-- can forward it to the visitor nodes, which the room's own broadcast does
|
||||
-- not reach.
|
||||
--
|
||||
-- 2. Correction (XEP-0308). An occupant sends a new body carrying:
|
||||
--
|
||||
-- <replace xmlns='urn:xmpp:message-correct:0' id='ORIG'/>
|
||||
--
|
||||
-- The request is honoured only when ORIG was authored by the same occupant
|
||||
-- and has not been moderated; anything else is dropped and reaches nobody.
|
||||
-- An honoured correction is relayed untouched: it carries the new body and
|
||||
-- asks to be stored, so the room archives it and replays it after the message
|
||||
-- it corrects, and a client joining later applies it the same way one in the
|
||||
-- room does. Nothing here rewrites the history entry.
|
||||
--
|
||||
-- 3. Retraction (XEP-0424). The author retracts their own message with a
|
||||
-- top level:
|
||||
--
|
||||
-- <retract xmlns='urn:xmpp:message-retract:1' id='ORIG'/>
|
||||
--
|
||||
-- The request is honoured only when ORIG was authored by the same occupant.
|
||||
-- The history entry is removed outright rather than tombstoned, so a joining
|
||||
-- client never receives the message at all, and the retraction is not stored
|
||||
-- either since there is no longer anything for it to pair with. The retraction
|
||||
-- stanza itself is what reaches the occupants already in the room.
|
||||
--
|
||||
-- The moderation the room broadcasts and the retraction are kept out of history:
|
||||
-- the first is already reflected in the tombstone, and the second removed the
|
||||
-- entry it referred to.
|
||||
--
|
||||
-- Rooms advertise 'muc#roominfo_messageModerationEnabled' so a client can tell
|
||||
-- whether the server is here to apply these operations, and offer the moderate
|
||||
-- and edit actions only when it is.
|
||||
--
|
||||
-- Needs to be activated under the muc component where message moderation and
|
||||
-- editing should be handled. Without it the client hides both actions, as the
|
||||
-- room stops advertising them.
|
||||
--
|
||||
-- Load it on a visitor prosody's muc component too. There it keeps the local
|
||||
-- history in step for the operations that are not replayed on their own, and
|
||||
-- advertises the room info field so visitors see the same actions. It does not
|
||||
-- check anything that reached it from the main prosody, which is where the room
|
||||
-- the occupants act in lives and where every request is checked. It does check
|
||||
-- the author of a visitor's own request, since that has not been past the main
|
||||
-- prosody yet at the point this node routes it.
|
||||
--
|
||||
-- Component "conference.meet.jitsi" "muc"
|
||||
-- modules_enabled = { "muc_message_moderation" }
|
||||
--
|
||||
-- Copyright (C) 2026-present 8x8, Inc.
|
||||
|
||||
local st = require 'util.stanza';
|
||||
local id = require 'util.id';
|
||||
local jid = require 'util.jid';
|
||||
local datetime = require 'util.datetime';
|
||||
|
||||
local ends_with = module:require 'util'.ends_with;
|
||||
|
||||
-- only the visitor prosody has a main_domain setting
|
||||
local main_domain = module:get_option_string('main_domain');
|
||||
local local_domain = module:get_option_string('muc_mapper_domain_base');
|
||||
|
||||
local FASTEN_NS = 'urn:xmpp:fasten:0';
|
||||
local MODERATE_NS = 'urn:xmpp:message-moderate:1';
|
||||
local RETRACT_NS = 'urn:xmpp:message-retract:1';
|
||||
local CORRECT_NS = 'urn:xmpp:message-correct:0';
|
||||
local HINTS_NS = 'urn:xmpp:hints';
|
||||
|
||||
-- Returns the id of the message the stanza asks to moderate and the optional
|
||||
-- reason, or nil when the stanza is not a moderation request.
|
||||
function parse_moderation_request(stanza)
|
||||
local apply_to = stanza:get_child('apply-to', FASTEN_NS);
|
||||
|
||||
if not apply_to or not apply_to.attr.id then
|
||||
return nil;
|
||||
end
|
||||
|
||||
local moderated = apply_to:get_child('moderated', MODERATE_NS);
|
||||
|
||||
if not moderated or not moderated:get_child('retract', RETRACT_NS) then
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- 'by' is only meaningful on a stanza a room has sent, where the server
|
||||
-- stamped it. On an occupant's own request it is whatever they typed.
|
||||
return apply_to.attr.id, moderated:get_child_text('reason'), moderated.attr.by;
|
||||
end
|
||||
|
||||
-- Returns the id of the message the stanza corrects and the new body, or nil
|
||||
-- when the stanza is not a correction.
|
||||
function parse_correction(stanza)
|
||||
local replace = stanza:get_child('replace', CORRECT_NS);
|
||||
|
||||
if not replace or not replace.attr.id then
|
||||
return nil;
|
||||
end
|
||||
|
||||
local body = stanza:get_child_text('body');
|
||||
|
||||
if not body then
|
||||
return nil;
|
||||
end
|
||||
|
||||
return replace.attr.id, body;
|
||||
end
|
||||
|
||||
-- Returns the id of the message the stanza retracts, or nil when the stanza is
|
||||
-- not a retraction. A moderation request also carries a <retract/>, but nested
|
||||
-- inside <apply-to><moderated/>, so it is not matched here.
|
||||
function parse_retraction(stanza)
|
||||
local retract = stanza:get_child('retract', RETRACT_NS);
|
||||
|
||||
if not retract or not retract.attr.id then
|
||||
return nil;
|
||||
end
|
||||
|
||||
return retract.attr.id;
|
||||
end
|
||||
|
||||
-- Finds the room history entry for a message id. Searches newest first, as
|
||||
-- edits and moderation overwhelmingly target recent messages.
|
||||
function find_history_entry(room, message_id)
|
||||
local history = room._history;
|
||||
|
||||
if not history or not message_id then
|
||||
return nil;
|
||||
end
|
||||
|
||||
for i = #history, 1, -1 do
|
||||
local entry = history[i];
|
||||
|
||||
if entry.stanza and entry.stanza.attr.id == message_id then
|
||||
return entry, i;
|
||||
end
|
||||
end
|
||||
|
||||
return nil;
|
||||
end
|
||||
|
||||
function is_entry_moderated(entry)
|
||||
return entry.stanza:get_child('moderated', MODERATE_NS) ~= nil;
|
||||
end
|
||||
|
||||
-- Replaces a history entry with a XEP-0425 tombstone. The body is dropped so
|
||||
-- the moderated text is never sent to anyone joining later.
|
||||
function tombstone_entry(entry, by, reason, stamp)
|
||||
local tombstone = st.clone(entry.stanza);
|
||||
|
||||
tombstone:remove_children('body');
|
||||
tombstone:remove_children('replace', CORRECT_NS);
|
||||
-- never nest two moderation markers if the same message is moderated twice
|
||||
tombstone:remove_children('moderated', MODERATE_NS);
|
||||
|
||||
local moderated = st.stanza('moderated', { xmlns = MODERATE_NS, by = by })
|
||||
:tag('retracted', { xmlns = RETRACT_NS, stamp = stamp }):up();
|
||||
|
||||
if reason and reason ~= '' then
|
||||
moderated:tag('reason'):text(reason):up();
|
||||
end
|
||||
|
||||
tombstone:add_child(moderated);
|
||||
entry.stanza = tombstone;
|
||||
|
||||
return entry;
|
||||
end
|
||||
|
||||
-- The stanza the room sends to announce a moderation. It carries no body, so a
|
||||
-- client that does not understand it simply ignores it.
|
||||
function build_moderation_broadcast(room, target_id, by, reason)
|
||||
local moderated = st.stanza('moderated', { xmlns = MODERATE_NS, by = by })
|
||||
:tag('retract', { xmlns = RETRACT_NS }):up();
|
||||
|
||||
if reason and reason ~= '' then
|
||||
moderated:tag('reason'):text(reason):up();
|
||||
end
|
||||
|
||||
local broadcast = st.message({ from = room.jid, type = 'groupchat', id = id.medium() })
|
||||
:tag('apply-to', { xmlns = FASTEN_NS, id = target_id });
|
||||
|
||||
broadcast:add_child(moderated);
|
||||
broadcast:up();
|
||||
broadcast:add_child(st.stanza('no-store', { xmlns = HINTS_NS }));
|
||||
|
||||
return broadcast;
|
||||
end
|
||||
|
||||
-- 'occupant' comes from the hook rather than from stanza.attr.from, which still
|
||||
-- holds the sender's real jid at this point; the room rewrites it to the
|
||||
-- occupant nick only after the event has run.
|
||||
function handle_moderation(origin, room, stanza, occupant, target_id, reason)
|
||||
if not occupant or occupant.role ~= 'moderator' then
|
||||
module:log('warn', 'Rejected moderation of %s from non-moderator %s', target_id, stanza.attr.from);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only moderators can moderate messages'));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
local entry = find_history_entry(room, target_id);
|
||||
|
||||
-- The message may have aged out of the room's history window. The moderator's
|
||||
-- decision still stands for everyone in the room, there is simply no entry
|
||||
-- left to turn into a tombstone.
|
||||
if entry then
|
||||
tombstone_entry(entry, occupant.nick, reason, datetime.datetime());
|
||||
end
|
||||
|
||||
local broadcast = build_moderation_broadcast(room, target_id, occupant.nick, reason);
|
||||
|
||||
room:broadcast_message(broadcast);
|
||||
|
||||
-- broadcast_message only reaches occupants of this room. Visitor nodes are
|
||||
-- separate prosodies, and the hook that forwards occupant messages to them
|
||||
-- never sees this stanza because the room sent it, so announce it for
|
||||
-- mod_visitors to fan out.
|
||||
module:fire_event('jitsi-message-moderated', { room = room; stanza = broadcast; });
|
||||
|
||||
-- the room has emitted its own stanza, don't relay the occupant's request
|
||||
return true;
|
||||
end
|
||||
|
||||
function handle_correction(origin, room, stanza, occupant, target_id, body)
|
||||
local entry = find_history_entry(room, target_id);
|
||||
|
||||
-- With no entry the message has aged out of the room's history window, so
|
||||
-- there is nothing to rewrite and no author to check it against. Relay it and
|
||||
-- let the receiving clients apply it to their own copy.
|
||||
if entry then
|
||||
if not occupant or entry.stanza.attr.from ~= occupant.nick then
|
||||
module:log('warn', 'Rejected correction of %s from %s, authored by %s',
|
||||
target_id, occupant and occupant.nick, entry.stanza.attr.from);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only the author can correct a message'));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
if is_entry_moderated(entry) then
|
||||
module:log('debug', 'Rejected correction of moderated message %s', target_id);
|
||||
origin.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Message has been moderated'));
|
||||
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
module:log('debug', 'Relaying correction of %s from %s', target_id, occupant and occupant.nick);
|
||||
|
||||
-- fall through. The correction carries the new body and asks to be stored, so
|
||||
-- the room archives it and replays it to anyone joining later, in order after
|
||||
-- the message it corrects. Nothing here has to rewrite the entry.
|
||||
end
|
||||
|
||||
-- The author retracting their own message. The history entry is dropped, so the
|
||||
-- message is simply absent for anyone joining later.
|
||||
function handle_retraction(origin, room, stanza, occupant, target_id)
|
||||
local entry, index = find_history_entry(room, target_id);
|
||||
|
||||
-- With no entry the message has aged out of the room's history window, so
|
||||
-- there is nothing to remove and no author to check it against.
|
||||
if entry then
|
||||
if not occupant or entry.stanza.attr.from ~= occupant.nick then
|
||||
module:log('warn', 'Rejected retraction of %s from %s, authored by %s',
|
||||
target_id, occupant and occupant.nick, entry.stanza.attr.from);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden', 'Only the author can retract a message'));
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
table.remove(room._history, index);
|
||||
end
|
||||
|
||||
-- The message is gone from history, so a stored retraction has nothing left to
|
||||
-- pair with. The client asks for it to be stored, override that.
|
||||
stanza:remove_children('store', HINTS_NS);
|
||||
stanza:add_child(st.stanza('no-store', { xmlns = HINTS_NS }));
|
||||
|
||||
-- fall through, the retraction the client sent is what reaches the occupants
|
||||
-- in the room
|
||||
end
|
||||
|
||||
function handle_groupchat(event)
|
||||
local origin, room, stanza, occupant = event.origin, event.room, event.stanza, event.occupant;
|
||||
|
||||
if stanza.attr.type ~= 'groupchat' then
|
||||
return;
|
||||
end
|
||||
|
||||
local moderate_id, reason = parse_moderation_request(stanza);
|
||||
|
||||
if moderate_id then
|
||||
return handle_moderation(origin, room, stanza, occupant, moderate_id, reason);
|
||||
end
|
||||
|
||||
local correct_id, body = parse_correction(stanza);
|
||||
|
||||
if correct_id then
|
||||
return handle_correction(origin, room, stanza, occupant, correct_id, body);
|
||||
end
|
||||
|
||||
local retract_id = parse_retraction(stanza);
|
||||
|
||||
if retract_id then
|
||||
return handle_retraction(origin, room, stanza, occupant, retract_id);
|
||||
end
|
||||
end
|
||||
|
||||
-- Whether this node may apply an operation to its own history entry. A stanza
|
||||
-- from an occupant of this node is a visitor's own request that the main prosody
|
||||
-- has not seen yet, so the author is checked against the entry. Anything else
|
||||
-- arrived from the main prosody, which has already checked it.
|
||||
function may_apply_locally(entry, occupant)
|
||||
if not occupant or jid.host(occupant.bare_jid) ~= local_domain then
|
||||
return true;
|
||||
end
|
||||
|
||||
return entry.stanza.attr.from == occupant.nick;
|
||||
end
|
||||
|
||||
-- On a visitor node this module only keeps the local history in step. The room
|
||||
-- that occupants act in lives on the main prosody, which has already checked the
|
||||
-- role or the authorship, so nothing is checked again here and no reply is sent.
|
||||
-- Runs above mod_fmuc's hook and falls through, so fmuc still routes the stanza
|
||||
-- to the local occupants exactly as before.
|
||||
function handle_main_groupchat(event)
|
||||
local room, stanza, occupant = event.room, event.stanza, event.occupant;
|
||||
|
||||
if stanza.attr.type ~= 'groupchat' then
|
||||
return;
|
||||
end
|
||||
|
||||
-- Editing and retraction already reach the occupants of this node through the
|
||||
-- normal forwarding, so only the local history entry needs bringing in line.
|
||||
local retract_id = parse_retraction(stanza);
|
||||
|
||||
if retract_id then
|
||||
local entry, index = find_history_entry(room, retract_id);
|
||||
|
||||
if entry and may_apply_locally(entry, occupant) then
|
||||
module:log('debug', 'Removing retracted %s from %s history', retract_id, room.jid);
|
||||
table.remove(room._history, index);
|
||||
end
|
||||
|
||||
return;
|
||||
end
|
||||
|
||||
-- A moderation is sent by the main room itself, so it never arrives as an
|
||||
-- occupant's stanza and nothing else routes it to the occupants here.
|
||||
if occupant then
|
||||
return;
|
||||
end
|
||||
|
||||
local from_host = jid.host(stanza.attr.from or '');
|
||||
|
||||
if not from_host or not ends_with(from_host, main_domain) then
|
||||
if parse_moderation_request(stanza) then
|
||||
module:log('debug', 'Ignoring moderation from %s, not under %s', stanza.attr.from, main_domain);
|
||||
end
|
||||
|
||||
return;
|
||||
end
|
||||
|
||||
local moderate_id, reason, by = parse_moderation_request(stanza);
|
||||
|
||||
if moderate_id then
|
||||
local entry = find_history_entry(room, moderate_id);
|
||||
|
||||
if entry then
|
||||
tombstone_entry(entry, by, reason, datetime.datetime());
|
||||
end
|
||||
|
||||
-- Announce it from this room rather than relaying what arrived. The
|
||||
-- forwarded stanza was sent by the main room, which is not an occupant
|
||||
-- here, so the from it ends up with depends on how the hop rewrites it.
|
||||
-- Building it locally means only the addressing had to survive the hop.
|
||||
module:log('debug', 'Announcing moderation of %s on %s', moderate_id, room.jid);
|
||||
|
||||
local broadcast = build_moderation_broadcast(room, moderate_id, by, reason);
|
||||
|
||||
-- Route it to the occupants of this node only. The main prosody's
|
||||
-- participants are occupants here as well and it has already told them;
|
||||
-- this node has no route to them and would only log a routing error.
|
||||
for _, o in room:each_occupant() do
|
||||
if jid.host(o.bare_jid) == local_domain then
|
||||
room:route_to_occupant(o, broadcast);
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- Belt and braces next to the no-store hint: moderation broadcasts, corrections
|
||||
-- and retractions are already reflected in the entry for the original message, or
|
||||
-- in its removal, so they must not be appended to history as messages of their own.
|
||||
function skip_history(event)
|
||||
local stanza = event.stanza;
|
||||
|
||||
if stanza:get_child('apply-to', FASTEN_NS)
|
||||
or stanza:get_child('retract', RETRACT_NS) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- Advertised so clients only offer moderating and editing where the server is
|
||||
-- here to apply them and keep the room history in step.
|
||||
local message_moderation_field = {
|
||||
name = 'muc#roominfo_messageModerationEnabled';
|
||||
type = 'boolean';
|
||||
label = 'Whether message moderation and editing are handled by the server.';
|
||||
value = 1;
|
||||
};
|
||||
|
||||
function add_room_info(event)
|
||||
table.insert(event.form, message_moderation_field);
|
||||
end
|
||||
|
||||
module:hook('muc-add-history', skip_history, 10);
|
||||
module:hook('muc-disco#info', add_room_info);
|
||||
module:hook('muc-config-form', add_room_info);
|
||||
|
||||
if main_domain then
|
||||
-- A visitor node only mirrors what the main prosody applied. The checking
|
||||
-- handlers are deliberately not registered here: they compare an author
|
||||
-- against the local occupant nick, and on a visitor node the history entry
|
||||
-- came from the main prosody's snapshot, so the comparison would refuse a
|
||||
-- visitor's own edit rather than apply it.
|
||||
--
|
||||
-- mod_fmuc handles visitor chat at priority 55 and stops there, so run above
|
||||
-- it and fall through once the local history has been brought in line.
|
||||
module:hook('muc-occupant-groupchat', handle_main_groupchat, 60);
|
||||
else
|
||||
module:hook('muc-occupant-groupchat', handle_groupchat, 10);
|
||||
end
|
||||
|
||||
module:log('info', 'Loaded MUC message moderation and correction for %s', module.host);
|
||||
219
resources/prosody-plugins/mod_muc_password_check.lua
Normal file
219
resources/prosody-plugins/mod_muc_password_check.lua
Normal file
@@ -0,0 +1,219 @@
|
||||
-- HTTP module that exposes GET and PUT endpoints at /room-info for querying
|
||||
-- and validating MUC room password information.
|
||||
--
|
||||
-- Endpoints:
|
||||
-- GET /room-info?room=<name>[&prefix=<subdomain>]
|
||||
-- Returns a JSON object: { conference, passcodeProtected, lobbyEnabled }
|
||||
-- where conference is the full room JID, passcodeProtected is true when the
|
||||
-- room has a password set, and lobbyEnabled is true when a lobby room is
|
||||
-- attached.
|
||||
--
|
||||
-- PUT /room-info?room=<name>[&prefix=<subdomain>]
|
||||
-- Body: application/json { passcode: "<value>" }
|
||||
-- Returns: { valid: true|false } indicating whether the submitted passcode
|
||||
-- matches the room password.
|
||||
--
|
||||
-- Authentication:
|
||||
-- Both endpoints require a valid ASAP Bearer token (RS256) in the
|
||||
-- Authorization header. Token verification is controlled by the
|
||||
-- enable_password_token_verification option (default: true).
|
||||
--
|
||||
-- Error responses:
|
||||
-- 400 Missing or invalid parameters (no room param, wrong Content-Type,
|
||||
-- empty body, missing passcode key).
|
||||
-- 403 Missing or invalid Bearer token.
|
||||
-- 404 Room not found.
|
||||
--
|
||||
-- The HTTP routes are registered on the MUC base domain (muc_mapper_domain_base,
|
||||
-- default "localhost") because the HTTP server runs on port 5280 on that host.
|
||||
-- The room query parameter is decoded using the muc_mapper_domain_prefix option
|
||||
-- (default "conference") to build the full internal MUC JID.
|
||||
--
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local inspect = require "inspect";
|
||||
local formdecode = require "util.http".formdecode;
|
||||
local urlencode = require "util.http".urlencode;
|
||||
local jid = require "util.jid";
|
||||
local json = require 'cjson.safe';
|
||||
local util = module:require "util";
|
||||
local async_handler_wrapper = util.async_handler_wrapper;
|
||||
local starts_with = util.starts_with;
|
||||
local process_host_module = util.process_host_module;
|
||||
local token_util = module:require "token/util".new(module);
|
||||
|
||||
-- option to enable/disable room API token verifications
|
||||
local enableTokenVerification
|
||||
= module:get_option_boolean("enable_password_token_verification", true);
|
||||
|
||||
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
if not muc_domain_base then
|
||||
module:log("warn", "No 'muc_domain_base' option set, disabling password check endpoint.");
|
||||
return ;
|
||||
end
|
||||
local muc_domain_prefix = module:get_option_string("muc_mapper_domain_prefix", "conference");
|
||||
|
||||
local json_content_type = "application/json";
|
||||
|
||||
--- Verifies the token
|
||||
-- @param token the token we received
|
||||
-- @param room_address the full room address jid
|
||||
-- @return true if values are ok or false otherwise
|
||||
function verify_token(token, room_address)
|
||||
if not enableTokenVerification then
|
||||
return true;
|
||||
end
|
||||
|
||||
-- if enableTokenVerification is enabled and we do not have token
|
||||
-- stop here, cause the main virtual host can have guest access enabled
|
||||
-- (allowEmptyToken = true) and we will allow access to rooms info without
|
||||
-- a token
|
||||
if token == nil then
|
||||
module:log("warn", "no token provided for %s", room_address);
|
||||
return false;
|
||||
end
|
||||
|
||||
local session = {};
|
||||
session.auth_token = token;
|
||||
local verified, reason, msg = token_util:process_and_verify_token(session);
|
||||
if not verified then
|
||||
module:log("warn", "not a valid token %s %s for %s", tostring(reason), tostring(msg), room_address);
|
||||
return false;
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
-- Validates the request by checking for required url param room and
|
||||
-- validates the token provided with the request
|
||||
-- @param request - The request to validate.
|
||||
-- @return [error_code, room]
|
||||
local function validate_and_get_room(request)
|
||||
if not request.url.query then
|
||||
module:log("warn", "No query");
|
||||
return 400, nil;
|
||||
end
|
||||
|
||||
local params = formdecode(request.url.query);
|
||||
local room_name = urlencode(params.room) or "";
|
||||
local subdomain = urlencode(params.prefix) or "";
|
||||
|
||||
if not room_name then
|
||||
module:log("warn", "Missing room param for %s", room_name);
|
||||
return 400, nil;
|
||||
end
|
||||
|
||||
local room_address = jid.join(room_name, muc_domain_prefix.."."..muc_domain_base);
|
||||
|
||||
if subdomain and subdomain ~= "" then
|
||||
room_address = "["..subdomain.."]"..room_address;
|
||||
end
|
||||
|
||||
-- verify access
|
||||
local token = request.headers["authorization"]
|
||||
|
||||
if token and starts_with(token,'Bearer ') then
|
||||
token = token:sub(8,#token)
|
||||
end
|
||||
|
||||
if not verify_token(token, room_address) then
|
||||
return 403, nil;
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_address);
|
||||
|
||||
if not room then
|
||||
module:log("warn", "No room found for %s", room_address);
|
||||
return 404, nil;
|
||||
else
|
||||
return 200, room;
|
||||
end
|
||||
end
|
||||
|
||||
function handle_validate_room_password (event)
|
||||
local request = event.request;
|
||||
|
||||
if request.headers.content_type ~= json_content_type
|
||||
or (not request.body or #request.body == 0) then
|
||||
module:log("warn", "Wrong content type: %s", request.headers.content_type);
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local params, error = json.decode(request.body);
|
||||
if not params then
|
||||
module:log("warn", "Missing params error:%s", error);
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local passcode = params["passcode"];
|
||||
|
||||
if not passcode then
|
||||
module:log("warn", "Missing passcode param");
|
||||
return { status_code = 400; };
|
||||
end
|
||||
|
||||
local error_code, room = validate_and_get_room(request);
|
||||
|
||||
if not room then
|
||||
return { status_code = error_code; }
|
||||
end
|
||||
|
||||
local json_msg_str, error_encode = json.encode({ valid = (room:get_password() == passcode) });
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Cannot encode json room:%s error:%s', room.jid, error_encode);
|
||||
return { status_code = 400; };
|
||||
end
|
||||
|
||||
local PUT_response = {
|
||||
headers = { content_type = "application/json"; };
|
||||
body = json_msg_str;
|
||||
};
|
||||
|
||||
-- module:log("debug","Sending response for room password validate: %s", inspect(PUT_response));
|
||||
|
||||
return PUT_response;
|
||||
end
|
||||
|
||||
--- Handles request for retrieving the room participants details
|
||||
-- @param event the http event, holds the request query
|
||||
-- @return GET response, containing a json with participants details
|
||||
function handle_get_room_password (event)
|
||||
local error_code, room = validate_and_get_room(event.request);
|
||||
|
||||
if not room then
|
||||
return { status_code = error_code; }
|
||||
end
|
||||
|
||||
room_details = {};
|
||||
room_details["conference"] = room.jid;
|
||||
room_details["passcodeProtected"] = room:get_password() ~= nil;
|
||||
room_details["lobbyEnabled"] = room._data ~= nil and room._data.lobbyroom ~= nil;
|
||||
|
||||
local json_msg_str, error = json.encode(room_details);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Cannot encode json room:%s error:%s', room.jid, error);
|
||||
return { status_code = 400; };
|
||||
end
|
||||
|
||||
local GET_response = {
|
||||
headers = {
|
||||
content_type = "application/json";
|
||||
};
|
||||
body = json_msg_str;
|
||||
};
|
||||
-- module:log("debug","Sending response for room password: %s", inspect(GET_response));
|
||||
|
||||
return GET_response;
|
||||
end
|
||||
|
||||
process_host_module(muc_domain_base, function(host_module, host)
|
||||
module:log("info","Adding http handler for /room-info on %s", host_module.host);
|
||||
host_module:depends("http");
|
||||
host_module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["GET room-info"] = function (event) return async_handler_wrapper(event, handle_get_room_password) end;
|
||||
["PUT room-info"] = function (event) return async_handler_wrapper(event, handle_validate_room_password) end;
|
||||
};
|
||||
});
|
||||
end);
|
||||
62
resources/prosody-plugins/mod_muc_password_whitelist.lua
Normal file
62
resources/prosody-plugins/mod_muc_password_whitelist.lua
Normal file
@@ -0,0 +1,62 @@
|
||||
-- Allows clients from whitelisted domains (muc_password_whitelist) to join
|
||||
-- password-protected MUC rooms without supplying the room password. The module
|
||||
-- injects the room's current password into the join stanza on behalf of
|
||||
-- whitelisted clients, so the standard password check in Prosody's MUC module
|
||||
-- succeeds transparently.
|
||||
-- This module is enabled under the MUC component.
|
||||
--- AUTHOR: https://gist.github.com/legastero Lance Stout
|
||||
local jid_split = require "util.jid".split;
|
||||
local whitelist = module:get_option_set("muc_password_whitelist");
|
||||
|
||||
local MUC_NS = "http://jabber.org/protocol/muc";
|
||||
|
||||
|
||||
module:hook("muc-occupant-pre-join", function (event)
|
||||
local room, stanza = event.room, event.stanza;
|
||||
|
||||
local user, domain, res = jid_split(event.stanza.attr.from);
|
||||
|
||||
--no user object means no way to check whitelist
|
||||
if user == nil then
|
||||
return
|
||||
end
|
||||
|
||||
if not whitelist then
|
||||
return;
|
||||
end
|
||||
if not whitelist:contains(domain) and not whitelist:contains(user..'@'..domain) then
|
||||
return;
|
||||
end
|
||||
|
||||
local join = stanza:get_child("x", MUC_NS);
|
||||
if not join then
|
||||
join = stanza:tag("x", { xmlns = MUC_NS });
|
||||
end
|
||||
|
||||
local password = join:get_child("password", MUC_NS);
|
||||
if password then
|
||||
-- removes <password... node,
|
||||
-- seems like password:text( appends text, not replacing it
|
||||
join:maptags(
|
||||
function(tag)
|
||||
for k, v in pairs(tag) do
|
||||
if k == "name" and v == "password" then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return tag
|
||||
end
|
||||
);
|
||||
|
||||
end
|
||||
|
||||
join:tag("password", { xmlns = MUC_NS }):text(room:get_password());
|
||||
|
||||
-- module:log("debug", "Applied password access whitelist for %s in room %s", event.stanza.attr.from, room.jid);
|
||||
end, -7); --- Run before the password check (priority -20), runs after lobby(priority -4) and members-only (priority -5).
|
||||
|
||||
|
||||
module:hook_global("config-reloaded", function (event)
|
||||
module:log("debug", "Reloading MUC password access whitelist");
|
||||
whitelist = module:get_option_set("muc_password_whitelist");
|
||||
end)
|
||||
273
resources/prosody-plugins/mod_muc_rate_limit.lua
Normal file
273
resources/prosody-plugins/mod_muc_rate_limit.lua
Normal file
@@ -0,0 +1,273 @@
|
||||
-- mod_muc_rate_limit - Per-room join and leave rate limiting for MUC components.
|
||||
--
|
||||
-- Prevents presence storms in large meetings by throttling the rate at which
|
||||
-- occupants can join or leave a room. When the configured rate is exceeded,
|
||||
-- excess presence events are held in a per-room FIFO queue and replayed at the
|
||||
-- allowed rate by a 1-second recurring timer.
|
||||
--
|
||||
-- Join limiting (muc-occupant-pre-join, priority 9):
|
||||
-- Each room maintains a token-bucket throttle (muc_rate_joins tokens/s,
|
||||
-- default 3). When the bucket is empty the join event is stopped and the
|
||||
-- origin/stanza pair is pushed onto a bounded queue (max 1000 entries).
|
||||
-- A timer is started (if not already running) that pops up to
|
||||
-- muc_rate_joins entries per second and replays them through
|
||||
-- room:handle_normal_presence. A delayed_join_skip flag on the replayed
|
||||
-- stanza prevents it from being re-queued on the second pass. Any join
|
||||
-- that arrives while the timer is active is also queued (even if the
|
||||
-- throttle would allow it) to preserve FIFO ordering.
|
||||
--
|
||||
-- Leave limiting (muc-occupant-pre-leave):
|
||||
-- Same token-bucket logic with muc_rate_leaves tokens/s (default 5).
|
||||
-- Queued leavers have their role set to nil and are saved immediately
|
||||
-- (removing them from the room roster) so that in-progress joins are not
|
||||
-- blocked; however publicise_occupant_status (the unavailable presence
|
||||
-- broadcast to peers) is deferred until the timer processes the event.
|
||||
--
|
||||
-- Room destruction (muc-room-destroyed, priority 1):
|
||||
-- Both queues are marked empty so the in-flight timer skips further
|
||||
-- processing cleanly without touching a destroyed room.
|
||||
--
|
||||
-- Configuration (set on the MUC component):
|
||||
-- muc_rate_joins (number, default 3) — max join events per second per room
|
||||
-- muc_rate_leaves (number, default 5) — max leave events per second per room
|
||||
--
|
||||
-- enable under the main muc component
|
||||
|
||||
local queue = require "util.queue";
|
||||
local new_throttle = require "util.throttle".create;
|
||||
local timer = require "util.timer";
|
||||
local st = require "util.stanza";
|
||||
|
||||
-- we max to 500 participants per meeting so this should be enough, we are not suppose to handle all
|
||||
-- participants in one meeting
|
||||
local PRESENCE_QUEUE_MAX_SIZE = 1000;
|
||||
|
||||
-- default to 3 participants per second
|
||||
local join_rate_per_conference = module:get_option_number("muc_rate_joins", 3);
|
||||
local leave_rate_per_conference = module:get_option_number("muc_rate_leaves", 5);
|
||||
|
||||
-- Measure/monitor the room rate limiting queue
|
||||
local measure = require "core.statsmanager".measure;
|
||||
local measure_longest_queue = measure("distribution",
|
||||
"/mod_" .. module.name .. "/longest_queue");
|
||||
local measure_rooms_with_queue = measure("rate",
|
||||
"/mod_" .. module.name .. "/rooms_with_queue");
|
||||
|
||||
-- throws a stat that the queue was full, counts the total number of times we hit it
|
||||
local measure_full_queue = measure("rate",
|
||||
"/mod_" .. module.name .. "/full_queue");
|
||||
|
||||
-- keeps track of the total times we had an error processing the queue
|
||||
local measure_errors_processing_queue = measure("rate",
|
||||
"/mod_" .. module.name .. "/errors_processing_queue");
|
||||
|
||||
-- we keep track here what was the longest queue we have seen
|
||||
local stat_longest_queue = 0;
|
||||
|
||||
-- Adds item to the queue
|
||||
-- @returns false if queue is full and item was not added, true otherwise
|
||||
local function add_item_to_queue(queue, item, room, from, send_stats)
|
||||
if not queue:push(item) then
|
||||
module:log('error',
|
||||
'Error pushing item in %s queue for %s in %s', send_stats and 'join' or 'leave', from, room.jid);
|
||||
|
||||
if send_stats then
|
||||
measure_full_queue();
|
||||
end
|
||||
|
||||
return false;
|
||||
else
|
||||
-- check is this the longest queue and if so throws a stat
|
||||
if send_stats and queue:count() > stat_longest_queue then
|
||||
stat_longest_queue = queue:count();
|
||||
measure_longest_queue(stat_longest_queue);
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- process join_rate_presence_queue in the room and pops element passing them to handle_normal_presence
|
||||
-- returns 1 if we want to reschedule it after 1 second
|
||||
local function timer_process_queue_elements (rate, queue, process, queue_empty_cb)
|
||||
if not queue or queue:count() == 0 or queue.empty then
|
||||
return;
|
||||
end
|
||||
|
||||
for _ = 1, rate do
|
||||
local ev = queue:pop();
|
||||
if ev then
|
||||
process(ev);
|
||||
end
|
||||
end
|
||||
|
||||
-- if there are elements left, schedule an execution in a second
|
||||
if queue:count() > 0 then
|
||||
return 1;
|
||||
else
|
||||
queue_empty_cb();
|
||||
end
|
||||
end
|
||||
|
||||
-- we check join rate before occupant joins. If rate is exceeded we queue the events and start a timer
|
||||
-- that will run every second processing the events passing them to the room handling function handle_normal_presence
|
||||
-- from where those arrived, this way we keep a maximum rate of joining
|
||||
module:hook("muc-occupant-pre-join", function (event)
|
||||
local room, stanza = event.room, event.stanza;
|
||||
|
||||
-- skipping events we had produced and clear our flag
|
||||
if stanza.delayed_join_skip == true then
|
||||
event.stanza.delayed_join_skip = nil;
|
||||
return nil;
|
||||
end
|
||||
|
||||
local throttle = room.join_rate_throttle;
|
||||
if not room.join_rate_throttle then
|
||||
throttle = new_throttle(join_rate_per_conference, 1); -- rate per one second
|
||||
room.join_rate_throttle = throttle;
|
||||
end
|
||||
|
||||
if not throttle:poll(1) then
|
||||
if not room.join_rate_presence_queue then
|
||||
-- if this is the first item for a room we increment the stat for rooms with queues
|
||||
measure_rooms_with_queue();
|
||||
room.join_rate_presence_queue = queue.new(PRESENCE_QUEUE_MAX_SIZE);
|
||||
end
|
||||
|
||||
if not add_item_to_queue(room.join_rate_presence_queue, event, room, stanza.attr.from, true) then
|
||||
-- let's not stop processing the event
|
||||
return nil;
|
||||
end
|
||||
|
||||
if not room.join_rate_queue_timer then
|
||||
timer.add_task(1, function ()
|
||||
if room.destroying then
|
||||
-- if room was destroyed in the mean time, ignore
|
||||
return;
|
||||
end
|
||||
|
||||
local status, result = pcall(
|
||||
timer_process_queue_elements,
|
||||
join_rate_per_conference,
|
||||
room.join_rate_presence_queue,
|
||||
function(ev)
|
||||
-- if the connection was closed while waiting in the queue, ignore
|
||||
if ev.origin.conn then
|
||||
-- we mark what we pass here so we can skip it on the next muc-occupant-pre-join event
|
||||
ev.stanza.delayed_join_skip = true;
|
||||
room:handle_normal_presence(ev.origin, ev.stanza);
|
||||
end
|
||||
end,
|
||||
function() -- empty callback
|
||||
room.join_rate_queue_timer = false;
|
||||
end
|
||||
);
|
||||
if not status then
|
||||
-- there was an error in the timer function
|
||||
module:log('error', 'Error processing join queue: %s', result);
|
||||
|
||||
measure_errors_processing_queue();
|
||||
|
||||
-- let's re-schedule timer so we do not lose the queue
|
||||
return 1;
|
||||
end
|
||||
|
||||
return result;
|
||||
end);
|
||||
room.join_rate_queue_timer = true;
|
||||
end
|
||||
|
||||
return true; -- we stop execution, so we do not process this join at the moment
|
||||
end
|
||||
|
||||
if room.join_rate_queue_timer then
|
||||
-- there is timer so we need to order the presences, put it in the queue
|
||||
|
||||
-- if add fails as queue is full we return false and the event will continue processing, we risk re-order
|
||||
-- but not losing it
|
||||
return add_item_to_queue(room.join_rate_presence_queue, event, room, stanza.attr.from, true);
|
||||
end
|
||||
|
||||
end, 9); -- as we will rate limit joins we need to be the first to execute
|
||||
-- we ran it after muc_max_occupants which is with priority 10, there is nothing to rate limit
|
||||
-- if max number of occupants is reached
|
||||
|
||||
-- clear queue on room destroy so timer will skip next run if any
|
||||
module:hook('muc-room-destroyed',function(event)
|
||||
if event.room.join_rate_presence_queue then
|
||||
event.room.join_rate_presence_queue.empty = true;
|
||||
end
|
||||
if event.room.leave_rate_presence_queue then
|
||||
event.room.leave_rate_presence_queue.empty = true;
|
||||
end
|
||||
end, 1); -- prosody handles it at 0
|
||||
|
||||
module:hook('muc-occupant-pre-leave', function (event)
|
||||
local occupant, room, stanza = event.occupant, event.room, event.stanza;
|
||||
local throttle = room.leave_rate_throttle;
|
||||
|
||||
if not throttle then
|
||||
throttle = new_throttle(leave_rate_per_conference, 1); -- rate per one second
|
||||
room.leave_rate_throttle = throttle;
|
||||
end
|
||||
|
||||
if not throttle:poll(1) then
|
||||
if not room.leave_rate_presence_queue then
|
||||
room.leave_rate_presence_queue = queue.new(PRESENCE_QUEUE_MAX_SIZE);
|
||||
end
|
||||
|
||||
-- we need it later when processing the event
|
||||
event.orig_role = occupant.role;
|
||||
|
||||
if not add_item_to_queue(room.leave_rate_presence_queue, event, room, stanza.attr.from, false) then
|
||||
-- let's not stop processing the event
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- set role to nil so the occupant will be removed from room occupants when we save it
|
||||
-- we remove occupant from the list early on batches so we can spare sending few presences
|
||||
occupant.role = nil;
|
||||
room:save_occupant(occupant);
|
||||
|
||||
if not room.leave_rate_queue_timer then
|
||||
timer.add_task(1, function ()
|
||||
local status, result = pcall(timer_process_queue_elements,
|
||||
leave_rate_per_conference,
|
||||
room.leave_rate_presence_queue,
|
||||
function(ev)
|
||||
local occupant, orig_role, origin, room, stanza
|
||||
= ev.occupant, ev.orig_role, ev.origin, ev.room, ev.stanza;
|
||||
|
||||
room:publicise_occupant_status(
|
||||
occupant,
|
||||
st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";}),
|
||||
nil, nil, nil, orig_role);
|
||||
|
||||
module:fire_event("muc-occupant-left", {
|
||||
room = room;
|
||||
nick = occupant.nick;
|
||||
occupant = occupant;
|
||||
origin = origin;
|
||||
stanza = stanza;
|
||||
});
|
||||
end,
|
||||
function() -- empty callback
|
||||
room.leave_rate_queue_timer = false;
|
||||
end
|
||||
);
|
||||
if not status then
|
||||
-- there was an error in the timer function
|
||||
module:log('error', 'Error processing leave queue: %s', result);
|
||||
|
||||
-- let's re-schedule timer so we do not lose the queue
|
||||
return 1;
|
||||
end
|
||||
|
||||
return result;
|
||||
end);
|
||||
room.leave_rate_queue_timer = true;
|
||||
end
|
||||
|
||||
return true; -- we stop execution, so we do not process this leave at the moment
|
||||
end
|
||||
end);
|
||||
127
resources/prosody-plugins/mod_muc_resource_validate.lua
Normal file
127
resources/prosody-plugins/mod_muc_resource_validate.lua
Normal file
@@ -0,0 +1,127 @@
|
||||
-- Validates the resourcepart of client JIDs when joining a MUC.
|
||||
-- Copyright (C) 2024-present 8x8, Inc.
|
||||
--
|
||||
-- Hooks into muc-occupant-pre-join and rejects joins whose MUC resource is invalid.
|
||||
--
|
||||
-- A valid resource consists only of alphanumerics and underscores and must
|
||||
-- NOT start with an underscore: ^[a-zA-Z0-9][a-zA-Z0-9_]*$
|
||||
--
|
||||
-- When "anonymous_strict" mode is enabled, users on domains whose
|
||||
-- authentication provider is considered anonymous are additionally required
|
||||
-- to use a MUC resource equal to the first 8 characters of their real JID
|
||||
-- username (the UUID prefix assigned by the server).
|
||||
-- The check is dynamic: the auth provider is read from prosody.hosts at
|
||||
-- join time, so no static domain list is needed.
|
||||
--
|
||||
-- The module also hooks muc-occupant-pre-change and rejects MUC nick (resource)
|
||||
-- changes: an occupant's resource is its stable identity for the session and
|
||||
-- must never change. Legitimate display-name changes travel in the presence
|
||||
-- <nick/> child, not the MUC resource, so they are unaffected.
|
||||
--
|
||||
-- Example configuration (place under the MUC component):
|
||||
--
|
||||
-- Component "conference.meet.jitsi" "muc"
|
||||
-- modules_enabled = { "muc_resource_validate" }
|
||||
-- -- Enable strict anonymous-user check
|
||||
-- anonymous_strict = true
|
||||
-- -- Optional: extend the list of auth methods treated as anonymous
|
||||
-- anonymous_auth_methods = { "anonymous", "jitsi-anonymous", "token", "custom-anon" }
|
||||
|
||||
local jid_split = require "util.jid".split;
|
||||
local st = require "util.stanza";
|
||||
local util = module:require "util";
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
|
||||
-- Valid resource: starts with alphanumeric, followed by zero-or-more alphanumerics / underscores.
|
||||
local VALID_RESOURCE_PATTERN = "^[a-zA-Z0-9][a-zA-Z0-9_]*$";
|
||||
|
||||
local anonymous_strict;
|
||||
local anonymous_auth_methods;
|
||||
|
||||
local function load_config()
|
||||
anonymous_strict = module:get_option_boolean("anonymous_strict", false);
|
||||
anonymous_auth_methods = module:get_option_set(
|
||||
"anonymous_auth_methods", { "anonymous", "jitsi-anonymous", "token" });
|
||||
end
|
||||
load_config();
|
||||
|
||||
-- Returns true when the domain's authentication provider is considered anonymous.
|
||||
-- Prosody's usermanager sets host.users.name to the value of the
|
||||
-- "authentication" config option.
|
||||
local function is_anonymous_domain(domain)
|
||||
local host_obj = prosody.hosts[domain];
|
||||
if not host_obj or not host_obj.users then
|
||||
return false;
|
||||
end
|
||||
return anonymous_auth_methods:contains(host_obj.users.name);
|
||||
end
|
||||
|
||||
-- Returns true (halts event processing) when the join should be rejected.
|
||||
local function check_resource(event)
|
||||
local room, origin, stanza = event.room, event.origin, event.stanza;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local user, domain = jid_split(stanza.attr.from);
|
||||
local _, _, muc_resource = jid_split(stanza.attr.to);
|
||||
|
||||
if user == nil then
|
||||
return;
|
||||
end
|
||||
|
||||
-- 1. Validate the resource part of the MUC JID (the occupant nickname).
|
||||
if not muc_resource or not muc_resource:match(VALID_RESOURCE_PATTERN) then
|
||||
module:log("warn", "Rejecting join with invalid MUC resource from %s (muc_resource: %s)",
|
||||
stanza.attr.from, tostring(muc_resource));
|
||||
origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Invalid resource"));
|
||||
return true;
|
||||
end
|
||||
|
||||
-- 2. Anonymous strict mode: MUC resource must equal the 8-char UUID prefix of the real JID username.
|
||||
if anonymous_strict and is_anonymous_domain(domain) then
|
||||
local uuid_prefix = user:sub(1, 8);
|
||||
if muc_resource ~= uuid_prefix then
|
||||
module:log("warn", "Rejecting anonymous user: MUC resource %s does not match UUID prefix %s (from %s)",
|
||||
muc_resource, uuid_prefix, stanza.attr.from);
|
||||
origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Invalid anonymous user ID"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Returns true (halts event processing) when a MUC nick (resource) change should
|
||||
-- be rejected. In Jitsi the occupant resource is the stable per-session identity
|
||||
-- and must never change.
|
||||
local function reject_nick_change(event)
|
||||
local room, origin, stanza = event.room, event.origin, event.stanza;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local orig_occupant = event.orig_occupant;
|
||||
|
||||
-- No original occupant means there is no rename to police here.
|
||||
if not orig_occupant then
|
||||
return;
|
||||
end
|
||||
|
||||
local _, _, current_resource = jid_split(orig_occupant.nick);
|
||||
local _, _, new_resource = jid_split(stanza.attr.to);
|
||||
|
||||
if new_resource ~= current_resource then
|
||||
module:log("warn", "Rejecting MUC nick change from %s (%s -> %s)",
|
||||
stanza.attr.from, tostring(current_resource), tostring(new_resource));
|
||||
origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Nick changes are not allowed"));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
module:log("info", "module loaded (anonymous_strict=%s)", tostring(anonymous_strict));
|
||||
|
||||
module:hook("muc-occupant-pre-join", check_resource, 11);
|
||||
module:hook("muc-occupant-pre-change", reject_nick_change, 11);
|
||||
|
||||
module:hook_global("config-reloaded", load_config);
|
||||
245
resources/prosody-plugins/mod_muc_size.lua
Normal file
245
resources/prosody-plugins/mod_muc_size.lua
Normal file
@@ -0,0 +1,245 @@
|
||||
-- Prosody IM
|
||||
-- Copyright (C) 2021-present 8x8, Inc.
|
||||
--
|
||||
-- mod_muc_size exposes HTTP endpoints that let external services query the
|
||||
-- occupancy and participant details of MUC rooms managed by the associated
|
||||
-- conference component. It provides three routes:
|
||||
--
|
||||
-- GET /room-size?room=<name>&domain=<base>[&subdomain=<sub>][&token=<jwt>]
|
||||
-- Returns {"participants": N} where N is the non-focus occupant count.
|
||||
-- Returns 404 when the room does not exist.
|
||||
--
|
||||
-- GET /room?room=<name>&domain=<base>[&subdomain=<sub>][&token=<jwt>]
|
||||
-- Returns a JSON array of occupant objects {jid, email, display_name},
|
||||
-- excluding the hidden Jitsi focus participant.
|
||||
-- Returns 404 when the room does not exist.
|
||||
--
|
||||
-- GET /sessions
|
||||
-- Returns the total number of active Prosody client sessions as a plain
|
||||
-- integer string.
|
||||
--
|
||||
-- The room address is built as <room>@<muc_domain_prefix>.<domain>, optionally
|
||||
-- prefixed with [<subdomain>] for multi-tenant deployments.
|
||||
-- Token-based JWT verification is optional and controlled by the
|
||||
-- enable_roomsize_token_verification module option (default: false).
|
||||
|
||||
local jid = require "util.jid";
|
||||
local it = require "util.iterators";
|
||||
local json = require 'cjson.safe';
|
||||
local iterators = require "util.iterators";
|
||||
local array = require"util.array";
|
||||
|
||||
local have_async = pcall(require, "util.async");
|
||||
if not have_async then
|
||||
module:log("error", "requires a version of Prosody with util.async");
|
||||
return;
|
||||
end
|
||||
|
||||
-- muc_domain_prefix is needed by the build_room_address fallback below.
|
||||
local muc_domain_prefix
|
||||
= module:get_option_string("muc_mapper_domain_prefix", "conference");
|
||||
|
||||
-- Load shared utility library. If it fails (e.g. a transitive dependency is
|
||||
-- missing in the current environment) log the error and fall back to inline
|
||||
-- implementations so the HTTP routes are always registered.
|
||||
local async_handler_wrapper, get_room_from_jid, build_room_address, is_focus;
|
||||
local ok_util, util_or_err = pcall(function() return module:require "util" end);
|
||||
if ok_util then
|
||||
local util = util_or_err;
|
||||
async_handler_wrapper = util.async_handler_wrapper;
|
||||
get_room_from_jid = util.get_room_from_jid;
|
||||
build_room_address = util.build_room_address;
|
||||
is_focus = util.is_focus;
|
||||
else
|
||||
module:log("warn", "mod_muc_size: util.lib.lua unavailable (%s); using inline fallbacks",
|
||||
tostring(util_or_err));
|
||||
async_handler_wrapper = function(_, handler) return handler(_) end;
|
||||
get_room_from_jid = function(room_jid)
|
||||
local _, host = jid.split(room_jid);
|
||||
local component = hosts[host];
|
||||
if component then
|
||||
local muc = component.modules.muc;
|
||||
if muc then return muc.get_room_from_jid(room_jid) end
|
||||
end
|
||||
end;
|
||||
build_room_address = function(room_name, domain_name, subdomain)
|
||||
local addr = jid.join(room_name, muc_domain_prefix.."."..domain_name);
|
||||
if subdomain and subdomain ~= "" then
|
||||
addr = "["..subdomain.."]"..addr;
|
||||
end
|
||||
return addr;
|
||||
end;
|
||||
is_focus = function(nick)
|
||||
return string.sub(nick, -string.len("/focus")) == "/focus";
|
||||
end;
|
||||
end
|
||||
|
||||
local tostring = tostring;
|
||||
|
||||
-- Simple query-string parser: "room=foo&domain=bar" → { room="foo", domain="bar" }.
|
||||
-- Avoids a dependency on the optional third-party net.url LuaRocks module.
|
||||
local function parse(q)
|
||||
local t = {};
|
||||
for k, v in (q or ""):gmatch("([^=&]+)=([^&]*)") do
|
||||
t[k] = v;
|
||||
end
|
||||
return t;
|
||||
end
|
||||
|
||||
-- option to enable/disable room API token verifications
|
||||
local enableTokenVerification
|
||||
= module:get_option_boolean("enable_roomsize_token_verification", false);
|
||||
|
||||
local ok, token_util_mod = pcall(function() return module:require "token/util" end);
|
||||
local token_util = ok and token_util_mod and token_util_mod.new(module) or nil;
|
||||
|
||||
-- no token configuration but required
|
||||
if token_util == nil and enableTokenVerification then
|
||||
log("error", "no token configuration but it is required");
|
||||
return;
|
||||
end
|
||||
|
||||
--- Verifies room name, domain name with the values in the token
|
||||
-- @param token the token we received
|
||||
-- @param room_address the full room address jid
|
||||
-- @return true if values are ok or false otherwise
|
||||
function verify_token(token, room_address)
|
||||
if not enableTokenVerification then
|
||||
return true;
|
||||
end
|
||||
|
||||
-- if enableTokenVerification is enabled and we do not have token
|
||||
-- stop here, cause the main virtual host can have guest access enabled
|
||||
-- (allowEmptyToken = true) and we will allow access to rooms info without
|
||||
-- a token
|
||||
if token == nil then
|
||||
log("warn", "no token provided");
|
||||
return false;
|
||||
end
|
||||
|
||||
local session = {};
|
||||
session.auth_token = token;
|
||||
local verified, reason = token_util:process_and_verify_token(session);
|
||||
if not verified then
|
||||
log("warn", "not a valid token %s", tostring(reason));
|
||||
return false;
|
||||
end
|
||||
|
||||
if not token_util:verify_room(session, room_address) then
|
||||
log("warn", "Token %s not allowed to join: %s",
|
||||
tostring(token), tostring(room_address));
|
||||
return false;
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
--- Handles request for retrieving the room size
|
||||
-- @param event the http event, holds the request query
|
||||
-- @return GET response, containing a json with participants count,
|
||||
-- the value is without counting the focus.
|
||||
function handle_get_room_size(event)
|
||||
if (not event.request.url.query) then
|
||||
return { status_code = 400; };
|
||||
end
|
||||
|
||||
local params = parse(event.request.url.query);
|
||||
local room_name = params["room"];
|
||||
local domain_name = params["domain"];
|
||||
local subdomain = params["subdomain"];
|
||||
|
||||
local room_address = build_room_address(room_name, domain_name, subdomain);
|
||||
|
||||
if not verify_token(params["token"], room_address) then
|
||||
return { status_code = 403; };
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_address);
|
||||
local participant_count = 0;
|
||||
|
||||
log("debug", "Querying room %s", tostring(room_address));
|
||||
|
||||
if room then
|
||||
local occupants = room._occupants;
|
||||
if occupants then
|
||||
participant_count = iterators.count(room:each_occupant());
|
||||
end
|
||||
log("debug",
|
||||
"there are %s occupants in room", tostring(participant_count));
|
||||
else
|
||||
log("debug", "no such room exists");
|
||||
return { status_code = 404; };
|
||||
end
|
||||
|
||||
if participant_count > 1 then
|
||||
participant_count = participant_count - 1;
|
||||
end
|
||||
|
||||
return { status_code = 200; body = [[{"participants":]]..participant_count..[[}]] };
|
||||
end
|
||||
|
||||
--- Handles request for retrieving the room participants details
|
||||
-- @param event the http event, holds the request query
|
||||
-- @return GET response, containing a json with participants details
|
||||
function handle_get_room (event)
|
||||
if (not event.request.url.query) then
|
||||
return { status_code = 400; };
|
||||
end
|
||||
|
||||
local params = parse(event.request.url.query);
|
||||
local room_name = params["room"];
|
||||
local domain_name = params["domain"];
|
||||
local subdomain = params["subdomain"];
|
||||
|
||||
local room_address = build_room_address(room_name, domain_name, subdomain);
|
||||
|
||||
if not verify_token(params["token"], room_address) then
|
||||
return { status_code = 403; };
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_address);
|
||||
local participant_count = 0;
|
||||
local occupants_json = array();
|
||||
|
||||
log("debug", "Querying room %s", tostring(room_address));
|
||||
|
||||
if room then
|
||||
local occupants = room._occupants;
|
||||
if occupants then
|
||||
participant_count = iterators.count(room:each_occupant());
|
||||
for _, occupant in room:each_occupant() do
|
||||
-- filter focus as we keep it as hidden participant
|
||||
if not is_focus(occupant.nick) then
|
||||
for _, pr in occupant:each_session() do
|
||||
local nick = pr:get_child_text("nick", "http://jabber.org/protocol/nick") or "";
|
||||
local email = pr:get_child_text("email") or "";
|
||||
occupants_json:push({
|
||||
jid = tostring(occupant.nick),
|
||||
email = tostring(email),
|
||||
display_name = tostring(nick)});
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
log("debug",
|
||||
"there are %s occupants in room", tostring(participant_count));
|
||||
else
|
||||
log("debug", "no such room exists");
|
||||
return { status_code = 404; };
|
||||
end
|
||||
|
||||
if participant_count > 1 then
|
||||
participant_count = participant_count - 1;
|
||||
end
|
||||
|
||||
return { status_code = 200; body = json.encode(occupants_json); };
|
||||
end;
|
||||
|
||||
module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["GET /room-size"] = function (event) return async_handler_wrapper(event,handle_get_room_size) end;
|
||||
["GET /sessions"] = function () return tostring(it.count(it.keys(prosody.full_sessions))); end;
|
||||
["GET /room"] = function (event) return async_handler_wrapper(event,handle_get_room) end;
|
||||
};
|
||||
});
|
||||
117
resources/prosody-plugins/mod_muc_wait_for_host.lua
Normal file
117
resources/prosody-plugins/mod_muc_wait_for_host.lua
Normal file
@@ -0,0 +1,117 @@
|
||||
-- Loaded on the main MUC component.
|
||||
-- Holds unauthenticated guests in a persistent lobby until an authenticated host joins.
|
||||
-- A "host" is any session with a JWT auth_token or with PLAIN credentials whose JID
|
||||
-- domain matches muc_mapper_domain_base. Prosody admins (e.g. jicofo) bypass the check
|
||||
-- and are not counted as hosts.
|
||||
-- When the first guest arrives before any host, a persistent lobby room is created via
|
||||
-- mod_persistent_lobby + mod_muc_lobby_rooms, and guests are held there. When an
|
||||
-- authenticated host joins the main room the lobby is destroyed and guests are admitted.
|
||||
-- Once a host has been detected the room.has_host flag is cached on the room object for
|
||||
-- the lifetime of the room, so subsequent joins skip the host re-check.
|
||||
-- By default, authenticated users are promoted to owner affiliation on join. Set
|
||||
-- wait_for_host_disable_auto_owners = true to disable automatic owner promotion.
|
||||
-- Depends on: mod_persistent_lobby (must be loaded on the main VirtualHost).
|
||||
local jid = require 'util.jid';
|
||||
local util = module:require "util";
|
||||
local is_admin = util.is_admin;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local is_moderated = util.is_moderated;
|
||||
local process_host_module = util.process_host_module;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
|
||||
local disable_auto_owners = module:get_option_boolean('wait_for_host_disable_auto_owners', false);
|
||||
|
||||
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
|
||||
if not muc_domain_base then
|
||||
module:log('warn', "No 'muc_mapper_domain_base' option set, disabling module");
|
||||
return
|
||||
end
|
||||
|
||||
-- to activate this you need the following config in general config file in log = { }
|
||||
-- { to = 'file', filename = '/var/log/prosody/prosody.audit.log', levels = { 'audit' } }
|
||||
local logger = require 'util.logger';
|
||||
local audit_logger = logger.make_logger('mod_'..module.name, 'audit');
|
||||
|
||||
local lobby_muc_component_config = 'lobby.' .. muc_domain_base;
|
||||
local lobby_host;
|
||||
|
||||
if not disable_auto_owners then
|
||||
module:hook('muc-occupant-joined', function (event)
|
||||
local room, occupant, session = event.room, event.occupant, event.origin;
|
||||
local is_moderated_room = is_moderated(room.jid);
|
||||
|
||||
-- for jwt authenticated and username and password authenticated
|
||||
-- only if it is not a moderated room
|
||||
if not is_moderated_room and
|
||||
(session.auth_token or (session.username and jid.host(occupant.bare_jid) == muc_domain_base)) then
|
||||
room:set_affiliation(true, occupant.bare_jid, 'owner');
|
||||
end
|
||||
end, 2);
|
||||
end
|
||||
|
||||
-- if not authenticated user is trying to join the room we enable lobby in it
|
||||
-- and wait for the moderator to join
|
||||
module:hook('muc-occupant-pre-join', function (event)
|
||||
local room, occupant, session = event.room, event.occupant, event.origin;
|
||||
|
||||
-- we ignore jicofo as we want it to join the room or if the room has already seen its
|
||||
-- authenticated host
|
||||
if is_admin(occupant.bare_jid) or is_healthcheck_room(room.jid) or room.has_host then
|
||||
return;
|
||||
end
|
||||
|
||||
local has_host = false;
|
||||
for _, o in room:each_occupant() do
|
||||
-- the main virtual host that requires tokens
|
||||
if jid.host(o.bare_jid) == muc_domain_base
|
||||
-- or this is anonymous that upgraded by passing token which we validated
|
||||
or prosody.full_sessions[o.jid].auth_token then
|
||||
room.has_host = true;
|
||||
end
|
||||
end
|
||||
|
||||
if not room.has_host then
|
||||
if module:fire_event('room_has_host', { room = room; occupant = occupant; session = session; }) then
|
||||
-- the host is here, let's drop the lobby
|
||||
room:set_members_only(false);
|
||||
-- this is set by create-persistent-lobby-room, so let's clear it
|
||||
room:set_persistent(false);
|
||||
|
||||
-- let's set the default role of 'participant' for the newly created occupant as it was nil when created
|
||||
-- when the room was still members_only, later if not disabled this participant will become a moderator
|
||||
occupant.role = room:get_default_role(room:get_affiliation(occupant.bare_jid)) or 'participant';
|
||||
|
||||
module:log('info', 'Host %s arrived in %s.', occupant.bare_jid, room.jid);
|
||||
audit_logger('room_jid:%s created_by:%s', room.jid,
|
||||
session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or 'nil');
|
||||
module:fire_event('room_host_arrived', room.jid, session);
|
||||
lobby_host:fire_event('destroy-lobby-room', {
|
||||
room = room,
|
||||
newjid = internal_room_jid_match_rewrite(room.jid),
|
||||
message = 'Host arrived.',
|
||||
});
|
||||
elseif not room:get_members_only() then
|
||||
-- let's enable lobby
|
||||
module:log('info', 'Will wait for host in %s.', room.jid);
|
||||
prosody.events.fire_event('create-persistent-lobby-room', {
|
||||
room = room;
|
||||
reason = 'waiting-for-host',
|
||||
skip_display_name_check = true;
|
||||
});
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
module:hook('room_has_host', function(event)
|
||||
local room, occupant, session = event.room, event.occupant, event.session;
|
||||
if session.auth_token
|
||||
or (session.username and jid.host(occupant.bare_jid) == muc_domain_base) then
|
||||
return true;
|
||||
end
|
||||
end);
|
||||
|
||||
process_host_module(lobby_muc_component_config, function(host_module, host)
|
||||
-- lobby muc component created
|
||||
module:log('info', 'Lobby component loaded %s', host);
|
||||
lobby_host = module:context(host_module);
|
||||
end);
|
||||
205
resources/prosody-plugins/mod_persistent_lobby.lua
Normal file
205
resources/prosody-plugins/mod_persistent_lobby.lua
Normal file
@@ -0,0 +1,205 @@
|
||||
-- This module allows lobby room to be created even when the main room is empty.
|
||||
-- Without this module, the empty main room will get deleted after grace period
|
||||
-- which triggers lobby room deletion even if there are still people in the lobby.
|
||||
--
|
||||
-- This module should be added to the main virtual host domain.
|
||||
-- It assumes you have properly configured the muc_lobby_rooms module and lobby muc component.
|
||||
--
|
||||
-- To trigger creation of lobby room:
|
||||
-- prosody.events.fire_event("create-persistent-lobby-room", { room = room; });
|
||||
--
|
||||
module:depends('room_destroy');
|
||||
|
||||
local util = module:require "util";
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local main_muc_component_host = module:get_option_string('main_muc');
|
||||
local lobby_muc_component_host = module:get_option_string('lobby_muc');
|
||||
|
||||
|
||||
if main_muc_component_host == nil then
|
||||
module:log('error', 'main_muc not configured. Cannot proceed.');
|
||||
return;
|
||||
end
|
||||
|
||||
if lobby_muc_component_host == nil then
|
||||
module:log('error', 'lobby not enabled missing lobby_muc config');
|
||||
return;
|
||||
end
|
||||
|
||||
|
||||
-- Helper function to wait till a component is loaded before running the given callback
|
||||
local function run_when_component_loaded(component_host_name, callback)
|
||||
local function trigger_callback()
|
||||
module:log('info', 'Component loaded %s', component_host_name);
|
||||
callback(module:context(component_host_name), component_host_name);
|
||||
end
|
||||
|
||||
if prosody.hosts[component_host_name] == nil then
|
||||
module:log('debug', 'Host %s not yet loaded. Will trigger when it is loaded.', component_host_name);
|
||||
prosody.events.add_handler('host-activated', function (host)
|
||||
if host == component_host_name then
|
||||
trigger_callback();
|
||||
end
|
||||
end);
|
||||
else
|
||||
trigger_callback();
|
||||
end
|
||||
end
|
||||
|
||||
-- Helper function to wait till a component's muc module is loaded before running the given callback
|
||||
local function run_when_muc_module_loaded(component_host_module, component_host_name, callback)
|
||||
local function trigger_callback()
|
||||
module:log('info', 'MUC module loaded for %s', component_host_name);
|
||||
callback(prosody.hosts[component_host_name].modules.muc, component_host_module);
|
||||
end
|
||||
|
||||
if prosody.hosts[component_host_name].modules.muc == nil then
|
||||
module:log('debug', 'MUC module for %s not yet loaded. Will trigger when it is loaded.', component_host_name);
|
||||
prosody.hosts[component_host_name].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
trigger_callback();
|
||||
end
|
||||
end);
|
||||
else
|
||||
trigger_callback()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local lobby_muc_service;
|
||||
local main_muc_service;
|
||||
local main_muc_module;
|
||||
|
||||
|
||||
-- Helper methods to track rooms that have persistent lobby
|
||||
local function set_persistent_lobby(room)
|
||||
room._data.persist_lobby = true;
|
||||
end
|
||||
|
||||
local function has_persistent_lobby(room)
|
||||
if room._data.persist_lobby == true then
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Helper method to trigger main room destroy
|
||||
local function trigger_room_destroy(room)
|
||||
prosody.events.fire_event("maybe-destroy-room", {
|
||||
room = room;
|
||||
reason = 'main room and lobby now empty';
|
||||
caller = module:get_name();
|
||||
});
|
||||
end
|
||||
|
||||
|
||||
-- For rooms with persistent lobby, we need to trigger deletion ourselves when both the main room
|
||||
-- and the lobby room are empty. This will be checked each time an occupant leaves the main room
|
||||
-- of if someone drops off the lobby.
|
||||
|
||||
|
||||
-- Handle events on main muc module
|
||||
run_when_component_loaded(main_muc_component_host, function(host_module, host_name)
|
||||
run_when_muc_module_loaded(host_module, host_name, function (main_muc, main_module)
|
||||
main_muc_service = main_muc; -- so it can be accessed from lobby muc event handlers
|
||||
main_muc_module = main_module;
|
||||
|
||||
main_module:hook("muc-occupant-left", function(event)
|
||||
-- Check if room should be destroyed when someone leaves the main room
|
||||
|
||||
local main_room = event.room;
|
||||
if is_healthcheck_room(main_room.jid) or not has_persistent_lobby(main_room)
|
||||
or main_room.destroying then
|
||||
return;
|
||||
end
|
||||
|
||||
local lobby_room_jid = main_room._data.lobbyroom;
|
||||
|
||||
-- If occupant leaving results in main room being empty, we trigger room destroy if
|
||||
-- a) lobby exists and is not empty
|
||||
-- b) lobby does not exist (possible for lobby to be disabled manually by moderator in meeting)
|
||||
--
|
||||
-- (main room destroy also triggers lobby room destroy in muc_lobby_rooms)
|
||||
if not main_room:has_occupant() then
|
||||
if lobby_room_jid == nil then -- lobby disabled
|
||||
trigger_room_destroy(main_room);
|
||||
else -- lobby exists
|
||||
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
|
||||
if lobby_room and not lobby_room:has_occupant() then
|
||||
trigger_room_destroy(main_room);
|
||||
end
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
end);
|
||||
end);
|
||||
|
||||
|
||||
-- Handle events on lobby muc module
|
||||
run_when_component_loaded(lobby_muc_component_host, function(host_module, host_name)
|
||||
run_when_muc_module_loaded(host_module, host_name, function (lobby_muc, lobby_module)
|
||||
lobby_muc_service = lobby_muc; -- so it can be accessed from main muc event handlers
|
||||
|
||||
lobby_module:hook("muc-occupant-left", function(event)
|
||||
-- Check if room should be destroyed when someone leaves the lobby
|
||||
local lobby_room = event.room;
|
||||
|
||||
if not lobby_room.main_room_jid then
|
||||
return;
|
||||
end
|
||||
|
||||
local main_room = get_room_from_jid(lobby_room.main_room_jid);
|
||||
|
||||
if not main_room or is_healthcheck_room(main_room.jid) or not has_persistent_lobby(main_room) then
|
||||
return;
|
||||
end
|
||||
|
||||
-- If both lobby room and main room are empty, we destroy main room.
|
||||
-- (main room destroy also triggers lobby room destroy in muc_lobby_rooms)
|
||||
if not lobby_room:has_occupant() and main_room and not main_room:has_occupant() then
|
||||
trigger_room_destroy(main_room);
|
||||
end
|
||||
|
||||
end);
|
||||
end);
|
||||
end);
|
||||
|
||||
|
||||
function handle_create_persistent_lobby(event)
|
||||
local room = event.room;
|
||||
prosody.events.fire_event("create-lobby-room", event);
|
||||
|
||||
set_persistent_lobby(room);
|
||||
room:set_persistent(true);
|
||||
end
|
||||
|
||||
|
||||
module:hook_global('create-persistent-lobby-room', handle_create_persistent_lobby);
|
||||
|
||||
|
||||
-- Stop other modules from destroying room if persistent lobby not empty
|
||||
function handle_maybe_destroy_main_room(event)
|
||||
local main_room = event.room;
|
||||
local caller = event.caller;
|
||||
|
||||
if caller == module:get_name() then
|
||||
-- we were the one that requested the deletion. Do not override.
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- deletion was requested by another module. Check for lobby occupants.
|
||||
if has_persistent_lobby(main_room) and main_room._data.lobbyroom then
|
||||
local lobby_room_jid = main_room._data.lobbyroom;
|
||||
local lobby_room = lobby_muc_service.get_room_from_jid(lobby_room_jid);
|
||||
if lobby_room and lobby_room:has_occupant() then
|
||||
module:log('info', 'Suppressing room destroy. Persistent lobby still occupied %s', lobby_room_jid);
|
||||
return true; -- stop room destruction
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module:hook_global("maybe-destroy-room", handle_maybe_destroy_main_room);
|
||||
560
resources/prosody-plugins/mod_polls_component.lua
Normal file
560
resources/prosody-plugins/mod_polls_component.lua
Normal file
@@ -0,0 +1,560 @@
|
||||
-- This module provides persistence for the "polls" feature,
|
||||
-- by keeping track of the state of polls in each room, and sending
|
||||
-- that state to new participants when they join.
|
||||
|
||||
local it = require 'util.iterators';
|
||||
local json = require 'cjson.safe';
|
||||
local array = require 'util.array';
|
||||
local st = require("util.stanza");
|
||||
local jid = require "util.jid";
|
||||
local util = module:require("util");
|
||||
|
||||
local NS_NICK = 'http://jabber.org/protocol/nick';
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local table_compare = util.table_compare;
|
||||
|
||||
local POLLS_LIMIT = 128;
|
||||
local POLL_PAYLOAD_LIMIT = 1024;
|
||||
|
||||
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
|
||||
if not main_virtual_host then
|
||||
module:log('warn', 'No muc_mapper_domain_base option set.');
|
||||
return;
|
||||
end
|
||||
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
|
||||
|
||||
-- this is the main virtual host of the main prosody that this vnode serves
|
||||
local main_domain = module:get_option_string('main_domain');
|
||||
-- only the visitor prosody has main_domain setting
|
||||
local is_visitor_prosody = main_domain ~= nil;
|
||||
|
||||
|
||||
local function validate_polls(data)
|
||||
if type(data) ~= 'table' then
|
||||
return false;
|
||||
end
|
||||
if data.type ~= 'polls' or type(data.pollId) ~= 'string' then
|
||||
return false;
|
||||
end
|
||||
if data.command ~= 'new-poll' and data.command ~= 'answer-poll' then
|
||||
return false;
|
||||
end
|
||||
if type(data.answers) ~= 'table' or #data.answers == 0 then
|
||||
return false;
|
||||
end
|
||||
|
||||
if data.command == "new-poll" then
|
||||
if type(data.question) ~= 'string' then
|
||||
return false;
|
||||
end
|
||||
|
||||
for _, answer in ipairs(data.answers) do
|
||||
if type(answer) ~= "table" or type(answer.name) ~= "string" then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
elseif data.command == "answer-poll" then
|
||||
for _, answer in ipairs(data.answers) do
|
||||
if type(answer) ~= "boolean" then
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
--- Returns a table having occupant id and occupant name.
|
||||
--- If the id cannot be extracted from nick a nil value is returned same and for name
|
||||
local function get_occupant_details(occupant)
|
||||
if not occupant then
|
||||
return nil
|
||||
end
|
||||
local presence = occupant:get_presence();
|
||||
local occupant_name;
|
||||
if presence then
|
||||
occupant_name = presence:get_child_text('nick', NS_NICK);
|
||||
end
|
||||
local _, _, occupant_id = jid.split(occupant.nick)
|
||||
if not occupant_id then
|
||||
return nil
|
||||
end
|
||||
return { ["occupant_id"] = occupant_id, ["occupant_name"] = occupant_name }
|
||||
end
|
||||
|
||||
local function send_polls_message(room, data_str, to)
|
||||
local stanza = st.message({
|
||||
from = module.host,
|
||||
to = to
|
||||
})
|
||||
:tag("json-message", { xmlns = "http://jitsi.org/jitmeet" })
|
||||
:text(data_str)
|
||||
:up();
|
||||
room:route_stanza(stanza);
|
||||
end
|
||||
|
||||
local function send_polls_message_to_all(room, data_str)
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
-- in case of visitor node send only to visitors
|
||||
if not is_visitor_prosody or room_occupant.role == 'visitor' then
|
||||
send_polls_message(room, data_str, room_occupant.jid);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Keeps track of the current state of the polls in each room,
|
||||
-- by listening to "new-poll" and "answer-poll" messages,
|
||||
-- and updating the room poll data accordingly.
|
||||
-- This mirrors the client-side poll update logic.
|
||||
module:hook('message/host', function(event)
|
||||
local session, stanza = event.origin, event.stanza;
|
||||
|
||||
-- we are interested in all messages without a body that are not groupchat
|
||||
if stanza.attr.type == 'groupchat' or stanza:get_child('body') then
|
||||
return;
|
||||
end
|
||||
|
||||
local json_message = stanza:get_child('json-message', 'http://jitsi.org/jitmeet')
|
||||
or stanza:get_child('json-message');
|
||||
if not json_message then
|
||||
return;
|
||||
end
|
||||
|
||||
local room;
|
||||
local occupant;
|
||||
if session.type == 's2sin' then
|
||||
if not json_message.attr.roomJid then
|
||||
module:log('warn', 'No room jid found in %s', stanza);
|
||||
return;
|
||||
end
|
||||
room = get_room_from_jid(room_jid_match_rewrite(json_message.attr.roomJid));
|
||||
else
|
||||
local main_room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
local occupant_jid = stanza.attr.from;
|
||||
|
||||
if not main_room then
|
||||
module:log('warn', 'No main room found for %s %s', session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
return;
|
||||
end
|
||||
|
||||
local found_room;
|
||||
occupant, found_room = get_occupant_by_real_jid(main_room, occupant_jid);
|
||||
room = found_room or main_room;
|
||||
|
||||
if not occupant then
|
||||
module:log('error', 'Occupant sending poll msg %s was not found in room %s', occupant_jid, room.jid)
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s %s', session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
return;
|
||||
end
|
||||
|
||||
local json_message_text = json_message:get_text();
|
||||
if string.len(json_message_text) >= POLL_PAYLOAD_LIMIT then
|
||||
module:log('error', 'Poll payload too large, discarding. Sender: %s to:%s', stanza.attr.from, stanza.attr.to);
|
||||
return true;
|
||||
end
|
||||
|
||||
local data, error = json.decode(json_message_text);
|
||||
if error then
|
||||
module:log('error', 'Error decoding data error:%s Sender: %s to:%s', error, stanza.attr.from, stanza.attr.to);
|
||||
return true;
|
||||
end
|
||||
|
||||
if not data or (data.command ~= "new-poll" and data.command ~= "answer-poll") then
|
||||
return;
|
||||
end
|
||||
|
||||
if not validate_polls(data) then
|
||||
module:log('error', 'Invalid poll data. Sender: %s (%s)', stanza.attr.from, json_message_text);
|
||||
return true;
|
||||
end
|
||||
|
||||
local occupant_details;
|
||||
if session.type ~= 's2sin' then
|
||||
local occupant_jid = stanza.attr.from;
|
||||
occupant_details = get_occupant_details(occupant)
|
||||
if not occupant_details then
|
||||
module:log("error", "Cannot retrieve poll creator or voter id and name for %s from %s",
|
||||
occupant.jid, room.jid)
|
||||
return
|
||||
end
|
||||
else
|
||||
-- this is a message from a visitor prosody, we will trust it
|
||||
occupant_details = { occupant_id = data.senderId; occupant_name = data.senderName; };
|
||||
end
|
||||
|
||||
if data.command == "new-poll" then
|
||||
if is_visitor_prosody then
|
||||
module:log("error", "Poll cannot be created on visitor node.");
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll cannot be created by visitor node'));
|
||||
return true;
|
||||
end
|
||||
|
||||
local poll_creator = occupant_details;
|
||||
|
||||
if room.polls.count >= POLLS_LIMIT then
|
||||
module:log("error", "Too many polls created in %s", room.jid)
|
||||
return true;
|
||||
end
|
||||
|
||||
if room.polls.by_id[data.pollId] ~= nil then
|
||||
module:log("error", "Poll already exists: %s", data.pollId);
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Poll already exists'));
|
||||
return true;
|
||||
end
|
||||
|
||||
if room.jitsiMetadata and room.jitsiMetadata.permissions
|
||||
and room.jitsiMetadata.permissions.pollCreationRestricted
|
||||
and not is_feature_allowed('create-polls', session.jitsi_meet_context_features) then
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Creation of polls not allowed for user'));
|
||||
return true;
|
||||
end
|
||||
|
||||
local answers = {}
|
||||
local compact_answers = {}
|
||||
for i, a in ipairs(data.answers) do
|
||||
table.insert(answers, { name = a.name });
|
||||
table.insert(compact_answers, { key = i, name = a.name});
|
||||
end
|
||||
|
||||
local poll = {
|
||||
pollId = data.pollId,
|
||||
senderId = poll_creator.occupant_id,
|
||||
senderName = poll_creator.occupant_name,
|
||||
question = data.question,
|
||||
answers = answers
|
||||
};
|
||||
|
||||
room.polls.by_id[data.pollId] = poll
|
||||
table.insert(room.polls.order, poll)
|
||||
room.polls.count = room.polls.count + 1;
|
||||
|
||||
local pollData = {
|
||||
event = event,
|
||||
room = room,
|
||||
poll = {
|
||||
pollId = data.pollId,
|
||||
senderId = poll_creator.occupant_id,
|
||||
senderName = poll_creator.occupant_name,
|
||||
question = data.question,
|
||||
answers = compact_answers
|
||||
}
|
||||
}
|
||||
|
||||
-- now send message to all participants
|
||||
-- Only relay the sanitized answers (name only) so no unexpected
|
||||
-- fields from the sender's payload are forwarded to participants.
|
||||
data.answers = answers;
|
||||
data.senderId = poll_creator.occupant_id;
|
||||
data.type = 'polls';
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
|
||||
module:context(jid.host(room.jid)):fire_event('poll-created', pollData);
|
||||
elseif data.command == "answer-poll" then
|
||||
local poll = room.polls.by_id[data.pollId];
|
||||
if poll == nil then
|
||||
module:log("warn", "answering inexistent poll %s", data.pollId);
|
||||
return;
|
||||
end
|
||||
|
||||
local voter = occupant_details;
|
||||
|
||||
-- Capture the voter's current vote state so we can detect changes.
|
||||
local previous_answers = {};
|
||||
for idx, answer in ipairs(poll.answers) do
|
||||
previous_answers[idx] = false;
|
||||
if answer.voters then
|
||||
for _, v in ipairs(answer.voters) do
|
||||
if v.id == voter.occupant_id then
|
||||
previous_answers[idx] = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Skip processing if the vote is identical to the previous one.
|
||||
local vote_changed = false;
|
||||
for idx, vote_flag in ipairs(data.answers) do
|
||||
if previous_answers[idx] ~= vote_flag then
|
||||
vote_changed = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
if not vote_changed then
|
||||
return true;
|
||||
end
|
||||
|
||||
-- Remove any previous votes by this voter before recording new ones,
|
||||
-- preventing duplicate votes for the same option.
|
||||
for _, answer in ipairs(poll.answers) do
|
||||
if answer.voters then
|
||||
local filtered = {};
|
||||
for _, v in ipairs(answer.voters) do
|
||||
if v.id ~= voter.occupant_id then
|
||||
table.insert(filtered, v);
|
||||
end
|
||||
end
|
||||
answer.voters = filtered;
|
||||
end
|
||||
end
|
||||
|
||||
local answers = {};
|
||||
for vote_option_idx, vote_flag in ipairs(data.answers) do
|
||||
local answer = poll.answers[vote_option_idx]
|
||||
|
||||
table.insert(answers, {
|
||||
key = vote_option_idx,
|
||||
value = vote_flag,
|
||||
name = answer.name,
|
||||
});
|
||||
|
||||
if vote_flag then
|
||||
local voters = answer.voters;
|
||||
if not voters then
|
||||
answer.voters = {};
|
||||
voters = answer.voters;
|
||||
end
|
||||
|
||||
table.insert(voters, {
|
||||
id = voter.occupant_id;
|
||||
name = vote_flag and voter.occupant_name or nil;
|
||||
});
|
||||
end
|
||||
end
|
||||
|
||||
local answerData = {
|
||||
data = data,
|
||||
event = event,
|
||||
room = room,
|
||||
pollId = poll.pollId,
|
||||
voterName = voter.occupant_name,
|
||||
voterId = voter.occupant_id,
|
||||
answers = answers,
|
||||
}
|
||||
|
||||
data.senderId = voter.occupant_id;
|
||||
data.senderName = voter.occupant_name;
|
||||
data.type = 'polls';
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
return;
|
||||
end
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
|
||||
module:context(jid.host(room.jid)):fire_event('answer-poll', answerData);
|
||||
end
|
||||
|
||||
return true;
|
||||
end);
|
||||
|
||||
-- Find in which poll in newPolls we have updated answers
|
||||
-- @returns poll, senderId, array of boolean values for the answers of this sender
|
||||
function find_updated_poll(oldPolls, newPolls)
|
||||
for _, v in pairs(newPolls) do
|
||||
local existing_poll = oldPolls[v.pollId];
|
||||
local senderId;
|
||||
|
||||
for idx, newAnswer in ipairs(v.answers) do
|
||||
-- let's examine now the voters
|
||||
-- Create lookup tables using id as key for efficient searching
|
||||
local oldLookup = {}
|
||||
local newLookup = {}
|
||||
|
||||
-- Build lookup table for old array
|
||||
if existing_poll.answers[idx].voters then
|
||||
for _, element in ipairs(existing_poll.answers[idx].voters) do
|
||||
oldLookup[element.id] = element
|
||||
end
|
||||
end
|
||||
|
||||
-- Build lookup table for new array
|
||||
if newAnswer.voters then
|
||||
for _, element in ipairs(newAnswer.voters) do
|
||||
newLookup[element.id] = element
|
||||
end
|
||||
end
|
||||
|
||||
-- Find removed elements (in old but not in new)
|
||||
if existing_poll.answers[idx].voters then
|
||||
for _, element in ipairs(existing_poll.answers[idx].voters) do
|
||||
if not newLookup[element.id] then
|
||||
senderId = element.id;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Find added elements (in new but not in old)
|
||||
if newAnswer.voters then
|
||||
for _, element in ipairs(newAnswer.voters) do
|
||||
if not oldLookup[element.id] then
|
||||
senderId = element.id;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if senderId ~= nil then
|
||||
-- an array of true/false values for this sender
|
||||
local senderAnswers = {};
|
||||
for idx, newAnswer in ipairs(v.answers) do
|
||||
senderAnswers[idx] = false;
|
||||
if newAnswer.voters then
|
||||
for _, element in ipairs(newAnswer.voters) do
|
||||
if element.id == senderId then
|
||||
senderAnswers[idx] = true;
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return v, senderId, senderAnswers;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local setup_muc_component = function(host_module, host)
|
||||
-- Sets up poll data in new rooms.
|
||||
host_module:hook("muc-room-created", function(event)
|
||||
local room = event.room;
|
||||
if is_healthcheck_room(room.jid) then return end
|
||||
room.polls = {
|
||||
by_id = {};
|
||||
order = {};
|
||||
count = 0;
|
||||
};
|
||||
end);
|
||||
|
||||
-- Sends the current poll state to new occupants after joining a room.
|
||||
host_module:hook("muc-occupant-joined", function(event)
|
||||
local room = event.room;
|
||||
if is_healthcheck_room(room.jid) then return end
|
||||
if room.polls == nil or #room.polls.order == 0 then
|
||||
return
|
||||
end
|
||||
|
||||
local data = {
|
||||
command = "old-polls",
|
||||
polls = {},
|
||||
type = 'polls'
|
||||
};
|
||||
for i, poll in ipairs(room.polls.order) do
|
||||
data.polls[i] = {
|
||||
pollId = poll.pollId,
|
||||
senderId = poll.senderId,
|
||||
senderName = poll.senderName,
|
||||
question = poll.question,
|
||||
answers = poll.answers
|
||||
};
|
||||
end
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
send_polls_message(room, json_msg_str, event.occupant.jid);
|
||||
end);
|
||||
|
||||
-- Handles poll updates coming for a visitor node, the event contain polls structure
|
||||
-- like 'old-polls' one
|
||||
host_module:hook('jitsi-polls-update', function(event)
|
||||
local polls_command, room = event.polls, event.room;
|
||||
-- this is the initial state coming from the main prosody when only jicofo is in the room
|
||||
if room.polls.count == 0 and it.count(room:each_occupant()) == 1 then
|
||||
for i, v in ipairs(polls_command.polls) do
|
||||
room.polls.by_id[v.pollId] = v;
|
||||
table.insert(room.polls.order, v);
|
||||
room.polls.count = room.polls.count + 1;
|
||||
end
|
||||
|
||||
return;
|
||||
end
|
||||
|
||||
-- at this point we need to find which is the new poll
|
||||
local new_poll;
|
||||
for _, v in pairs(polls_command.polls) do
|
||||
if not room.polls.by_id[v.pollId] then
|
||||
new_poll = v;
|
||||
break;
|
||||
end
|
||||
end
|
||||
|
||||
if not new_poll then
|
||||
-- this is an update of the voters in some of the existing polls
|
||||
local updatedPoll, senderId, answers = find_updated_poll(room.polls.by_id, polls_command.polls);
|
||||
|
||||
if not updatedPoll then
|
||||
module:log('warn', 'no new or updated poll found in update for room %s', room.jid);
|
||||
return;
|
||||
end
|
||||
|
||||
local data = {
|
||||
answers = answers,
|
||||
command = 'answer-poll',
|
||||
pollId = updatedPoll.pollId,
|
||||
senderId = senderId,
|
||||
roomJid = internal_room_jid_match_rewrite(room.jid),
|
||||
type = 'polls'
|
||||
};
|
||||
|
||||
-- we need to update the history
|
||||
room.polls.by_id[updatedPoll.pollId].answers = updatedPoll.answers;
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
|
||||
return;
|
||||
end
|
||||
|
||||
room.polls.by_id[new_poll.pollId] = new_poll;
|
||||
table.insert(room.polls.order, new_poll);
|
||||
room.polls.count = room.polls.count + 1;
|
||||
|
||||
local data = {
|
||||
answers = new_poll.answers,
|
||||
command = 'new-poll',
|
||||
pollId = new_poll.pollId,
|
||||
question = new_poll.question,
|
||||
senderId = new_poll.senderId,
|
||||
roomJid = internal_room_jid_match_rewrite(room.jid),
|
||||
type = 'polls'
|
||||
};
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
|
||||
send_polls_message_to_all(room, json_msg_str);
|
||||
end);
|
||||
end
|
||||
|
||||
process_host_module(muc_domain_prefix..'.'..main_virtual_host, setup_muc_component);
|
||||
process_host_module('breakout.' .. main_virtual_host, setup_muc_component);
|
||||
|
||||
process_host_module(main_virtual_host, function(host_module)
|
||||
module:context(host_module.host):fire_event('jitsi-add-identity', {
|
||||
name = 'polls'; host = module.host;
|
||||
});
|
||||
end);
|
||||
19
resources/prosody-plugins/mod_presence_identity.lua
Normal file
19
resources/prosody-plugins/mod_presence_identity.lua
Normal file
@@ -0,0 +1,19 @@
|
||||
local stanza = require "util.stanza";
|
||||
local update_presence_identity = module:require "util".update_presence_identity;
|
||||
|
||||
-- For all received presence messages, if the jitsi_meet_context_(user|group)
|
||||
-- values are set in the session, then insert them into the presence messages
|
||||
-- for that session.
|
||||
function on_message(event)
|
||||
local stanza, session = event.stanza, event.origin;
|
||||
if stanza and session then
|
||||
update_presence_identity(
|
||||
stanza,
|
||||
session.jitsi_meet_context_user,
|
||||
session.jitsi_meet_context_group
|
||||
);
|
||||
end
|
||||
end
|
||||
|
||||
module:hook("pre-presence/bare", on_message);
|
||||
module:hook("pre-presence/full", on_message);
|
||||
257
resources/prosody-plugins/mod_rate_limit.lua
Normal file
257
resources/prosody-plugins/mod_rate_limit.lua
Normal file
@@ -0,0 +1,257 @@
|
||||
-- Rate limits connection based on their ip address.
|
||||
-- Rate limits creating sessions (new connections),
|
||||
-- rate limits sent stanzas from same ip address (presence, iq, messages)
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local cache = require"util.cache";
|
||||
local ceil = math.ceil;
|
||||
local http_server = require "net.http.server";
|
||||
local gettime = require "util.time".now
|
||||
local filters = require "util.filters";
|
||||
local new_throttle = require "util.throttle".create;
|
||||
local timer = require "util.timer";
|
||||
local ip_util = require "util.ip";
|
||||
local new_ip = ip_util.new_ip;
|
||||
local match_ip = ip_util.match;
|
||||
local parse_cidr = ip_util.parse_cidr;
|
||||
local get_ip = module:require "util".get_ip;
|
||||
|
||||
local config = {};
|
||||
local limits_resolution = 1;
|
||||
|
||||
local function load_config()
|
||||
-- Max allowed login rate in events per second.
|
||||
config.login_rate = module:get_option_number("rate_limit_login_rate", 3);
|
||||
-- The rate to which sessions from IPs exceeding the join rate will be limited, in bytes per second.
|
||||
config.ip_rate = module:get_option_number("rate_limit_ip_rate", 2000);
|
||||
-- The rate to which sessions exceeding the stanza(iq, presence, message) rate will be limited, in bytes per second.
|
||||
config.session_rate = module:get_option_number("rate_limit_session_rate", 1000);
|
||||
-- The time in seconds, after which the limit for an IP address is lifted.
|
||||
config.timeout = module:get_option_number("rate_limit_timeout", 60);
|
||||
-- List of regular expressions for IP addresses that are not limited by this module.
|
||||
config.whitelist = module:get_option_set("rate_limit_whitelist", { "127.0.0.1", "::1" })._items;
|
||||
-- The size of the cache that saves state for IP addresses
|
||||
config.cache_size = module:get_option_number("rate_limit_cache_size", 10000);
|
||||
|
||||
-- Max allowed presence rate in events per second.
|
||||
config.presence_rate = module:get_option_number("rate_limit_presence_rate", 4);
|
||||
-- Max allowed iq rate in events per second.
|
||||
config.iq_rate = module:get_option_number("rate_limit_iq_rate", 15);
|
||||
-- Max allowed message rate in events per second.
|
||||
config.message_rate = module:get_option_number("rate_limit_message_rate", 3);
|
||||
-- Number of stanza rate violations allowed while already throttled before the session is closed.
|
||||
config.max_violations = module:get_option_number("rate_limit_max_violations", 10);
|
||||
|
||||
-- A list of hosts for which sessions we ignore rate limiting
|
||||
config.whitelist_hosts = module:get_option_set("rate_limit_whitelist_hosts", {});
|
||||
|
||||
local wl = "";
|
||||
for ip in config.whitelist do wl = wl .. ip .. "," end
|
||||
local wl_hosts = "";
|
||||
for j in config.whitelist_hosts do wl_hosts = wl_hosts .. j .. "," end
|
||||
module:log("info", "Loaded configuration: ");
|
||||
module:log("info", "- ip_rate=%s bytes/sec, session_rate=%s bytes/sec, timeout=%s sec, cache size=%s, whitelist=%s, whitelist_hosts=%s",
|
||||
config.ip_rate, config.session_rate, config.timeout, config.cache_size, wl, wl_hosts);
|
||||
module:log("info", "- login_rate=%s/sec, presence_rate=%s/sec, iq_rate=%s/sec, message_rate=%s/sec, max_violations=%s",
|
||||
config.login_rate, config.presence_rate, config.iq_rate, config.message_rate, config.max_violations);
|
||||
end
|
||||
load_config();
|
||||
|
||||
-- Maps an IP address to a util.throttle which keeps the rate of login/join events from that IP.
|
||||
local login_rates = cache.new(config.cache_size);
|
||||
|
||||
-- Keeps the IP addresses that have exceeded the allowed login/join rate (i.e. the IP addresses whose sessions need
|
||||
-- to be limited). Mapped to the last instant at which the rate was exceeded.
|
||||
local limited_ips = cache.new(config.cache_size);
|
||||
|
||||
local function is_whitelisted(ip)
|
||||
local parsed_ip = new_ip(ip)
|
||||
for entry in config.whitelist do
|
||||
if match_ip(parsed_ip, parse_cidr(entry)) then
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
return false;
|
||||
end
|
||||
|
||||
local function is_whitelisted_host(h)
|
||||
return config.whitelist_hosts:contains(h);
|
||||
end
|
||||
|
||||
-- Add an IP to the set of limied IPs
|
||||
local function limit_ip(ip)
|
||||
module:log("info", "Limiting %s due to login/join rate exceeded.", ip);
|
||||
limited_ips:set(ip, gettime());
|
||||
end
|
||||
|
||||
-- Installable as a session filter to limit the reading rate for a session. Based on mod_limits.
|
||||
local function limit_bytes_in(bytes, session)
|
||||
local sess_throttle = session.jitsi_throttle;
|
||||
if sess_throttle then
|
||||
-- if the limit timeout has elapsed let's stop the throttle
|
||||
if not sess_throttle.start or gettime() - sess_throttle.start > config.timeout then
|
||||
module:log("info", "Stop throttling session=%s, ip=%s.", session.id, session.ip);
|
||||
session.jitsi_throttle = nil;
|
||||
return bytes;
|
||||
end
|
||||
local ok, _, outstanding = sess_throttle:poll(#bytes, true);
|
||||
if not ok then
|
||||
session.log("debug",
|
||||
"Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding);
|
||||
outstanding = ceil(outstanding);
|
||||
session.conn:pause(); -- Read no more data from the connection until there is no outstanding data
|
||||
local outstanding_data = bytes:sub(-outstanding);
|
||||
bytes = bytes:sub(1, #bytes-outstanding);
|
||||
timer.add_task(limits_resolution, function ()
|
||||
if not session.conn then return; end
|
||||
if sess_throttle:peek(#outstanding_data) then
|
||||
session.log("debug", "Resuming paused session");
|
||||
session.conn:resume();
|
||||
end
|
||||
-- Handle what we can of the outstanding data
|
||||
session.data(outstanding_data);
|
||||
end);
|
||||
end
|
||||
end
|
||||
return bytes;
|
||||
end
|
||||
|
||||
-- Throttles reading from the connection of a specific session.
|
||||
local function throttle_session(session, rate, timeout)
|
||||
if not session.jitsi_throttle and not session.jitsi_throttle_setlimit then
|
||||
if (session.conn and session.conn.setlimit) then
|
||||
session.jitsi_throttle_counter = session.jitsi_throttle_counter + 1;
|
||||
module:log("info", "Enabling throttle (%s bytes/s) via setlimit, session=%s, ip=%s, counter=%s.",
|
||||
rate, session.id, session.ip, session.jitsi_throttle_counter);
|
||||
session.conn:setlimit(rate);
|
||||
session.jitsi_throttle_setlimit = true;
|
||||
if timeout then
|
||||
if session.jitsi_throttle_timer then
|
||||
-- if there was a timer stop it as we will schedule a new one
|
||||
session.jitsi_throttle_timer:stop();
|
||||
session.jitsi_throttle_timer = nil;
|
||||
end
|
||||
session.jitsi_throttle_timer = module:add_timer(timeout, function()
|
||||
if session.conn then
|
||||
module:log("info", "Stop throttling session=%s, ip=%s.", session.id, session.ip);
|
||||
session.conn:setlimit(0);
|
||||
end
|
||||
session.jitsi_throttle_setlimit = nil;
|
||||
session.jitsi_throttle_timer = nil;
|
||||
session.jitsi_throttle_violations = 0;
|
||||
end);
|
||||
end
|
||||
else
|
||||
module:log("info", "Enabling throttle (%s bytes/s) via filter, session=%s, ip=%s.", rate, session.id, session.ip);
|
||||
session.jitsi_throttle = new_throttle(rate, 2);
|
||||
filters.add_filter(session, "bytes/in", limit_bytes_in, 1000);
|
||||
-- throttle.start used for stop throttling after the timeout
|
||||
session.jitsi_throttle.start = gettime();
|
||||
end
|
||||
else
|
||||
-- update the throttling start
|
||||
if session.jitsi_throttle then
|
||||
session.jitsi_throttle.start = gettime();
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- checks different stanzas for rate limiting (per session)
|
||||
function filter_stanza(stanza, session)
|
||||
local rate = session[stanza.name.."_rate"];
|
||||
if rate then
|
||||
local ok, _, _ = rate:poll(1, true);
|
||||
if not ok then
|
||||
if session.jitsi_throttle or session.jitsi_throttle_setlimit then
|
||||
-- already throttled and still violating
|
||||
session.jitsi_throttle_violations = session.jitsi_throttle_violations + 1;
|
||||
if session.jitsi_throttle_violations >= config.max_violations then
|
||||
module:log("warn", "%s rate exceeded %d times while throttled for %s, closing connection.",
|
||||
stanza.name, session.jitsi_throttle_violations, session.full_jid);
|
||||
session:close({ condition = "policy-violation", text = "Rate limit exceeded" });
|
||||
return;
|
||||
end
|
||||
end
|
||||
module:log("info", "%s rate exceeded for %s, limiting.", stanza.name, session.full_jid);
|
||||
throttle_session(session, config.session_rate, config.timeout);
|
||||
end
|
||||
end
|
||||
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local function on_login(session, ip)
|
||||
local login_rate = login_rates:get(ip);
|
||||
if not login_rate then
|
||||
module:log("debug", "Create new join rate for %s", ip);
|
||||
login_rate = new_throttle(config.login_rate, 2);
|
||||
login_rates:set(ip, login_rate);
|
||||
end
|
||||
|
||||
local ok, _, _ = login_rate:poll(1, true);
|
||||
if not ok then
|
||||
module:log("info", "Join rate exceeded for %s, limiting.", ip);
|
||||
limit_ip(ip);
|
||||
end
|
||||
end
|
||||
|
||||
local function filter_hook(session)
|
||||
-- ignore s2s sessions, in both directions. s2s is restricted to the hosts in
|
||||
-- s2s_whitelist, so a rate limit adds no protection, but it can throttle a legitimate
|
||||
-- burst of stanzas from a trusted node.
|
||||
-- Use base_type, not type. util.session sets base_type when it creates the session and
|
||||
-- never changes it. It sets type to 's2sin_unauthed' first, then to 's2sin' after the
|
||||
-- session authenticates.
|
||||
if session.base_type == 's2sin' or session.base_type == 's2sout' then
|
||||
return;
|
||||
end
|
||||
|
||||
local ip = get_ip(session);
|
||||
module:log("debug", "New session from %s", ip);
|
||||
if is_whitelisted(ip) or is_whitelisted_host(session.host) then
|
||||
return;
|
||||
end
|
||||
|
||||
on_login(session, ip);
|
||||
|
||||
-- creates the stanzas rates
|
||||
session.jitsi_throttle_counter = 0;
|
||||
session.jitsi_throttle_violations = 0;
|
||||
session.presence_rate = new_throttle(config.presence_rate, 2);
|
||||
session.iq_rate = new_throttle(config.iq_rate, 2);
|
||||
session.message_rate = new_throttle(config.message_rate, 2);
|
||||
filters.add_filter(session, "stanzas/in", filter_stanza);
|
||||
|
||||
local oldt = limited_ips:get(ip);
|
||||
if oldt then
|
||||
local newt = gettime();
|
||||
local elapsed = newt - oldt;
|
||||
if elapsed < config.timeout then
|
||||
if elapsed < 5 then
|
||||
module:log("info", "IP address %s was limited %s seconds ago, refreshing.", ip, elapsed);
|
||||
limited_ips:set(ip, newt);
|
||||
end
|
||||
throttle_session(session, config.ip_rate);
|
||||
else
|
||||
module:log("info", "Removing the limit for %s", ip);
|
||||
limited_ips:set(ip, nil);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function module.load()
|
||||
filters.add_filter_hook(filter_hook);
|
||||
end
|
||||
|
||||
function module.unload()
|
||||
filters.remove_filter_hook(filter_hook);
|
||||
end
|
||||
|
||||
module:hook_global("config-reloaded", load_config);
|
||||
|
||||
-- we calculate the stats on the configured interval (60 seconds by default)
|
||||
local measure_limited_ips = module:measure('limited-ips', 'amount'); -- we send stats for the total number limited ips
|
||||
module:hook_global('stats-update', function ()
|
||||
measure_limited_ips(limited_ips:count());
|
||||
end);
|
||||
695
resources/prosody-plugins/mod_reservations.lua
Normal file
695
resources/prosody-plugins/mod_reservations.lua
Normal file
@@ -0,0 +1,695 @@
|
||||
--- This is a port of Jicofo's Reservation System as a prosody module
|
||||
-- ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
|
||||
--
|
||||
-- We try to retain the same behaviour and interfaces where possible, but there
|
||||
-- is some difference:
|
||||
-- * In the event that the DELETE call fails, Jicofo's reservation
|
||||
-- system retains reservation data and allows re-creation of room if requested by
|
||||
-- the same creator without making further call to the API; this module does not
|
||||
-- offer this behaviour. Re-creation of a closed room will behave like a new meeting
|
||||
-- and trigger a new API call to validate the reservation.
|
||||
-- * Jicofo's reservation system expect int-based conflict_id. We take any sensible string.
|
||||
--
|
||||
-- In broad strokes, this module works by intercepting Conference IQs sent to focus component
|
||||
-- and buffers it until reservation is confirmed (by calling the provided API endpoint).
|
||||
-- The IQ events are routed on to focus component if reservation is valid, or error
|
||||
-- response is sent back to the origin if reservation is denied. Events are routed as usual
|
||||
-- if the room already exists.
|
||||
--
|
||||
--
|
||||
-- Installation:
|
||||
-- =============
|
||||
--
|
||||
-- Under domain config,
|
||||
-- 1. add "reservations" to modules_enabled.
|
||||
-- 2. Specify URL base for your API endpoint using "reservations_api_prefix" (required)
|
||||
-- 3. Optional config:
|
||||
-- * set "reservations_api_timeout" to change API call timeouts (defaults to 20 seconds)
|
||||
-- * set "reservations_api_headers" to specify custom HTTP headers included in
|
||||
-- all API calls e.g. to provide auth tokens.
|
||||
-- * set "reservations_api_retry_count" to the number of times API call failures are retried (defaults to 3)
|
||||
-- * set "reservations_api_retry_delay" seconds to wait between retries (defaults to 3s)
|
||||
-- * set "reservations_api_should_retry_for_code" to a function that takes an HTTP response code and
|
||||
-- returns true if API call should be retried. By default, retries are done for 5XX
|
||||
-- responses. Timeouts are never retried, and HTTP call failures are always retried.
|
||||
-- * set "reservations_enable_max_occupants" to true to enable integration with
|
||||
-- mod_muc_max_occupants. Setting thia will allow optional "max_occupants" (integer)
|
||||
-- payload from API to influence max occupants allowed for a given room.
|
||||
-- * set "reservations_enable_lobby_support" to true to enable integration
|
||||
-- with "muc_lobby_rooms". Setting this will allow optional "lobby" (boolean)
|
||||
-- fields in API payload. If set to true, Lobby will be enabled for the room.
|
||||
-- "persistent_lobby" module must also be enabled for this to work.
|
||||
-- * set "reservations_enable_password_support" to allow optional "password" (string)
|
||||
-- field in API payload. If set and not empty, then room password will be set
|
||||
-- to the given string.
|
||||
-- * By default, reservation checks are skipped for breakout rooms. You can subject
|
||||
-- breakout rooms to the same checks by setting "reservations_skip_breakout_rooms" to false.
|
||||
--
|
||||
--
|
||||
-- Example config:
|
||||
--
|
||||
-- VirtualHost "jitmeet.example.com"
|
||||
-- modules_enabled = {
|
||||
-- "reservations";
|
||||
-- }
|
||||
-- reservations_api_prefix = "http://reservation.example.com"
|
||||
--
|
||||
-- --- The following are all optional
|
||||
-- reservations_api_headers = {
|
||||
-- ["Authorization"] = "Bearer TOKEN-237958623045";
|
||||
-- }
|
||||
-- reservations_api_timeout = 10 -- timeout if API does not respond within 10s
|
||||
-- reservations_api_retry_count = 5 -- retry up to 5 times
|
||||
-- reservations_api_retry_delay = 1 -- wait 1s between retries
|
||||
-- reservations_api_should_retry_for_code = function (code)
|
||||
-- return code >= 500 or code == 408
|
||||
-- end
|
||||
--
|
||||
-- reservations_enable_max_occupants = true -- support "max_occupants" field
|
||||
-- reservations_enable_lobby_support = true -- support "lobby" field
|
||||
-- reservations_enable_password_support = true -- support "password" field
|
||||
--
|
||||
|
||||
local jid = require 'util.jid';
|
||||
local http = require "net.http";
|
||||
local json = require 'cjson.safe';
|
||||
local st = require "util.stanza";
|
||||
local timer = require 'util.timer';
|
||||
local datetime = require 'util.datetime';
|
||||
|
||||
local util = module:require "util";
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local process_host_module = util.process_host_module;
|
||||
|
||||
local api_prefix = module:get_option("reservations_api_prefix");
|
||||
local api_headers = module:get_option("reservations_api_headers");
|
||||
local api_timeout = module:get_option("reservations_api_timeout", 20);
|
||||
local api_retry_count = tonumber(module:get_option("reservations_api_retry_count", 3));
|
||||
local api_retry_delay = tonumber(module:get_option("reservations_api_retry_delay", 3));
|
||||
local max_occupants_enabled = module:get_option("reservations_enable_max_occupants", false);
|
||||
local lobby_support_enabled = module:get_option("reservations_enable_lobby_support", false);
|
||||
local password_support_enabled = module:get_option("reservations_enable_password_support", false);
|
||||
local skip_breakout_room = module:get_option("reservations_skip_breakout_rooms", true);
|
||||
|
||||
|
||||
-- Option for user to control HTTP response codes that will result in a retry.
|
||||
-- Defaults to returning true on any 5XX code or 0
|
||||
local api_should_retry_for_code = module:get_option("reservations_api_should_retry_for_code", function (code)
|
||||
return code >= 500;
|
||||
end)
|
||||
|
||||
|
||||
local muc_component_host = module:get_option_string("main_muc");
|
||||
local breakout_muc_component_host = module:get_option_string('breakout_rooms_muc', 'breakout.'..module.host);
|
||||
|
||||
|
||||
-- How often to check and evict expired reservation data
|
||||
local expiry_check_period = 60;
|
||||
|
||||
|
||||
-- Cannot proceed if "reservations_api_prefix" not configured
|
||||
if not api_prefix then
|
||||
module:log("error", "reservations_api_prefix not specified. Disabling %s", module:get_name());
|
||||
return;
|
||||
end
|
||||
|
||||
|
||||
-- get/infer focus component hostname so we can intercept IQ bound for it
|
||||
local focus_component_host = module:get_option_string("focus_component");
|
||||
if not focus_component_host then
|
||||
local muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
if not muc_domain_base then
|
||||
module:log("error", "Could not infer focus domain. Disabling %s", module:get_name());
|
||||
return;
|
||||
end
|
||||
focus_component_host = 'focus.'..muc_domain_base;
|
||||
end
|
||||
|
||||
-- common HTTP headers added to all API calls
|
||||
local http_headers = {
|
||||
["User-Agent"] = "Prosody ("..prosody.version.."; "..prosody.platform..")";
|
||||
};
|
||||
if api_headers then -- extra headers from config
|
||||
for key, value in pairs(api_headers) do
|
||||
http_headers[key] = value;
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
--- Utils
|
||||
|
||||
--- Converts int timestamp to datetime string compatible with Java SimpleDateFormat
|
||||
-- @param t timestamps in seconds. Supports int (as returned by os.time()) or higher
|
||||
-- precision (as returned by socket.gettime())
|
||||
-- @return formatted datetime string (yyyy-MM-dd'T'HH:mm:ss.SSSX)
|
||||
local function to_java_date_string(t)
|
||||
local t_secs, mantissa = math.modf(t);
|
||||
local ms_str = (mantissa == 0) and '.000' or tostring(mantissa):sub(2,5);
|
||||
local date_str = os.date("!%Y-%m-%dT%H:%M:%S", t_secs);
|
||||
return date_str..ms_str..'Z';
|
||||
end
|
||||
|
||||
|
||||
--- Start non-blocking HTTP call
|
||||
-- @param url URL to call
|
||||
-- @param options options table as expected by net.http where we provide optional headers, body or method.
|
||||
-- @param callback if provided, called with callback(response_body, response_code) when call complete.
|
||||
-- @param timeout_callback if provided, called without args when request times out.
|
||||
-- @param retries how many times to retry on failure; 0 means no retries.
|
||||
local function async_http_request(url, options, callback, timeout_callback, retries)
|
||||
local completed = false;
|
||||
local timed_out = false;
|
||||
local retries = retries or api_retry_count;
|
||||
|
||||
local function cb_(response_body, response_code)
|
||||
if not timed_out then -- request completed before timeout
|
||||
completed = true;
|
||||
if (response_code == 0 or api_should_retry_for_code(response_code)) and retries > 0 then
|
||||
module:log("warn", "API Response code %d. Will retry after %ds", response_code, api_retry_delay);
|
||||
timer.add_task(api_retry_delay, function()
|
||||
async_http_request(url, options, callback, timeout_callback, retries - 1)
|
||||
end)
|
||||
return;
|
||||
end
|
||||
|
||||
if callback then
|
||||
callback(response_body, response_code)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local request = http.request(url, options, cb_);
|
||||
|
||||
timer.add_task(api_timeout, function ()
|
||||
timed_out = true;
|
||||
|
||||
if not completed then
|
||||
http.destroy_request(request);
|
||||
if timeout_callback then
|
||||
timeout_callback()
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
end
|
||||
|
||||
--- Returns current timestamp
|
||||
local function now()
|
||||
-- Don't really need higher precision of socket.gettime(). Besides, we loose
|
||||
-- milliseconds precision when converting back to timestamp from date string
|
||||
-- when we use datetime.parse(t), so let's be consistent.
|
||||
return os.time();
|
||||
end
|
||||
|
||||
--- Start RoomReservation implementation
|
||||
|
||||
-- Status enums used in RoomReservation:meta.status
|
||||
local STATUS = {
|
||||
PENDING = 0;
|
||||
SUCCESS = 1;
|
||||
FAILED = -1;
|
||||
}
|
||||
|
||||
local RoomReservation = {};
|
||||
RoomReservation.__index = RoomReservation;
|
||||
|
||||
function newRoomReservation(room_jid, creator_jid)
|
||||
return setmetatable({
|
||||
room_jid = room_jid;
|
||||
|
||||
-- Reservation metadata. store as table so we can set and read atomically.
|
||||
-- N.B. This should always be updated using self.set_status_*
|
||||
meta = {
|
||||
status = STATUS.PENDING;
|
||||
mail_owner = jid.bare(creator_jid);
|
||||
conflict_id = nil;
|
||||
start_time = now(); -- timestamp, in seconds
|
||||
expires_at = nil; -- timestamp, in seconds
|
||||
error_text = nil;
|
||||
error_code = nil;
|
||||
};
|
||||
|
||||
-- Array of pending events that we need to route once API call is complete
|
||||
pending_events = {};
|
||||
|
||||
-- Set true when API call trigger has been triggered (by enqueue of first event)
|
||||
api_call_triggered = false;
|
||||
}, RoomReservation);
|
||||
end
|
||||
|
||||
|
||||
--- Extracts room name from room jid
|
||||
function RoomReservation:get_room_name()
|
||||
return jid.node(self.room_jid);
|
||||
end
|
||||
|
||||
--- Checks if reservation data is expires and should be evicted from store
|
||||
function RoomReservation:is_expired()
|
||||
return self.meta.expires_at ~= nil and now() > self.meta.expires_at;
|
||||
end
|
||||
|
||||
--- Main entry point for handing and routing events.
|
||||
function RoomReservation:enqueue_or_route_event(event)
|
||||
if self.meta.status == STATUS.PENDING then
|
||||
table.insert(self.pending_events, event)
|
||||
if self.api_call_triggered ~= true then
|
||||
self:call_api_create_conference();
|
||||
end
|
||||
else
|
||||
-- API call already complete. Immediately route without enqueueing.
|
||||
-- This could happen if request comes in between the time reservation approved
|
||||
-- and when Jicofo actually creates the room.
|
||||
module:log("debug", "Reservation details already stored. Skipping queue for %s", self.room_jid);
|
||||
self:route_event(event);
|
||||
end
|
||||
end
|
||||
|
||||
--- Updates status and initiates event routing. Called internally when API call complete.
|
||||
function RoomReservation:set_status_success(start_time, duration, mail_owner, conflict_id, data)
|
||||
module:log("info", "Reservation created successfully for %s", self.room_jid);
|
||||
self.meta = {
|
||||
status = STATUS.SUCCESS;
|
||||
mail_owner = mail_owner or self.meta.mail_owner;
|
||||
conflict_id = conflict_id;
|
||||
start_time = start_time;
|
||||
expires_at = start_time + duration;
|
||||
error_text = nil;
|
||||
error_code = nil;
|
||||
}
|
||||
if max_occupants_enabled and data.max_occupants then
|
||||
self.meta.max_occupants = data.max_occupants
|
||||
end
|
||||
if lobby_support_enabled and data.lobby then
|
||||
self.meta.lobby = data.lobby
|
||||
end
|
||||
if password_support_enabled and data.password then
|
||||
self.meta.password = data.password
|
||||
end
|
||||
self:route_pending_events()
|
||||
end
|
||||
|
||||
--- Updates status and initiates error response to pending events. Called internally when API call complete.
|
||||
function RoomReservation:set_status_failed(error_code, error_text)
|
||||
module:log("info", "Reservation creation failed for %s - (%s) %s", self.room_jid, error_code, error_text);
|
||||
self.meta = {
|
||||
status = STATUS.FAILED;
|
||||
mail_owner = self.meta.mail_owner;
|
||||
conflict_id = nil;
|
||||
start_time = self.meta.start_time;
|
||||
-- Retain reservation rejection for a short while so we have time to report failure to
|
||||
-- existing clients and not trigger a re-query too soon.
|
||||
-- N.B. Expiry could take longer since eviction happens periodically.
|
||||
expires_at = now() + 30;
|
||||
error_text = error_text;
|
||||
error_code = error_code;
|
||||
}
|
||||
self:route_pending_events()
|
||||
end
|
||||
|
||||
--- Triggers routing of all enqueued events
|
||||
function RoomReservation:route_pending_events()
|
||||
if self.meta.status == STATUS.PENDING then -- should never be called while PENDING. check just in case.
|
||||
return;
|
||||
end
|
||||
|
||||
module:log("debug", "Routing all pending events for %s", self.room_jid);
|
||||
local event;
|
||||
|
||||
while #self.pending_events ~= 0 do
|
||||
event = table.remove(self.pending_events);
|
||||
self:route_event(event)
|
||||
end
|
||||
end
|
||||
|
||||
--- Event routing implementation
|
||||
function RoomReservation:route_event(event)
|
||||
-- this should only be called after API call complete and status no longer PENDING
|
||||
assert(self.meta.status ~= STATUS.PENDING, "Attempting to route event while API call still PENDING")
|
||||
|
||||
local meta = self.meta;
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
if meta.status == STATUS.FAILED then
|
||||
module:log("debug", "Route: Sending reservation error to %s", stanza.attr.from);
|
||||
self:reply_with_error(event, meta.error_code, meta.error_text);
|
||||
else
|
||||
if meta.status == STATUS.SUCCESS then
|
||||
if self:is_expired() then
|
||||
module:log("debug", "Route: Sending reservation expiry to %s", stanza.attr.from);
|
||||
self:reply_with_error(event, 419, "Reservation expired");
|
||||
else
|
||||
module:log("debug", "Route: Forwarding on event from %s", stanza.attr.from);
|
||||
prosody.core_post_stanza(origin, stanza, false); -- route iq to intended target (focus)
|
||||
end
|
||||
else
|
||||
-- this should never happen unless dev made a mistake. Block by default just in case.
|
||||
module:log("error", "Reservation for %s has invalid state %s. Rejecting request.", self.room_jid, meta.status);
|
||||
self:reply_with_error(event, 500, "Failed to determine reservation state");
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Generates reservation-error stanza and sends to event origin.
|
||||
function RoomReservation:reply_with_error(event, error_code, error_text)
|
||||
local stanza = event.stanza;
|
||||
local id = stanza.attr.id;
|
||||
local to = stanza.attr.from;
|
||||
local from = stanza.attr.to;
|
||||
|
||||
event.origin.send(
|
||||
st.iq({ type="error", to=to, from=from, id=id })
|
||||
:tag("error", { type="cancel" })
|
||||
:tag("service-unavailable", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):up()
|
||||
:tag("text", { xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" }):text(error_text):up()
|
||||
:tag("reservation-error", { xmlns="http://jitsi.org/protocol/focus", ["error-code"]=tostring(error_code) })
|
||||
);
|
||||
end
|
||||
|
||||
--- Initiates non-blocking API call to validate reservation
|
||||
function RoomReservation:call_api_create_conference()
|
||||
self.api_call_triggered = true;
|
||||
|
||||
local url = api_prefix..'/conference';
|
||||
local request_data = {
|
||||
name = self:get_room_name();
|
||||
start_time = to_java_date_string(self.meta.start_time);
|
||||
mail_owner = self.meta.mail_owner;
|
||||
}
|
||||
|
||||
local http_options = {
|
||||
body = http.formencode(request_data); -- because Jicofo reservation encodes as form data instead JSON
|
||||
method = 'POST';
|
||||
headers = http_headers;
|
||||
}
|
||||
|
||||
module:log("debug", "Sending POST /conference for %s", self.room_jid);
|
||||
async_http_request(url, http_options, function (response_body, response_code)
|
||||
self:on_api_create_conference_complete(response_body, response_code);
|
||||
end, function ()
|
||||
self:on_api_call_timeout();
|
||||
end);
|
||||
end
|
||||
|
||||
--- Parses and validates HTTP response body for conference payload
|
||||
-- Ref: https://github.com/jitsi/jicofo/blob/master/doc/reservation.md
|
||||
-- @return nil if invalid, or table with payload parsed from JSON response
|
||||
function RoomReservation:parse_conference_response(response_body)
|
||||
local data, error = json.decode(response_body);
|
||||
|
||||
if data == nil then -- invalid JSON payload
|
||||
module:log("error", "Invalid JSON response from API - %s error:%s", response_body, error);
|
||||
return;
|
||||
end
|
||||
|
||||
if data.name == nil or data.name:lower() ~= self:get_room_name() then
|
||||
module:log("error", "Missing or mismatching room name - %s", data.name);
|
||||
return;
|
||||
end
|
||||
|
||||
if data.id == nil then
|
||||
module:log("error", "Missing id");
|
||||
return;
|
||||
end
|
||||
|
||||
if data.mail_owner == nil then
|
||||
module:log("error", "Missing mail_owner");
|
||||
return;
|
||||
end
|
||||
|
||||
local duration = tonumber(data.duration);
|
||||
if duration == nil then
|
||||
module:log("error", "Missing or invalid duration - %s", data.duration);
|
||||
return;
|
||||
end
|
||||
data.duration = duration;
|
||||
|
||||
-- if optional "max_occupants" field set, cast to number
|
||||
if data.max_occupants ~= nil then
|
||||
local max_occupants = tonumber(data.max_occupants)
|
||||
if max_occupants == nil or max_occupants < 1 then
|
||||
-- N.B. invalid max_occupants rejected even if max_occupants_enabled=false
|
||||
module:log("error", "Invalid value for max_occupants - %s", data.max_occupants);
|
||||
return;
|
||||
end
|
||||
data.max_occupants = max_occupants
|
||||
end
|
||||
|
||||
-- if optional "lobby" field set, accept boolean true or "true"
|
||||
if data.lobby ~= nil then
|
||||
if (type(data.lobby) == "boolean" and data.lobby) or data.lobby == "true" then
|
||||
data.lobby = true
|
||||
else
|
||||
data.lobby = false
|
||||
end
|
||||
end
|
||||
|
||||
-- if optional "password" field set, it has to be string
|
||||
if data.password ~= nil then
|
||||
if type(data.password) ~= "string" then
|
||||
-- N.B. invalid "password" rejected even if reservations_enable_password_support=false
|
||||
module:log("error", "Invalid type for password - string expected");
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
local start_time = datetime.parse(data.start_time); -- N.B. we lose milliseconds portion of the date
|
||||
if start_time == nil then
|
||||
module:log("error", "Missing or invalid start_time - %s", data.start_time);
|
||||
return;
|
||||
end
|
||||
data.start_time = start_time;
|
||||
|
||||
return data;
|
||||
end
|
||||
|
||||
--- Parses and validates HTTP error response body for API call.
|
||||
-- Expect JSON with a "message" field.
|
||||
-- @return message string, or generic error message if invalid payload.
|
||||
function RoomReservation:parse_error_message_from_response(response_body)
|
||||
local data = json.decode(response_body);
|
||||
if data ~= nil and data.message ~= nil then
|
||||
module:log("debug", "Invalid error response body. Will use generic error message.");
|
||||
return data.message;
|
||||
else
|
||||
return "Rejected by reservation server";
|
||||
end
|
||||
end
|
||||
|
||||
--- callback on API timeout
|
||||
function RoomReservation:on_api_call_timeout()
|
||||
self:set_status_failed(500, 'Reservation lookup timed out');
|
||||
end
|
||||
|
||||
--- callback on API response
|
||||
function RoomReservation:on_api_create_conference_complete(response_body, response_code)
|
||||
if response_code == 200 or response_code == 201 then
|
||||
self:handler_conference_data_returned_from_api(response_body);
|
||||
elseif response_code == 409 then
|
||||
self:handle_conference_already_exist(response_body);
|
||||
elseif response_code == nil then -- warrants a retry, but this should be done automatically by the http call method.
|
||||
self:set_status_failed(500, 'Could not contact reservation server');
|
||||
else
|
||||
self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
|
||||
end
|
||||
end
|
||||
|
||||
function RoomReservation:handler_conference_data_returned_from_api(response_body)
|
||||
local data = self:parse_conference_response(response_body);
|
||||
if not data then -- invalid response from API
|
||||
module:log("error", "API returned success code but invalid payload");
|
||||
self:set_status_failed(500, 'Invalid response from reservation server');
|
||||
else
|
||||
self:set_status_success(data.start_time, data.duration, data.mail_owner, data.id, data)
|
||||
end
|
||||
end
|
||||
|
||||
function RoomReservation:handle_conference_already_exist(response_body)
|
||||
local data = json.decode(response_body);
|
||||
if data == nil or data.conflict_id == nil then
|
||||
-- yes, in the case of 409, API expected to return "id" as "conflict_id".
|
||||
self:set_status_failed(409, 'Invalid response from reservation server');
|
||||
else
|
||||
local url = api_prefix..'/conference/'..data.conflict_id;
|
||||
local http_options = {
|
||||
method = 'GET';
|
||||
headers = http_headers;
|
||||
}
|
||||
|
||||
async_http_request(url, http_options, function(response_body, response_code)
|
||||
if response_code == 200 then
|
||||
self:handler_conference_data_returned_from_api(response_body);
|
||||
else
|
||||
self:set_status_failed(response_code, self:parse_error_message_from_response(response_body));
|
||||
end
|
||||
end, function ()
|
||||
self:on_api_call_timeout();
|
||||
end);
|
||||
end
|
||||
end
|
||||
|
||||
--- End RoomReservation
|
||||
|
||||
--- Store reservations lookups that are still pending or with room still active
|
||||
local reservations = {}
|
||||
|
||||
local function get_or_create_reservations(room_jid, creator_jid)
|
||||
if reservations[room_jid] == nil then
|
||||
module:log("debug", "Creating new reservation data for %s", room_jid);
|
||||
reservations[room_jid] = newRoomReservation(room_jid, creator_jid);
|
||||
end
|
||||
|
||||
return reservations[room_jid];
|
||||
end
|
||||
|
||||
local function evict_expired_reservations()
|
||||
local expired = {}
|
||||
|
||||
-- first, gather jids of expired rooms. So we don't remove from table while iterating.
|
||||
for room_jid, res in pairs(reservations) do
|
||||
if res:is_expired() then
|
||||
table.insert(expired, room_jid);
|
||||
end
|
||||
end
|
||||
|
||||
local room;
|
||||
for _, room_jid in ipairs(expired) do
|
||||
room = get_room_from_jid(room_jid);
|
||||
if room then
|
||||
-- Close room if still active (reservation duration exceeded)
|
||||
module:log("info", "Room exceeded reservation duration. Terminating %s", room_jid);
|
||||
room:destroy(nil, "Scheduled conference duration exceeded.");
|
||||
-- Rely on room_destroyed to calls DELETE /conference and drops reservation[room_jid]
|
||||
else
|
||||
module:log("error", "Reservation references expired room that is no longer active. Dropping %s", room_jid);
|
||||
-- This should not happen unless evict_expired_reservations somehow gets triggered
|
||||
-- between the time room is destroyed and room_destroyed callback is called. (Possible?)
|
||||
-- But just in case, we drop the reservation to avoid repeating this path on every pass.
|
||||
reservations[room_jid] = nil;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
timer.add_task(expiry_check_period, function()
|
||||
evict_expired_reservations();
|
||||
return expiry_check_period;
|
||||
end)
|
||||
|
||||
|
||||
--- Intercept conference IQ to Jicofo handle reservation checks before allowing normal event flow
|
||||
module:log("info", "Hook to global pre-iq/host");
|
||||
module:hook("pre-iq/host", function(event)
|
||||
local stanza = event.stanza;
|
||||
|
||||
if stanza.name ~= "iq" or stanza.attr.to ~= focus_component_host or stanza.attr.type ~= 'set' then
|
||||
return; -- not IQ for jicofo. Ignore this event.
|
||||
end
|
||||
|
||||
local conference = stanza:get_child('conference', 'http://jitsi.org/protocol/focus');
|
||||
if conference == nil then
|
||||
return; -- not Conference IQ. Ignore.
|
||||
end
|
||||
|
||||
local room_jid = room_jid_match_rewrite(conference.attr.room);
|
||||
|
||||
if get_room_from_jid(room_jid) ~= nil then
|
||||
module:log("debug", "Skip reservation check for existing room %s", room_jid);
|
||||
return; -- room already exists. Continue with normal flow
|
||||
end
|
||||
|
||||
if skip_breakout_room then
|
||||
local _, host = jid.split(room_jid);
|
||||
if host == breakout_muc_component_host then
|
||||
module:log("debug", "Skip reservation check for breakout room %s", room_jid);
|
||||
return;
|
||||
end
|
||||
end
|
||||
|
||||
local res = get_or_create_reservations(room_jid, stanza.attr.from);
|
||||
res:enqueue_or_route_event(event); -- hand over to reservation obj to route event
|
||||
return true;
|
||||
|
||||
end);
|
||||
|
||||
|
||||
--- Forget reservation details once room destroyed so query is repeated if room re-created
|
||||
local function room_destroyed(event)
|
||||
local res;
|
||||
local room = event.room
|
||||
|
||||
if not is_healthcheck_room(room.jid) then
|
||||
res = reservations[room.jid]
|
||||
|
||||
-- drop reservation data for this room
|
||||
reservations[room.jid] = nil
|
||||
|
||||
if res then -- just in case event triggered more than once?
|
||||
module:log("info", "Dropped reservation data for destroyed room %s", room.jid);
|
||||
|
||||
local conflict_id = res.meta.conflict_id
|
||||
if conflict_id then
|
||||
local url = api_prefix..'/conference/'..conflict_id;
|
||||
local http_options = {
|
||||
method = 'DELETE';
|
||||
headers = http_headers;
|
||||
}
|
||||
|
||||
module:log("debug", "Sending DELETE /conference/%s", conflict_id);
|
||||
async_http_request(url, http_options);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function room_created(event)
|
||||
local room = event.room
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local res = reservations[room.jid]
|
||||
|
||||
if res and max_occupants_enabled and res.meta.max_occupants ~= nil then
|
||||
module:log("info", "Setting max_occupants %d for room %s", res.meta.max_occupants, room.jid);
|
||||
room._data.max_occupants = res.meta.max_occupants
|
||||
end
|
||||
|
||||
if res and password_support_enabled and res.meta.password ~= nil then
|
||||
module:log("info", "Setting password for room %s", room.jid);
|
||||
room:set_password(res.meta.password);
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local function room_pre_create(event)
|
||||
local room = event.room
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local res = reservations[room.jid]
|
||||
|
||||
if res and lobby_support_enabled and res.meta.lobby then
|
||||
module:log("info", "Enabling lobby for room %s", room.jid);
|
||||
prosody.events.fire_event("create-persistent-lobby-room", { room = room; });
|
||||
end
|
||||
end
|
||||
|
||||
process_host_module(muc_component_host, function(host_module, host)
|
||||
module:log("info", "Hook to muc-room-destroyed on %s", host);
|
||||
host_module:hook("muc-room-destroyed", room_destroyed, -1);
|
||||
|
||||
if max_occupants_enabled or password_support_enabled then
|
||||
module:log("info", "Hook to muc-room-created on %s (max_occupants or password integration enabled)", host);
|
||||
host_module:hook("muc-room-created", room_created);
|
||||
end
|
||||
|
||||
if lobby_support_enabled then
|
||||
module:log("info", "Hook to muc-room-pre-create on %s (lobby integration enabled)", host);
|
||||
host_module:hook("muc-room-pre-create", room_pre_create);
|
||||
end
|
||||
end);
|
||||
15
resources/prosody-plugins/mod_room_destroy.lua
Normal file
15
resources/prosody-plugins/mod_room_destroy.lua
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Handle room destroy requests it such a way that it can be suppressed by other
|
||||
-- modules that handle room lifecycle and wish to keep the room alive.
|
||||
|
||||
function handle_room_destroy(event)
|
||||
local room = event.room;
|
||||
local reason = event.reason;
|
||||
local caller = event.caller;
|
||||
|
||||
module:log('info', 'Destroying room %s (requested by %s)', room.jid, caller);
|
||||
room:set_persistent(false);
|
||||
room:destroy(nil, reason);
|
||||
end
|
||||
|
||||
module:hook_global("maybe-destroy-room", handle_room_destroy, -1);
|
||||
module:log('info', 'loaded');
|
||||
591
resources/prosody-plugins/mod_room_metadata_component.lua
Normal file
591
resources/prosody-plugins/mod_room_metadata_component.lua
Normal file
@@ -0,0 +1,591 @@
|
||||
-- mod_room_metadata_component.lua
|
||||
--
|
||||
-- Prosody component that provides a generic key-value metadata store for MUC
|
||||
-- rooms. Metadata is held in room.jitsiMetadata (a Lua table) and broadcast
|
||||
-- to all occupants as a JSON <json-message> stanza whenever it changes.
|
||||
--
|
||||
-- ── Metadata updates (client → component) ────────────────────────────────────
|
||||
-- Clients send a <message> to this component containing a <room_metadata
|
||||
-- xmlns='http://jitsi.org/jitmeet'> child whose text is JSON:
|
||||
--
|
||||
-- { "key": "<field-name>", "data": <any JSON value> }
|
||||
--
|
||||
-- Authorization is a three-step process (on_message):
|
||||
-- 1. The sender must be an occupant of the room identified by the session's
|
||||
-- jitsi_web_query_room field (set from the WebSocket ?room= URL param).
|
||||
-- 2. The 'jitsi-metadata-allow-moderation' event is fired on the main virtual
|
||||
-- host so that other modules can override the default access rules:
|
||||
-- · returns false → deny the update unconditionally
|
||||
-- · returns non-nil → allow the update and use the returned value as data
|
||||
-- · returns nil → fall through to the default moderator-only check
|
||||
-- 3. Default: the occupant must have role='moderator'.
|
||||
--
|
||||
-- If authorized and the value changed, room.jitsiMetadata[key] is updated and
|
||||
-- the full metadata table is broadcast to every occupant. A
|
||||
-- 'jitsi-metadata-updated' event is also fired on the main MUC module so that
|
||||
-- other modules can react to specific key changes.
|
||||
--
|
||||
-- ── Metadata delivery (component → client) ────────────────────────────────────
|
||||
-- Initial delivery: a stanza filter intercepts the self-presence (status 110)
|
||||
-- about to be sent to a newly-joining occupant and, before it goes out, pushes
|
||||
-- the current metadata to that client. TURN credentials (from external_services)
|
||||
-- are included in this initial push. room.sent_initial_metadata[bare_jid] is
|
||||
-- set to prevent double-delivery on admin reconnects.
|
||||
--
|
||||
-- Broadcast: broadcastMetadata() / send_metadata() send a <json-message> to
|
||||
-- every occupant. Admins (jicofo) additionally receive the room's participant
|
||||
-- and moderator lists from room._data, plus the transcription HTTP headers.
|
||||
-- Those headers may carry secrets (e.g. a custom OpenAI API key), so they are
|
||||
-- stripped from the payload sent to regular occupants (and redacted in logs).
|
||||
--
|
||||
-- ── Internal metadata changes ─────────────────────────────────────────────────
|
||||
-- Other modules update room.jitsiMetadata directly and then fire
|
||||
-- 'room-metadata-changed' on the MUC host to trigger a broadcast.
|
||||
--
|
||||
-- ── Legacy startMuted shim ────────────────────────────────────────────────────
|
||||
-- When a moderator sends a presence containing <startmuted> (old client API),
|
||||
-- the values are copied into room.jitsiMetadata.startMuted and a
|
||||
-- 'room-metadata-changed' broadcast is triggered. This preserves backward
|
||||
-- compatibility until all clients switch to the metadata API.
|
||||
--
|
||||
-- ── TURN / extdisco gating (optional) ────────────────────────────────────────
|
||||
-- When extdisco_occpuant_check=true, extdisco IQ-get requests are intercepted
|
||||
-- at priority 100 so that only room occupants can obtain TURN credentials.
|
||||
--
|
||||
-- Component "metadata.jitmeet.example.com" "room_metadata_component"
|
||||
-- muc_component = "conference.jitmeet.example.com"
|
||||
-- breakout_rooms_component = "breakout.jitmeet.example.com"
|
||||
local array = require 'util.array';
|
||||
local filters = require 'util.filters';
|
||||
local jid_node = require 'util.jid'.node;
|
||||
local json = require 'util.json';
|
||||
local st = require 'util.stanza';
|
||||
local jid = require 'util.jid';
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local process_host_module = util.process_host_module;
|
||||
local table_shallow_copy = util.table_shallow_copy;
|
||||
local table_add = util.table_add;
|
||||
local table_equals = util.table_equals;
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local get_occupant_by_real_jid = util.get_occupant_by_real_jid;
|
||||
|
||||
local dt = require "util.datetime";
|
||||
local ext_services = module:depends("external_services");
|
||||
local get_services = ext_services.get_services;
|
||||
|
||||
local MUC_NS = 'http://jabber.org/protocol/muc';
|
||||
local COMPONENT_IDENTITY_TYPE = 'room_metadata';
|
||||
local FORM_KEY = 'muc#roominfo_jitsimetadata';
|
||||
|
||||
local muc_component_host = module:get_option_string('muc_component');
|
||||
|
||||
if muc_component_host == nil then
|
||||
module:log('error', 'No muc_component specified. No muc to operate on!');
|
||||
return;
|
||||
end
|
||||
|
||||
local main_virtual_host = module:get_option_string('muc_mapper_domain_base');
|
||||
if not main_virtual_host then
|
||||
module:log('warn', 'No muc_mapper_domain_base option set.');
|
||||
return;
|
||||
end
|
||||
|
||||
local breakout_rooms_component_host = module:get_option_string('breakout_rooms_component');
|
||||
-- TODO: flip default once mobile clients update to latest ljm that supports transition of turn data in metadata
|
||||
local extdisco_occpuant_check = module:get_option_boolean('extdisco_occpuant_check', false);
|
||||
|
||||
-- Keys that clients (including moderators) are not allowed to set via the
|
||||
-- metadata message API. These are either server-controlled fields or keys whose
|
||||
-- values are assembled and injected by the server at broadcast time.
|
||||
local blocked_metadata_keys = module:get_option_set('room_metadata_blocked_keys', {
|
||||
'allownersEnabled',
|
||||
'asyncTranscription',
|
||||
'audioTranslationAvailable',
|
||||
'audioTranslationListenerCounts',
|
||||
'audioTranslationRequests',
|
||||
'conferencePresetsServiceEnabled',
|
||||
'dialinEnabled',
|
||||
'moderators',
|
||||
'participants',
|
||||
'participantsSoftLimit',
|
||||
'services',
|
||||
'transcriberType',
|
||||
'transcription',
|
||||
'visitorsEnabled',
|
||||
});
|
||||
|
||||
module:log("info", "Starting room metadata for %s", muc_component_host);
|
||||
|
||||
local main_muc_module;
|
||||
|
||||
-- Utility functions
|
||||
|
||||
-- Returns json string with the metadata for the room.
|
||||
-- @param room The room object.
|
||||
-- @param metadata Optional metadata to use instead of the room's jitsiMetadata.
|
||||
function getMetadataJSON(room, metadata)
|
||||
local res, error = json.encode({
|
||||
type = COMPONENT_IDENTITY_TYPE,
|
||||
metadata = metadata or room.jitsiMetadata or {}
|
||||
});
|
||||
|
||||
if not res then
|
||||
module:log('error', 'Error encoding data room:%s', room.jid, error);
|
||||
end
|
||||
|
||||
return res;
|
||||
end
|
||||
|
||||
-- Returns a shallow copy of the room metadata with jicofo-only fields removed,
|
||||
-- suitable for sending to regular (non-admin) occupants. Currently this strips
|
||||
-- transcription.httpHeaders, which may carry secrets (e.g. a custom OpenAI API
|
||||
-- key) meant only for jicofo and the transcriber.
|
||||
function get_client_metadata(room)
|
||||
local metadata = table_shallow_copy(room.jitsiMetadata) or {};
|
||||
|
||||
if metadata.transcription and metadata.transcription.httpHeaders then
|
||||
local transcription_copy = table_shallow_copy(metadata.transcription);
|
||||
transcription_copy.httpHeaders = nil;
|
||||
metadata.transcription = transcription_copy;
|
||||
end
|
||||
|
||||
return metadata;
|
||||
end
|
||||
|
||||
function broadcastMetadata(room)
|
||||
-- Build the client-safe payload once and reuse it for every non-admin
|
||||
-- occupant; admins get a freshly assembled payload in send_metadata.
|
||||
local client_json = getMetadataJSON(room, get_client_metadata(room));
|
||||
if not client_json then
|
||||
return;
|
||||
end
|
||||
|
||||
for _, occupant in room:each_occupant() do
|
||||
send_metadata(occupant, room, client_json)
|
||||
end
|
||||
end
|
||||
|
||||
function send_metadata(occupant, room, json_msg, include_services)
|
||||
if not json_msg or is_admin(occupant.bare_jid) then
|
||||
local metadata_to_send;
|
||||
|
||||
if is_admin(occupant.bare_jid) then
|
||||
-- jicofo gets the full metadata (including transcription httpHeaders)
|
||||
-- plus the main meeting participant and moderator lists.
|
||||
metadata_to_send = table_shallow_copy(room.jitsiMetadata) or {};
|
||||
|
||||
local participants;
|
||||
local moderators = array();
|
||||
|
||||
if room._data.participants then
|
||||
participants = array();
|
||||
participants:append(room._data.participants);
|
||||
end
|
||||
|
||||
if room._data.moderators then
|
||||
moderators:append(room._data.moderators);
|
||||
end
|
||||
|
||||
metadata_to_send.participants = participants;
|
||||
metadata_to_send.moderators = moderators;
|
||||
|
||||
-- The aggregated live-translation request map is for jicofo only.
|
||||
-- It is kept on room._data (never in jitsiMetadata) so it is never
|
||||
-- broadcast to regular clients.
|
||||
metadata_to_send.audioTranslationRequests = room._data.audioTranslationRequests;
|
||||
|
||||
-- Neutral, default-open extension point: an external module may contribute
|
||||
-- additional jicofo-only metadata fields by returning a table from this
|
||||
-- event -- for instance per-room translator connect headers. Fired inside
|
||||
-- the admin branch, so injected fields reach only jicofo and are never
|
||||
-- broadcast to client occupants. No handler means nothing is added; this
|
||||
-- module holds no token/entitlement logic of its own.
|
||||
local admin_extra = main_muc_module and main_muc_module:fire_event(
|
||||
'jitsi-room-metadata-admin-extra', { room = room; });
|
||||
if type(admin_extra) == 'table' then
|
||||
for k, v in pairs(admin_extra) do
|
||||
metadata_to_send[k] = v;
|
||||
end
|
||||
end
|
||||
|
||||
module:log('info', 'Sending metadata to jicofo room=%s,meeting_id=%s', room.jid, room._data.meetingId);
|
||||
else
|
||||
-- regular occupants get a sanitized copy (jicofo-only fields removed)
|
||||
metadata_to_send = get_client_metadata(room);
|
||||
|
||||
if include_services then
|
||||
metadata_to_send.services = {};
|
||||
|
||||
for _, srv in ipairs(get_services()) do
|
||||
table.insert(metadata_to_send.services, {
|
||||
type = srv.type;
|
||||
transport = srv.transport;
|
||||
host = srv.host;
|
||||
port = srv.port and string.format('%d', srv.port) or nil;
|
||||
username = srv.username;
|
||||
password = srv.password;
|
||||
expires = srv.expires and dt.datetime(srv.expires) or nil;
|
||||
restricted = srv.restricted and '1' or nil;
|
||||
});
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
json_msg = getMetadataJSON(room, metadata_to_send);
|
||||
end
|
||||
|
||||
local stanza = st.message({ from = module.host; to = occupant.jid; })
|
||||
:tag('json-message', {
|
||||
xmlns = 'http://jitsi.org/jitmeet',
|
||||
room = internal_room_jid_match_rewrite(room.jid)
|
||||
}):text(json_msg):up();
|
||||
module:send(stanza);
|
||||
end
|
||||
|
||||
-- Handling events
|
||||
|
||||
function room_created(event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
if not room.jitsiMetadata then
|
||||
room.jitsiMetadata = {};
|
||||
end
|
||||
|
||||
room.sent_initial_metadata = {};
|
||||
end
|
||||
|
||||
function on_message(event)
|
||||
local session = event.origin;
|
||||
|
||||
-- Check the type of the incoming stanza to avoid loops:
|
||||
if event.stanza.attr.type == 'error' then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
if not session or not session.jitsi_web_query_room then
|
||||
return false;
|
||||
end
|
||||
|
||||
local message = event.stanza:get_child(COMPONENT_IDENTITY_TYPE, 'http://jitsi.org/jitmeet');
|
||||
if not message then
|
||||
return true;
|
||||
end
|
||||
|
||||
local messageText = message:get_text();
|
||||
if not messageText or messageText == '' then
|
||||
return true;
|
||||
end
|
||||
|
||||
local roomJid = message.attr.room;
|
||||
if not roomJid then
|
||||
return true;
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(roomJid));
|
||||
|
||||
if not room then
|
||||
module:log('warn', 'No room found for %s/%s',
|
||||
session.jitsi_web_query_prefix, session.jitsi_web_query_room);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- check that the participant requesting is a moderator and is an occupant in the room
|
||||
local from = event.stanza.attr.from;
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
|
||||
if not occupant then
|
||||
module:log('warn', 'No occupant %s found for %s', from, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
local jsonData, error = json.decode(messageText);
|
||||
if jsonData == nil then -- invalid JSON
|
||||
module:log("error", "Invalid JSON message: %s error:%s", messageText, error);
|
||||
return false;
|
||||
end
|
||||
|
||||
if type(jsonData.key) ~= 'string' then
|
||||
module:log('error', 'Invalid JSON payload, key is not a string: %s', messageText);
|
||||
return true;
|
||||
end
|
||||
|
||||
if jsonData.key == nil or jsonData.data == nil or jsonData.data == json.null then
|
||||
module:log("error", "Invalid JSON payload, key or data are missing: %s", messageText);
|
||||
return false;
|
||||
end
|
||||
|
||||
-- Fire 'jitsi-metadata-allow-moderation' so other modules can override the
|
||||
-- default moderator-only access control on a per-key basis.
|
||||
-- false → deny the update unconditionally (e.g. key restricted to server)
|
||||
-- non-nil → allow the update; the returned value replaces jsonData.data
|
||||
-- (lets a hook sanitise or filter the payload before it is stored)
|
||||
-- nil → no opinion; fall through to the default moderator-only check
|
||||
local res = module:context(main_virtual_host):fire_event('jitsi-metadata-allow-moderation',
|
||||
{ room = room; actor = occupant; key = jsonData.key ; data = jsonData.data; session = session; });
|
||||
|
||||
if res == false then
|
||||
module:log('warn', 'Occupant %s features do not allow this operation(%s) for %s', from, jsonData.key, room.jid);
|
||||
return false;
|
||||
elseif res ~= nil then
|
||||
jsonData.data = res;
|
||||
else
|
||||
if occupant.role ~= 'moderator' then
|
||||
module:log('warn', 'Occupant %s is not moderator and not allowed this operation(%s) for %s',
|
||||
from, jsonData.key, room.jid);
|
||||
return false;
|
||||
end
|
||||
end
|
||||
|
||||
if blocked_metadata_keys:contains(jsonData.key) then
|
||||
module:log('warn', 'Occupant %s attempted to set blocked metadata key "%s" in room:%s',
|
||||
from, jsonData.key, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
local old_value = room.jitsiMetadata[jsonData.key];
|
||||
if not table_equals(old_value, jsonData.data) then
|
||||
room.jitsiMetadata[jsonData.key] = jsonData.data;
|
||||
|
||||
module:log('info', 'Metadata key "%s" updated by %s in room:%s,meeting_id:%s', jsonData.key, from, room.jid, room._data.meetingId);
|
||||
broadcastMetadata(room);
|
||||
|
||||
-- fire and event for the change
|
||||
main_muc_module:fire_event('jitsi-metadata-updated', { room = room; actor = occupant; key = jsonData.key; });
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
-- Module operations
|
||||
|
||||
-- handle messages to this component
|
||||
module:hook("message/host", on_message);
|
||||
|
||||
-- operates on already loaded main muc module
|
||||
function process_main_muc_loaded(main_muc, host_module)
|
||||
main_muc_module = host_module;
|
||||
|
||||
module:log('debug', 'Main muc loaded');
|
||||
module:log("info", "Hook to muc events on %s", muc_component_host);
|
||||
|
||||
host_module:hook("muc-room-created", room_created, -1);
|
||||
|
||||
-- The room metadata was updated internally (from another module).
|
||||
host_module:hook("room-metadata-changed", function(event)
|
||||
local room = event.room;
|
||||
local json_msg = getMetadataJSON(room);
|
||||
|
||||
local log_json = json_msg;
|
||||
if room.jitsiMetadata and room.jitsiMetadata.transcription
|
||||
and room.jitsiMetadata.transcription.httpHeaders then
|
||||
local metadata_copy = table_shallow_copy(room.jitsiMetadata);
|
||||
local transcription_copy = table_shallow_copy(metadata_copy.transcription);
|
||||
local headers_redacted = {};
|
||||
for k, _ in pairs(transcription_copy.httpHeaders) do
|
||||
headers_redacted[k] = '***';
|
||||
end
|
||||
transcription_copy.httpHeaders = headers_redacted;
|
||||
metadata_copy.transcription = transcription_copy;
|
||||
log_json = getMetadataJSON(room, metadata_copy) or log_json;
|
||||
end
|
||||
module:log('info', 'Metadata changed internally in room:%s,meeting_id:%s - broadcasting data:%s', room.jid, room._data.meetingId, log_json);
|
||||
broadcastMetadata(room);
|
||||
end);
|
||||
|
||||
-- TODO: Once clients update to read/write metadata for startMuted policy we can drop this
|
||||
-- this is to convert presence settings from old clients to metadata
|
||||
host_module:hook('muc-broadcast-presence', function (event)
|
||||
local actor, occupant, room, stanza, x = event.actor, event.occupant, event.room, event.stanza, event.x;
|
||||
|
||||
if is_healthcheck_room(room.jid) or occupant.role ~= 'moderator' then
|
||||
return;
|
||||
end
|
||||
|
||||
local startMuted = stanza:get_child('startmuted', 'http://jitsi.org/jitmeet/start-muted');
|
||||
|
||||
if not startMuted then
|
||||
return;
|
||||
end
|
||||
|
||||
if not room.jitsiMetadata then
|
||||
room.jitsiMetadata = {};
|
||||
end
|
||||
|
||||
local startMutedMetadata = room.jitsiMetadata.startMuted or {};
|
||||
|
||||
local audioNewValue = startMuted.attr.audio == 'true';
|
||||
local videoNewValue = startMuted.attr.video == 'true';
|
||||
local send_update = false;
|
||||
|
||||
if startMutedMetadata.audio ~= audioNewValue then
|
||||
startMutedMetadata.audio = audioNewValue;
|
||||
send_update = true;
|
||||
end
|
||||
if startMutedMetadata.video ~= videoNewValue then
|
||||
startMutedMetadata.video = videoNewValue;
|
||||
send_update = true;
|
||||
end
|
||||
|
||||
if send_update then
|
||||
room.jitsiMetadata.startMuted = startMutedMetadata;
|
||||
|
||||
host_module:fire_event('room-metadata-changed', { room = room; });
|
||||
end
|
||||
end);
|
||||
|
||||
-- The the connection jid for authenticated users (like jicofo) stays the same,
|
||||
-- so leaving and re-joining will result not sending metatadata again.
|
||||
-- Make sure we clear the sent_initial_metadata entry for the occupant on leave.
|
||||
host_module:hook("muc-occupant-left", function(event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
|
||||
if room.sent_initial_metadata then
|
||||
room.sent_initial_metadata[jid.bare(event.occupant.jid)] = nil;
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
-- process or waits to process the main muc component
|
||||
process_host_module(muc_component_host, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_main_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
-- breakout rooms support
|
||||
function process_breakout_muc_loaded(breakout_muc, host_module)
|
||||
module:log('debug', 'Breakout rooms muc loaded');
|
||||
module:log("info", "Hook to muc events on %s", breakout_rooms_component_host);
|
||||
|
||||
host_module:hook("muc-room-created", room_created, -1);
|
||||
end
|
||||
|
||||
if breakout_rooms_component_host then
|
||||
process_host_module(breakout_rooms_component_host, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
process_breakout_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_breakout_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
end
|
||||
|
||||
-- checks whether the event is from an occupant of the room specified in the session,
|
||||
-- if not, it returns an error and stops the processing of the event
|
||||
local function check_occupant(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
local room_name = origin.jitsi_web_query_room;
|
||||
if not room_name then
|
||||
module:log('warn', 'No room in session: %s', origin.full_jid);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
return true;
|
||||
end
|
||||
|
||||
local subdomain = origin.jitsi_web_query_prefix or '';
|
||||
local room = get_room_by_name_and_subdomain(room_name, subdomain);
|
||||
if not room then
|
||||
module:log('warn', 'Room not found (%s/%s) for %s', subdomain, room_name, origin.full_jid);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
return true;
|
||||
end
|
||||
|
||||
local from = stanza.attr.from or origin.full_jid;
|
||||
local occupant = get_occupant_by_real_jid(room, from);
|
||||
|
||||
if not occupant then
|
||||
module:log('warn', '%s not an occupant of %s/%s', from, subdomain, room_name);
|
||||
origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
-- Send a message update for metadata before sending the first self presence
|
||||
function filter_stanza(stanza, session)
|
||||
if not stanza.attr or not stanza.attr.to or stanza.name ~= 'presence' or stanza.attr.type == 'unavailable' then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local bare_to = jid.bare(stanza.attr.to);
|
||||
local muc_x = stanza:get_child('x', MUC_NS..'#user');
|
||||
if not muc_x or not presence_check_status(muc_x, '110') then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(jid.bare(stanza.attr.from)));
|
||||
|
||||
if not room or not room.sent_initial_metadata or is_healthcheck_room(room.jid) then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
if room.sent_initial_metadata[bare_to] then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
local occupant;
|
||||
for _, o in room:each_occupant() do
|
||||
if o.bare_jid == bare_to then
|
||||
occupant = o;
|
||||
end
|
||||
end
|
||||
|
||||
if not occupant then
|
||||
module:log('warn', 'No occupant %s found for %s', bare_to, room.jid);
|
||||
return stanza;
|
||||
end
|
||||
|
||||
room.sent_initial_metadata[bare_to] = true;
|
||||
|
||||
send_metadata(occupant, room, nil, true);
|
||||
|
||||
return stanza;
|
||||
end
|
||||
function filter_session(session)
|
||||
-- domain mapper is filtering on default priority 0
|
||||
-- allowners is -1 and we need it after that, permissions is -2
|
||||
filters.add_filter(session, 'stanzas/out', filter_stanza, -3);
|
||||
end
|
||||
|
||||
-- enable filtering presences
|
||||
filters.add_filter_hook(filter_session);
|
||||
|
||||
process_host_module(main_virtual_host, function(host_module)
|
||||
local main_host_module = module:context(host_module.host);
|
||||
main_host_module:fire_event('jitsi-add-identity', {
|
||||
name = 'room_metadata'; host = module.host;
|
||||
});
|
||||
|
||||
if extdisco_occpuant_check then
|
||||
-- Hook at priority 100 so we run before mod_external_services (default priority 0) for both XEP-0215 v2 and legacy v1.
|
||||
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:2:services', check_occupant, 100);
|
||||
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:2:service', check_occupant, 100);
|
||||
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:1:services', check_occupant, 100);
|
||||
main_host_module:hook('iq-get/host/urn:xmpp:extdisco:1:service', check_occupant, 100);
|
||||
end
|
||||
end);
|
||||
174
resources/prosody-plugins/mod_roster_command.lua
Normal file
174
resources/prosody-plugins/mod_roster_command.lua
Normal file
@@ -0,0 +1,174 @@
|
||||
-----------------------------------------------------------
|
||||
-- mod_roster_command: Manage rosters through prosodyctl
|
||||
-- version 0.02
|
||||
-----------------------------------------------------------
|
||||
-- Copyright (C) 2011 Matthew Wild
|
||||
-- Copyright (C) 2011 Adam Nielsen
|
||||
--
|
||||
-- This project is MIT/X11 licensed. Please see the
|
||||
-- COPYING file in the source package for more information.
|
||||
-----------------------------------------------------------
|
||||
--
|
||||
-- Source: https://modules.prosody.im/mod_roster_command.html (community module,
|
||||
-- minor local patch to handle bare host JIDs in subscribe/unsubscribe).
|
||||
--
|
||||
-- This module is NOT loaded in prosody.cfg.lua. It is a prosodyctl command
|
||||
-- plugin, invoked at provisioning time via:
|
||||
-- prosodyctl mod_roster_command subscribe <jid1> <jid2>
|
||||
-- Used by Ansible to subscribe focus.<domain> to focus@auth.<domain>.
|
||||
-- Only needed for prosody versions prior 13
|
||||
-----------------------------------------------------------
|
||||
|
||||
if module.host ~= "*" then
|
||||
module:log("error", "Do not load this module in Prosody, for correct usage see: https://modules.prosody.im/mod_roster_command.html");
|
||||
return;
|
||||
end
|
||||
|
||||
|
||||
-- Workaround for lack of util.startup...
|
||||
local prosody = _G.prosody;
|
||||
local hosts = prosody.hosts;
|
||||
prosody.bare_sessions = prosody.bare_sessions or {};
|
||||
_G.bare_sessions = _G.bare_sessions or prosody.bare_sessions;
|
||||
|
||||
local usermanager = require "core.usermanager";
|
||||
local rostermanager = require "core.rostermanager";
|
||||
local storagemanager = require "core.storagemanager";
|
||||
local jid = require "util.jid";
|
||||
local warn = require"util.prosodyctl".show_warning;
|
||||
|
||||
-- Make a *one-way* subscription. User will see when contact is online,
|
||||
-- contact will not see when user is online.
|
||||
function subscribe(user_jid, contact_jid)
|
||||
local user_username, user_host = jid.split(user_jid);
|
||||
local contact_username, contact_host = jid.split(contact_jid);
|
||||
if not hosts[user_host] then
|
||||
warn("The host '%s' is not configured for this server.", user_host);
|
||||
return;
|
||||
end
|
||||
if hosts[user_host].users.name == "null" then
|
||||
storagemanager.initialize_host(user_host);
|
||||
usermanager.initialize_host(user_host);
|
||||
end
|
||||
-- Update user's roster to say subscription request is pending. Bare hosts (e.g. components) don't have rosters.
|
||||
if user_username ~= nil then
|
||||
rostermanager.set_contact_pending_out(user_username, user_host, contact_jid);
|
||||
end
|
||||
if hosts[contact_host] then
|
||||
if contact_host ~= user_host and hosts[contact_host].users.name == "null" then
|
||||
storagemanager.initialize_host(contact_host);
|
||||
usermanager.initialize_host(contact_host);
|
||||
end
|
||||
-- Update contact's roster to say subscription request is pending...
|
||||
rostermanager.set_contact_pending_in(contact_username, contact_host, user_jid);
|
||||
-- Update contact's roster to say subscription request approved...
|
||||
rostermanager.subscribed(contact_username, contact_host, user_jid);
|
||||
-- Update user's roster to say subscription request approved. Bare hosts (e.g. components) don't have rosters.
|
||||
if user_username ~= nil then
|
||||
rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Make a mutual subscription between jid1 and jid2. Each JID will see
|
||||
-- when the other one is online.
|
||||
function subscribe_both(jid1, jid2)
|
||||
subscribe(jid1, jid2);
|
||||
subscribe(jid2, jid1);
|
||||
end
|
||||
|
||||
-- Unsubscribes user from contact (not contact from user, if subscribed).
|
||||
function unsubscribe(user_jid, contact_jid)
|
||||
local user_username, user_host = jid.split(user_jid);
|
||||
local contact_username, contact_host = jid.split(contact_jid);
|
||||
if not hosts[user_host] then
|
||||
warn("The host '%s' is not configured for this server.", user_host);
|
||||
return;
|
||||
end
|
||||
if hosts[user_host].users.name == "null" then
|
||||
storagemanager.initialize_host(user_host);
|
||||
usermanager.initialize_host(user_host);
|
||||
end
|
||||
-- Update user's roster to say subscription is cancelled...
|
||||
rostermanager.unsubscribe(user_username, user_host, contact_jid);
|
||||
if hosts[contact_host] then
|
||||
if contact_host ~= user_host and hosts[contact_host].users.name == "null" then
|
||||
storagemanager.initialize_host(contact_host);
|
||||
usermanager.initialize_host(contact_host);
|
||||
end
|
||||
-- Update contact's roster to say subscription is cancelled...
|
||||
rostermanager.unsubscribed(contact_username, contact_host, user_jid);
|
||||
end
|
||||
end
|
||||
|
||||
-- Cancel any subscription in either direction.
|
||||
function unsubscribe_both(jid1, jid2)
|
||||
unsubscribe(jid1, jid2);
|
||||
unsubscribe(jid2, jid1);
|
||||
end
|
||||
|
||||
-- Set the name shown and group used in the contact list
|
||||
function rename(user_jid, contact_jid, contact_nick, contact_group)
|
||||
local user_username, user_host = jid.split(user_jid);
|
||||
if not hosts[user_host] then
|
||||
warn("The host '%s' is not configured for this server.", user_host);
|
||||
return;
|
||||
end
|
||||
if hosts[user_host].users.name == "null" then
|
||||
storagemanager.initialize_host(user_host);
|
||||
usermanager.initialize_host(user_host);
|
||||
end
|
||||
|
||||
-- Load user's roster and find the contact
|
||||
local roster = rostermanager.load_roster(user_username, user_host);
|
||||
local item = roster[contact_jid];
|
||||
if item then
|
||||
if contact_nick then
|
||||
item.name = contact_nick;
|
||||
end
|
||||
if contact_group then
|
||||
item.groups = {}; -- Remove from all current groups
|
||||
item.groups[contact_group] = true;
|
||||
end
|
||||
rostermanager.save_roster(user_username, user_host, roster);
|
||||
end
|
||||
end
|
||||
|
||||
function remove(user_jid, contact_jid)
|
||||
unsubscribe_both(user_jid, contact_jid);
|
||||
local user_username, user_host = jid.split(user_jid);
|
||||
local roster = rostermanager.load_roster(user_username, user_host);
|
||||
roster[contact_jid] = nil;
|
||||
rostermanager.save_roster(user_username, user_host, roster);
|
||||
end
|
||||
|
||||
function module.command(arg)
|
||||
local command = arg[1];
|
||||
if not command then
|
||||
warn("Valid subcommands: (un)subscribe(_both) | rename");
|
||||
return 0;
|
||||
end
|
||||
table.remove(arg, 1);
|
||||
if command == "subscribe" then
|
||||
subscribe(arg[1], arg[2]);
|
||||
return 0;
|
||||
elseif command == "subscribe_both" then
|
||||
subscribe_both(arg[1], arg[2]);
|
||||
return 0;
|
||||
elseif command == "unsubscribe" then
|
||||
unsubscribe(arg[1], arg[2]);
|
||||
return 0;
|
||||
elseif command == "unsubscribe_both" then
|
||||
unsubscribe_both(arg[1], arg[2]);
|
||||
return 0;
|
||||
elseif command == "remove" then
|
||||
remove(arg[1], arg[2]);
|
||||
return 0;
|
||||
elseif command == "rename" then
|
||||
rename(arg[1], arg[2], arg[3], arg[4]);
|
||||
return 0;
|
||||
else
|
||||
warn("Unknown command: %s", command);
|
||||
return 1;
|
||||
end
|
||||
end
|
||||
47
resources/prosody-plugins/mod_roster_command.patch
Normal file
47
resources/prosody-plugins/mod_roster_command.patch
Normal file
@@ -0,0 +1,47 @@
|
||||
# HG changeset patch
|
||||
# User Boris Grozev <boris@jitsi.org>
|
||||
# Date 1609874100 21600
|
||||
# Tue Jan 05 13:15:00 2021 -0600
|
||||
# Node ID f646babfc401494ff33f2126ef6c4df541ebf846
|
||||
# Parent 456b9f608fcf9667cfba1bd7bf9eba2151af50d0
|
||||
mod_roster_command: Fix subscription when the "user JID" is a bare domain.
|
||||
|
||||
Do not attempt to update the roster when the user is bare domain (e.g. a
|
||||
component), since they don't have rosters and the attempt results in an error:
|
||||
|
||||
$ prosodyctl mod_roster_command subscribe proxy.example.com contact@example.com
|
||||
xxxxxxxxxxFailed to execute command: Error: /usr/lib/prosody/core/rostermanager.lua:104: attempt to concatenate local 'username' (a nil value)
|
||||
stack traceback:
|
||||
/usr/lib/prosody/core/rostermanager.lua:104: in function 'load_roster'
|
||||
/usr/lib/prosody/core/rostermanager.lua:305: in function 'set_contact_pending_out'
|
||||
mod_roster_command.lua:44: in function 'subscribe'
|
||||
|
||||
diff -r 456b9f608fcf -r f646babfc401 mod_roster_command/mod_roster_command.lua
|
||||
--- a/mod_roster_command/mod_roster_command.lua Tue Jan 05 13:49:50 2021 +0000
|
||||
+++ b/mod_roster_command/mod_roster_command.lua Tue Jan 05 13:15:00 2021 -0600
|
||||
@@ -40,8 +40,10 @@
|
||||
storagemanager.initialize_host(user_host);
|
||||
usermanager.initialize_host(user_host);
|
||||
end
|
||||
- -- Update user's roster to say subscription request is pending...
|
||||
- rostermanager.set_contact_pending_out(user_username, user_host, contact_jid);
|
||||
+ -- Update user's roster to say subscription request is pending. Bare hosts (e.g. components) don't have rosters.
|
||||
+ if user_username ~= nil then
|
||||
+ rostermanager.set_contact_pending_out(user_username, user_host, contact_jid);
|
||||
+ end
|
||||
if hosts[contact_host] then
|
||||
if contact_host ~= user_host and hosts[contact_host].users.name == "null" then
|
||||
storagemanager.initialize_host(contact_host);
|
||||
@@ -51,8 +53,10 @@
|
||||
rostermanager.set_contact_pending_in(contact_username, contact_host, user_jid);
|
||||
-- Update contact's roster to say subscription request approved...
|
||||
rostermanager.subscribed(contact_username, contact_host, user_jid);
|
||||
- -- Update user's roster to say subscription request approved...
|
||||
- rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid);
|
||||
+ -- Update user's roster to say subscription request approved. Bare hosts (e.g. components) don't have rosters.
|
||||
+ if user_username ~= nil then
|
||||
+ rostermanager.process_inbound_subscription_approval(user_username, user_host, contact_jid);
|
||||
+ end
|
||||
end
|
||||
end
|
||||
|
||||
26
resources/prosody-plugins/mod_s2s_whitelist.lua
Normal file
26
resources/prosody-plugins/mod_s2s_whitelist.lua
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Using as a base version https://hg.prosody.im/prosody-modules/file/c1a8ce147885/mod_s2s_whitelist/mod_s2s_whitelist.lua
|
||||
local st = require "util.stanza";
|
||||
|
||||
local whitelist = module:get_option_inherited_set("s2s_whitelist", {});
|
||||
|
||||
module:hook("route/remote", function (event)
|
||||
if not whitelist:contains(event.to_host) then
|
||||
-- make sure we do not send error replies for errors
|
||||
if event.stanza.attr.type == 'error' then
|
||||
module:log('debug', 'Not whitelisted destination domain for an error: %s', event.stanza);
|
||||
return true;
|
||||
end
|
||||
|
||||
module:send(st.error_reply(event.stanza, "cancel", "not-allowed", "Communication with this domain is restricted"));
|
||||
return true;
|
||||
end
|
||||
end, 100);
|
||||
|
||||
module:hook("s2s-stream-features", function (event)
|
||||
if not whitelist:contains(event.origin.from_host) then
|
||||
event.origin:close({
|
||||
condition = "policy-violation";
|
||||
text = "Communication with this domain is restricted";
|
||||
});
|
||||
end
|
||||
end, 1000);
|
||||
20
resources/prosody-plugins/mod_s2sout_override.lua
Normal file
20
resources/prosody-plugins/mod_s2sout_override.lua
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Using as a base version https://hg.prosody.im/prosody-modules/file/6cf2f32dbf40/mod_s2sout_override/mod_s2sout_override.lua
|
||||
--% requires: s2sout-pre-connect-event
|
||||
|
||||
local url = require"socket.url";
|
||||
local basic_resolver = require "net.resolvers.basic";
|
||||
|
||||
local override_for = module:get_option(module.name, {}); -- map of host to "tcp://example.com:5269"
|
||||
|
||||
module:hook("s2sout-pre-connect", function(event)
|
||||
local override = override_for[event.session.to_host];
|
||||
if type(override) == "string" then
|
||||
override = url.parse(override);
|
||||
end
|
||||
if type(override) == "table" and override.scheme == "tcp" and type(override.host) == "string" then
|
||||
event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5269, override.scheme, {});
|
||||
elseif type(override) == "table" and override.scheme == "tls" and type(override.host) == "string" then
|
||||
event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5270, "tcp",
|
||||
{ servername = event.session.to_host; sslctx = event.session.ssl_ctx });
|
||||
end
|
||||
end);
|
||||
20
resources/prosody-plugins/mod_secure_interfaces.lua
Normal file
20
resources/prosody-plugins/mod_secure_interfaces.lua
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Using version https://hg.prosody.im/prosody-modules/file/6c806a99f802/mod_secure_interfaces/mod_secure_interfaces.lua
|
||||
local secure_interfaces = module:get_option_set("secure_interfaces", { "127.0.0.1", "::1" });
|
||||
|
||||
module:hook("stream-features", function (event)
|
||||
local session = event.origin;
|
||||
if session.type ~= "c2s_unauthed" then return; end
|
||||
local socket = session.conn:socket();
|
||||
if not socket.getsockname then
|
||||
module:log("debug", "Unable to determine local address of incoming connection");
|
||||
return;
|
||||
end
|
||||
local localip = socket:getsockname();
|
||||
if secure_interfaces:contains(localip) then
|
||||
-- module:log("debug", "Marking session from %s to %s as secure", session.ip or "[?]", localip);
|
||||
session.secure = true;
|
||||
session.conn.starttls = false;
|
||||
-- else
|
||||
-- module:log("debug", "Not marking session from %s to %s as secure", session.ip or "[?]", localip);
|
||||
end
|
||||
end, 2500);
|
||||
143
resources/prosody-plugins/mod_short_lived_token.lua
Normal file
143
resources/prosody-plugins/mod_short_lived_token.lua
Normal file
@@ -0,0 +1,143 @@
|
||||
-- to be enabled under the main virtual host with all required settings
|
||||
-- short_lived_token = {
|
||||
-- issuer = 'myissuer';
|
||||
-- accepted_audiences = { 'file-sharing' };
|
||||
-- key_path = '/etc/prosody/short_lived_token.key';
|
||||
-- key_id = 'my_kid';
|
||||
-- ttl_seconds = 30;
|
||||
-- };
|
||||
-- The key in key_path can be generated via: openssl genrsa -out $PRIVATE_KEY_PATH 2048
|
||||
-- And you can get the public key from it, which can be used ot verify those tokens via:
|
||||
-- openssl rsa -in $PRIVATE_KEY_PATH -pubout -out $PUBLIC_KEY_PATH
|
||||
|
||||
local jid = require 'util.jid';
|
||||
local st = require 'util.stanza';
|
||||
local jwt = module:require 'luajwtjitsi';
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_vpaas = util.is_vpaas;
|
||||
local process_host_module = util.process_host_module;
|
||||
local table_find = util.table_find;
|
||||
local create_throttle = require 'util.throttle'.create;
|
||||
|
||||
local SERVICE_TYPE = 'short-lived-token';
|
||||
local options = module:get_option('short_lived_token');
|
||||
|
||||
if not (options.issuer and options.accepted_audiences
|
||||
and options.key_path and options.key_id and options.ttl_seconds) then
|
||||
module:log('error', 'Missing required options for short_lived_token');
|
||||
return;
|
||||
end
|
||||
|
||||
local f = io.open(options.key_path, 'r');
|
||||
if f then
|
||||
options.key = f:read('*all');
|
||||
f:close();
|
||||
end
|
||||
|
||||
local accepted_requests = {};
|
||||
for _, host in pairs(options.accepted_audiences) do
|
||||
accepted_requests[string.format('%s:%s:0', SERVICE_TYPE, host)] = host;
|
||||
end
|
||||
|
||||
local server_region_name = module:get_option_string('region_name');
|
||||
|
||||
local main_muc_component_host = module:get_option_string('main_muc');
|
||||
if main_muc_component_host == nil then
|
||||
module:log('error', 'main_muc not configured. Cannot proceed.');
|
||||
return;
|
||||
end
|
||||
local main_muc_service;
|
||||
|
||||
function generateToken(session, audience, room, occupant)
|
||||
local t = os.time();
|
||||
local exp = t + options.ttl_seconds;
|
||||
local presence = occupant:get_presence(session.full_jid);
|
||||
local _, _, id = extract_subdomain(jid.node(room.jid));
|
||||
local sub = module.host;
|
||||
|
||||
if session.jitsi_web_query_prefix and session.jitsi_web_query_prefix ~= '' then
|
||||
sub = session.jitsi_web_query_prefix;
|
||||
end
|
||||
|
||||
local payload = {
|
||||
iss = options.issuer,
|
||||
aud = audience,
|
||||
nbf = t,
|
||||
exp = exp,
|
||||
sub = sub,
|
||||
context = {
|
||||
group = session.jitsi_meet_context_group or session.granted_jitsi_meet_context_group_id,
|
||||
user = session.jitsi_meet_context_user or {
|
||||
id = session.full_jid,
|
||||
name = presence:get_child_text('nick', 'http://jabber.org/protocol/nick'),
|
||||
email = presence:get_child_text("email") or nil,
|
||||
nick = jid.resource(occupant.nick)
|
||||
},
|
||||
features = session.jitsi_meet_context_features
|
||||
},
|
||||
room = session.jitsi_web_query_room,
|
||||
meeting_id = room._data.meetingId,
|
||||
granted_from = session.granted_jitsi_meet_context_user_id,
|
||||
customer_id = id or session.jitsi_meet_context_group or session.granted_jitsi_meet_context_group_id,
|
||||
backend_region = server_region_name,
|
||||
user_region = session.user_region
|
||||
};
|
||||
|
||||
local alg = 'RS256';
|
||||
local token, err = jwt.encode(payload, options.key, alg, { kid = options.key_id });
|
||||
if not err then
|
||||
return token
|
||||
else
|
||||
module:log('error', 'Error generating token: %s', err);
|
||||
return ''
|
||||
end
|
||||
end
|
||||
|
||||
module:hook('external_service/credentials', function (event)
|
||||
local requested_credentials, services, session, stanza
|
||||
= event.requested_credentials, event.services, event.origin, event.stanza;
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
|
||||
if not room then
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
|
||||
return;
|
||||
end
|
||||
|
||||
local occupant = room:get_occupant_by_real_jid(session.full_jid);
|
||||
if not occupant then
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed'));
|
||||
return;
|
||||
end
|
||||
|
||||
for request in requested_credentials do
|
||||
local host = accepted_requests[request];
|
||||
if host then
|
||||
services:push({
|
||||
type = SERVICE_TYPE;
|
||||
host = host;
|
||||
username = 'token';
|
||||
password = generateToken(session, host, room, occupant);
|
||||
expires = os.time() + options.ttl_seconds;
|
||||
restricted = true;
|
||||
transport = 'https';
|
||||
port = 443;
|
||||
});
|
||||
end
|
||||
end
|
||||
|
||||
end);
|
||||
|
||||
process_host_module(main_muc_component_host, function(host_module, host)
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
|
||||
if muc_module then
|
||||
main_muc_service = muc_module;
|
||||
else
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
main_muc_service = prosody.hosts[host].modules.muc;
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
395
resources/prosody-plugins/mod_speakerstats_component.lua
Normal file
395
resources/prosody-plugins/mod_speakerstats_component.lua
Normal file
@@ -0,0 +1,395 @@
|
||||
-- Tracks speaking time statistics for conference participants and broadcasts
|
||||
-- them to clients via XMPP messages. Receives incoming <message> stanzas
|
||||
-- carrying <speakerstats> (dominant speaker events) or <faceLandmarks> children
|
||||
-- from clients, and sends outgoing <message> stanzas with a <json-message>
|
||||
-- payload (type "speakerstats") to each occupant on request or on join.
|
||||
-- Aggregates per-room stats across Jicofo and MUC events, supporting Jibri
|
||||
-- recording sessions and face landmarks data.
|
||||
local util = module:require "util";
|
||||
local is_admin = util.is_admin;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local is_jibri = util.is_jibri;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local process_host_module = util.process_host_module;
|
||||
local is_focus_nick = util.is_focus_nick;
|
||||
local is_transcriber = util.is_transcriber;
|
||||
local jid_resource = require "util.jid".resource;
|
||||
local st = require "util.stanza";
|
||||
local socket = require "socket";
|
||||
local json = require 'cjson.safe';
|
||||
local jid_split = require 'util.jid'.split;
|
||||
local queue = require "util.queue";
|
||||
local array = require "util.array";
|
||||
|
||||
-- we use async to detect Prosody 0.10 and earlier
|
||||
local have_async = pcall(require, "util.async");
|
||||
if not have_async then
|
||||
module:log("warn", "speaker stats will not work with Prosody version 0.10 or less.");
|
||||
return;
|
||||
end
|
||||
|
||||
local muc_component_host = module:get_option_string("muc_component");
|
||||
local main_virtual_host = module:get_option_string("muc_mapper_domain_base");
|
||||
|
||||
if muc_component_host == nil or main_virtual_host == nil then
|
||||
module:log("error", "No muc_component specified. No muc to operate on!");
|
||||
return;
|
||||
end
|
||||
local breakout_room_component_host = "breakout." .. main_virtual_host;
|
||||
local face_landmarks_history_size = module:get_option_number('faceLandmarks_history_size', 20);
|
||||
|
||||
module:log("info", "Starting speakerstats for %s", muc_component_host);
|
||||
|
||||
local main_muc_service;
|
||||
|
||||
-- Searches all rooms in the main muc component that holds a breakout room
|
||||
-- caches it if found so we don't search it again
|
||||
-- we should not cache objects in _data as this is being serialized when calling room:save()
|
||||
local function get_main_room(breakout_room)
|
||||
if breakout_room.main_room then
|
||||
return breakout_room.main_room;
|
||||
end
|
||||
|
||||
-- let's search all rooms to find the main room
|
||||
for room in main_muc_service.each_room() do
|
||||
if room._data and room._data.breakout_rooms_active and room._data.breakout_rooms[breakout_room.jid] then
|
||||
breakout_room.main_room = room;
|
||||
return room;
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- receives messages from client currently connected to the room
|
||||
-- clients indicates their own dominant speaker events
|
||||
function on_message(event)
|
||||
-- Check the type of the incoming stanza to avoid loops:
|
||||
if event.stanza.attr.type == "error" then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
local speakerStats
|
||||
= event.stanza:get_child('speakerstats', 'http://jitsi.org/jitmeet');
|
||||
if speakerStats then
|
||||
local roomAddress = speakerStats.attr.room;
|
||||
local silence = speakerStats.attr.silence == 'true';
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(roomAddress));
|
||||
|
||||
if not room then
|
||||
module:log("warn", "No room found %s", roomAddress);
|
||||
return false;
|
||||
end
|
||||
|
||||
if not room.speakerStats then
|
||||
module:log("warn", "No speakerStats found for %s", roomAddress);
|
||||
return false;
|
||||
end
|
||||
|
||||
local roomSpeakerStats = room.speakerStats;
|
||||
local from = event.stanza.attr.from;
|
||||
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
if not occupant then
|
||||
module:log("warn", "No occupant %s found for %s", from, roomAddress);
|
||||
return false;
|
||||
end
|
||||
|
||||
local newDominantSpeaker = roomSpeakerStats[occupant.jid];
|
||||
local oldDominantSpeakerId = roomSpeakerStats['dominantSpeakerId'];
|
||||
|
||||
if oldDominantSpeakerId and occupant.jid ~= oldDominantSpeakerId then
|
||||
local oldDominantSpeaker = roomSpeakerStats[oldDominantSpeakerId];
|
||||
if oldDominantSpeaker then
|
||||
oldDominantSpeaker:setDominantSpeaker(false, false);
|
||||
end
|
||||
end
|
||||
|
||||
if newDominantSpeaker then
|
||||
newDominantSpeaker:setDominantSpeaker(true, silence);
|
||||
end
|
||||
|
||||
room.speakerStats['dominantSpeakerId'] = occupant.jid;
|
||||
end
|
||||
|
||||
local newFaceLandmarks = event.stanza:get_child('faceLandmarks', 'http://jitsi.org/jitmeet');
|
||||
|
||||
if newFaceLandmarks then
|
||||
local roomAddress = newFaceLandmarks.attr.room;
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(roomAddress));
|
||||
|
||||
if not room then
|
||||
module:log("warn", "No room found %s", roomAddress);
|
||||
return false;
|
||||
end
|
||||
if not room.speakerStats then
|
||||
module:log("warn", "No speakerStats found for %s", roomAddress);
|
||||
return false;
|
||||
end
|
||||
local from = event.stanza.attr.from;
|
||||
|
||||
local occupant = room:get_occupant_by_real_jid(from);
|
||||
if not occupant or not room.speakerStats[occupant.jid] then
|
||||
module:log("warn", "No occupant %s found for %s", from, roomAddress);
|
||||
return false;
|
||||
end
|
||||
local faceLandmarks = room.speakerStats[occupant.jid].faceLandmarks;
|
||||
faceLandmarks:push(
|
||||
{
|
||||
faceExpression = newFaceLandmarks.attr.faceExpression,
|
||||
timestamp = tonumber(newFaceLandmarks.attr.timestamp),
|
||||
duration = tonumber(newFaceLandmarks.attr.duration),
|
||||
})
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Start SpeakerStats implementation
|
||||
local SpeakerStats = {};
|
||||
SpeakerStats.__index = SpeakerStats;
|
||||
|
||||
function new_SpeakerStats(nick, context_user)
|
||||
return setmetatable({
|
||||
totalDominantSpeakerTime = 0;
|
||||
_dominantSpeakerStart = 0;
|
||||
_isSilent = false;
|
||||
_isDominantSpeaker = false;
|
||||
nick = nick;
|
||||
context_user = context_user;
|
||||
displayName = nil;
|
||||
faceLandmarks = queue.new(face_landmarks_history_size);
|
||||
}, SpeakerStats);
|
||||
end
|
||||
|
||||
-- Changes the dominantSpeaker data for current occupant
|
||||
-- saves start time if it is new dominat speaker
|
||||
-- or calculates and accumulates time of speaking
|
||||
function SpeakerStats:setDominantSpeaker(isNowDominantSpeaker, silence)
|
||||
-- module:log("debug", "set isDominant %s for %s", tostring(isNowDominantSpeaker), self.nick);
|
||||
|
||||
local now = socket.gettime()*1000;
|
||||
|
||||
if not self:isDominantSpeaker() and isNowDominantSpeaker and not silence then
|
||||
self._dominantSpeakerStart = now;
|
||||
elseif self:isDominantSpeaker() then
|
||||
if not isNowDominantSpeaker then
|
||||
if not self._isSilent then
|
||||
local timeElapsed = math.floor(now - self._dominantSpeakerStart);
|
||||
|
||||
self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed;
|
||||
self._dominantSpeakerStart = 0;
|
||||
end
|
||||
elseif self._isSilent and not silence then
|
||||
self._dominantSpeakerStart = now;
|
||||
elseif not self._isSilent and silence then
|
||||
local timeElapsed = math.floor(now - self._dominantSpeakerStart);
|
||||
|
||||
self.totalDominantSpeakerTime = self.totalDominantSpeakerTime + timeElapsed;
|
||||
self._dominantSpeakerStart = 0;
|
||||
end
|
||||
end
|
||||
|
||||
self._isDominantSpeaker = isNowDominantSpeaker;
|
||||
self._isSilent = silence;
|
||||
end
|
||||
|
||||
-- Returns true if the tracked user is currently a dominant speaker.
|
||||
function SpeakerStats:isDominantSpeaker()
|
||||
return self._isDominantSpeaker;
|
||||
end
|
||||
|
||||
-- Returns true if the tracked user is currently silent.
|
||||
function SpeakerStats:isSilent()
|
||||
return self._isSilent;
|
||||
end
|
||||
--- End SpeakerStats
|
||||
|
||||
-- create speakerStats for the room
|
||||
function room_created(event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return ;
|
||||
end
|
||||
room.speakerStats = {};
|
||||
room.speakerStats.sessionId = room._data.meetingId;
|
||||
end
|
||||
|
||||
-- create speakerStats for the breakout
|
||||
function breakout_room_created(event)
|
||||
local room = event.room;
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return ;
|
||||
end
|
||||
local main_room = get_main_room(room);
|
||||
room.speakerStats = {};
|
||||
room.speakerStats.isBreakout = true
|
||||
room.speakerStats.breakoutRoomId = jid_split(room.jid)
|
||||
room.speakerStats.sessionId = main_room._data.meetingId;
|
||||
end
|
||||
|
||||
-- Create SpeakerStats object for the joined user
|
||||
function occupant_joined(event)
|
||||
local occupant, room, stanza = event.occupant, event.room, event.stanza;
|
||||
|
||||
if is_healthcheck_room(room.jid)
|
||||
or is_admin(occupant.bare_jid)
|
||||
or is_transcriber(occupant.jid)
|
||||
or is_jibri(occupant) then
|
||||
return;
|
||||
end
|
||||
|
||||
local nick = jid_resource(occupant.nick);
|
||||
|
||||
if room.speakerStats then
|
||||
-- lets send the current speaker stats to that user, so he can update
|
||||
-- its local stats
|
||||
if next(room.speakerStats) ~= nil then
|
||||
local users_json = {};
|
||||
for jid, values in pairs(room.speakerStats) do
|
||||
-- skip reporting those without a nick('dominantSpeakerId')
|
||||
-- and skip focus if sneaked into the table
|
||||
if values and type(values) == 'table' and values.nick ~= nil and not is_focus_nick(values.nick) then
|
||||
local totalDominantSpeakerTime = values.totalDominantSpeakerTime;
|
||||
local faceLandmarks = values.faceLandmarks;
|
||||
if totalDominantSpeakerTime > 0 or room:get_occupant_jid(jid) == nil or values:isDominantSpeaker()
|
||||
or next(faceLandmarks) ~= nil then
|
||||
-- before sending we need to calculate current dominant speaker state
|
||||
if values:isDominantSpeaker() and not values:isSilent() then
|
||||
local timeElapsed = math.floor(socket.gettime()*1000 - values._dominantSpeakerStart);
|
||||
totalDominantSpeakerTime = totalDominantSpeakerTime + timeElapsed;
|
||||
end
|
||||
|
||||
users_json[values.nick] = {
|
||||
displayName = values.displayName,
|
||||
totalDominantSpeakerTime = totalDominantSpeakerTime,
|
||||
faceLandmarks = array(faceLandmarks:items())
|
||||
};
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if next(users_json) ~= nil then
|
||||
local body_json = {};
|
||||
body_json.type = 'speakerstats';
|
||||
body_json.users = users_json;
|
||||
|
||||
local json_msg_str, error = json.encode(body_json);
|
||||
|
||||
if json_msg_str then
|
||||
local stanza = st.message({
|
||||
from = module.host;
|
||||
to = occupant.jid; })
|
||||
:tag("json-message", {xmlns='http://jitsi.org/jitmeet'})
|
||||
:text(json_msg_str):up();
|
||||
|
||||
room:route_stanza(stanza);
|
||||
else
|
||||
module:log('error', 'Error encoding room:%s error:%s', room.jid, error);
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local context_user = event.origin and event.origin.jitsi_meet_context_user or nil;
|
||||
room.speakerStats[occupant.jid] = new_SpeakerStats(nick, context_user);
|
||||
end
|
||||
end
|
||||
|
||||
-- Occupant left set its dominant speaker to false and update the store the
|
||||
-- display name
|
||||
function occupant_leaving(event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
if not room.speakerStats then
|
||||
return;
|
||||
end
|
||||
|
||||
local occupant = event.occupant;
|
||||
|
||||
local speakerStatsForOccupant = room.speakerStats[occupant.jid];
|
||||
if speakerStatsForOccupant then
|
||||
speakerStatsForOccupant:setDominantSpeaker(false, false);
|
||||
|
||||
-- set display name
|
||||
local displayName = occupant:get_presence():get_child_text(
|
||||
'nick', 'http://jabber.org/protocol/nick');
|
||||
speakerStatsForOccupant.displayName = displayName;
|
||||
end
|
||||
end
|
||||
|
||||
-- Conference ended, send speaker stats
|
||||
function room_destroyed(event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
module:fire_event("send-speaker-stats", { room = room; roomSpeakerStats = room.speakerStats; });
|
||||
end
|
||||
|
||||
module:hook("message/host", on_message);
|
||||
|
||||
function process_main_muc_loaded(main_muc, host_module)
|
||||
-- the conference muc component
|
||||
module:log("info", "Hook to muc events on %s", host_module.host);
|
||||
main_muc_service = main_muc;
|
||||
module:log("info", "Main muc service %s", main_muc_service)
|
||||
host_module:hook("muc-room-created", room_created, -1);
|
||||
host_module:hook("muc-occupant-joined", occupant_joined, -1);
|
||||
host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1);
|
||||
host_module:hook("muc-room-destroyed", room_destroyed, 1); -- prosody handles it at 0
|
||||
end
|
||||
|
||||
function process_breakout_muc_loaded(breakout_muc, host_module)
|
||||
-- the Breakout muc component
|
||||
module:log("info", "Hook to muc events on %s", host_module.host);
|
||||
host_module:hook("muc-room-created", breakout_room_created, -1);
|
||||
host_module:hook("muc-occupant-joined", occupant_joined, -1);
|
||||
host_module:hook("muc-occupant-pre-leave", occupant_leaving, -1);
|
||||
host_module:hook("muc-room-destroyed", room_destroyed, 1); -- prosody handles it at 0
|
||||
end
|
||||
|
||||
-- process or waits to process the conference muc component
|
||||
process_host_module(muc_component_host, function(host_module, host)
|
||||
module:log('info', 'Conference component loaded %s', host);
|
||||
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
if muc_module then
|
||||
process_main_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_main_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
-- process or waits to process the breakout rooms muc component
|
||||
process_host_module(breakout_room_component_host, function(host_module, host)
|
||||
module:log('info', 'Breakout component loaded %s', host);
|
||||
|
||||
local muc_module = prosody.hosts[host].modules.muc;
|
||||
if muc_module then
|
||||
process_breakout_muc_loaded(muc_module, host_module);
|
||||
else
|
||||
module:log('debug', 'Will wait for muc to be available');
|
||||
prosody.hosts[host].events.add_handler('module-loaded', function(event)
|
||||
if (event.module == 'muc') then
|
||||
process_breakout_muc_loaded(prosody.hosts[host].modules.muc, host_module);
|
||||
end
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
process_host_module(main_virtual_host, function(host_module)
|
||||
module:context(host_module.host):fire_event('jitsi-add-identity', {
|
||||
name = 'speakerstats'; host = module.host;
|
||||
});
|
||||
end);
|
||||
153
resources/prosody-plugins/mod_system_chat_message.lua
Normal file
153
resources/prosody-plugins/mod_system_chat_message.lua
Normal file
@@ -0,0 +1,153 @@
|
||||
-- HTTP module that exposes a POST /send-system-chat-message endpoint for
|
||||
-- delivering private system chat messages to specific participants in a MUC
|
||||
-- room. Intended for internal system use (e.g. by a backend service), not for
|
||||
-- end-user clients.
|
||||
--
|
||||
-- Authentication uses a SEPARATE ASAP key pair from the one used for login
|
||||
-- tokens (mod_auth_token). The key server URL is read from
|
||||
-- prosody_password_public_key_repo_url (not asap_key_server), so login tokens
|
||||
-- are not accepted.
|
||||
--
|
||||
-- Request format:
|
||||
-- POST /send-system-chat-message
|
||||
-- Content-Type: application/json
|
||||
-- Authorization: Bearer <system-token>
|
||||
-- Body: {
|
||||
-- "room": "<room-jid>",
|
||||
-- "connectionJIDs": ["<full-jid>", ...],
|
||||
-- "message": "<text>",
|
||||
-- "displayName": "<name>" (optional)
|
||||
-- }
|
||||
--
|
||||
-- Each JID in connectionJIDs receives a private <message> stanza from the
|
||||
-- room JID containing a <json-message xmlns='http://jitsi.org/jitmeet'>
|
||||
-- payload of the form:
|
||||
-- { "type": "system_chat_message", "message": "...", "displayName": "..." }
|
||||
--
|
||||
-- Responses:
|
||||
-- 200 Messages dispatched.
|
||||
-- 400 Missing or invalid parameters / wrong Content-Type.
|
||||
-- 401 Missing, malformed, or unverifiable token.
|
||||
-- 404 Room not found.
|
||||
--
|
||||
-- Copyright (C) 2024-present 8x8, Inc.
|
||||
|
||||
local util = module:require "util";
|
||||
local token_util = module:require "token/util".new(module);
|
||||
|
||||
local async_handler_wrapper = util.async_handler_wrapper;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local starts_with = util.starts_with;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
|
||||
local st = require "util.stanza";
|
||||
local json = require "cjson.safe";
|
||||
|
||||
local asapKeyServer = module:get_option_string("prosody_password_public_key_repo_url", "");
|
||||
|
||||
if asapKeyServer then
|
||||
-- init token util with our asap keyserver
|
||||
token_util:set_asap_key_server(asapKeyServer)
|
||||
end
|
||||
|
||||
function verify_token(token)
|
||||
if token == nil then
|
||||
module:log("warn", "no token provided");
|
||||
return false;
|
||||
end
|
||||
|
||||
local session = {};
|
||||
session.auth_token = token;
|
||||
local verified, reason, msg = token_util:process_and_verify_token(session);
|
||||
if not verified then
|
||||
module:log("warn", "not a valid token %s %s", tostring(reason), tostring(msg));
|
||||
return false;
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
function handle_send_system_message (event)
|
||||
local request = event.request;
|
||||
|
||||
module:log("debug", "Request for sending a system message received: reqid %s", request.headers["request_id"])
|
||||
|
||||
-- verify payload
|
||||
if request.headers.content_type ~= "application/json"
|
||||
or (not request.body or #request.body == 0) then
|
||||
module:log("error", "Wrong content type: %s or missing payload", request.headers.content_type);
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local payload = json.decode(request.body);
|
||||
|
||||
if not payload then
|
||||
module:log("error", "Request body is missing");
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local displayName = payload["displayName"];
|
||||
local message = payload["message"];
|
||||
local connectionJIDs = payload["connectionJIDs"];
|
||||
local payload_room = payload["room"];
|
||||
|
||||
if not message or not connectionJIDs or not payload_room then
|
||||
module:log("error", "One of [message, connectionJIDs, room] was not provided");
|
||||
return { status_code = 400; }
|
||||
end
|
||||
|
||||
local room_jid = room_jid_match_rewrite(payload_room);
|
||||
local room = get_room_from_jid(room_jid);
|
||||
|
||||
if not room then
|
||||
module:log("error", "Room %s not found", room_jid);
|
||||
return { status_code = 404; }
|
||||
end
|
||||
|
||||
-- verify access
|
||||
local token = request.headers["authorization"]
|
||||
if not token then
|
||||
module:log("error", "Authorization header was not provided for conference %s", room_jid)
|
||||
return { status_code = 401 };
|
||||
end
|
||||
if starts_with(token, 'Bearer ') then
|
||||
token = token:sub(8, #token)
|
||||
else
|
||||
module:log("error", "Authorization header is invalid")
|
||||
return { status_code = 401 };
|
||||
end
|
||||
|
||||
if not verify_token(token, room_jid) then
|
||||
return { status_code = 401 };
|
||||
end
|
||||
|
||||
local data = {
|
||||
displayName = displayName,
|
||||
type = "system_chat_message",
|
||||
message = message,
|
||||
};
|
||||
|
||||
for _, to in ipairs(connectionJIDs) do
|
||||
local stanza = st.message({
|
||||
from = room.jid,
|
||||
to = to
|
||||
})
|
||||
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' })
|
||||
:text(json.encode(data))
|
||||
:up();
|
||||
|
||||
room:route_stanza(stanza);
|
||||
end
|
||||
|
||||
return { status_code = 200 };
|
||||
end
|
||||
|
||||
module:log("info", "Adding http handler for /send-system-chat-message on %s", module.host);
|
||||
module:depends("http");
|
||||
module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["POST send-system-chat-message"] = function(event)
|
||||
return async_handler_wrapper(event, handle_send_system_message)
|
||||
end;
|
||||
};
|
||||
});
|
||||
92
resources/prosody-plugins/mod_test_observer.lua
Normal file
92
resources/prosody-plugins/mod_test_observer.lua
Normal file
@@ -0,0 +1,92 @@
|
||||
-- Test-only module. Never load in production.
|
||||
-- Hooks MUC events on the conference component and stores them in a shared table
|
||||
-- so that mod_test_observer_http (loaded on the main VirtualHost) can serve them
|
||||
-- over plain HTTP without virtual-host routing conflicts.
|
||||
|
||||
-- Shared table. Absolute path so mod_test_observer_http (on a different host)
|
||||
-- can access the same table via module:shared("/<this_host>/mod_test_observer").
|
||||
-- module:shared() uses the path string as-is — no automatic host prefix.
|
||||
local shared = module:shared("/" .. module.host .. "/mod_test_observer");
|
||||
if not shared.events then shared.events = {}; end
|
||||
if not shared.rooms then shared.rooms = {}; end
|
||||
if not shared.jibri_iqs then shared.jibri_iqs = {}; end
|
||||
if not shared.dial_iqs then shared.dial_iqs = {}; end
|
||||
|
||||
local tracked = {
|
||||
"muc-room-pre-create";
|
||||
"muc-room-created";
|
||||
"muc-occupant-pre-join";
|
||||
"muc-occupant-joined";
|
||||
"muc-occupant-left";
|
||||
"muc-room-destroyed";
|
||||
};
|
||||
|
||||
for _, name in ipairs(tracked) do
|
||||
module:hook(name, function(event)
|
||||
local entry = { event = name, timestamp = os.time() };
|
||||
if event.room then entry.room = event.room.jid end
|
||||
if event.occupant then entry.occupant = tostring(event.occupant.bare_jid) end
|
||||
table.insert(shared.events, entry);
|
||||
end, -1000); -- run after all other handlers
|
||||
end
|
||||
|
||||
-- Keep a live reference to each room so mod_test_observer_http can query state
|
||||
-- without needing get_room_from_jid (which is not accessible from the VirtualHost
|
||||
-- module's context in Prosody 13).
|
||||
module:hook("muc-room-created", function(event)
|
||||
shared.rooms[event.room.jid] = event.room;
|
||||
end, -999);
|
||||
|
||||
module:hook("muc-room-destroyed", function(event)
|
||||
shared.rooms[event.room.jid] = nil;
|
||||
end, -999);
|
||||
|
||||
-- Record Jibri IQs that reach the MUC component (i.e. passed mod_filter_iq_jibri).
|
||||
-- If the filter blocks an IQ it never arrives here, so absence = blocked.
|
||||
-- High priority (500) so this runs before mod_muc's iq/full handler, which
|
||||
-- returns non-nil and would prevent lower-priority hooks from firing.
|
||||
-- Guard: only record IQs addressed to this component (to = room@<this_host>/nick).
|
||||
-- The MUC re-routes the IQ to the occupant's real JID on another host, which
|
||||
-- would fire iq/full a second time — skip those forwarded copies.
|
||||
local jid_util = require "util.jid";
|
||||
module:hook("iq/full", function(event)
|
||||
local stanza = event.stanza;
|
||||
-- Only capture initial requests; ignore error/result responses routed back
|
||||
-- through the MUC (e.g. @xmpp/client auto-replying to unhandled IQs).
|
||||
local iq_type = stanza.attr.type;
|
||||
if iq_type ~= "set" and iq_type ~= "get" then return; end
|
||||
local _, to_host = jid_util.split(stanza.attr.to);
|
||||
if to_host ~= module.host then return; end
|
||||
local jibri = stanza:get_child('jibri', 'http://jitsi.org/protocol/jibri');
|
||||
if jibri then
|
||||
table.insert(shared.jibri_iqs, {
|
||||
from = stanza.attr.from;
|
||||
to = stanza.attr.to;
|
||||
action = jibri.attr.action;
|
||||
recording_mode = jibri.attr.recording_mode;
|
||||
});
|
||||
end
|
||||
local dial = stanza:get_child('dial', 'urn:xmpp:rayo:1');
|
||||
if dial then
|
||||
local headers = {};
|
||||
for _, child in ipairs(dial.tags) do
|
||||
if child.name == "header" and child.attr.name then
|
||||
headers[child.attr.name] = child.attr.value;
|
||||
end
|
||||
end
|
||||
table.insert(shared.dial_iqs, {
|
||||
from = stanza.attr.from;
|
||||
to = stanza.attr.to;
|
||||
dial_to = dial.attr.to;
|
||||
room_name_header = headers["JvbRoomName"];
|
||||
room_pass_header = headers["JvbRoomPassword"];
|
||||
headers = headers;
|
||||
-- Raw serialized form of the stanza as util.stanza would write it
|
||||
-- to the wire. Used by JS tests to inspect the actual byte content
|
||||
-- of forwarded attributes.
|
||||
raw_xml = tostring(stanza);
|
||||
});
|
||||
end
|
||||
end, 500);
|
||||
|
||||
module:log("info", "test_observer loaded");
|
||||
658
resources/prosody-plugins/mod_test_observer_http.lua
Normal file
658
resources/prosody-plugins/mod_test_observer_http.lua
Normal file
@@ -0,0 +1,658 @@
|
||||
-- Test-only module. Never load in production.
|
||||
-- Serves HTTP endpoints for test assertions. Must be loaded on the main VirtualHost
|
||||
-- (not the MUC component) so that plain HTTP on port 5280 is reachable with
|
||||
-- Host: localhost — the VirtualHost's domain.
|
||||
--
|
||||
-- Data is supplied by mod_test_observer (loaded on the MUC component) via
|
||||
-- module:shared. Load order does not matter; shared tables are created lazily.
|
||||
--
|
||||
-- Also serves a mock access manager endpoint for mod_muc_auth_ban tests:
|
||||
-- GET /test-observer/access-manager — called by Prosody; returns configured response
|
||||
-- POST /test-observer/access-manager — called by tests to configure the response
|
||||
|
||||
local json = require "cjson.safe";
|
||||
local io = require "io";
|
||||
local jid_lib = require "util.jid";
|
||||
|
||||
-- Mock access-manager state for mod_muc_auth_ban tests.
|
||||
-- access: true → return {"access": true} (user allowed)
|
||||
-- access: false → return {"access": false} (user banned)
|
||||
-- status: any non-200 value → return that HTTP status code with no JSON body
|
||||
-- (simulates HTTP errors; mod_muc_auth_ban fails open on non-200)
|
||||
-- non_json: true → return a 200 with a plain-text body (not valid JSON)
|
||||
-- (tests the nil-check bug: json.decode returns nil, r['access'] would crash)
|
||||
-- Reset to the default (allow, 200) between tests via POST /test-observer/access-manager.
|
||||
local access_manager_state = { access = true, status = 200, non_json = false };
|
||||
|
||||
-- Mock OTLP trace receiver for mod_trace tests. Stores every exported
|
||||
-- resource_spans payload POSTed by the module's Exporter so tests can assert
|
||||
-- a span was (or was not) exported without needing a real OTLP collector.
|
||||
local otlp_traces = {};
|
||||
|
||||
-- ASAP public key servers: serve test RSA public keys so that Prosody can
|
||||
-- fetch them when verifying RS256 tokens signed by the matching private keys.
|
||||
-- util.lib.lua constructs the URL as: <asap_key_server>/<sha256hex(kid)>.pem
|
||||
--
|
||||
-- Two separate key pairs are used:
|
||||
-- Login tokens (kid "test-asap-key"): signed by the login key pair,
|
||||
-- served at /test-observer/asap-keys/
|
||||
-- used by mod_auth_token (VirtualHost "localhost")
|
||||
-- System tokens (kid "test-system-asap-key"): signed by the system key pair,
|
||||
-- served at /test-observer/system-asap-keys/
|
||||
-- used by mod_muc_end_meeting and similar HTTP API modules
|
||||
local ASAP_KEY_PATH = "/opt/prosody-jitsi-plugins/test-asap-public.pem";
|
||||
local ASAP_KID_SHA256 = "dc6983da8e703a3f51d4c1cb92b52c982f7853ce3d5ba20c782fcd13616f6dfc";
|
||||
|
||||
local SYSTEM_ASAP_KEY_PATH = "/opt/prosody-jitsi-plugins/test-system-asap-public.pem";
|
||||
local SYSTEM_ASAP_KID_SHA256 = "e76ed986a75a90756e5add6e8b56efc3d3f027764436d2744d29a33f0ec24fea";
|
||||
|
||||
local function load_pem(path)
|
||||
local f = io.open(path, "r");
|
||||
if not f then return nil; end
|
||||
local data = f:read("*all");
|
||||
f:close();
|
||||
return data;
|
||||
end
|
||||
|
||||
local asap_public_key = load_pem(ASAP_KEY_PATH);
|
||||
if asap_public_key then
|
||||
module:log("info", "Loaded test ASAP public key from %s", ASAP_KEY_PATH);
|
||||
else
|
||||
module:log("warn", "Test ASAP public key not found at %s; login ASAP key-server routes will 404", ASAP_KEY_PATH);
|
||||
end
|
||||
|
||||
local system_asap_public_key = load_pem(SYSTEM_ASAP_KEY_PATH);
|
||||
if system_asap_public_key then
|
||||
module:log("info", "Loaded test system ASAP public key from %s", SYSTEM_ASAP_KEY_PATH);
|
||||
else
|
||||
module:log("warn", "Test system ASAP public key not found at %s; system ASAP key-server routes will 404", SYSTEM_ASAP_KEY_PATH);
|
||||
end
|
||||
|
||||
-- session-info: capture mod_jitsi_session / mod_auth_token fields after
|
||||
-- resource-bind so tests can assert URL query params and JWT claims were
|
||||
-- stored correctly on the session object.
|
||||
local session_info = {}; -- full_jid -> field snapshot
|
||||
|
||||
local function capture_session_info(event)
|
||||
local session = event.session;
|
||||
local jid = session.full_jid;
|
||||
if jid then
|
||||
session_info[jid] = {
|
||||
previd = session.previd,
|
||||
customusername = session.customusername,
|
||||
jitsi_web_query_room = session.jitsi_web_query_room,
|
||||
jitsi_web_query_prefix = session.jitsi_web_query_prefix,
|
||||
auth_token = session.auth_token,
|
||||
user_region = session.user_region,
|
||||
user_agent_header = session.user_agent_header,
|
||||
-- Fields set by mod_auth_token after JWT verification:
|
||||
jitsi_meet_room = session.jitsi_meet_room,
|
||||
jitsi_meet_context_user = session.jitsi_meet_context_user,
|
||||
jitsi_meet_context_group = session.jitsi_meet_context_group,
|
||||
jitsi_meet_context_features = session.jitsi_meet_context_features,
|
||||
};
|
||||
end
|
||||
end
|
||||
|
||||
-- resource-bind fires on each VirtualHost's own event bus, not globally.
|
||||
-- Register on every host already active at module load time, and on any host
|
||||
-- that activates later (e.g. hs256.localhost loads after localhost since
|
||||
-- VirtualHosts are initialized in config order).
|
||||
local function register_on_host(host_obj)
|
||||
if host_obj and host_obj.events then
|
||||
host_obj.events.add_handler("resource-bind", capture_session_info, 10);
|
||||
end
|
||||
end
|
||||
|
||||
for _, host_obj in pairs(prosody.hosts) do
|
||||
register_on_host(host_obj);
|
||||
end
|
||||
|
||||
-- host-activated passes the hostname string directly (not a table).
|
||||
module:hook_global("host-activated", function(host)
|
||||
register_on_host(prosody.hosts[host]);
|
||||
end);
|
||||
|
||||
-- /conference.localhost/mod_test_observer is the absolute path for the shared
|
||||
-- table created by mod_test_observer running on conference.localhost.
|
||||
local MUC_HOST = module:get_option_string("muc_mapper_domain_base", "localhost");
|
||||
local MUC_COMPONENT = module:get_option_string("muc_mapper_domain_prefix", "conference") .. "." .. MUC_HOST;
|
||||
local shared = module:shared("/" .. MUC_COMPONENT .. "/mod_test_observer");
|
||||
|
||||
local function urldecode(s)
|
||||
return (s:gsub("%%(%x%x)", function(hex)
|
||||
return string.char(tonumber(hex, 16));
|
||||
end));
|
||||
end
|
||||
|
||||
local function parse_query(q)
|
||||
local params = {};
|
||||
for k, v in (q or ""):gmatch("([^&=]+)=([^&]*)") do
|
||||
params[urldecode(k)] = urldecode(v);
|
||||
end
|
||||
return params;
|
||||
end
|
||||
|
||||
module:provides("http", {
|
||||
default_path = "/test-observer";
|
||||
route = {
|
||||
-- GET /test-observer/asap-keys/<sha256hex(kid)>.pem
|
||||
-- Returns the login RSA public key PEM so mod_auth_token can verify RS256 login tokens.
|
||||
-- kid must be "test-asap-key" (its SHA256 hex is the filename).
|
||||
["GET /asap-keys/"..ASAP_KID_SHA256..".pem"] = function()
|
||||
if not asap_public_key then
|
||||
return { status_code = 404; body = "key not found" };
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/x-pem-file" };
|
||||
body = asap_public_key;
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/system-asap-keys/<sha256hex(kid)>.pem
|
||||
-- Returns the system RSA public key PEM for mod_muc_end_meeting and similar
|
||||
-- system HTTP API modules that use prosody_password_public_key_repo_url.
|
||||
-- kid must be "test-system-asap-key" (its SHA256 hex is the filename).
|
||||
["GET /system-asap-keys/"..SYSTEM_ASAP_KID_SHA256..".pem"] = function()
|
||||
if not system_asap_public_key then
|
||||
return { status_code = 404; body = "key not found" };
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/x-pem-file" };
|
||||
body = system_asap_public_key;
|
||||
};
|
||||
end;
|
||||
|
||||
["GET /events"] = function()
|
||||
local events = shared.events or {};
|
||||
-- cjson encodes an empty Lua table as {} (object); force array literal.
|
||||
local body = #events == 0 and "[]" or json.encode(events);
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = body;
|
||||
};
|
||||
end;
|
||||
|
||||
["DELETE /events"] = function()
|
||||
-- Replace with a fresh table; mod_test_observer always reads shared.events
|
||||
-- via the shared reference so it will see the new table immediately.
|
||||
shared.events = {};
|
||||
return { status_code = 204 };
|
||||
end;
|
||||
|
||||
-- GET /test-observer/jibri-iqs
|
||||
-- Returns the list of Jibri IQs that reached the MUC (i.e. passed mod_filter_iq_jibri).
|
||||
["GET /jibri-iqs"] = function()
|
||||
local iqs = shared.jibri_iqs or {};
|
||||
local body = #iqs == 0 and "[]" or json.encode(iqs);
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = body;
|
||||
};
|
||||
end;
|
||||
|
||||
-- DELETE /test-observer/jibri-iqs
|
||||
-- Clears the recorded Jibri IQ list. Call before each test.
|
||||
["DELETE /jibri-iqs"] = function()
|
||||
shared.jibri_iqs = {};
|
||||
return { status_code = 204 };
|
||||
end;
|
||||
|
||||
-- GET /test-observer/dial-iqs
|
||||
-- Returns the list of Rayo dial IQs that reached the MUC (i.e. passed mod_filter_iq_rayo).
|
||||
["GET /dial-iqs"] = function()
|
||||
local iqs = shared.dial_iqs or {};
|
||||
local body = #iqs == 0 and "[]" or json.encode(iqs);
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = body;
|
||||
};
|
||||
end;
|
||||
|
||||
-- DELETE /test-observer/dial-iqs
|
||||
-- Clears the recorded Rayo dial IQ list. Call before each test.
|
||||
["DELETE /dial-iqs"] = function()
|
||||
shared.dial_iqs = {};
|
||||
return { status_code = 204 };
|
||||
end;
|
||||
|
||||
-- POST /test-observer/sessions/context
|
||||
-- Body: { "jid": "node@localhost/resource", "user_id": "...", "features": { "flip": true } }
|
||||
-- Sets jitsi_meet_context_user / jitsi_meet_context_features on the c2s session so that
|
||||
-- mod_muc_flip (and other JWT-aware modules) see the same context as they would with a
|
||||
-- real token — without needing a JWT auth module in the test setup.
|
||||
["POST /sessions/context"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}") or {};
|
||||
local full_jid = data.jid;
|
||||
local user_id = data.user_id;
|
||||
local features = data.features or {};
|
||||
if not full_jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid"}' };
|
||||
end
|
||||
local node, host, resource = jid_lib.split(full_jid);
|
||||
local host_obj = host and prosody.hosts[host];
|
||||
if not host_obj then
|
||||
return { status_code = 404; body = '{"error":"host not found"}' };
|
||||
end
|
||||
local user_obj = host_obj.sessions and host_obj.sessions[node];
|
||||
if not user_obj then
|
||||
return { status_code = 404; body = '{"error":"user not found"}' };
|
||||
end
|
||||
local session = user_obj.sessions and user_obj.sessions[resource];
|
||||
if not session then
|
||||
return { status_code = 404; body = '{"error":"session not found"}' };
|
||||
end
|
||||
if user_id then
|
||||
session.jitsi_meet_context_user = { id = user_id };
|
||||
end
|
||||
session.jitsi_meet_context_features = features;
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = '{"ok":true}';
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/sessions/features?jid=user@localhost/resource
|
||||
-- Returns the live jitsi_meet_context_features for the session.
|
||||
-- Reading live (not a snapshot) captures side-effects from modules such as
|
||||
-- mod_jitsi_permissions, which may set or update features after resource-bind.
|
||||
-- Returns 200 {"features": <object|null>} or 404.
|
||||
["GET /sessions/features"] = function(event)
|
||||
local params = parse_query(event.request.url.query);
|
||||
local full_jid = params["jid"];
|
||||
if not full_jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid param"}' };
|
||||
end
|
||||
local node, host, resource = jid_lib.split(full_jid);
|
||||
local host_obj = host and prosody.hosts[host];
|
||||
if not host_obj then
|
||||
return { status_code = 404; body = '{"error":"host not found"}' };
|
||||
end
|
||||
local user_obj = host_obj.sessions and host_obj.sessions[node];
|
||||
if not user_obj then
|
||||
return { status_code = 404; body = '{"error":"user not found"}' };
|
||||
end
|
||||
local session = user_obj.sessions and user_obj.sessions[resource];
|
||||
if not session then
|
||||
return { status_code = 404; body = '{"error":"session not found"}' };
|
||||
end
|
||||
local features = session.jitsi_meet_context_features;
|
||||
local body = features and json.encode({ features = features }) or '{"features":null}';
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = body;
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/rooms/participants?jid=room@conference.localhost
|
||||
-- Returns: { participants_details: { userId: fullNick }, kicked_participant_nick?, flip_participant_nick? }
|
||||
-- Exposes mod_muc_flip's per-room tracking tables for test assertions.
|
||||
["GET /rooms/participants"] = function(event)
|
||||
local params = parse_query(event.request.url.query);
|
||||
local room_jid = params["jid"];
|
||||
if not room_jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid param"}' };
|
||||
end
|
||||
local room = (shared.rooms or {})[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
local result = { participants_details = room._data.participants_details or {} };
|
||||
if room._data.kicked_participant_nick ~= nil then
|
||||
result.kicked_participant_nick = room._data.kicked_participant_nick;
|
||||
end
|
||||
if room._data.flip_participant_nick ~= nil then
|
||||
result.flip_participant_nick = room._data.flip_participant_nick;
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = json.encode(result);
|
||||
};
|
||||
end;
|
||||
|
||||
-- POST /test-observer/rooms/hide-display-name
|
||||
-- Body: { "jid": "room@conference.localhost", "hidden": true|false }
|
||||
-- Sets room._data.hideDisplayNameForGuests for mod_muc_displayname tests.
|
||||
["POST /rooms/hide-display-name"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}") or {};
|
||||
local room_jid = data.jid;
|
||||
local hidden = data.hidden;
|
||||
if room_jid == nil or hidden == nil then
|
||||
return { status_code = 400; body = '{"error":"missing jid or hidden"}' };
|
||||
end
|
||||
local room = (shared.rooms or {})[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
room._data.hideDisplayNameForGuests = hidden;
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = '{"ok":true}';
|
||||
};
|
||||
end;
|
||||
|
||||
-- POST /test-observer/rooms/breakout-rooms-active
|
||||
-- Body: { "jid": "room@conference.localhost", "active": true|false }
|
||||
-- Sets room._data.breakout_rooms_active, which mod_muc_cleanup_backend_services
|
||||
-- checks to skip the destroy-timer logic when breakout rooms are running.
|
||||
["POST /rooms/breakout-rooms-active"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}") or {};
|
||||
local room_jid = data.jid;
|
||||
local active = data.active;
|
||||
if room_jid == nil or active == nil then
|
||||
return { status_code = 400; body = '{"error":"missing jid or active"}' };
|
||||
end
|
||||
local room = (shared.rooms or {})[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
room._data.breakout_rooms_active = active;
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = '{"ok":true}';
|
||||
};
|
||||
end;
|
||||
|
||||
-- POST /test-observer/rooms/max-occupants
|
||||
-- Body: { "jid": "room@conference.localhost", "max_occupants": 4 }
|
||||
-- Sets room._data.max_occupants so per-room limit tests can override the
|
||||
-- global muc_max_occupants without restarting Prosody.
|
||||
["POST /rooms/max-occupants"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}") or {};
|
||||
local room_jid = data.jid;
|
||||
local max = tonumber(data.max_occupants);
|
||||
if not room_jid or not max then
|
||||
return { status_code = 400; body = '{"error":"missing jid or max_occupants"}' };
|
||||
end
|
||||
local room = (shared.rooms or {})[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
room._data.max_occupants = max;
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = '{"ok":true}';
|
||||
};
|
||||
end;
|
||||
|
||||
-- POST /test-observer/otlp-traces
|
||||
-- Called by mod_trace's Exporter. Body is the raw OTLP
|
||||
-- ExportTraceServiceRequest JSON; stored verbatim for test assertions.
|
||||
["POST /otlp-traces"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}");
|
||||
if data then
|
||||
table.insert(otlp_traces, data);
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = "{}";
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/otlp-traces
|
||||
-- Returns every ExportTraceServiceRequest body received so far.
|
||||
["GET /otlp-traces"] = function()
|
||||
local body = #otlp_traces == 0 and "[]" or json.encode(otlp_traces);
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = body;
|
||||
};
|
||||
end;
|
||||
|
||||
-- DELETE /test-observer/otlp-traces
|
||||
-- Clears the recorded export list. Call before each test.
|
||||
["DELETE /otlp-traces"] = function()
|
||||
otlp_traces = {};
|
||||
return { status_code = 204 };
|
||||
end;
|
||||
|
||||
-- GET /test-observer/access-manager
|
||||
-- Mock access-manager endpoint called by mod_muc_auth_ban.
|
||||
-- Returns {"access": true} or {"access": false} based on the current
|
||||
-- access_manager_state, or a non-200 status to simulate HTTP errors.
|
||||
-- The real access manager receives `Authorization: Bearer <token>` but
|
||||
-- this mock ignores it and returns the globally configured response.
|
||||
["GET /access-manager"] = function()
|
||||
if access_manager_state.status ~= 200 then
|
||||
return { status_code = access_manager_state.status; body = "error" };
|
||||
end
|
||||
if access_manager_state.non_json then
|
||||
-- Return a 200 with a non-JSON body to exercise the nil-check
|
||||
-- bug in mod_muc_auth_ban: json.decode returns nil, and
|
||||
-- r['access'] would crash if the module doesn't guard for nil.
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "text/plain" };
|
||||
body = "not-valid-json";
|
||||
};
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = json.encode({ access = access_manager_state.access });
|
||||
};
|
||||
end;
|
||||
|
||||
-- POST /test-observer/access-manager
|
||||
-- Body: { "access": true|false, "status": <http-status-code>, "non_json": true|false }
|
||||
-- Configures what the mock access manager returns.
|
||||
-- Call this from tests before connecting the client under test.
|
||||
-- Call with { "access": true, "status": 200 } to reset to the default.
|
||||
["POST /access-manager"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}") or {};
|
||||
if data.access ~= nil then
|
||||
access_manager_state.access = data.access;
|
||||
end
|
||||
if data.status ~= nil then
|
||||
access_manager_state.status = tonumber(data.status) or 200;
|
||||
end
|
||||
if data.non_json ~= nil then
|
||||
access_manager_state.non_json = data.non_json;
|
||||
end
|
||||
return { status_code = 204 };
|
||||
end;
|
||||
|
||||
-- GET /test-observer/session-info?jid=user@localhost/resource
|
||||
-- Returns the mod_jitsi_session fields captured at resource-bind time.
|
||||
["GET /session-info"] = function(event)
|
||||
local params = parse_query(event.request.url.query);
|
||||
local jid = params["jid"];
|
||||
if not jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid param"}' };
|
||||
end
|
||||
local info = session_info[jid];
|
||||
if not info then
|
||||
return { status_code = 404; body = '{"error":"session not found"}' };
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = json.encode(info);
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/session-live?jid=user@localhost/resource
|
||||
-- Returns the CURRENT values of the JWT-derived fields on the live c2s
|
||||
-- session, read straight from prosody.full_sessions. Unlike
|
||||
-- /session-info (a snapshot taken at resource-bind) this reflects
|
||||
-- changes made after the session was established -- notably the claim
|
||||
-- refresh performed by mod_auth_token on XEP-0198 stream resumption,
|
||||
-- which never binds a new resource.
|
||||
["GET /session-live"] = function(event)
|
||||
local params = parse_query(event.request.url.query);
|
||||
local jid = params["jid"];
|
||||
if not jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid param"}' };
|
||||
end
|
||||
local session = prosody.full_sessions[jid];
|
||||
if not session then
|
||||
return { status_code = 404; body = '{"error":"session not found"}' };
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = json.encode({
|
||||
auth_token = session.auth_token,
|
||||
jitsi_web_query_room = session.jitsi_web_query_room,
|
||||
jitsi_web_query_prefix = session.jitsi_web_query_prefix,
|
||||
jitsi_meet_room = session.jitsi_meet_room,
|
||||
jitsi_meet_domain = session.jitsi_meet_domain,
|
||||
jitsi_meet_str_tenant = session.jitsi_meet_str_tenant,
|
||||
jitsi_meet_context_user = session.jitsi_meet_context_user,
|
||||
jitsi_meet_context_group = session.jitsi_meet_context_group,
|
||||
jitsi_meet_context_features = session.jitsi_meet_context_features,
|
||||
});
|
||||
};
|
||||
end;
|
||||
|
||||
-- POST /test-observer/rooms/lobby
|
||||
-- Body: { "jid": "room@conference.localhost", "enabled": true|false }
|
||||
-- Enables or disables the lobby for the given room by firing the
|
||||
-- create-lobby-room / destroy-lobby-room global Prosody events.
|
||||
-- Enabling also sets members-only; disabling unsets it.
|
||||
-- Response: { ok, lobbyroom } (lobbyroom is nil when disabling).
|
||||
["POST /rooms/lobby"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}") or {};
|
||||
local room_jid = data.jid;
|
||||
local enabled = data.enabled;
|
||||
if not room_jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid"}' };
|
||||
end
|
||||
local room = (shared.rooms or {})[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
if enabled then
|
||||
prosody.events.fire_event('create-lobby-room', { room = room });
|
||||
else
|
||||
room:set_members_only(false);
|
||||
prosody.events.fire_event('destroy-lobby-room', { room = room });
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = json.encode({ ok = true; lobbyroom = room._data.lobbyroom });
|
||||
};
|
||||
end;
|
||||
|
||||
-- POST /test-observer/rooms/affiliation
|
||||
-- Body: { "jid": "room@conference.localhost", "occupant_jid": "user@localhost", "affiliation": "member" }
|
||||
-- Sets the affiliation of occupant_jid in the given room.
|
||||
["POST /rooms/affiliation"] = function(event)
|
||||
local data = json.decode(event.request.body or "{}") or {};
|
||||
local room_jid = data.jid;
|
||||
local occupant_jid = data.occupant_jid;
|
||||
local affiliation = data.affiliation;
|
||||
if not room_jid or not occupant_jid or not affiliation then
|
||||
return { status_code = 400; body = '{"error":"missing required fields"}' };
|
||||
end
|
||||
local room = (shared.rooms or {})[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
room:set_affiliation(true, occupant_jid, affiliation);
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = '{"ok":true}';
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/rooms/metadata?jid=room@conference.localhost
|
||||
-- Returns: { jid, metadata } where metadata is room.jitsiMetadata.
|
||||
["GET /rooms/metadata"] = function(event)
|
||||
local params = parse_query(event.request.url.query);
|
||||
local room_jid = params["jid"];
|
||||
if not room_jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid param"}' };
|
||||
end
|
||||
local rooms = shared.rooms or {};
|
||||
local room = rooms[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
local encoded, err = json.encode({
|
||||
jid = room.jid;
|
||||
metadata = room.jitsiMetadata or {};
|
||||
});
|
||||
if not encoded then
|
||||
return { status_code = 500; body = json.encode({ error = 'encode failed: ' .. tostring(err) }) };
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = encoded;
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/rooms/audio-translation-requests?jid=room@conference.localhost
|
||||
-- Returns: { jid, audioTranslationRequests } where the value is the
|
||||
-- aggregated map stored on room._data by mod_audio_translation_component
|
||||
-- (omitted when there are no subscriptions).
|
||||
["GET /rooms/audio-translation-requests"] = function(event)
|
||||
local params = parse_query(event.request.url.query);
|
||||
local room_jid = params["jid"];
|
||||
if not room_jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid param"}' };
|
||||
end
|
||||
local rooms = shared.rooms or {};
|
||||
local room = rooms[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
local encoded, err = json.encode({
|
||||
jid = room.jid;
|
||||
audioTranslationRequests = room._data.audioTranslationRequests;
|
||||
});
|
||||
if not encoded then
|
||||
return { status_code = 500; body = json.encode({ error = 'encode failed: ' .. tostring(err) }) };
|
||||
end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = encoded;
|
||||
};
|
||||
end;
|
||||
|
||||
-- GET /test-observer/rooms?jid=room@conference.localhost
|
||||
-- Returns: { jid, hidden, occupant_count }
|
||||
["GET /rooms"] = function(event)
|
||||
local params = parse_query(event.request.url.query);
|
||||
local room_jid = params["jid"];
|
||||
if not room_jid then
|
||||
return { status_code = 400; body = '{"error":"missing jid param"}' };
|
||||
end
|
||||
local rooms = shared.rooms or {};
|
||||
local room = rooms[room_jid];
|
||||
if not room then
|
||||
return { status_code = 404; body = '{"error":"room not found"}' };
|
||||
end
|
||||
local count = 0;
|
||||
for _ in room:each_occupant() do count = count + 1; end
|
||||
return {
|
||||
status_code = 200;
|
||||
headers = { ["Content-Type"] = "application/json" };
|
||||
body = json.encode({
|
||||
jid = room.jid;
|
||||
hidden = room:get_hidden();
|
||||
occupant_count = count;
|
||||
});
|
||||
};
|
||||
end;
|
||||
};
|
||||
});
|
||||
|
||||
module:log("info", "test_observer_http loaded");
|
||||
116
resources/prosody-plugins/mod_time_restricted.lua
Normal file
116
resources/prosody-plugins/mod_time_restricted.lua
Normal file
@@ -0,0 +1,116 @@
|
||||
--- activate under the main muc component
|
||||
|
||||
local MIN = module:get_option_number("conference_max_minutes", 5)
|
||||
local TIMEOUT = MIN * 60
|
||||
|
||||
-- Gratis time granted past the limit so people can say their goodbyes and
|
||||
-- leave on their own instead of being cut off mid-sentence. Clients still see
|
||||
-- their countdown end at TIMEOUT - they spend the grace period in the visible
|
||||
-- over-schedule state, which is the cue to wrap up - the room is simply not
|
||||
-- torn down until it elapses. 0 (the default) destroys the room the moment the
|
||||
-- limit is reached.
|
||||
local GRACE = module:get_option_number("conference_grace_seconds", 0)
|
||||
local DESTROY_AT = TIMEOUT + GRACE
|
||||
|
||||
local is_healthcheck_room = module:require "util".is_healthcheck_room
|
||||
local st = require "util.stanza"
|
||||
local json = require "cjson.safe"
|
||||
local array = require "util.array";
|
||||
module:log('info', "loaded")
|
||||
|
||||
local restricted_rooms = array{};
|
||||
|
||||
-- The `type` the time-timer feature in jitsi-meet matches on. Sent as a
|
||||
-- json-message from the room JID so lib-jitsi-meet surfaces it as a
|
||||
-- NON_PARTICIPANT_MESSAGE_RECEIVED (a server-originated, non-participant
|
||||
-- message) rather than a participant endpoint message.
|
||||
local TIME_RESTRICTED_MESSAGE_TYPE = 'time_restricted';
|
||||
|
||||
-- Sends the time-timer json-message to a single occupant. `durationSeconds` is
|
||||
-- the full meeting length and `elapsedSeconds` how far in we are, so every
|
||||
-- client - first joiner or last - lands on the same countdown.
|
||||
local function send_time_timer(room, to_jid)
|
||||
local elapsed = os.time() - (room.time_restricted_created or os.time());
|
||||
local body, error = json.encode({
|
||||
type = TIME_RESTRICTED_MESSAGE_TYPE;
|
||||
durationSeconds = TIMEOUT;
|
||||
elapsedSeconds = elapsed;
|
||||
});
|
||||
|
||||
if not body then
|
||||
module:log('error', 'failed to encode time-timer message room:%s error:%s', room.jid, error);
|
||||
return;
|
||||
end
|
||||
|
||||
-- from = room.jid so the stanza routes through ChatRoom.onMessage in
|
||||
-- lib-jitsi-meet (which only processes messages whose bare from matches the
|
||||
-- room) and is emitted as JSON_MESSAGE_RECEIVED -> NON_PARTICIPANT_MESSAGE_RECEIVED.
|
||||
local stanza = st.message({ from = room.jid; to = to_jid; })
|
||||
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(body):up();
|
||||
module:send(stanza);
|
||||
end
|
||||
|
||||
module:hook("muc-room-created", function (event)
|
||||
local room = event.room
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return
|
||||
end
|
||||
|
||||
room.time_restricted_created = os.time();
|
||||
|
||||
room.destroy_timer = module:add_timer(DESTROY_AT, function()
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return
|
||||
end
|
||||
restricted_rooms[room.jid] = true;
|
||||
-- %g (not %d) so a fractional conference_max_minutes does not crash the
|
||||
-- timer; for the usual whole-minute configs it renders identically.
|
||||
room:destroy(nil, string.format('This meeting reached its %g-minute time limit and has ended.', MIN));
|
||||
|
||||
module:log('info', "the conference terminated %s", room.jid);
|
||||
end)
|
||||
end)
|
||||
|
||||
-- Every occupant is told the meeting's limit as soon as they join, so the
|
||||
-- client holds the full picture from the start and decides for itself when to
|
||||
-- surface it (`timeTimer.suppressForSeconds` in jitsi-meet delays the visible
|
||||
-- countdown by a configurable number of seconds). `elapsedSeconds` keeps late
|
||||
-- joiners in sync with everyone else.
|
||||
module:hook("muc-occupant-joined", function (event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return
|
||||
end
|
||||
|
||||
-- Only for rooms this module saw created: without a creation stamp we
|
||||
-- cannot say how far in the meeting is, and reporting elapsed = 0 for a
|
||||
-- room that has been running for a while would be worse than saying
|
||||
-- nothing.
|
||||
if room.time_restricted_created then
|
||||
send_time_timer(room, occupant.jid);
|
||||
end
|
||||
end);
|
||||
|
||||
module:hook("muc-room-pre-create", function(event)
|
||||
local origin, stanza, room = event.origin, event.stanza, event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return
|
||||
end
|
||||
|
||||
if restricted_rooms[room.jid] then
|
||||
module:log('info', "the conference id blocked %s", room.jid);
|
||||
origin.send(st.error_reply(stanza, "cancel", "resource-constraint", nil, module.host));
|
||||
return true;
|
||||
end
|
||||
end, 200);
|
||||
|
||||
module:hook('muc-room-destroyed',function(event)
|
||||
local room = event.room;
|
||||
if room.destroy_timer then
|
||||
room.destroy_timer:stop();
|
||||
room.destroy_timer = nil;
|
||||
end
|
||||
end, 1); -- prosody handles it at 0
|
||||
86
resources/prosody-plugins/mod_token_affiliation.lua
Normal file
86
resources/prosody-plugins/mod_token_affiliation.lua
Normal file
@@ -0,0 +1,86 @@
|
||||
-- mod_token_affiliation.lua
|
||||
--
|
||||
-- Grants owner affiliation (and thus moderator role) to MUC occupants whose
|
||||
-- JWT contains moderator/affiliation claims, and member affiliation to all
|
||||
-- other authenticated occupants.
|
||||
--
|
||||
-- JWT claims inspected (context.user.*):
|
||||
-- affiliation == "owner" | "moderator" | "teacher" → owner
|
||||
-- moderator == true | "true" → owner
|
||||
-- Authenticated user with none of the above → member
|
||||
--
|
||||
-- The affiliation is written to the room's affiliation list inside
|
||||
-- muc-occupant-pre-join (before the join completes), so the very first
|
||||
-- presence broadcast already carries the correct affiliation and role.
|
||||
-- No filtering or second hook required.
|
||||
--
|
||||
-- Originally imported from:
|
||||
-- https://github.com/jitsi-contrib/prosody-plugins/tree/main/token_affiliation
|
||||
|
||||
local LOGLEVEL = module:get_option_string("token_affiliation_log_level", "debug")
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
local is_healthcheck_room = util.is_healthcheck_room
|
||||
|
||||
module:log(LOGLEVEL, "loaded")
|
||||
|
||||
local function affiliation_from_token(session)
|
||||
if not session or not session.auth_token then
|
||||
return nil
|
||||
end
|
||||
|
||||
local context_user = session.jitsi_meet_context_user
|
||||
if context_user then
|
||||
if context_user["affiliation"] == "owner"
|
||||
or context_user["affiliation"] == "moderator"
|
||||
or context_user["affiliation"] == "teacher"
|
||||
or context_user["moderator"] == "true"
|
||||
or context_user["moderator"] == true then
|
||||
return "owner"
|
||||
end
|
||||
end
|
||||
|
||||
return "member"
|
||||
end
|
||||
|
||||
-- Hook priority -3.5: runs after lobby-bypass plugins such as mod_token_lobby_bypass
|
||||
-- (priority -3), so any affiliation they grant is visible here and can be upgraded
|
||||
-- to the correct token-derived value. Runs before the lobby gate (-4) and
|
||||
-- Prosody's built-in members_only check (-5).
|
||||
module:hook("muc-occupant-pre-join", function(event)
|
||||
local room, occupant, session = event.room, event.occupant, event.origin
|
||||
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
|
||||
module:log(LOGLEVEL, "skip, %s", occupant.jid)
|
||||
return
|
||||
end
|
||||
|
||||
-- When lobby is active (members-only) only proceed when the user already
|
||||
-- has a non-none affiliation (admitted by moderator, or granted by a bypass
|
||||
-- plugin that ran at priority -3) or is bypassing via the room password.
|
||||
-- Otherwise let the lobby gate (-4) block this first join attempt.
|
||||
if room:get_members_only() then
|
||||
local existing = room:get_affiliation(occupant.bare_jid)
|
||||
if not existing or existing == 'none' then
|
||||
local join_el = event.stanza:get_child('x', 'http://jabber.org/protocol/muc')
|
||||
local join_pwd = join_el and join_el:get_child_text('password', 'http://jabber.org/protocol/muc')
|
||||
local bypassing_via_password = join_pwd and room:get_password() and join_pwd == room:get_password()
|
||||
if not bypassing_via_password then
|
||||
module:log(LOGLEVEL, "skip, lobby active for %s", occupant.bare_jid)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local affiliation = affiliation_from_token(session)
|
||||
if not affiliation then
|
||||
return
|
||||
end
|
||||
|
||||
room:set_affiliation(true, occupant.bare_jid, affiliation)
|
||||
if affiliation == "owner" then
|
||||
occupant.role = "moderator"
|
||||
end
|
||||
module:log(LOGLEVEL, "set affiliation=%s for %s", affiliation, occupant.bare_jid)
|
||||
end, -3.5)
|
||||
200
resources/prosody-plugins/mod_token_verification.lua
Normal file
200
resources/prosody-plugins/mod_token_verification.lua
Normal file
@@ -0,0 +1,200 @@
|
||||
-- Enforces JWT token room-claim verification at MUC join/create time. Loaded
|
||||
-- on the MUC component. For each join or room-create, calls
|
||||
-- token_util:verify_room() to check that the token's room claim matches the
|
||||
-- target room JID. Admins and domains listed in token_verification_allowlist
|
||||
-- are exempt. Anonymous users (no token) are allowed through. When
|
||||
-- token_verification_require_token_for_moderation is set, also blocks room
|
||||
-- config IQs (e.g. granting moderator status) from unauthenticated users.
|
||||
-- Records the verified conference on the session (jitsi_meet_verified_room) and
|
||||
-- answers the 'jitsi-verify-session-rooms' event, so that a session whose JWT
|
||||
-- claims are refreshed after the join (mod_auth_token, on stream resumption) is
|
||||
-- re-checked against it.
|
||||
-- Token authentication
|
||||
-- Copyright (C) 2021-present 8x8, Inc.
|
||||
|
||||
local log = module._log;
|
||||
local host = module.host;
|
||||
local st = require "util.stanza";
|
||||
local jid_split = require 'util.jid'.split;
|
||||
local jid_bare = require 'util.jid'.bare;
|
||||
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
|
||||
local DEBUG = false;
|
||||
|
||||
local measure_success = module:measure('success', 'counter');
|
||||
local measure_fail = module:measure('fail', 'counter');
|
||||
|
||||
local parentHostName = string.gmatch(tostring(host), "%w+.(%w.+)")();
|
||||
if parentHostName == nil then
|
||||
module:log("error", "Failed to start - unable to get parent hostname");
|
||||
return;
|
||||
end
|
||||
|
||||
local parentCtx = module:context(parentHostName);
|
||||
if parentCtx == nil then
|
||||
module:log("error",
|
||||
"Failed to start - unable to get parent context for host: %s",
|
||||
tostring(parentHostName));
|
||||
return;
|
||||
end
|
||||
|
||||
local token_util = module:require "token/util".new(parentCtx);
|
||||
|
||||
-- no token configuration
|
||||
if token_util == nil then
|
||||
return;
|
||||
end
|
||||
|
||||
module:log("debug",
|
||||
"%s - starting MUC token verifier app_id: %s app_secret: %s allow empty: %s",
|
||||
tostring(host), tostring(token_util.appId), tostring(token_util.appSecret),
|
||||
tostring(token_util.allowEmptyToken));
|
||||
|
||||
-- option to disable room modification (sending muc config form) for guest that do not provide token
|
||||
local require_token_for_moderation;
|
||||
-- option to allow domains to skip token verification
|
||||
local allowlist;
|
||||
local function load_config()
|
||||
require_token_for_moderation = module:get_option_boolean("token_verification_require_token_for_moderation");
|
||||
allowlist = module:get_option_set('token_verification_allowlist', {});
|
||||
end
|
||||
load_config();
|
||||
|
||||
-- verify user and whether he is allowed to join a room based on the token information
|
||||
local function verify_user(session, stanza)
|
||||
if DEBUG then
|
||||
module:log("debug", "Session token: %s, session room: %s",
|
||||
tostring(session.auth_token), tostring(session.jitsi_meet_room));
|
||||
end
|
||||
|
||||
-- token not required for admin users
|
||||
local user_jid = stanza.attr.from;
|
||||
if is_admin(user_jid) then
|
||||
if DEBUG then module:log("debug", "Token not required from admin user: %s", user_jid); end
|
||||
return true;
|
||||
end
|
||||
|
||||
-- token not required for users matching allow list
|
||||
local user_bare_jid = jid_bare(user_jid);
|
||||
local _, user_domain = jid_split(user_jid);
|
||||
|
||||
-- allowlist for participants, jigasi (sip & transcriber), jibri (recorder & sip)
|
||||
if allowlist:contains(user_domain)
|
||||
or allowlist:contains(user_bare_jid)
|
||||
|
||||
-- allow main participants in visitor mode
|
||||
or session.type == 's2sin' then
|
||||
if DEBUG then module:log("debug", "Token not required from user in allow list: %s", user_jid); end
|
||||
return true;
|
||||
end
|
||||
|
||||
if DEBUG then module:log("debug", "Will verify token for user: %s, room: %s ", user_jid, stanza.attr.to); end
|
||||
local res, err, reason = token_util:verify_room(session, stanza.attr.to);
|
||||
if not res then
|
||||
if not err and not reason then
|
||||
reason = 'Room and token mismatched';
|
||||
end
|
||||
|
||||
module:log('error', 'Token %s not allowed to join: %s err: %s reason: %s',
|
||||
tostring(session.auth_token), tostring(stanza.attr.to), err, reason);
|
||||
|
||||
local response = st.error_reply(stanza, 'cancel', 'not-allowed', reason);
|
||||
if err then
|
||||
response:tag(err, { xmlns = 'http://jitsi.org/jitmeet' });
|
||||
end
|
||||
|
||||
session.send(response);
|
||||
return false; -- we need to just return non nil
|
||||
end
|
||||
if DEBUG then module:log("debug", "allowed: %s to enter/create room: %s", user_jid, stanza.attr.to); end
|
||||
|
||||
-- Remember the conference this session was verified for, so claims that are
|
||||
-- refreshed later (mod_auth_token, on stream resumption) can be checked
|
||||
-- against it without going through a join again.
|
||||
session.jitsi_meet_verified_room = jid_bare(stanza.attr.to);
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
module:hook("muc-room-pre-create", function(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
if DEBUG then module:log("debug", "pre create: %s %s", tostring(origin), tostring(stanza)); end
|
||||
if not verify_user(origin, stanza) then
|
||||
measure_fail(1);
|
||||
return true; -- Returning any value other than nil will halt processing of the event
|
||||
end
|
||||
measure_success(1);
|
||||
end, 99);
|
||||
|
||||
module:hook("muc-occupant-pre-join", function(event)
|
||||
local origin, room, stanza = event.origin, event.room, event.stanza;
|
||||
if DEBUG then module:log("debug", "pre join: %s %s", tostring(room), tostring(stanza)); end
|
||||
if not verify_user(origin, stanza) then
|
||||
measure_fail(1);
|
||||
return true; -- Returning any value other than nil will halt processing of the event
|
||||
end
|
||||
measure_success(1);
|
||||
end, 99);
|
||||
|
||||
-- Verifies the token claims currently on a session against the conference it
|
||||
-- was verified for on join. Fired by mod_auth_token when a connection resuming
|
||||
-- a hibernating session (XEP-0198) presents a different token, which refreshes
|
||||
-- the claims of the live session: the hooks above only run at join time, so
|
||||
-- this applies the same room check to a session that is already in a room.
|
||||
-- Answering with res=false makes mod_auth_token keep the claims that were
|
||||
-- verified on join.
|
||||
module:hook_global('jitsi-verify-session-rooms', function(event)
|
||||
local session = event.session;
|
||||
local room_jid = session.jitsi_meet_verified_room;
|
||||
|
||||
if not room_jid then
|
||||
return;
|
||||
end
|
||||
|
||||
-- Being an occupant is deliberately not required: a session that moved into
|
||||
-- a breakout room has left the main room it joined, and that main room is
|
||||
-- still the conference its claims have to cover. Only a room that is gone
|
||||
-- (everyone left while the session was hibernating) is skipped.
|
||||
if not prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local res, err, reason = token_util:verify_room(session, room_jid);
|
||||
|
||||
if not res then
|
||||
measure_fail(1);
|
||||
return { res = false; room = room_jid; error = err; reason = reason; };
|
||||
end
|
||||
|
||||
measure_success(1);
|
||||
end);
|
||||
|
||||
for event_name, method in pairs {
|
||||
-- Normal room interactions
|
||||
["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ;
|
||||
-- Host room
|
||||
["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ;
|
||||
} do
|
||||
module:hook(event_name, function (event)
|
||||
local session, stanza = event.origin, event.stanza;
|
||||
|
||||
-- if we do not require token we pass it through(default behaviour)
|
||||
-- or the request is coming from admin (focus)
|
||||
if not require_token_for_moderation or is_admin(stanza.attr.from) then
|
||||
return;
|
||||
end
|
||||
|
||||
-- jitsi_meet_room is set after the token had been verified
|
||||
if not session.auth_token or not session.jitsi_meet_room then
|
||||
session.send(
|
||||
st.error_reply(
|
||||
stanza, "cancel", "not-allowed", "Room modification disabled for guests"));
|
||||
return true;
|
||||
end
|
||||
|
||||
end, -1); -- the default prosody hook is on -2
|
||||
end
|
||||
|
||||
module:hook_global('config-reloaded', load_config);
|
||||
46
resources/prosody-plugins/mod_trace.lua
Normal file
46
resources/prosody-plugins/mod_trace.lua
Normal file
@@ -0,0 +1,46 @@
|
||||
local otel = module:require "otel"
|
||||
local http = require "net.http"
|
||||
|
||||
local otlp_endpoint = module:get_option("otlp_endpoint")
|
||||
|
||||
if not otlp_endpoint then
|
||||
module:log("warn", "module disabled: otlp_endpoint unset")
|
||||
return
|
||||
end
|
||||
|
||||
local exporter = otel.Exporter.new(http.request, otlp_endpoint)
|
||||
local processor = otel.Processor.new(exporter)
|
||||
local tracer = otel.Tracer.new(processor, "prosody", "muc")
|
||||
|
||||
local function find_tag(tags, name)
|
||||
for _, tag in ipairs(tags) do
|
||||
if tag.name == name then
|
||||
return tag
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
module:hook("iq/full", function(event)
|
||||
local iq_element = event.stanza.tags[1]
|
||||
if not iq_element then
|
||||
return
|
||||
end
|
||||
|
||||
if iq_element.name ~= "conference-modify" or iq_element.attr.xmlns ~= "jitsi:colibri2" then
|
||||
return
|
||||
end
|
||||
|
||||
local traceparent = find_tag(iq_element.tags, "traceparent")
|
||||
if not traceparent then
|
||||
return
|
||||
end
|
||||
|
||||
local span = tracer:start_span("colibri.conference-modify", {
|
||||
trace_id = traceparent.attr.trace_id,
|
||||
span_id = traceparent.attr.parent_id,
|
||||
})
|
||||
span:end_span()
|
||||
|
||||
traceparent.attr.parent_id = span.span_id
|
||||
end, 1)
|
||||
31
resources/prosody-plugins/mod_turncredentials_http.lua
Normal file
31
resources/prosody-plugins/mod_turncredentials_http.lua
Normal file
@@ -0,0 +1,31 @@
|
||||
-- http endpoint to expose turn credentials for other services
|
||||
-- Copyright (C) 2023-present 8x8, Inc.
|
||||
|
||||
local ext_services = module:depends("external_services");
|
||||
local get_services = ext_services.get_services;
|
||||
|
||||
local async_handler_wrapper = module:require "util".async_handler_wrapper;
|
||||
local json = require 'cjson.safe';
|
||||
|
||||
--- Handles request for retrieving turn credentials
|
||||
-- @param event the http event, holds the request query
|
||||
-- @return GET response, containing a json with participants details
|
||||
function handle_get_turn_credentials (event)
|
||||
local GET_response = {
|
||||
headers = {
|
||||
content_type = "application/json";
|
||||
};
|
||||
body = json.encode(get_services());
|
||||
};
|
||||
return GET_response;
|
||||
end;
|
||||
|
||||
function module.load()
|
||||
module:depends("http");
|
||||
module:provides("http", {
|
||||
default_path = "/";
|
||||
route = {
|
||||
["GET turn-credentials"] = function (event) return async_handler_wrapper(event,handle_get_turn_credentials) end;
|
||||
};
|
||||
});
|
||||
end
|
||||
600
resources/prosody-plugins/mod_visitors.lua
Normal file
600
resources/prosody-plugins/mod_visitors.lua
Normal file
@@ -0,0 +1,600 @@
|
||||
--- activate under main vhost
|
||||
--- In /etc/hosts add:
|
||||
--- vm1-ip-address visitors1.domain.com
|
||||
--- vm1-ip-address conference.visitors1.domain.com
|
||||
--- vm2-ip-address visitors2.domain.com
|
||||
--- vm2-ip-address conference.visitors2.domain.com
|
||||
--- Enable in global modules: 's2s_bidi' and 'certs_all'
|
||||
--- Make sure 's2s' is not in modules_disabled
|
||||
--- Open port 5269 on the provider side and on the firewall on the machine (iptables -I INPUT 4 -p tcp -m tcp --dport 5269 -j ACCEPT)
|
||||
--- NOTE: Make sure all communication between prosodies is using the real jids ([foo]room1@muc.example.com)
|
||||
local st = require 'util.stanza';
|
||||
local jid = require 'util.jid';
|
||||
local new_id = require 'util.id'.medium;
|
||||
local util = module:require 'util';
|
||||
local filter_identity_from_presence = util.filter_identity_from_presence;
|
||||
local is_admin = util.is_admin;
|
||||
local presence_check_status = util.presence_check_status;
|
||||
local process_host_module = util.process_host_module;
|
||||
local is_jibri = util.is_jibri;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local json = require 'cjson.safe';
|
||||
|
||||
-- Debug flag
|
||||
local DEBUG = false;
|
||||
|
||||
local MUC_NS = 'http://jabber.org/protocol/muc';
|
||||
|
||||
-- required parameter for custom muc component prefix, defaults to 'conference'
|
||||
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
|
||||
|
||||
local main_muc_component_config = module:get_option_string('main_muc');
|
||||
if main_muc_component_config == nil then
|
||||
module:log('error', 'visitors rooms not enabled missing main_muc config');
|
||||
return ;
|
||||
end
|
||||
|
||||
-- Advertise the component for discovery via disco#items
|
||||
module:add_identity('component', 'visitors', 'visitors.'..module.host);
|
||||
|
||||
local sent_iq_cache = require 'util.cache'.new(200);
|
||||
|
||||
-- visitors_nodes = {
|
||||
-- roomjid1 = {
|
||||
-- nodes = {
|
||||
-- ['conference.visitors1.jid'] = 2, // number of main participants, on 0 we clean it
|
||||
-- ['conference.visitors2.jid'] = 3
|
||||
-- }
|
||||
-- },
|
||||
-- roomjid2 = {}
|
||||
--}
|
||||
local visitors_nodes = {};
|
||||
|
||||
-- forward declaration; assigned below. Referenced by a closure registered on the main muc's
|
||||
-- 'room-metadata-changed' event (see process_host_module(main_muc_component_config, ...) below) so vnodes
|
||||
-- are refreshed whenever the main room's metadata (e.g. asyncTranscription) changes.
|
||||
local update_vnodes_for_room;
|
||||
|
||||
-- sends connect or update iq
|
||||
-- @parameter type - Type of iq to send 'connect' or 'update'
|
||||
local function send_visitors_iq(conference_service, room, type)
|
||||
-- send iq informing the vnode that the connect is done and it will allow visitors to join
|
||||
local iq_id = new_id();
|
||||
sent_iq_cache:set(iq_id, socket.gettime());
|
||||
local visitors_iq = st.iq({
|
||||
type = 'set',
|
||||
to = conference_service,
|
||||
from = module.host,
|
||||
id = iq_id })
|
||||
:tag('visitors', { xmlns = 'jitsi:visitors',
|
||||
room = jid.join(jid.node(room.jid), conference_service) })
|
||||
:tag(type, { xmlns = 'jitsi:visitors',
|
||||
password = type ~= 'disconnect' and room:get_password() or '',
|
||||
lobby = room._data.lobbyroom and 'true' or 'false',
|
||||
meetingId = room._data.meetingId,
|
||||
createdTimestamp = room.created_timestamp and tostring(room.created_timestamp) or nil,
|
||||
allowUnauthenticatedAccess = room._data.allowUnauthenticatedAccess ~= nil and tostring(room._data.allowUnauthenticatedAccess) or nil
|
||||
});
|
||||
|
||||
if type == 'update' then
|
||||
visitors_iq:tag('moderators', { xmlns = 'jitsi:visitors' });
|
||||
|
||||
for _, o in room:each_occupant() do
|
||||
if not is_admin(o.bare_jid) and o.role == 'moderator' then
|
||||
visitors_iq:tag('item', { epId = jid.resource(o.nick) }):up();
|
||||
end
|
||||
end
|
||||
|
||||
visitors_iq:up();
|
||||
|
||||
-- files that are shared in the room
|
||||
if room.jitsi_shared_files then
|
||||
visitors_iq:tag('files', { xmlns = 'jitsi:visitors' });
|
||||
for k, v in pairs(room.jitsi_shared_files) do
|
||||
visitors_iq:tag('file', {
|
||||
id = k
|
||||
}):text(json.encode(v)):up();
|
||||
end
|
||||
visitors_iq:up();
|
||||
end
|
||||
|
||||
if room.polls and room.polls.count > 0 then
|
||||
-- polls created in the room that we want to send to the visitor nodes
|
||||
local data = {
|
||||
command = "old-polls",
|
||||
polls = {},
|
||||
type = 'polls'
|
||||
};
|
||||
for i, poll in ipairs(room.polls.order) do
|
||||
data.polls[i] = {
|
||||
pollId = poll.pollId,
|
||||
senderId = poll.senderId,
|
||||
senderName = poll.senderName,
|
||||
question = poll.question,
|
||||
answers = poll.answers
|
||||
};
|
||||
end
|
||||
|
||||
local json_msg_str, error = json.encode(data);
|
||||
if not json_msg_str then
|
||||
module:log('error', 'Error encoding data room:%s error:%s', room.jid, error);
|
||||
end
|
||||
|
||||
visitors_iq:tag('polls'):text(json_msg_str):up();
|
||||
end
|
||||
end
|
||||
|
||||
-- Forward the main room's metadata (e.g. asyncTranscription) so visitor nodes/clients can make the
|
||||
-- same decisions main-room clients do (sent on both 'connect' and 'update', not just 'update').
|
||||
if room.jitsiMetadata then
|
||||
local json_metadata, metadata_error = json.encode(room.jitsiMetadata);
|
||||
if json_metadata then
|
||||
visitors_iq:tag('metadata', { xmlns = 'jitsi:visitors' }):text(json_metadata):up();
|
||||
else
|
||||
module:log('error', 'Error encoding metadata for room:%s error:%s', room.jid, metadata_error);
|
||||
end
|
||||
end
|
||||
|
||||
visitors_iq:up();
|
||||
|
||||
module:send(visitors_iq);
|
||||
end
|
||||
|
||||
-- Filter out identity information (nick name, email, etc) from a presence stanza,
|
||||
-- if the hideDisplayNameForGuests option for the room is set.
|
||||
-- This is applied to presence of main room participants before it is sent out to vnodes.
|
||||
local function filter_stanza_nick_if_needed(stanza, room)
|
||||
if not stanza or stanza.name ~= 'presence' or stanza.attr.type == 'error' or stanza.attr.type == 'unavailable' then
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- if hideDisplayNameForGuests we want to drop any display name from the presence stanza
|
||||
if room and room._data.hideDisplayNameForGuests then
|
||||
return filter_identity_from_presence(stanza);
|
||||
end
|
||||
|
||||
return stanza;
|
||||
end
|
||||
|
||||
-- an event received from visitors component, which receives iqs from jicofo
|
||||
local function connect_vnode(event)
|
||||
local room, vnode = event.room, event.vnode;
|
||||
local conference_service = muc_domain_prefix..'.'..vnode..'.meet.jitsi';
|
||||
|
||||
if visitors_nodes[room.jid] and
|
||||
visitors_nodes[room.jid].nodes and
|
||||
visitors_nodes[room.jid].nodes[conference_service] then
|
||||
-- nothing to do
|
||||
return;
|
||||
end
|
||||
|
||||
if visitors_nodes[room.jid] == nil then
|
||||
visitors_nodes[room.jid] = {};
|
||||
end
|
||||
if visitors_nodes[room.jid].nodes == nil then
|
||||
visitors_nodes[room.jid].nodes = {};
|
||||
end
|
||||
|
||||
local sent_main_participants = 0;
|
||||
|
||||
-- send update initially so we can report the moderators that will join
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
|
||||
-- let's send message history
|
||||
local event = {
|
||||
room = room;
|
||||
to = conference_service;
|
||||
next_stanza = function() end; -- muc-get-history should define this iterator
|
||||
};
|
||||
module:context(main_muc_component_config):fire_event("muc-get-history", event);
|
||||
for msg in event.next_stanza, event do
|
||||
-- the messages stored in history has been stored before domain_mapper and
|
||||
-- contain the virtual jid for a from
|
||||
msg.attr.from = room_jid_match_rewrite(msg.attr.from);
|
||||
room:route_stanza(msg);
|
||||
end
|
||||
|
||||
for _, o in room:each_occupant() do
|
||||
if not is_admin(o.bare_jid) then
|
||||
local fmuc_pr = filter_stanza_nick_if_needed(st.clone(o:get_presence()), room);
|
||||
local user, _, res = jid.split(o.nick);
|
||||
fmuc_pr.attr.to = jid.join(user, conference_service , res);
|
||||
fmuc_pr.attr.from = o.jid;
|
||||
-- add <x>
|
||||
fmuc_pr:tag('x', { xmlns = MUC_NS });
|
||||
|
||||
-- if there is a password on the main room let's add the password for the vnode join
|
||||
-- as we will set the password to the vnode room and we will need it
|
||||
local pass = room:get_password();
|
||||
if pass and pass ~= '' then
|
||||
fmuc_pr:tag('password'):text(pass);
|
||||
end
|
||||
fmuc_pr:up();
|
||||
|
||||
module:send(fmuc_pr);
|
||||
|
||||
sent_main_participants = sent_main_participants + 1;
|
||||
end
|
||||
end
|
||||
visitors_nodes[room.jid].nodes[conference_service] = sent_main_participants;
|
||||
|
||||
send_visitors_iq(conference_service, room, 'connect');
|
||||
end
|
||||
module:hook('jitsi-connect-vnode', connect_vnode);
|
||||
|
||||
-- listens for responses to the iq sent for connecting vnode
|
||||
local function stanza_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
if stanza.name ~= 'iq' then
|
||||
return;
|
||||
end
|
||||
|
||||
-- we receive error from vnode for our disconnect message as the room was already destroyed (all visitors left)
|
||||
if (stanza.attr.type == 'result' or stanza.attr.type == 'error') and sent_iq_cache:get(stanza.attr.id) then
|
||||
sent_iq_cache:set(stanza.attr.id, nil);
|
||||
return true;
|
||||
end
|
||||
end
|
||||
module:hook('iq/host', stanza_handler, 10);
|
||||
|
||||
-- an event received from visitors component, which receives iqs from jicofo
|
||||
local function disconnect_vnode(event)
|
||||
local room, vnode = event.room, event.vnode;
|
||||
|
||||
if visitors_nodes[event.room.jid] == nil then
|
||||
-- maybe the room was already destroyed and vnodes cleared
|
||||
return;
|
||||
end
|
||||
|
||||
local conference_service = muc_domain_prefix..'.'..vnode..'.meet.jitsi';
|
||||
|
||||
visitors_nodes[room.jid].nodes[conference_service] = nil;
|
||||
|
||||
send_visitors_iq(conference_service, room, 'disconnect');
|
||||
end
|
||||
module:hook('jitsi-disconnect-vnode', disconnect_vnode);
|
||||
|
||||
-- takes care when the visitor nodes destroys the room to count the leaving participants from there, and if its really destroyed
|
||||
-- we clean up, so if we establish again the connection to the same visitor node to send the main participants
|
||||
module:hook('presence/full', function(event)
|
||||
local stanza = event.stanza;
|
||||
local room_name, from_host = jid.split(stanza.attr.from);
|
||||
if stanza.attr.type == 'unavailable' and from_host ~= main_muc_component_config then
|
||||
local room_jid = jid.join(room_name, main_muc_component_config); -- converts from visitor to main room jid
|
||||
|
||||
local x = stanza:get_child('x', 'http://jabber.org/protocol/muc#user');
|
||||
if not presence_check_status(x, '110') then
|
||||
return;
|
||||
end
|
||||
|
||||
if visitors_nodes[room_jid] and visitors_nodes[room_jid].nodes
|
||||
and visitors_nodes[room_jid].nodes[from_host] then
|
||||
visitors_nodes[room_jid].nodes[from_host] = visitors_nodes[room_jid].nodes[from_host] - 1;
|
||||
-- we clean only on disconnect coming from jicofo
|
||||
end
|
||||
end
|
||||
end, 900);
|
||||
|
||||
process_host_module(main_muc_component_config, function(host_module, host)
|
||||
-- room metadata changed (e.g. asyncTranscription) -- refresh all connected vnodes so their
|
||||
-- mirrored room and visitor clients stay in sync with the main room. Called via a wrapper closure
|
||||
-- (rather than passing update_vnodes_for_room directly) since it is only assigned further down in
|
||||
-- this file, after this hook is registered.
|
||||
host_module:hook('room-metadata-changed', function(event)
|
||||
update_vnodes_for_room(event);
|
||||
end);
|
||||
|
||||
-- detects presence change in a main participant and propagate it to the used visitor nodes
|
||||
host_module:hook('muc-occupant-pre-change', function (event)
|
||||
local room, stanzaEv, occupant = event.room, event.stanza, event.dest_occupant;
|
||||
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
|
||||
|
||||
-- filter focus and jibri
|
||||
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil or is_jibri(occupant.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
local user, _, res = jid.split(occupant.nick);
|
||||
-- a change in the presence of a main participant we need to update all active visitor nodes
|
||||
for k in pairs(vnodes) do
|
||||
local fmuc_pr = st.clone(stanza);
|
||||
fmuc_pr.attr.to = jid.join(user, k, res);
|
||||
fmuc_pr.attr.from = occupant.jid;
|
||||
module:send(fmuc_pr);
|
||||
end
|
||||
end);
|
||||
|
||||
-- when a main participant leaves inform the visitor nodes
|
||||
host_module:hook('muc-occupant-left', function (event)
|
||||
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
|
||||
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
|
||||
|
||||
-- ignore jicofo and jibri
|
||||
if is_admin(occupant.bare_jid) or visitors_nodes[room.jid] == nil or visitors_nodes[room.jid].nodes == nil
|
||||
or is_jibri(occupant.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
--this is probably participant kick scenario, create an unavailable presence and send to vnodes.
|
||||
if not stanza then
|
||||
stanza = st.presence {from = occupant.nick; type = "unavailable";};
|
||||
end
|
||||
|
||||
-- we want to update visitor node that a main participant left or kicked.
|
||||
if stanza then
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
local user, _, res = jid.split(occupant.nick);
|
||||
for k in pairs(vnodes) do
|
||||
local fmuc_pr = st.clone(stanza);
|
||||
fmuc_pr.attr.to = jid.join(user, k, res);
|
||||
fmuc_pr.attr.from = occupant.jid;
|
||||
module:send(fmuc_pr);
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
-- cleanup cache
|
||||
host_module:hook('muc-room-destroyed',function(event)
|
||||
local room = event.room;
|
||||
|
||||
-- room is destroyed let's disconnect all vnodes
|
||||
if visitors_nodes[room.jid] then
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
send_visitors_iq(conference_service, room, 'disconnect');
|
||||
end
|
||||
|
||||
visitors_nodes[room.jid] = nil;
|
||||
end
|
||||
end, 1); -- prosody handles it at 0
|
||||
|
||||
-- detects new participants joining main room and sending them to the visitor nodes
|
||||
host_module:hook('muc-occupant-joined', function (event)
|
||||
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
|
||||
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
|
||||
|
||||
-- filter focus, ignore jibri
|
||||
if is_admin(stanza.attr.from) or visitors_nodes[room.jid] == nil or is_jibri(occupant.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
local user, _, res = jid.split(occupant.nick);
|
||||
-- a main participant we need to update all active visitor nodes
|
||||
for k in pairs(vnodes) do
|
||||
if occupant.role == 'moderator' then
|
||||
-- first send that the participant is a moderator
|
||||
send_visitors_iq(k, room, 'update');
|
||||
end
|
||||
local fmuc_pr = st.clone(stanza);
|
||||
fmuc_pr.attr.to = jid.join(user, k, res);
|
||||
fmuc_pr.attr.from = occupant.jid;
|
||||
module:send(fmuc_pr);
|
||||
end
|
||||
end);
|
||||
-- forwards messages from main participants to vnodes
|
||||
host_module:hook('muc-occupant-groupchat', function(event)
|
||||
local room, stanzaEv, occupant = event.room, event.stanza, event.occupant;
|
||||
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
|
||||
|
||||
-- filter sending messages from transcribers/jibris to visitors
|
||||
if not visitors_nodes[room.jid] then
|
||||
return;
|
||||
end
|
||||
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
local user = jid.node(occupant.nick);
|
||||
-- a main participant we need to update all active visitor nodes
|
||||
for k in pairs(vnodes) do
|
||||
local fmuc_msg = st.clone(stanza);
|
||||
fmuc_msg.attr.to = jid.join(user, k);
|
||||
fmuc_msg.attr.from = occupant.jid;
|
||||
module:send(fmuc_msg);
|
||||
end
|
||||
end);
|
||||
-- forwards a moderation applied by the room to the vnodes. The hook above only
|
||||
-- handles messages sent by an occupant; a moderation is sent by the room itself,
|
||||
-- so mod_muc_message_moderation announces it here.
|
||||
host_module:hook('jitsi-message-moderated', function(event)
|
||||
local room, stanza = event.room, event.stanza;
|
||||
|
||||
if not visitors_nodes[room.jid] or not visitors_nodes[room.jid].nodes then
|
||||
return;
|
||||
end
|
||||
|
||||
for conference_service in pairs(visitors_nodes[room.jid].nodes) do
|
||||
local fmuc_msg = st.clone(stanza);
|
||||
fmuc_msg.attr.to = jid.join(jid.node(room.jid), conference_service);
|
||||
fmuc_msg.attr.from = room_jid_match_rewrite(room.jid);
|
||||
module:log('debug', 'Forwarding moderation to %s', fmuc_msg.attr.to);
|
||||
module:send(fmuc_msg);
|
||||
end
|
||||
end);
|
||||
|
||||
-- receiving messages from visitor nodes and forward them to local main participants
|
||||
-- and forward them to the rest of visitor nodes
|
||||
host_module:hook('muc-occupant-groupchat', function(event)
|
||||
local occupant, room, stanzaEv = event.occupant, event.room, event.stanza;
|
||||
local stanza = filter_stanza_nick_if_needed(stanzaEv, room);
|
||||
local to = stanza.attr.to;
|
||||
local from = stanza.attr.from;
|
||||
local from_vnode = jid.host(from);
|
||||
|
||||
if occupant or not (visitors_nodes[to]
|
||||
and visitors_nodes[to].nodes
|
||||
and visitors_nodes[to].nodes[from_vnode]) then
|
||||
return;
|
||||
end
|
||||
|
||||
if host_module:fire_event('jitsi-visitor-groupchat-pre-route', event) then
|
||||
-- message filtered
|
||||
return;
|
||||
end
|
||||
|
||||
-- a message from visitor occupant of known visitor node
|
||||
stanza.attr.from = to;
|
||||
for _, o in room:each_occupant() do
|
||||
-- send it to the nick to be able to route it to the room (ljm multiple rooms) from unknown occupant
|
||||
room:route_to_occupant(o, stanza);
|
||||
end
|
||||
-- let's add the message to the history of the room
|
||||
host_module:fire_event("muc-add-history", { room = room; stanza = stanza; from = from; visitor = true; });
|
||||
|
||||
-- now we need to send to rest of visitor nodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for k in pairs(vnodes) do
|
||||
if k ~= from_vnode then
|
||||
local st_copy = st.clone(stanza);
|
||||
st_copy.attr.to = jid.join(jid.node(room.jid), k);
|
||||
module:send(st_copy);
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end, 55); -- prosody check for unknown participant chat is prio 50, we want to override it
|
||||
|
||||
-- Handle private messages from visitor nodes to main participants
|
||||
-- This routes forwarded private messages through the proper MUC system
|
||||
host_module:hook('message/full', function(event)
|
||||
local stanza = event.stanza;
|
||||
|
||||
-- Only handle chat messages (private messages)
|
||||
if stanza.attr.type ~= 'chat' then
|
||||
return; -- Let other handlers process non-chat messages
|
||||
end
|
||||
|
||||
local to = stanza.attr.to;
|
||||
|
||||
-- Early return if this is not targeted at our MUC component
|
||||
if jid.host(to) ~= main_muc_component_config then
|
||||
return; -- Not for our MUC component, let other handlers process
|
||||
end
|
||||
|
||||
local from = stanza.attr.from;
|
||||
local from_host = jid.host(from);
|
||||
local to_node = jid.node(to);
|
||||
local to_resource = jid.resource(to);
|
||||
|
||||
-- Check if this is a private message from a known visitor node
|
||||
local target_room_jid = jid.bare(to);
|
||||
|
||||
-- Early return if we don't have any visitor nodes for this room
|
||||
if not (visitors_nodes[target_room_jid] and visitors_nodes[target_room_jid].nodes) then
|
||||
return; -- No visitor nodes for this room, let default MUC handle it
|
||||
end
|
||||
|
||||
-- Early return if the from_host is not a known visitor node
|
||||
if not visitors_nodes[target_room_jid].nodes[from_host] then
|
||||
-- This could be a main->visitor message, let it go through s2s
|
||||
return; -- Not from a known visitor node, let default MUC handle it
|
||||
end
|
||||
|
||||
-- At this point we know it's a visitor message, handle it
|
||||
local room = prosody.hosts[main_muc_component_config].modules.muc.get_room_from_jid(target_room_jid);
|
||||
if room then
|
||||
-- Find the occupant
|
||||
local occupant = room:get_occupant_by_nick(to);
|
||||
if occupant then
|
||||
-- Add addresses element (XEP-0033) to store original visitor JID for reply functionality
|
||||
stanza:tag('addresses', { xmlns = 'http://jabber.org/protocol/address' })
|
||||
:tag('address', { type = 'ofrom', jid = stanza.attr.from }):up()
|
||||
:up();
|
||||
|
||||
-- Change from to be the main domain equivalent for proper client recognition
|
||||
-- Use bare JID without resource
|
||||
stanza.attr.from = jid.join(to_node, main_muc_component_config);
|
||||
|
||||
room:route_to_occupant(occupant, stanza);
|
||||
|
||||
return true;
|
||||
else
|
||||
module:log('warn', 'VISITOR PRIVATE MESSAGE: Occupant not found for %s', to);
|
||||
end
|
||||
else
|
||||
module:log('warn', 'VISITOR PRIVATE MESSAGE: Room not found for %s', to);
|
||||
end
|
||||
|
||||
return false;
|
||||
end, 10); -- Normal priority since we're in the right place now
|
||||
|
||||
-- Main->visitor private messages work via s2s routing automatically
|
||||
-- No special handling needed!
|
||||
|
||||
host_module:hook('muc-config-submitted/muc#roomconfig_roomsecret', function(event)
|
||||
if event.status_codes['104'] then
|
||||
local room = event.room;
|
||||
|
||||
if visitors_nodes[room.jid] then
|
||||
-- we need to update all vnodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
end
|
||||
end
|
||||
end
|
||||
end, -100); -- we want to run last in order to check is the status code 104
|
||||
|
||||
host_module:hook('muc-set-affiliation', function (event)
|
||||
if event.actor and not is_admin(event.actor) and event.affiliation == 'owner' then
|
||||
local room = event.room;
|
||||
|
||||
if not visitors_nodes[room.jid] then
|
||||
return;
|
||||
end
|
||||
-- we need to update all vnodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
end
|
||||
end
|
||||
end, -2);
|
||||
|
||||
host_module:hook('poll-created', function (event)
|
||||
local room = event.room;
|
||||
|
||||
if not visitors_nodes[room.jid] then
|
||||
return;
|
||||
end
|
||||
|
||||
-- we need to update all vnodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
end
|
||||
end);
|
||||
host_module:hook('answer-poll', function (event)
|
||||
local room, stanza = event.room, event.event.stanza;
|
||||
|
||||
if not visitors_nodes[room.jid] then
|
||||
return;
|
||||
end
|
||||
|
||||
local from = stanza.attr.from;
|
||||
|
||||
-- we need to update all vnodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
-- skip sending the answer to the node from where it originates
|
||||
if conference_service ~= from then
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
end
|
||||
end
|
||||
end);
|
||||
end);
|
||||
|
||||
function update_vnodes_for_room(event)
|
||||
local room = event.room;
|
||||
if visitors_nodes[room.jid] then
|
||||
-- we need to update all vnodes
|
||||
local vnodes = visitors_nodes[room.jid].nodes;
|
||||
for conference_service in pairs(vnodes) do
|
||||
send_visitors_iq(conference_service, room, 'update');
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
module:hook('jitsi-lobby-enabled', update_vnodes_for_room);
|
||||
module:hook('jitsi-lobby-disabled', update_vnodes_for_room);
|
||||
module:hook('jitsi-filesharing-updated', update_vnodes_for_room);
|
||||
788
resources/prosody-plugins/mod_visitors_component.lua
Normal file
788
resources/prosody-plugins/mod_visitors_component.lua
Normal file
@@ -0,0 +1,788 @@
|
||||
module:log('info', 'Starting visitors_component at %s', module.host);
|
||||
|
||||
local array = require "util.array";
|
||||
local http = require 'net.http';
|
||||
local jid = require 'util.jid';
|
||||
local st = require 'util.stanza';
|
||||
local util = module:require 'util';
|
||||
local is_admin = util.is_admin;
|
||||
local is_healthcheck_room = util.is_healthcheck_room;
|
||||
local is_sip_jigasi = util.is_sip_jigasi;
|
||||
local room_jid_match_rewrite = util.room_jid_match_rewrite;
|
||||
local get_room_from_jid = util.get_room_from_jid;
|
||||
local get_focus_occupant = util.get_focus_occupant;
|
||||
local get_room_by_name_and_subdomain = util.get_room_by_name_and_subdomain;
|
||||
local internal_room_jid_match_rewrite = util.internal_room_jid_match_rewrite;
|
||||
local table_find = util.table_find;
|
||||
local is_vpaas = util.is_vpaas;
|
||||
local is_sip_jibri_join = util.is_sip_jibri_join;
|
||||
local process_host_module = util.process_host_module;
|
||||
local respond_iq_result = util.respond_iq_result;
|
||||
local split_string = util.split_string;
|
||||
local new_id = require 'util.id'.medium;
|
||||
local uuid_generate = require 'util.uuid'.generate;
|
||||
local json = require 'cjson.safe';
|
||||
local inspect = require 'inspect';
|
||||
|
||||
-- Debug flag
|
||||
local DEBUG = false;
|
||||
|
||||
-- will be initialized once the main virtual host module is initialized
|
||||
local token_util;
|
||||
|
||||
local MUC_NS = 'http://jabber.org/protocol/muc';
|
||||
|
||||
local muc_domain_prefix = module:get_option_string('muc_mapper_domain_prefix', 'conference');
|
||||
local muc_domain_base = module:get_option_string('muc_mapper_domain_base');
|
||||
if not muc_domain_base then
|
||||
module:log('warn', 'No muc_domain_base option set.');
|
||||
return;
|
||||
end
|
||||
|
||||
-- A list of domains which to be ignored for visitors. The config is set under the main virtual host
|
||||
local ignore_list = module:context(muc_domain_base):get_option_set('visitors_ignore_list', {});
|
||||
|
||||
local auto_allow_promotion = module:get_option_boolean('auto_allow_visitor_promotion', false);
|
||||
|
||||
-- whether to always advertise that visitors feature is enabled for rooms
|
||||
-- can be set to off and being controlled by another module, turning it on and off for rooms
|
||||
local always_visitors_enabled = module:get_option_boolean('always_visitors_enabled', true);
|
||||
|
||||
local visitors_queue_service = module:get_option_string('visitors_queue_service');
|
||||
local http_headers = {
|
||||
["User-Agent"] = "Prosody (" .. prosody.version .. "; " .. prosody.platform .. ")",
|
||||
["Content-Type"] = "application/json",
|
||||
["Accept"] = "application/json"
|
||||
};
|
||||
|
||||
-- This is a map to keep data for room and the jids that were allowed to join after visitor mode is enabled
|
||||
-- automatically allowed or allowed by a moderator
|
||||
local visitors_promotion_map = {};
|
||||
|
||||
-- A map with key room jid. The content is a map with key jid from which the request is received
|
||||
-- and the value is a table that has the json message that needs to be sent to any future moderator that joins
|
||||
-- and the vnode from which the request is received and where the response will be sent
|
||||
local visitors_promotion_requests = {};
|
||||
|
||||
local cache = require 'util.cache';
|
||||
local sent_iq_cache = cache.new(200);
|
||||
|
||||
-- Function to get visitors room metadata
|
||||
local function get_visitors_room_metadata(room)
|
||||
if not room.jitsiMetadata then
|
||||
room.jitsiMetadata = {};
|
||||
end
|
||||
if not room.jitsiMetadata.visitors then
|
||||
room.jitsiMetadata.visitors = {};
|
||||
end
|
||||
return room.jitsiMetadata.visitors;
|
||||
end
|
||||
|
||||
-- Sends a json-message to the destination jid
|
||||
-- @param to_jid the destination jid
|
||||
-- @param json_message the message content to send
|
||||
function send_json_message(to_jid, json_message)
|
||||
local stanza = st.message({ from = module.host; to = to_jid; })
|
||||
:tag('json-message', { xmlns = 'http://jitsi.org/jitmeet' }):text(json_message):up();
|
||||
module:send(stanza);
|
||||
end
|
||||
|
||||
local function request_promotion_received(room, from_jid, from_vnode, nick, time, user_id, group_id, force_promote_requested)
|
||||
if DEBUG then
|
||||
module:log('debug', 'Received promotion request from %s for room %s, nick: %s, time: %s, user_id: %s, group_id: %s, force_promote_requested: %s',
|
||||
from_jid, room.jid, nick, time, user_id, group_id, force_promote_requested);
|
||||
end
|
||||
|
||||
-- if visitors is enabled for the room
|
||||
if visitors_promotion_map[room.jid] then
|
||||
local force_promote = auto_allow_promotion or get_visitors_room_metadata(room).autoPromote;
|
||||
if not force_promote and force_promote_requested == 'true' then
|
||||
-- Let's do the force_promote checks if requested
|
||||
-- if it is vpaas meeting we trust the moderator computation from visitor node (value of force_promote_requested)
|
||||
-- if it is not vpaas we need to check further settings only if they exist
|
||||
if is_vpaas(room) or not room._data.moderators
|
||||
-- all moderators are allowed to auto promote, the fact that user_id and force_promote_requested are set
|
||||
-- means that the user has token and is moderator on visitor node side
|
||||
or room._data.allModerators
|
||||
|
||||
-- can be used by external modules to set multiple moderator ids (table of values) or a group
|
||||
or table_find(room._data.moderators, user_id)
|
||||
or table_find(room._data.moderators, group_id)
|
||||
then
|
||||
force_promote = true;
|
||||
end
|
||||
end
|
||||
|
||||
-- only for raise hand, ignore lowering the hand
|
||||
if time and time > 0 and force_promote then
|
||||
-- we are in auto-allow mode, let's reply with accept
|
||||
-- we store where the request is coming from so we can send back the response
|
||||
local username = uuid_generate();
|
||||
visitors_promotion_map[room.jid][username] = {
|
||||
from = from_vnode;
|
||||
jid = from_jid;
|
||||
};
|
||||
|
||||
local req_from = visitors_promotion_map[room.jid][username].from;
|
||||
local req_jid = visitors_promotion_map[room.jid][username].jid;
|
||||
local focus_occupant = get_focus_occupant(room);
|
||||
local focus_jid = focus_occupant and focus_occupant.bare_jid or nil;
|
||||
|
||||
local iq_id = new_id();
|
||||
sent_iq_cache:set(iq_id, socket.gettime());
|
||||
|
||||
local node = jid.node(room.jid);
|
||||
|
||||
module:send(st.iq({
|
||||
type='set', to = req_from, from = module.host, id = iq_id })
|
||||
:tag('visitors', {
|
||||
xmlns='jitsi:visitors',
|
||||
room = jid.join(node, muc_domain_prefix..'.'..req_from),
|
||||
focusjid = focus_jid })
|
||||
:tag('promotion-response', {
|
||||
xmlns='jitsi:visitors',
|
||||
jid = req_jid,
|
||||
username = username ,
|
||||
allow = 'true' }):up());
|
||||
return true;
|
||||
else
|
||||
-- send promotion request to all moderators
|
||||
local body_json = {};
|
||||
body_json.type = 'visitors';
|
||||
body_json.room = internal_room_jid_match_rewrite(room.jid);
|
||||
body_json.action = 'promotion-request';
|
||||
body_json.nick = nick;
|
||||
body_json.from = from_jid;
|
||||
|
||||
if time and time > 0 then
|
||||
-- raise hand
|
||||
body_json.on = true;
|
||||
else
|
||||
-- lower hand, we want to inform interested parties that
|
||||
-- the visitor is no longer interested in joining the main call
|
||||
body_json.on = false;
|
||||
end
|
||||
|
||||
local msg_to_send, error = json.encode(body_json);
|
||||
|
||||
if not msg_to_send then
|
||||
module:log('error', 'Error encoding msg room:%s error:%s', room.jid, error)
|
||||
return true;
|
||||
end
|
||||
|
||||
if visitors_promotion_requests[room.jid] then
|
||||
visitors_promotion_requests[room.jid][from_jid] = {
|
||||
msg = msg_to_send;
|
||||
from = from_vnode;
|
||||
};
|
||||
else
|
||||
module:log('warn', 'Received promotion request for room %s with visitors not enabled. %s',
|
||||
room.jid, msg_to_send);
|
||||
end
|
||||
|
||||
-- let's send a notification to every moderator
|
||||
for _, occupant in room:each_occupant() do
|
||||
if occupant.role == 'moderator' and not is_admin(occupant.bare_jid) then
|
||||
send_json_message(occupant.jid, msg_to_send);
|
||||
end
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
end
|
||||
|
||||
module:log('warn', 'Received promotion request from %s for room %s without active visitors', from, room.jid);
|
||||
end
|
||||
|
||||
local function connect_vnode_received(room, vnode)
|
||||
module:context(muc_domain_base):fire_event('jitsi-connect-vnode', { room = room; vnode = vnode; });
|
||||
|
||||
if not visitors_promotion_map[room.jid] then
|
||||
-- visitors is enabled
|
||||
visitors_promotion_map[room.jid] = {};
|
||||
visitors_promotion_requests[room.jid] = {};
|
||||
room._connected_vnodes = cache.new(16); -- we up to 16 vnodes for this prosody
|
||||
end
|
||||
|
||||
room._connected_vnodes:set(vnode..'.meet.jitsi', {});
|
||||
end
|
||||
|
||||
local function disconnect_vnode_received(room, vnode)
|
||||
module:context(muc_domain_base):fire_event('jitsi-disconnect-vnode', { room = room; vnode = vnode; });
|
||||
|
||||
if not room._connected_vnodes then
|
||||
return;
|
||||
end
|
||||
room._connected_vnodes:set(vnode..'.meet.jitsi', nil);
|
||||
|
||||
if room._connected_vnodes:count() == 0 then
|
||||
visitors_promotion_map[room.jid] = nil;
|
||||
visitors_promotion_requests[room.jid] = nil;
|
||||
room._connected_vnodes = nil;
|
||||
end
|
||||
end
|
||||
|
||||
-- returns the accumulated data for visitors nodes, count all visitors requesting transcriptions
|
||||
-- and accumulated languages requested
|
||||
-- @returns count, languages
|
||||
function get_visitors_languages(room)
|
||||
if not room._connected_vnodes then
|
||||
return;
|
||||
end
|
||||
|
||||
local count = 0;
|
||||
local languages = array();
|
||||
|
||||
-- iterate over visitor nodes we are connected to and accumulate data if we have it
|
||||
for k, v in room._connected_vnodes:items() do
|
||||
if v.count then
|
||||
count = count + v.count;
|
||||
end
|
||||
if v.langs then
|
||||
for k in pairs(v.langs) do
|
||||
local val = v.langs[k]
|
||||
if not languages[val] then
|
||||
languages:push(val);
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return count, languages:sort():concat(',');
|
||||
end
|
||||
|
||||
-- listens for iq request for promotion and forward it to moderators in the meeting for approval
|
||||
-- or auto-allow it if such the config is set enabling it
|
||||
local function stanza_handler(event)
|
||||
local origin, stanza = event.origin, event.stanza;
|
||||
|
||||
if stanza.name ~= 'iq' then
|
||||
return;
|
||||
end
|
||||
|
||||
if DEBUG then
|
||||
module:log('debug', 'Received stanza %s from %s', stanza, origin.full_jid);
|
||||
end
|
||||
|
||||
if stanza.attr.type == 'result' and sent_iq_cache:get(stanza.attr.id) then
|
||||
sent_iq_cache:set(stanza.attr.id, nil);
|
||||
return true;
|
||||
end
|
||||
|
||||
if stanza.attr.type ~= 'set' and stanza.attr.type ~= 'get' then
|
||||
return; -- We do not want to reply to these, so leave.
|
||||
end
|
||||
|
||||
local visitors_iq = event.stanza:get_child('visitors', 'jitsi:visitors');
|
||||
if not visitors_iq then
|
||||
return;
|
||||
end
|
||||
|
||||
-- set stanzas are coming from s2s connection
|
||||
if stanza.attr.type == 'set' and origin.type ~= 's2sin' then
|
||||
module:log('warn', 'not from s2s session, ignore! %s', stanza);
|
||||
return true;
|
||||
end
|
||||
|
||||
local room_jid = visitors_iq.attr.room;
|
||||
local room = get_room_from_jid(room_jid_match_rewrite(room_jid));
|
||||
|
||||
if not room then
|
||||
-- this maybe as we receive the iq from jicofo after the room is already destroyed
|
||||
module:log('debug', 'No room found %s', room_jid);
|
||||
return true;
|
||||
end
|
||||
|
||||
local from_vnode;
|
||||
if room._connected_vnodes then
|
||||
from_vnode = room._connected_vnodes:get(stanza.attr.from);
|
||||
end
|
||||
|
||||
local processed;
|
||||
-- promotion request is coming from visitors and is a set and is over the s2s connection
|
||||
local request_promotion = visitors_iq:get_child('promotion-request');
|
||||
if request_promotion then
|
||||
if not from_vnode then
|
||||
module:log('warn', 'Received forged request_promotion message: %s %s',stanza, inspect(room._connected_vnodes));
|
||||
return true; -- stop processing
|
||||
end
|
||||
|
||||
local display_name = visitors_iq:get_child_text('nick', 'http://jabber.org/protocol/nick');
|
||||
processed = request_promotion_received(
|
||||
room,
|
||||
request_promotion.attr.jid,
|
||||
stanza.attr.from,
|
||||
display_name,
|
||||
tonumber(request_promotion.attr.time),
|
||||
request_promotion.attr.userId,
|
||||
request_promotion.attr.groupId,
|
||||
request_promotion.attr.forcePromote
|
||||
);
|
||||
end
|
||||
|
||||
-- connect and disconnect are only received from jicofo
|
||||
if is_admin(jid.bare(stanza.attr.from)) then
|
||||
for item in visitors_iq:childtags('connect-vnode') do
|
||||
connect_vnode_received(room, item.attr.vnode);
|
||||
processed = true;
|
||||
end
|
||||
|
||||
for item in visitors_iq:childtags('disconnect-vnode') do
|
||||
disconnect_vnode_received(room, item.attr.vnode);
|
||||
processed = true;
|
||||
end
|
||||
end
|
||||
|
||||
-- request to update metadata service for jigasi languages
|
||||
local transcription_languages = visitors_iq:get_child('transcription-languages');
|
||||
|
||||
if transcription_languages
|
||||
and (transcription_languages.attr.langs or transcription_languages.attr.count) then
|
||||
if not from_vnode then
|
||||
module:log('warn', 'Received forged transcription_languages message: %s %s',stanza, inspect(room._connected_vnodes));
|
||||
return true; -- stop processing
|
||||
end
|
||||
|
||||
local metadata = get_visitors_room_metadata(room);
|
||||
|
||||
-- we keep the split by languages array to optimize accumulating languages
|
||||
from_vnode.langs = split_string(transcription_languages.attr.langs, ',');
|
||||
from_vnode.count = transcription_languages.attr.count;
|
||||
|
||||
local count, languages = get_visitors_languages(room);
|
||||
|
||||
if metadata.transcribingLanguages ~= languages then
|
||||
metadata.transcribingLanguages = languages;
|
||||
processed = true;
|
||||
end
|
||||
|
||||
if metadata.transcribingCount ~= count then
|
||||
metadata.transcribingCount = count;
|
||||
processed = true;
|
||||
end
|
||||
|
||||
if processed then
|
||||
module:context(muc_domain_prefix..'.'..muc_domain_base)
|
||||
:fire_event('room-metadata-changed', { room = room; });
|
||||
end
|
||||
end
|
||||
|
||||
-- A visitor (allowed via the transcription JWT feature, see mod_filter_iq_rayo's
|
||||
-- 'jitsi-metadata-allow-moderation' hook) set 'recording' metadata (isTranscribingEnabled) on their
|
||||
-- vnode's local mirrored room. Apply it to the real room here so jicofo (which only watches the main
|
||||
-- room's metadata) sees it and starts async transcription.
|
||||
local recording_metadata_el = visitors_iq:get_child('recording-metadata');
|
||||
if recording_metadata_el then
|
||||
if not from_vnode then
|
||||
module:log('warn', 'Received forged recording_metadata message: %s %s',
|
||||
stanza, inspect(room._connected_vnodes));
|
||||
return true; -- stop processing
|
||||
end
|
||||
|
||||
local data, decode_error = json.decode(recording_metadata_el:get_text());
|
||||
if data then
|
||||
if not room.jitsiMetadata then
|
||||
room.jitsiMetadata = {};
|
||||
end
|
||||
room.jitsiMetadata.recording = data;
|
||||
processed = true;
|
||||
|
||||
module:context(muc_domain_prefix..'.'..muc_domain_base)
|
||||
:fire_event('room-metadata-changed', { room = room; });
|
||||
else
|
||||
module:log('error', 'Failed to decode recording metadata for room:%s error:%s', room.jid, decode_error);
|
||||
end
|
||||
end
|
||||
|
||||
if not processed then
|
||||
module:log('warn', 'Unknown iq received for %s: %s', module.host, stanza);
|
||||
end
|
||||
|
||||
respond_iq_result(origin, stanza);
|
||||
return processed;
|
||||
end
|
||||
|
||||
local function process_promotion_response(room, id, approved)
|
||||
if not approved then
|
||||
module:log('debug', 'promotion not approved %s, %s', room.jid, id);
|
||||
return;
|
||||
end
|
||||
|
||||
if DEBUG then
|
||||
module:log('debug', 'Processing promotion response for room %s, id %s, approved %s',
|
||||
room.jid, id, approved);
|
||||
end
|
||||
|
||||
-- lets reply to participant that requested promotion
|
||||
local username = uuid_generate();
|
||||
visitors_promotion_map[room.jid][username] = {
|
||||
from = visitors_promotion_requests[room.jid][id].from;
|
||||
jid = id;
|
||||
};
|
||||
|
||||
local req_from = visitors_promotion_map[room.jid][username].from;
|
||||
local req_jid = visitors_promotion_map[room.jid][username].jid;
|
||||
local focus_occupant = get_focus_occupant(room);
|
||||
local focus_jid = focus_occupant and focus_occupant.bare_jid or nil;
|
||||
|
||||
local iq_id = new_id();
|
||||
sent_iq_cache:set(iq_id, socket.gettime());
|
||||
|
||||
local node = jid.node(room.jid);
|
||||
|
||||
module:send(st.iq({
|
||||
type='set', to = req_from, from = module.host, id = iq_id })
|
||||
:tag('visitors', {
|
||||
xmlns='jitsi:visitors',
|
||||
room = jid.join(node, muc_domain_prefix..'.'..req_from),
|
||||
focusjid = focus_jid })
|
||||
:tag('promotion-response', {
|
||||
xmlns='jitsi:visitors',
|
||||
jid = req_jid,
|
||||
username = username,
|
||||
allow = approved }):up());
|
||||
end
|
||||
|
||||
-- if room metadata does not have visitors.live set to `true` and there are no occupants in the meeting
|
||||
-- it will skip calling goLive endpoint
|
||||
local function go_live(room)
|
||||
|
||||
if DEBUG then
|
||||
module:log('debug', 'Checking if room %s is live', room.jid);
|
||||
end
|
||||
|
||||
if room._jitsi_go_live_sent then
|
||||
if DEBUG then
|
||||
module:log('debug', 'Room %s already sent go live request, skipping', room.jid);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
-- if missing we assume room is live, only skip if it is marked explicitly as false
|
||||
if room.jitsiMetadata and room.jitsiMetadata.visitors
|
||||
and room.jitsiMetadata.visitors.live ~= nil and room.jitsiMetadata.visitors.live == false then
|
||||
if DEBUG then
|
||||
module:log('debug', 'Room %s is not live, skipping go live request', room.jid);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
local has_occupant = false;
|
||||
for _, occupant in room:each_occupant() do
|
||||
if not is_admin(occupant.bare_jid) then
|
||||
has_occupant = true;
|
||||
break;
|
||||
end
|
||||
end
|
||||
|
||||
-- when there is an occupant then go live
|
||||
if not has_occupant then
|
||||
if DEBUG then
|
||||
module:log('debug', 'Room %s has no occupants, skipping go live request', room.jid);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
-- let's inform the queue service
|
||||
local function cb(content_, code_, response_, request_)
|
||||
local room = room;
|
||||
if code_ ~= 200 then
|
||||
module:log('warn', 'External call to visitors_queue_service/golive failed. Code %s, Content %s',
|
||||
code_, content_)
|
||||
end
|
||||
end
|
||||
|
||||
local headers = http_headers or {};
|
||||
headers['Authorization'] = token_util:generateAsapToken();
|
||||
|
||||
local ev = {
|
||||
conference = internal_room_jid_match_rewrite(room.jid)
|
||||
};
|
||||
|
||||
room._jitsi_go_live_sent = true;
|
||||
|
||||
http.request(visitors_queue_service..'/golive', {
|
||||
headers = headers,
|
||||
method = 'POST',
|
||||
body = json.encode(ev);
|
||||
}, cb);
|
||||
end
|
||||
|
||||
module:hook('iq/host', stanza_handler, 10);
|
||||
|
||||
process_host_module(muc_domain_base, function(host_module, host)
|
||||
token_util = module:require "token/util".new(host_module);
|
||||
end);
|
||||
|
||||
process_host_module(muc_domain_prefix..'.'..muc_domain_base, function(host_module, host)
|
||||
-- if visitor mode is started, then you are not allowed to join without request/response exchange of iqs -> deny access
|
||||
-- check list of allowed jids for the room
|
||||
host_module:hook('muc-occupant-pre-join', function (event)
|
||||
local room, stanza, occupant, session = event.room, event.stanza, event.occupant, event.origin;
|
||||
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) then
|
||||
if DEBUG then
|
||||
module:log('debug', 'Skipping visitor checks for healthcheck room %s or admin %s',
|
||||
room.jid, occupant.bare_jid);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
-- visitors were already in the room one way or another they have access
|
||||
-- skip password challenge
|
||||
local join = stanza:get_child('x', MUC_NS);
|
||||
if join and room:get_password() and
|
||||
visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][jid.node(stanza.attr.from)] then
|
||||
join:tag('password', { xmlns = MUC_NS }):text(room:get_password());
|
||||
end
|
||||
|
||||
local is_live = get_visitors_room_metadata(room).live;
|
||||
|
||||
-- we skip any checks when auto-allow is enabled and room is live
|
||||
if (auto_allow_promotion or get_visitors_room_metadata(room).autoPromote and (is_live or is_live == nil))
|
||||
or ignore_list:contains(jid.host(stanza.attr.from)) -- jibri or other domains to ignore
|
||||
or table_find(room._data.moderators, session.jitsi_meet_context_user and session.jitsi_meet_context_user.id)
|
||||
or table_find(room._data.participants, session.jitsi_meet_context_user and session.jitsi_meet_context_user.id) then
|
||||
if DEBUG then
|
||||
module:log('debug', 'Auto-allowing visitor %s in room %s', stanza.attr.from, room.jid);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if visitors_promotion_map[room.jid] then
|
||||
local in_ignore_list = ignore_list:contains(jid.host(stanza.attr.from));
|
||||
|
||||
-- now let's check for jid
|
||||
if visitors_promotion_map[room.jid][jid.node(stanza.attr.from)] -- promotion was approved
|
||||
or in_ignore_list then -- jibri or other domains to ignore
|
||||
-- allow join
|
||||
if not in_ignore_list then
|
||||
-- let's update metadata
|
||||
local metadata = get_visitors_room_metadata(room);
|
||||
if not metadata.promoted then
|
||||
metadata.promoted = {};
|
||||
end
|
||||
metadata.promoted[jid.resource(occupant.nick)] = true;
|
||||
module:context(muc_domain_prefix..'.'..muc_domain_base)
|
||||
:fire_event('room-metadata-changed', { room = room; });
|
||||
end
|
||||
|
||||
return;
|
||||
end
|
||||
module:log('error', 'Visitor needs to be allowed by a moderator %s', stanza.attr.from);
|
||||
session.send(st.error_reply(stanza, 'cancel', 'not-allowed', 'Visitor needs to be allowed by a moderator')
|
||||
:tag('promotion-not-allowed', { xmlns = 'jitsi:visitors' }));
|
||||
return true;
|
||||
elseif is_vpaas(room) then
|
||||
-- special case for vpaas where if someone with a visitor token tries to join a room, where
|
||||
-- there are no visitors yet, we deny access
|
||||
if session.jitsi_meet_context_user and session.jitsi_meet_context_user.role == 'visitor' then
|
||||
session.log('warn', 'Deny user join as visitor in the main meeting, not approved');
|
||||
session.send(st.error_reply(
|
||||
stanza, 'cancel', 'not-allowed', 'Visitor tried to join the main room without approval')
|
||||
:tag('no-main-participants', { xmlns = 'jitsi:visitors' }));
|
||||
return true;
|
||||
end
|
||||
elseif room._data.participants then
|
||||
-- This is non jaas room which has a list of participants allowed to participate in the main room
|
||||
-- but this occupant is not one of them and the room is either not live or has no participants joined
|
||||
if room:get_members_only() then
|
||||
-- if there is a lobby, let's pass it through it will wait for the main participant
|
||||
return;
|
||||
end
|
||||
session.log('warn',
|
||||
'Deny user join in the main not live meeting, not in the list of main participants');
|
||||
session.send(st.error_reply(
|
||||
stanza, 'cancel', 'not-allowed',
|
||||
'Tried to join the main (not live or without main participants) room')
|
||||
:tag('not-live-room', { xmlns = 'jitsi:visitors' }));
|
||||
return true;
|
||||
end
|
||||
|
||||
end, 7); -- after muc_meeting_id, the logic for not joining before jicofo
|
||||
host_module:hook('muc-room-destroyed', function (event)
|
||||
visitors_promotion_map[event.room.jid] = nil;
|
||||
visitors_promotion_requests[event.room.jid] = nil;
|
||||
end, 1); -- prosody handles it at 0
|
||||
|
||||
host_module:hook('muc-occupant-joined', function (event)
|
||||
local room, occupant = event.room, event.occupant;
|
||||
|
||||
if DEBUG then
|
||||
module:log('debug', 'Occupant %s joined room %s', occupant.jid, room.jid);
|
||||
end
|
||||
|
||||
if is_healthcheck_room(room.jid) or is_admin(occupant.bare_jid) or occupant.role ~= 'moderator' -- luacheck: ignore
|
||||
or not visitors_promotion_requests[event.room.jid] then
|
||||
if DEBUG then
|
||||
module:log('debug', 'Skipping visitor checks for healthcheck room %s or admin %s or not moderator %s',
|
||||
room.jid, occupant.bare_jid, occupant.role);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
|
||||
send_json_message(occupant.jid, value.msg);
|
||||
end
|
||||
end);
|
||||
host_module:hook('muc-set-affiliation', function (event)
|
||||
-- the actor can be nil if is coming from allowners or similar module we want to skip it here
|
||||
-- as we will handle it in occupant_joined
|
||||
local actor, affiliation, jid, room = event.actor, event.affiliation, event.jid, event.room;
|
||||
|
||||
if is_admin(jid) or is_healthcheck_room(room.jid) or not actor or not affiliation == 'owner' -- luacheck: ignore
|
||||
or not visitors_promotion_requests[event.room.jid] then
|
||||
return;
|
||||
end
|
||||
|
||||
-- event.jid is the bare jid of participant
|
||||
for _, occupant in room:each_occupant() do
|
||||
if occupant.bare_jid == event.jid then
|
||||
for _,value in pairs(visitors_promotion_requests[event.room.jid]) do
|
||||
send_json_message(occupant.jid, value.msg);
|
||||
end
|
||||
end
|
||||
end
|
||||
end);
|
||||
|
||||
host_module:hook('jitsi-endpoint-message-received', function(event)
|
||||
local data, error, occupant, room, stanza
|
||||
= event.message, event.error, event.occupant, event.room, event.stanza;
|
||||
|
||||
if not data or data.type ~= 'visitors'
|
||||
or (data.action ~= "promotion-response" and data.action ~= "demote-request") then
|
||||
if error then
|
||||
module:log('error', 'Error decoding error:%s', error);
|
||||
end
|
||||
return;
|
||||
end
|
||||
|
||||
if occupant.role ~= 'moderator' then
|
||||
module:log('error', 'Occupant %s sending response message but not moderator in room %s',
|
||||
occupant.jid, room.jid);
|
||||
return false;
|
||||
end
|
||||
|
||||
if data.action == "demote-request" then
|
||||
if occupant.nick ~= room.jid..'/'..data.actor then
|
||||
module:log('error', 'Bad actor in demote request %s', stanza);
|
||||
event.origin.send(st.error_reply(stanza, "cancel", "bad-request"));
|
||||
return true;
|
||||
end
|
||||
|
||||
-- when demoting we want to send message to the demoted participant and to moderators
|
||||
local target_jid = room.jid..'/'..data.id;
|
||||
stanza.attr.type = 'chat'; -- it is safe as we are not using this stanza instance anymore
|
||||
stanza.attr.from = module.host;
|
||||
|
||||
for _, room_occupant in room:each_occupant() do
|
||||
-- do not send it to jicofo or back to the sender
|
||||
if room_occupant.jid ~= occupant.jid and not is_admin(room_occupant.bare_jid) then
|
||||
if room_occupant.role == 'moderator'
|
||||
or room_occupant.nick == target_jid then
|
||||
stanza.attr.to = room_occupant.jid;
|
||||
room:route_stanza(stanza);
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
if data.id then
|
||||
process_promotion_response(room, data.id, data.approved and 'true' or 'false');
|
||||
else
|
||||
-- we are in the case with admit all, we need to read data.ids
|
||||
for _,value in pairs(data.ids) do
|
||||
process_promotion_response(room, value, data.approved and 'true' or 'false');
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return true; -- halt processing, but return true that we handled it
|
||||
end);
|
||||
|
||||
if visitors_queue_service then
|
||||
host_module:hook('muc-room-created', function (event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
go_live(room);
|
||||
end, -2); -- metadata hook on -1
|
||||
host_module:hook('jitsi-metadata-updated', function (event)
|
||||
if event.key == 'visitors' then
|
||||
go_live(event.room);
|
||||
end
|
||||
end);
|
||||
-- when metadata changed internally from another module
|
||||
host_module:hook('room-metadata-changed', function (event)
|
||||
go_live(event.room);
|
||||
end);
|
||||
host_module:hook('muc-occupant-joined', function (event)
|
||||
local room = event.room;
|
||||
|
||||
if is_healthcheck_room(room.jid) then
|
||||
return;
|
||||
end
|
||||
|
||||
if always_visitors_enabled then
|
||||
if not room.jitsiMetadata then
|
||||
room.jitsiMetadata = {};
|
||||
end
|
||||
room.jitsiMetadata.visitorsEnabled = true;
|
||||
end
|
||||
|
||||
go_live(room);
|
||||
end);
|
||||
end
|
||||
|
||||
if always_visitors_enabled then
|
||||
local visitorsEnabledField = {
|
||||
name = "muc#roominfo_visitorsEnabled";
|
||||
type = "boolean";
|
||||
label = "Whether visitors are enabled.";
|
||||
value = 1;
|
||||
};
|
||||
-- Append "visitors enabled" to the MUC config form.
|
||||
host_module:context(host):hook("muc-disco#info", function(event)
|
||||
table.insert(event.form, visitorsEnabledField);
|
||||
end);
|
||||
host_module:context(host):hook("muc-config-form", function(event)
|
||||
table.insert(event.form, visitorsEnabledField);
|
||||
end);
|
||||
end
|
||||
end);
|
||||
|
||||
prosody.events.add_handler('pre-jitsi-authentication', function(session)
|
||||
if not session.customusername or not session.jitsi_web_query_room then
|
||||
return nil;
|
||||
end
|
||||
|
||||
local room = get_room_by_name_and_subdomain(session.jitsi_web_query_room, session.jitsi_web_query_prefix);
|
||||
if not room then
|
||||
return nil;
|
||||
end
|
||||
|
||||
if visitors_promotion_map[room.jid] and visitors_promotion_map[room.jid][session.customusername] then
|
||||
-- user was previously allowed to join, let him use the requested jid
|
||||
return session.customusername;
|
||||
end
|
||||
end);
|
||||
|
||||
-- when occupant is leaving breakout to join the main room and visitors are enabled
|
||||
-- make sure we will allow that participant to join as it is already part of the main room
|
||||
function handle_occupant_leaving_breakout(event)
|
||||
local main_room, occupant, stanza = event.main_room, event.occupant, event.stanza;
|
||||
local presence_status = stanza:get_child_text('status');
|
||||
|
||||
if presence_status ~= 'switch_room' or not main_room or not visitors_promotion_map[main_room.jid] then
|
||||
return;
|
||||
end
|
||||
|
||||
local node = jid.node(occupant.bare_jid);
|
||||
|
||||
visitors_promotion_map[main_room.jid][node] = {
|
||||
from = 'none';
|
||||
jid = occupant.bare_jid;
|
||||
};
|
||||
end
|
||||
module:hook_global('jitsi-breakout-occupant-leaving', handle_occupant_leaving_breakout);
|
||||
22
resources/prosody-plugins/muc_owner_allow_kick-0.12.patch
Normal file
22
resources/prosody-plugins/muc_owner_allow_kick-0.12.patch
Normal file
@@ -0,0 +1,22 @@
|
||||
--- muc.lib.lua 2016-10-26 18:26:53.432377291 +0000
|
||||
+++ muc.lib.lua 2016-10-26 18:41:40.754426072 +0000
|
||||
@@ -1582,16 +1582,16 @@
|
||||
if event.allowed ~= nil then
|
||||
return event.allowed, event.error, event.condition;
|
||||
end
|
||||
+ local occupant_affiliation = self:get_affiliation(occupant.bare_jid);
|
||||
|
||||
-- Can't do anything to other owners or admins
|
||||
- local occupant_affiliation = self:get_affiliation(occupant.bare_jid);
|
||||
- if occupant_affiliation == "owner" or occupant_affiliation == "admin" then
|
||||
+ local actor_affiliation = self:get_affiliation(actor);
|
||||
+ if (occupant_affiliation == "owner" and actor_affiliation ~= "owner") or (occupant_affiliation == "admin" and actor_affiliation ~= "admin" and actor_affiliation ~= "owner") then
|
||||
return nil, "cancel", "not-allowed";
|
||||
end
|
||||
|
||||
-- If you are trying to give or take moderator role you need to be an owner or admin
|
||||
if occupant.role == "moderator" or role == "moderator" then
|
||||
- local actor_affiliation = self:get_affiliation(actor);
|
||||
if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then
|
||||
return nil, "cancel", "not-allowed";
|
||||
end
|
||||
203
resources/prosody-plugins/otel.lib.lua
Normal file
203
resources/prosody-plugins/otel.lib.lua
Normal file
@@ -0,0 +1,203 @@
|
||||
-- This opentelemetry library was inspired by https://github.com/yangxikun/opentelemetry-lua,
|
||||
-- but simplified and adapted to the Prosody ecosystem.
|
||||
|
||||
local json = require "util.json"
|
||||
local array = require "util.array"
|
||||
local time = require "util.time"
|
||||
local random = require "util.random"
|
||||
local hex = require "util.hex"
|
||||
|
||||
local m = {}
|
||||
|
||||
--------------------------------------------------
|
||||
-- Util --
|
||||
--------------------------------------------------
|
||||
|
||||
local function random_hex(n)
|
||||
return hex.to(random.bytes(n))
|
||||
end
|
||||
local function to_nanoseconds(t)
|
||||
return tostring(math.floor(t * 1e9))
|
||||
end
|
||||
|
||||
|
||||
--------------------------------------------------
|
||||
-- Attribute --
|
||||
--------------------------------------------------
|
||||
|
||||
local Attribute = {}
|
||||
m.Attribute = Attribute
|
||||
|
||||
function Attribute.string(value)
|
||||
return {
|
||||
string_value = value
|
||||
}
|
||||
end
|
||||
|
||||
--------------------------------------------------
|
||||
-- Span --
|
||||
--------------------------------------------------
|
||||
|
||||
local SpanStatus = {
|
||||
UNSET = 0,
|
||||
OK = 1,
|
||||
ERROR = 2,
|
||||
}
|
||||
|
||||
local SpanKind = {
|
||||
UNSPECIFIED = 0,
|
||||
INTERNAL = 1,
|
||||
SERVER = 2,
|
||||
CLIENT = 3,
|
||||
PRODUCER = 4,
|
||||
CONSUMER = 5,
|
||||
}
|
||||
|
||||
local Span = {}
|
||||
Span.__index = Span
|
||||
m.Span = Span
|
||||
|
||||
function Span.new(name, tracer, opts)
|
||||
local self = {
|
||||
name = name,
|
||||
tracer = tracer,
|
||||
parent_context = opts.context,
|
||||
trace_id = opts.context and opts.context.trace_id or random_hex(16),
|
||||
span_id = random_hex(8),
|
||||
start_time = time.now(),
|
||||
attributes = array {},
|
||||
}
|
||||
|
||||
return setmetatable(self, Span)
|
||||
end
|
||||
|
||||
function Span:set_attribute(k, v)
|
||||
table.insert(self.attributes, { key = k, value = v })
|
||||
return self
|
||||
end
|
||||
|
||||
function Span:end_span()
|
||||
self.end_time = time.now()
|
||||
self.tracer.processor:add(self)
|
||||
end
|
||||
|
||||
function Span:encode()
|
||||
return {
|
||||
trace_id = self.trace_id,
|
||||
span_id = self.span_id,
|
||||
parent_span_id = self.parent_context and self.parent_context.span_id,
|
||||
name = self.name,
|
||||
start_time_unix_nano = to_nanoseconds(self.start_time),
|
||||
end_time_unix_nano = to_nanoseconds(self.end_time),
|
||||
attributes = self.attributes,
|
||||
kind = self.kind or SpanKind.INTERNAL,
|
||||
status = {
|
||||
code = self.status or SpanStatus.OK
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
--------------------------------------------------
|
||||
-- Processor --
|
||||
--------------------------------------------------
|
||||
|
||||
local Processor = {}
|
||||
Processor.__index = Processor
|
||||
m.Processor = Processor
|
||||
|
||||
function Processor.new(exporter)
|
||||
local self = {
|
||||
exporter = exporter
|
||||
}
|
||||
return setmetatable(self, Processor)
|
||||
end
|
||||
|
||||
function Processor:add(span)
|
||||
self.exporter:export({ span })
|
||||
end
|
||||
|
||||
--------------------------------------------------
|
||||
-- Exporter --
|
||||
--------------------------------------------------
|
||||
|
||||
local Exporter = {}
|
||||
Exporter.__index = Exporter
|
||||
m.Exporter = Exporter
|
||||
|
||||
function Exporter.new(request_fn, endpoint)
|
||||
local self = {
|
||||
request_fn = request_fn,
|
||||
endpoint = endpoint,
|
||||
}
|
||||
return setmetatable(self, Exporter)
|
||||
end
|
||||
|
||||
function Exporter:export(spans)
|
||||
local body = {
|
||||
resource_spans = {
|
||||
{
|
||||
resource = spans[1].tracer.resource,
|
||||
scope_spans = {
|
||||
{
|
||||
scope = {
|
||||
name = spans[1].tracer.name,
|
||||
},
|
||||
spans = {}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for _, span in ipairs(spans) do
|
||||
table.insert(
|
||||
body.resource_spans[1].scope_spans[1].spans,
|
||||
span:encode()
|
||||
)
|
||||
end
|
||||
|
||||
self.request_fn(
|
||||
self.endpoint, {
|
||||
method = "POST",
|
||||
headers = {
|
||||
["Content-Type"] = "application/json",
|
||||
},
|
||||
body = json.encode(body),
|
||||
},
|
||||
function(body, code, response)
|
||||
if code ~= 200 then
|
||||
module:log("error", json.encode(body))
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
--------------------------------------------------
|
||||
-- Tracer --
|
||||
--------------------------------------------------
|
||||
|
||||
local Tracer = {}
|
||||
Tracer.__index = Tracer
|
||||
m.Tracer = Tracer
|
||||
|
||||
function Tracer.new(processor, service, name)
|
||||
return setmetatable({
|
||||
processor = processor,
|
||||
resource = {
|
||||
attributes = {
|
||||
{
|
||||
key = "service.name",
|
||||
value = Attribute.string(service)
|
||||
}
|
||||
}
|
||||
},
|
||||
name = name,
|
||||
}, Tracer)
|
||||
end
|
||||
|
||||
function Tracer:start_span(name, context)
|
||||
return Span.new(name, self, { context = context })
|
||||
end
|
||||
|
||||
return m
|
||||
14
resources/prosody-plugins/stanza_router_no-log.patch
Normal file
14
resources/prosody-plugins/stanza_router_no-log.patch
Normal file
@@ -0,0 +1,14 @@
|
||||
diff -r 423f240d1173 core/stanza_router.lua
|
||||
--- a/core/stanza_router.lua Tue Feb 21 10:06:54 2023 +0000
|
||||
+++ b/core/stanza_router.lua Wed May 24 11:56:02 2023 -0500
|
||||
@@ -207,7 +207,9 @@
|
||||
else
|
||||
local host_session = hosts[from_host];
|
||||
if not host_session then
|
||||
- log("error", "No hosts[from_host] (please report): %s", stanza);
|
||||
+ -- moved it to debug as it fills visitor's prosody logs and this is a situation where we try to send
|
||||
+ -- presence back to the main server and we don't need anyway as it came from there
|
||||
+ log("debug", "No hosts[from_host] (please report): %s", stanza);
|
||||
else
|
||||
local xmlns = stanza.attr.xmlns;
|
||||
stanza.attr.xmlns = nil;
|
||||
140
resources/prosody-plugins/token/jwk.lib.lua
Normal file
140
resources/prosody-plugins/token/jwk.lib.lua
Normal file
@@ -0,0 +1,140 @@
|
||||
local basexx = require "basexx";
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Helper function to encode bytes to base64
|
||||
function base64_encode(bytes)
|
||||
return basexx.to_base64(bytes)
|
||||
end
|
||||
|
||||
-- Pure Lua ASN.1 DER encoder (no external dependencies)
|
||||
local ASN1 = {}
|
||||
|
||||
-- Encode ASN.1 length field
|
||||
function ASN1.encode_length(len)
|
||||
if len < 128 then
|
||||
return string.char(len)
|
||||
elseif len < 256 then
|
||||
return string.char(0x81, len)
|
||||
elseif len < 65536 then
|
||||
return string.char(0x82, math.floor(len / 256), len % 256)
|
||||
else
|
||||
local b1 = math.floor(len / 65536)
|
||||
local b2 = math.floor((len % 65536) / 256)
|
||||
local b3 = len % 256
|
||||
return string.char(0x83, b1, b2, b3)
|
||||
end
|
||||
end
|
||||
|
||||
-- Encode ASN.1 INTEGER
|
||||
function ASN1.encode_integer(bytes)
|
||||
-- ASN.1 INTEGER tag is 0x02
|
||||
-- If the high bit is set, prepend 0x00 to indicate positive number
|
||||
if bytes:byte(1) >= 0x80 then
|
||||
bytes = string.char(0x00) .. bytes
|
||||
end
|
||||
return string.char(0x02) .. ASN1.encode_length(#bytes) .. bytes
|
||||
end
|
||||
|
||||
-- Encode ASN.1 SEQUENCE
|
||||
function ASN1.encode_sequence(content)
|
||||
-- ASN.1 SEQUENCE tag is 0x30
|
||||
return string.char(0x30) .. ASN1.encode_length(#content) .. content
|
||||
end
|
||||
|
||||
-- Encode ASN.1 BIT STRING
|
||||
function ASN1.encode_bit_string(content)
|
||||
-- ASN.1 BIT STRING tag is 0x03
|
||||
-- First byte indicates number of unused bits (0x00 for byte-aligned)
|
||||
return string.char(0x03) .. ASN1.encode_length(#content + 1) .. string.char(0x00) .. content
|
||||
end
|
||||
|
||||
-- Encode ASN.1 OBJECT IDENTIFIER
|
||||
function ASN1.encode_oid(oid_bytes)
|
||||
-- ASN.1 OID tag is 0x06
|
||||
return string.char(0x06) .. ASN1.encode_length(#oid_bytes) .. oid_bytes
|
||||
end
|
||||
|
||||
-- Encode ASN.1 NULL
|
||||
function ASN1.encode_null()
|
||||
-- ASN.1 NULL tag is 0x05, length 0
|
||||
return string.char(0x05, 0x00)
|
||||
end
|
||||
|
||||
-- Convert DER to PEM format
|
||||
function ASN1.der_to_pem(der, label)
|
||||
label = label or "PUBLIC KEY"
|
||||
local base64 = base64_encode(der)
|
||||
|
||||
-- Break into 64-character lines
|
||||
local lines = {}
|
||||
for i = 1, #base64, 64 do
|
||||
table.insert(lines, base64:sub(i, i + 63))
|
||||
end
|
||||
|
||||
return "-----BEGIN " .. label .. "-----\n" ..
|
||||
table.concat(lines, "\n") .. "\n" ..
|
||||
"-----END " .. label .. "-----\n"
|
||||
end
|
||||
|
||||
-- Helper function to decode base64url
|
||||
function base64url_decode(str)
|
||||
-- Convert base64url to base64
|
||||
str = str:gsub('-', '+'):gsub('_', '/')
|
||||
-- Add padding if needed
|
||||
local padding = #str % 4
|
||||
if padding > 0 then
|
||||
str = str .. string.rep('=', 4 - padding)
|
||||
end
|
||||
return basexx.from_base64(str)
|
||||
end
|
||||
|
||||
-- Helper function to convert JWK to PEM format
|
||||
function M.jwk_to_pem(jwk)
|
||||
if not jwk.n or not jwk.e then
|
||||
return nil;
|
||||
end
|
||||
-- Decode the modulus (n) and exponent (e) from base64url
|
||||
local n_bytes = base64url_decode(jwk.n)
|
||||
local e_bytes = base64url_decode(jwk.e)
|
||||
|
||||
-- Build RSA public key structure
|
||||
-- RSAPublicKey ::= SEQUENCE {
|
||||
-- modulus INTEGER, -- n
|
||||
-- publicExponent INTEGER -- e
|
||||
-- }
|
||||
local modulus_asn1 = ASN1.encode_integer(n_bytes)
|
||||
local exponent_asn1 = ASN1.encode_integer(e_bytes)
|
||||
local rsa_pubkey = ASN1.encode_sequence(modulus_asn1 .. exponent_asn1)
|
||||
|
||||
-- Build SubjectPublicKeyInfo structure
|
||||
-- SubjectPublicKeyInfo ::= SEQUENCE {
|
||||
-- algorithm AlgorithmIdentifier,
|
||||
-- subjectPublicKey BIT STRING
|
||||
-- }
|
||||
|
||||
-- RSA OID: 1.2.840.113549.1.1.1 (rsaEncryption)
|
||||
-- Encoded as: 06 09 2A 86 48 86 F7 0D 01 01 01
|
||||
local rsa_oid = string.char(0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01)
|
||||
local rsa_oid_encoded = ASN1.encode_oid(rsa_oid)
|
||||
|
||||
-- AlgorithmIdentifier ::= SEQUENCE {
|
||||
-- algorithm OBJECT IDENTIFIER,
|
||||
-- parameters NULL
|
||||
-- }
|
||||
local algorithm_id = ASN1.encode_sequence(rsa_oid_encoded .. ASN1.encode_null())
|
||||
|
||||
-- Wrap the RSA public key in a BIT STRING
|
||||
local subject_public_key = ASN1.encode_bit_string(rsa_pubkey)
|
||||
|
||||
-- Final SubjectPublicKeyInfo
|
||||
local spki = ASN1.encode_sequence(algorithm_id .. subject_public_key)
|
||||
|
||||
-- Convert to PEM format
|
||||
return ASN1.der_to_pem(spki, "PUBLIC KEY")
|
||||
end
|
||||
|
||||
-- Expose internals for unit testing (harmless in production).
|
||||
M.ASN1 = ASN1
|
||||
|
||||
return M
|
||||
665
resources/prosody-plugins/token/util.lib.lua
Normal file
665
resources/prosody-plugins/token/util.lib.lua
Normal file
@@ -0,0 +1,665 @@
|
||||
-- Token authentication
|
||||
-- Copyright (C) 2021-present 8x8, Inc.
|
||||
|
||||
local basexx = require "basexx";
|
||||
local have_async, async = pcall(require, "util.async");
|
||||
local hex = require "util.hex";
|
||||
local jwt = module:require "luajwtjitsi";
|
||||
local jwk_to_pem = module:require "token/jwk".jwk_to_pem;
|
||||
local jid = require "util.jid";
|
||||
local json_safe = require "cjson.safe";
|
||||
local path = require "util.paths";
|
||||
local sha256 = require "util.hashes".sha256;
|
||||
local main_util = module:require "util";
|
||||
local ends_with = main_util.ends_with;
|
||||
local http_get_with_retry = main_util.http_get_with_retry;
|
||||
local extract_subdomain = main_util.extract_subdomain;
|
||||
local starts_with = main_util.starts_with;
|
||||
local table_shallow_copy = main_util.table_shallow_copy;
|
||||
local get_room_from_jid = main_util.get_room_from_jid;
|
||||
local cjson_safe = require 'cjson.safe'
|
||||
local timer = require "util.timer";
|
||||
local async = require "util.async";
|
||||
local inspect = require 'inspect';
|
||||
|
||||
local nr_retries = 3;
|
||||
local ssl = require "ssl";
|
||||
|
||||
-- TODO: Figure out a less arbitrary default cache size.
|
||||
local cacheSize = module:get_option_number("jwt_pubkey_cache_size", 128);
|
||||
|
||||
-- the cache for generated asap jwt tokens
|
||||
local jwtKeyCache = require 'util.cache'.new(cacheSize);
|
||||
|
||||
local ASAPTTL_THRESHOLD = module:get_option_number('asap_ttl_threshold', 600);
|
||||
local ASAPTTL = module:get_option_number('asap_ttl', 3600);
|
||||
local ASAPIssuer = module:get_option_string('asap_issuer', 'jitsi');
|
||||
local ASAPAudience = module:get_option_string('asap_audience', 'jitsi');
|
||||
local ASAPKeyId = module:get_option_string('asap_key_id', 'jitsi');
|
||||
local ASAPKeyPath = module:get_option_string('asap_key_path', '/etc/prosody/certs/asap.key');
|
||||
|
||||
local ASAPKey;
|
||||
local f = io.open(ASAPKeyPath, 'r');
|
||||
|
||||
if f then
|
||||
ASAPKey = f:read('*all');
|
||||
f:close();
|
||||
end
|
||||
|
||||
-- counts the tokens with a verified signature that flag their room claim as a
|
||||
-- Lua pattern
|
||||
local measure_regex_room_claim = module:measure('regex_room_claim', 'counter');
|
||||
|
||||
--- Tells whether a token asks for its room claim to be used as a Lua pattern,
|
||||
-- which the context.room.regex field marks.
|
||||
-- @param room_context the context.room value from the token
|
||||
-- @return true when the room claim is a pattern
|
||||
local function is_regex_room_claim(room_context)
|
||||
if type(room_context) ~= 'table' then
|
||||
return false;
|
||||
end
|
||||
|
||||
return room_context['regex'] == true or room_context['regex'] == 'true';
|
||||
end
|
||||
|
||||
--- Counts the quantifiers ('*', '+', '-' and '?') in a Lua pattern.
|
||||
-- A quantifier is only special outside a character class, so the contents of
|
||||
-- a class ('[a-z]') are skipped, and so is the character after a '%' escape.
|
||||
-- @param pattern the Lua pattern to inspect
|
||||
-- @return the number of quantifiers in the pattern
|
||||
local function count_pattern_quantifiers(pattern)
|
||||
local count = 0;
|
||||
local index = 1;
|
||||
local length = #pattern;
|
||||
local in_class = false;
|
||||
|
||||
while index <= length do
|
||||
local char = pattern:sub(index, index);
|
||||
|
||||
if char == '%' then
|
||||
-- '%' escapes the next character, so skip both
|
||||
index = index + 2;
|
||||
else
|
||||
if in_class then
|
||||
if char == ']' then
|
||||
in_class = false;
|
||||
end
|
||||
elseif char == '[' then
|
||||
in_class = true;
|
||||
elseif char == '*' or char == '+' or char == '-' or char == '?' then
|
||||
count = count + 1;
|
||||
end
|
||||
index = index + 1;
|
||||
end
|
||||
end
|
||||
|
||||
return count;
|
||||
end
|
||||
|
||||
local Util = {}
|
||||
Util.__index = Util
|
||||
|
||||
--- Constructs util class for token verifications.
|
||||
-- Constructor that uses the passed module to extract all the
|
||||
-- needed configurations.
|
||||
-- If configuration is missing returns nil
|
||||
-- @param module the module in which options to check for configs.
|
||||
-- @return the new instance or nil
|
||||
function Util.new(module)
|
||||
local self = setmetatable({}, Util)
|
||||
|
||||
self.appId = module:get_option_string("app_id");
|
||||
self.appSecret = module:get_option_string("app_secret");
|
||||
self.asapKeyServer = module:get_option_string("asap_key_server");
|
||||
-- A URL that will return json file with a mapping between kids and public keys
|
||||
-- If the response Cache-Control header we will respect it and refresh it
|
||||
self.cacheKeysUrl = module:get_option_string("cache_keys_url");
|
||||
self.signatureAlgorithm = module:get_option_string("signature_algorithm");
|
||||
self.allowEmptyToken = module:get_option_boolean("allow_empty_token");
|
||||
|
||||
self.cache = require"util.cache".new(cacheSize);
|
||||
|
||||
--[[
|
||||
Multidomain can be supported in some deployments. In these deployments
|
||||
there is a virtual conference muc, which address contains the subdomain
|
||||
to use. Those deployments are accessible
|
||||
by URL https://domain/subdomain.
|
||||
Then the address of the room will be:
|
||||
roomName@conference.subdomain.domain. This is like a virtual address
|
||||
where there is only one muc configured by default with address:
|
||||
conference.domain and the actual presentation of the room in that muc
|
||||
component is [subdomain]roomName@conference.domain.
|
||||
These setups relay on configuration 'muc_domain_base' which holds
|
||||
the main domain and we use it to subtract subdomains from the
|
||||
virtual addresses.
|
||||
The following configurations are for multidomain setups and domain name
|
||||
verification:
|
||||
--]]
|
||||
|
||||
-- optional parameter for custom muc component prefix,
|
||||
-- defaults to "conference"
|
||||
self.muc_domain_prefix = module:get_option_string(
|
||||
"muc_mapper_domain_prefix", "conference");
|
||||
-- domain base, which is the main domain used in the deployment,
|
||||
-- the main VirtualHost for the deployment
|
||||
self.muc_domain_base = module:get_option_string("muc_mapper_domain_base");
|
||||
-- The "real" MUC domain that we are proxying to
|
||||
if self.muc_domain_base then
|
||||
self.muc_domain = module:get_option_string(
|
||||
"muc_mapper_domain",
|
||||
self.muc_domain_prefix.."."..self.muc_domain_base);
|
||||
end
|
||||
-- whether domain name verification is enabled, by default it is enabled
|
||||
-- when disabled checking domain name and tenant if available will be skipped, we will check only room name.
|
||||
self.enableDomainVerification = module:get_option_boolean('enable_domain_verification', true);
|
||||
|
||||
--[[
|
||||
A room claim that is flagged with context.room.regex is used as a Lua
|
||||
pattern. Lua patterns backtrack, so the cost of a match grows as
|
||||
(room name length) ^ (number of quantifiers). Both are limited to keep
|
||||
the cost of one match bounded, because a match blocks Prosody.
|
||||
Measured worst case with the defaults below is about 70ms; lowering
|
||||
the quantifier limit to 2 brings that down to about 2ms.
|
||||
--]]
|
||||
self.regexEnabled = module:get_option_boolean('token_regex_enabled', true);
|
||||
self.regexRoomMaxLength = module:get_option_number('token_regex_room_max_length', 128);
|
||||
self.regexRoomMaxQuantifiers = module:get_option_number('token_regex_max_quantifiers', 3);
|
||||
|
||||
if self.allowEmptyToken == true then
|
||||
module:log("warn", "WARNING - empty tokens allowed");
|
||||
end
|
||||
|
||||
if self.appId == nil then
|
||||
module:log("error", "'app_id' must not be empty");
|
||||
return nil;
|
||||
end
|
||||
|
||||
if self.appSecret == nil and self.asapKeyServer == nil and self.cacheKeysUrl == nil then
|
||||
module:log("error", "'app_secret', 'asap_key_server or 'cacheKeysUrl' must be specified");
|
||||
return nil;
|
||||
end
|
||||
|
||||
-- Set defaults for signature algorithm
|
||||
if self.signatureAlgorithm == nil then
|
||||
if self.asapKeyServer ~= nil or self.cacheKeysUrl then
|
||||
self.signatureAlgorithm = "RS256"
|
||||
elseif self.appSecret ~= nil then
|
||||
self.signatureAlgorithm = "HS256"
|
||||
end
|
||||
end
|
||||
|
||||
--array of accepted issuers: by default only includes our appId
|
||||
self.acceptedIssuers = module:get_option_array('asap_accepted_issuers',{self.appId})
|
||||
|
||||
--array of accepted audiences: by default only includes our appId
|
||||
self.acceptedAudiences = module:get_option_array('asap_accepted_audiences',{'*'})
|
||||
|
||||
self.requireRoomClaim = module:get_option_boolean('asap_require_room_claim', true);
|
||||
|
||||
if (self.asapKeyServer or self.cacheKeysUrl) and not have_async then
|
||||
module:log("error", "requires a version of Prosody with util.async");
|
||||
return nil;
|
||||
end
|
||||
|
||||
if self.cacheKeysUrl then
|
||||
self.cachedKeys = {};
|
||||
local update_keys_cache;
|
||||
update_keys_cache = async.runner(function (name)
|
||||
local content, code, cache_for;
|
||||
content, code, cache_for = http_get_with_retry(self.cacheKeysUrl, nr_retries);
|
||||
if content ~= nil then
|
||||
local keys_to_delete = table_shallow_copy(self.cachedKeys);
|
||||
-- Let's convert any certificate to public key
|
||||
for k, v in pairs(cjson_safe.decode(content) or {}) do
|
||||
-- JWKS format
|
||||
if k == "keys" and type(v) == "table" then
|
||||
for _, key in ipairs(v) do
|
||||
if key.kid then
|
||||
self.cachedKeys[key.kid] = jwk_to_pem(key);
|
||||
|
||||
-- do not clean this key if it already exists
|
||||
keys_to_delete[key.kid] = nil;
|
||||
end
|
||||
end
|
||||
-- direct PEM mapping (Firebase)
|
||||
elseif starts_with(v, '-----BEGIN CERTIFICATE-----') then
|
||||
self.cachedKeys[k] = ssl.loadcertificate(v):pubkey();
|
||||
-- do not clean this key if it already exists
|
||||
keys_to_delete[k] = nil;
|
||||
end
|
||||
end
|
||||
-- let's schedule the clean in an hour and a half, current tokens will be valid for an hour
|
||||
timer.add_task(90*60, function ()
|
||||
for k, _ in pairs(keys_to_delete) do
|
||||
self.cachedKeys[k] = nil;
|
||||
end
|
||||
end);
|
||||
|
||||
if cache_for then
|
||||
cache_for = tonumber(cache_for);
|
||||
-- let's schedule new update 60 seconds before the cache expiring
|
||||
if cache_for > 60 then
|
||||
cache_for = cache_for - 60;
|
||||
end
|
||||
timer.add_task(cache_for, function ()
|
||||
update_keys_cache:run("update_keys_cache");
|
||||
end);
|
||||
else
|
||||
-- no cache header let's consider updating in 6hours
|
||||
timer.add_task(6*60*60, function ()
|
||||
update_keys_cache:run("update_keys_cache");
|
||||
end);
|
||||
end
|
||||
else
|
||||
module:log('warn', 'Failed to retrieve cached public keys code:%s', code);
|
||||
-- failed let's retry in 30 seconds
|
||||
timer.add_task(30, function ()
|
||||
update_keys_cache:run("update_keys_cache");
|
||||
end);
|
||||
end
|
||||
end);
|
||||
update_keys_cache:run("update_keys_cache");
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function Util:set_asap_key_server(asapKeyServer)
|
||||
self.asapKeyServer = asapKeyServer;
|
||||
end
|
||||
|
||||
function Util:set_asap_accepted_issuers(acceptedIssuers)
|
||||
self.acceptedIssuers = acceptedIssuers;
|
||||
end
|
||||
|
||||
function Util:set_asap_accepted_audiences(acceptedAudiences)
|
||||
self.acceptedAudiences = acceptedAudiences;
|
||||
end
|
||||
|
||||
function Util:set_asap_require_room_claim(checkRoom)
|
||||
self.requireRoomClaim = checkRoom;
|
||||
end
|
||||
|
||||
function Util:clear_asap_cache()
|
||||
self.cache = require"util.cache".new(cacheSize);
|
||||
end
|
||||
|
||||
--- Returns the public key by keyID
|
||||
-- @param keyId the key ID to request
|
||||
-- @return the public key (the content of requested resource) or nil
|
||||
function Util:get_public_key(keyId)
|
||||
local content = self.cache:get(keyId);
|
||||
local code;
|
||||
if content == nil then
|
||||
-- If the key is not found in the cache.
|
||||
-- module:log("debug", "Cache miss for key: %s", keyId);
|
||||
local keyurl = path.join(self.asapKeyServer, hex.to(sha256(keyId))..'.pem');
|
||||
-- module:log("debug", "Fetching public key from: %s", keyurl);
|
||||
content, code = http_get_with_retry(keyurl, nr_retries);
|
||||
if content ~= nil then
|
||||
self.cache:set(keyId, content);
|
||||
else
|
||||
if code == nil then
|
||||
-- this is timeout after nr_retries retries
|
||||
module:log('warn', 'Timeout retrieving %s from %s', keyId, keyurl);
|
||||
end
|
||||
end
|
||||
return content;
|
||||
else
|
||||
-- If the key is in the cache, use it.
|
||||
-- module:log("debug", "Cache hit for key: %s", keyId);
|
||||
return content;
|
||||
end
|
||||
end
|
||||
|
||||
--- Verifies token and process needed values to be stored in the session.
|
||||
-- Token is obtained from session.auth_token.
|
||||
-- Stores in session the following values:
|
||||
-- session.jitsi_meet_room - the room name value from the token
|
||||
-- session.jitsi_meet_domain - the domain name value from the token
|
||||
-- session.jitsi_meet_context_user - the user details from the token
|
||||
-- session.jitsi_meet_context_room - the room details from the token
|
||||
-- session.jitsi_meet_context_group - the group value from the token
|
||||
-- session.jitsi_meet_context_features - the features value from the token
|
||||
-- @param session the current session
|
||||
-- @return false and error
|
||||
function Util:process_and_verify_token(session)
|
||||
if session.auth_token == nil then
|
||||
if self.allowEmptyToken then
|
||||
return true;
|
||||
else
|
||||
return false, "not-allowed", "token required";
|
||||
end
|
||||
end
|
||||
|
||||
local key;
|
||||
if session.public_key then
|
||||
-- We're using an public key stored in the session
|
||||
-- module:log("debug","Public key was found on the session");
|
||||
key = session.public_key;
|
||||
elseif (self.asapKeyServer or self.cacheKeysUrl) and session.auth_token ~= nil then
|
||||
-- We're fetching an public key from an ASAP server
|
||||
local dotFirst = session.auth_token:find("%.");
|
||||
if not dotFirst then return false, "not-allowed", "Invalid token" end
|
||||
local headerPartEncoded = basexx.from_url64(session.auth_token:sub(1,dotFirst-1));
|
||||
if not headerPartEncoded then return false, "not-allowed", "Invalid token" end
|
||||
local header, err = json_safe.decode(headerPartEncoded);
|
||||
if err then
|
||||
return false, "not-allowed", "bad token format";
|
||||
end
|
||||
local kid = header["kid"];
|
||||
if kid == nil then
|
||||
return false, "not-allowed", "'kid' claim is missing";
|
||||
end
|
||||
local alg = header["alg"];
|
||||
if alg == nil then
|
||||
return false, "not-allowed", "'alg' claim is missing";
|
||||
end
|
||||
if alg.sub(alg,1,2) ~= "RS" then
|
||||
return false, "not-allowed", "'kid' claim only support with RS family";
|
||||
end
|
||||
|
||||
if self.cachedKeys and self.cachedKeys[kid] then
|
||||
key = self.cachedKeys[kid];
|
||||
else
|
||||
key = self:get_public_key(kid);
|
||||
end
|
||||
|
||||
if key == nil then
|
||||
return false, "not-allowed", "could not obtain public key";
|
||||
end
|
||||
elseif self.appSecret ~= nil then
|
||||
-- We're using a symmetric secret
|
||||
key = self.appSecret
|
||||
end
|
||||
|
||||
if key == nil then
|
||||
return false, "not-allowed", "signature verification key is missing";
|
||||
end
|
||||
|
||||
-- now verify the whole token
|
||||
local claims, msg = jwt.verify(
|
||||
session.auth_token,
|
||||
self.signatureAlgorithm,
|
||||
key,
|
||||
self.acceptedIssuers,
|
||||
self.acceptedAudiences
|
||||
)
|
||||
if claims ~= nil then
|
||||
if self.requireRoomClaim then
|
||||
local roomClaim = claims["room"];
|
||||
if roomClaim == nil then
|
||||
return false, "not-authorized", "room claim is missing";
|
||||
end
|
||||
end
|
||||
|
||||
-- Binds room name to the session which is later checked on MUC join
|
||||
session.jitsi_meet_room = claims["room"];
|
||||
-- Binds domain name to the session
|
||||
if claims["sub"] == nil then
|
||||
return false, "not-authorized", "sub claim is missing";
|
||||
end
|
||||
session.jitsi_meet_domain = claims["sub"];
|
||||
session.jitsi_meet_auth_issuer = claims["iss"];
|
||||
|
||||
-- Binds the user details to the session if available
|
||||
if claims["context"] ~= nil then
|
||||
session.jitsi_meet_str_tenant = claims["context"]["tenant"];
|
||||
|
||||
if claims["context"]["user"] ~= nil then
|
||||
session.jitsi_meet_context_user = claims["context"]["user"];
|
||||
end
|
||||
|
||||
if claims["context"]["group"] ~= nil then
|
||||
-- Binds any group details to the session
|
||||
session.jitsi_meet_context_group = claims["context"]["group"];
|
||||
end
|
||||
|
||||
if claims["context"]["features"] ~= nil then
|
||||
-- Binds any features details to the session
|
||||
session.jitsi_meet_context_features = claims["context"]["features"];
|
||||
end
|
||||
if claims["context"]["room"] ~= nil then
|
||||
session.jitsi_meet_context_room = claims["context"]["room"]
|
||||
|
||||
-- the signature is verified at this point, so report the tokens
|
||||
-- that ask for their room claim to be used as a pattern
|
||||
if is_regex_room_claim(claims["context"]["room"]) then
|
||||
measure_regex_room_claim(1);
|
||||
module:log('info', 'Room claim is a pattern. tenant:%s iss:%s room:%s',
|
||||
claims["context"]["tenant"], claims["iss"], claims["room"]);
|
||||
end
|
||||
end
|
||||
elseif claims["user_id"] then
|
||||
session.jitsi_meet_context_user = {};
|
||||
session.jitsi_meet_context_user.id = claims["user_id"];
|
||||
end
|
||||
|
||||
-- fire event that token has been verified and pass the session and the decoded token
|
||||
prosody.events.fire_event('jitsi-authentication-token-verified', {
|
||||
session = session;
|
||||
claims = claims;
|
||||
});
|
||||
|
||||
if session.contextRequired and claims["context"] == nil then
|
||||
return false, "not-allowed", 'jwt missing required context claim';
|
||||
end
|
||||
|
||||
return true;
|
||||
else
|
||||
return false, "not-allowed", msg;
|
||||
end
|
||||
end
|
||||
|
||||
--- Verifies room name and domain if necessary.
|
||||
-- Checks configs and if necessary checks the room name extracted from
|
||||
-- room_address against the one saved in the session when token was verified.
|
||||
-- Also verifies domain name from token against the domain in the room_address,
|
||||
-- if enableDomainVerification is enabled.
|
||||
-- @param session the current session
|
||||
-- @param room_address the whole room address as received
|
||||
-- @return returns true in case room was verified or there is no need to verify
|
||||
-- it and returns false in case verification was processed
|
||||
-- and was not successful
|
||||
function Util:verify_room(session, room_address)
|
||||
-- extract room name using all chars, except the not allowed ones
|
||||
local room,_,_ = jid.split(room_address);
|
||||
if room == nil then
|
||||
module:log('error', 'Unable to get name of the MUC room ? to: %s', room_address);
|
||||
return false, 'invalid-room-address', 'Room address is invalid';
|
||||
end
|
||||
local room_instance = get_room_from_jid(jid.join(room, self.muc_domain));
|
||||
if not room_instance then
|
||||
module:log('info', 'Room does not exists:%s', room);
|
||||
return false, 'room-does-not-exist', 'Room does not exist';
|
||||
end
|
||||
|
||||
if self.allowEmptyToken and session.auth_token == nil then
|
||||
if room_instance._data.allowUnauthenticatedAccess == false then
|
||||
return false, 'authentication-required', 'Authentication required';
|
||||
end
|
||||
return true;
|
||||
end
|
||||
|
||||
local auth_room_is_pattern = is_regex_room_claim(session.jitsi_meet_context_room);
|
||||
|
||||
local auth_room = session.jitsi_meet_room;
|
||||
if auth_room then
|
||||
if type(auth_room) == 'string' then
|
||||
-- Room names are lowercase. Lowercase a literal room claim so that
|
||||
-- a token with uppercase letters still matches. Keep a pattern as
|
||||
-- written: pattern classes are case-sensitive (%d vs %D).
|
||||
if not auth_room_is_pattern then
|
||||
auth_room = string.lower(auth_room);
|
||||
end
|
||||
else
|
||||
module:log('warn', 'session.jitsi_meet_room not string: %s', inspect(auth_room));
|
||||
end
|
||||
end
|
||||
|
||||
if not self.enableDomainVerification then
|
||||
-- if auth_room is missing, this means user is anonymous (no token for its domain) we let it through
|
||||
if auth_room and (room ~= auth_room and not ends_with(room, ']'..auth_room)) and auth_room ~= '*' then
|
||||
return false, 'room-mismatch', 'Room does not match the room from token';
|
||||
end
|
||||
|
||||
return true;
|
||||
end
|
||||
|
||||
local room_address_to_verify = jid.bare(room_address);
|
||||
local room_node = jid.node(room_address);
|
||||
-- parses bare room address, for multidomain expected format is:
|
||||
-- [subdomain]roomName@conference.domain
|
||||
local target_subdomain, target_room = extract_subdomain(room_node);
|
||||
|
||||
-- if we have '*' as room name in token, this means all rooms are allowed
|
||||
-- so we will use the actual name of the room when constructing strings
|
||||
-- to verify subdomains and domains to simplify checks
|
||||
local room_to_check;
|
||||
if auth_room == '*' then
|
||||
-- authorized for accessing any room assign to room_to_check the actual
|
||||
-- room name
|
||||
if target_room ~= nil then
|
||||
-- we are in multidomain mode and we were able to extract room name
|
||||
room_to_check = target_room;
|
||||
else
|
||||
-- no target_room, room_address_to_verify does not contain subdomain
|
||||
-- so we get just the node which is the room name
|
||||
room_to_check = room_node;
|
||||
end
|
||||
else
|
||||
-- no wildcard, so check room against authorized room from the token
|
||||
if auth_room_is_pattern then
|
||||
if not self.regexEnabled then
|
||||
module:log('warn', 'Room claim patterns are disabled, refusing the claim: %s', auth_room);
|
||||
return false, 'invalid-regex', 'Room claim patterns are not enabled';
|
||||
end
|
||||
|
||||
local match_target = target_room ~= nil and target_room or room_node;
|
||||
|
||||
if #match_target > self.regexRoomMaxLength then
|
||||
module:log('warn', 'Room name is longer than %s, not matching it against a pattern: %s',
|
||||
self.regexRoomMaxLength, match_target);
|
||||
return false, 'invalid-regex', 'Room name is too long to match against the room claim';
|
||||
end
|
||||
|
||||
local quantifiers = count_pattern_quantifiers(auth_room);
|
||||
if quantifiers > self.regexRoomMaxQuantifiers then
|
||||
module:log('warn', 'Room claim has %s quantifiers, more than the limit of %s: %s',
|
||||
quantifiers, self.regexRoomMaxQuantifiers, auth_room);
|
||||
return false, 'invalid-regex', 'Room claim pattern is too complex';
|
||||
end
|
||||
|
||||
local ok, result = pcall(string.match, match_target, auth_room);
|
||||
if not ok then
|
||||
return false, 'invalid-regex', 'Room claim is not a valid Lua pattern';
|
||||
end
|
||||
room_to_check = result;
|
||||
else
|
||||
-- not a regex
|
||||
room_to_check = auth_room;
|
||||
end
|
||||
|
||||
if not room_to_check then
|
||||
if not self.requireRoomClaim then
|
||||
-- if we do not require to have the room claim, and it is missing
|
||||
-- there is no point of continue and verifying the roomName and the tenant
|
||||
return true;
|
||||
end
|
||||
|
||||
return false, 'room-name-does-not-match', 'Room name cannot be matched to the one from token.';
|
||||
end
|
||||
end
|
||||
|
||||
if session.jitsi_meet_str_tenant
|
||||
and string.lower(session.jitsi_meet_str_tenant) ~= session.jitsi_web_query_prefix then
|
||||
session.jitsi_meet_tenant_mismatch = true;
|
||||
|
||||
if room_instance._data.allowUnauthenticatedAccess == false then
|
||||
return false;
|
||||
end
|
||||
|
||||
module:log('warn', 'Tenant differs for user:%s group:%s url_tenant:%s token_tenant:%s',
|
||||
session.jitsi_meet_context_user and session.jitsi_meet_context_user.id or '',
|
||||
session.jitsi_meet_context_group,
|
||||
session.jitsi_web_query_prefix, session.jitsi_meet_str_tenant);
|
||||
end
|
||||
|
||||
local auth_domain = string.lower(session.jitsi_meet_domain);
|
||||
local subdomain_to_check;
|
||||
if target_subdomain then
|
||||
if auth_domain == '*' then
|
||||
-- check for wildcard in JWT claim, allow access if found
|
||||
subdomain_to_check = target_subdomain;
|
||||
else
|
||||
-- no wildcard in JWT claim, so check subdomain against sub in token
|
||||
subdomain_to_check = auth_domain;
|
||||
end
|
||||
-- from this point we depend on muc_domain_base,
|
||||
-- deny access if option is missing
|
||||
if not self.muc_domain_base then
|
||||
module:log("warn", "No 'muc_domain_base' option set, denying access!");
|
||||
return false, 'server-missing-config', 'Misconfiguration of server';
|
||||
end
|
||||
|
||||
return room_address_to_verify == jid.join(
|
||||
"["..subdomain_to_check.."]"..room_to_check, self.muc_domain);
|
||||
else
|
||||
if auth_domain == '*' then
|
||||
-- check for wildcard in JWT claim, allow access if found
|
||||
subdomain_to_check = self.muc_domain;
|
||||
else
|
||||
-- no wildcard in JWT claim, so check subdomain against sub in token
|
||||
subdomain_to_check = self.muc_domain_prefix.."."..auth_domain;
|
||||
end
|
||||
-- we do not have a domain part (multidomain is not enabled)
|
||||
-- verify with info from the token
|
||||
return room_address_to_verify == jid.join(room_to_check, subdomain_to_check);
|
||||
end
|
||||
end
|
||||
|
||||
function Util:generateAsapToken(audience)
|
||||
if not ASAPKey then
|
||||
module:log('warn', 'No ASAP Key read, asap key generation is disabled');
|
||||
return ''
|
||||
end
|
||||
|
||||
audience = audience or ASAPAudience
|
||||
local t = os.time()
|
||||
local err
|
||||
local exp_key = 'asap_exp.'..audience
|
||||
local token_key = 'asap_token.'..audience
|
||||
local exp = jwtKeyCache:get(exp_key)
|
||||
local token = jwtKeyCache:get(token_key)
|
||||
|
||||
--if we find a token and it isn't too far from expiry, then use it
|
||||
if token ~= nil and exp ~= nil then
|
||||
exp = tonumber(exp)
|
||||
if (exp - t) > ASAPTTL_THRESHOLD then
|
||||
return token
|
||||
end
|
||||
end
|
||||
|
||||
--expiry is the current time plus TTL
|
||||
exp = t + ASAPTTL
|
||||
local payload = {
|
||||
iss = ASAPIssuer,
|
||||
aud = audience,
|
||||
nbf = t,
|
||||
exp = exp,
|
||||
}
|
||||
|
||||
-- encode
|
||||
local alg = 'RS256'
|
||||
token, err = jwt.encode(payload, ASAPKey, alg, { kid = ASAPKeyId })
|
||||
if not err then
|
||||
token = 'Bearer '..token
|
||||
jwtKeyCache:set(exp_key, exp)
|
||||
jwtKeyCache:set(token_key, token)
|
||||
return token
|
||||
else
|
||||
return ''
|
||||
end
|
||||
end
|
||||
|
||||
return Util;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user