diff --git a/README.md b/README.md index 64ec739..7ca4d1e 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,11 @@ jitsi/ assets/ public/politictalk/ branding.json + favicon.ico pgLogo.svg pg_bg.png + interface_config/ + politictalk-overrides.js local/ README.md custom-config.js @@ -26,6 +29,8 @@ jitsi/ docker-compose.override.yml nginx/ politictalk.parallelglobe.io.conf + prosody-plugins/ + mod_politictalk_roles.lua scripts/ deploy-vps.sh local-jitsi-setup.sh @@ -33,20 +38,28 @@ jitsi/ local-jitsi-stop.sh local-jitsi-sync.sh templates/ + web/ + plugin.head.html + title.html ``` ## Current Meeting Policy - Meetings start in audio-only mode. - Participants join with microphone muted. +- Participants cannot unmute themselves until a host allows them through Jitsi AV moderation. - Participants join with camera off. -- Camera/prejoin camera controls are hidden. +- Jitsi prejoin is disabled because PgPlatform owns the PoliticTalk prejoin/auth step. +- Camera/premeeting camera controls are hidden. - Toolbar is limited to microphone, chat, raise hand, fullscreen, noise suppression, participants pane, and hangup. - Chat and polls are enabled. - Invite/share controls are disabled. - Room names are not stored in recent rooms. - E2EE support is enabled in the Jitsi config. - The logo and dynamic branding point to PoliticTalk public assets. +- Browser title, favicon, Open Graph metadata, and in-meeting watermark/logo use PoliticTalk branding. +- When JWT auth is enabled, Jitsi auto-owner is disabled so the first entrant cannot become moderator automatically. +- A custom Prosody module maps PoliticTalk JWT roles to Jitsi moderator/member roles, enables host-controlled AV moderation, and closes the room when the host leaves. ## Local Docker Testing @@ -91,6 +104,19 @@ jitsi/config/politictalk.parallelglobe.io-config.js jitsi/assets/public/politictalk/* -> /etc/jitsi/meet/public/politictalk/ + -> /usr/share/jitsi-meet/images/politictalk/ + +jitsi/web/title.html + -> /usr/share/jitsi-meet/title.html + +jitsi/web/plugin.head.html + -> /usr/share/jitsi-meet/plugin.head.html + +jitsi/interface_config/politictalk-overrides.js + -> appended to /usr/share/jitsi-meet/interface_config.js + +jitsi/prosody-plugins/mod_politictalk_roles.lua + -> /usr/share/jitsi-meet/prosody-plugins/mod_politictalk_roles.lua jitsi/nginx/politictalk.parallelglobe.io.conf -> /etc/nginx/sites-available/politictalk.parallelglobe.io.conf @@ -114,15 +140,33 @@ cd /path/to/pg/jitsi sudo DEPLOY_NGINX=1 ./scripts/deploy-vps.sh ``` -For current `config.js`, branding, and asset changes, an nginx reload is enough. Future JWT/auth changes may require restarting Prosody and Jicofo. +For current `config.js`, branding, and asset changes, an nginx reload is enough. JWT/auth and Prosody plugin changes require restarting Prosody and Jicofo, and usually Jitsi Videobridge. -## Future Auth Work +The `politictalk_roles` Prosody module should be configured with the PgApi +inactive-room callback from `templates/prosody-token-auth.cfg.lua.example`. +That callback clears the event `meetingCode` when the last host leaves, so +participants remain on the platform waiting screen instead of reaching a +blocked Jitsi room. -The future authenticated flow should be: +## Token Auth Rollout + +The authenticated flow is: ```text PgPlatform -> PgApi verifies event/user -> PgApi creates short-lived Jitsi JWT -> PgPlatform prejoin/iframe -> Jitsi validates JWT on VPS ``` +PgApi now supports JWT-backed `join-link` responses when these environment values are set: + +```text +jitsi/templates/pgapi-politictalk-jwt.env.example +``` + +The VPS-side Prosody token-auth shape is documented here: + +```text +jitsi/templates/prosody-token-auth.cfg.lua.example +``` + Do not commit real secrets here. JWT secrets, Prosody passwords, and private keys should stay in VPS-only environment/config files. diff --git a/assets/public/politictalk/branding.json b/assets/public/politictalk/branding.json index d864e43..708dfac 100644 --- a/assets/public/politictalk/branding.json +++ b/assets/public/politictalk/branding.json @@ -1,9 +1,9 @@ { "inviteDomain": "politictalk.parallelglobe.io", "backgroundColor": "#101820", - "logoClickUrl": "https://parallelglobe.io/", - "logoImageUrl": "/_api/public/politictalk/pgLogo.svg", - "premeetingBackground": "url(/_api/public/politictalk/pg_bg.png)", + "logoClickUrl": "https://parallelglobe.io/politictalk", + "logoImageUrl": "/images/politictalk/pgLogo.svg", + "premeetingBackground": "url(/images/politictalk/pg_bg.png)", "customTheme": { "palette": { "ui01": "#101820", diff --git a/assets/public/politictalk/favicon.ico b/assets/public/politictalk/favicon.ico new file mode 100644 index 0000000..309def1 Binary files /dev/null and b/assets/public/politictalk/favicon.ico differ diff --git a/assets/public/politictalk/pg_globe.png b/assets/public/politictalk/pg_globe.png new file mode 100644 index 0000000..6be1713 Binary files /dev/null and b/assets/public/politictalk/pg_globe.png differ diff --git a/config/politictalk.parallelglobe.io-config.js b/config/politictalk.parallelglobe.io-config.js index 01b0c6b..b01f69f 100644 --- a/config/politictalk.parallelglobe.io-config.js +++ b/config/politictalk.parallelglobe.io-config.js @@ -711,7 +711,7 @@ var config = { // will be joined when no room is specified. disabled: false, // If set, landing page will redirect to this URL. - customUrl: 'https://parallelglobe.io/calendar' + customUrl: 'https://parallelglobe.io/politictalk' }, // Configs for the lobby screen. @@ -790,9 +790,9 @@ var config = { // Configs for prejoin page. prejoinConfig: { - // When true, users see the PoliticTalk prejoin screen before entering the room. - enabled: true, - hideDisplayName: false, + // Platform owns PoliticTalk prejoin/auth, so Jitsi enters the room directly. + enabled: false, + hideDisplayName: true, hideExtraJoinButtons: ['no-audio', 'by-phone'], preCallTestEnabled: false, preCallTestICEUrl: '', @@ -1465,7 +1465,7 @@ var config = { } } */ - dynamicBrandingUrl: '/_api/public/politictalk/branding.json', + dynamicBrandingUrl: '/images/politictalk/branding.json', // A list of allowed URL domains for shared video. // @@ -1862,7 +1862,7 @@ var config = { // }, // Application logo url - defaultLogoUrl: '/_api/public/politictalk/pgLogo.svg', + defaultLogoUrl: '/images/politictalk/pgLogo.svg', // Settings for the Excalidraw whiteboard integration. // whiteboard: { diff --git a/interface_config/politictalk-overrides.js b/interface_config/politictalk-overrides.js new file mode 100644 index 0000000..611a670 --- /dev/null +++ b/interface_config/politictalk-overrides.js @@ -0,0 +1,21 @@ +// BEGIN POLITICTALK INTERFACE OVERRIDES +interfaceConfig.APP_NAME = 'PoliticTalk'; +interfaceConfig.NATIVE_APP_NAME = 'PoliticTalk'; +interfaceConfig.PROVIDER_NAME = 'ParallelGlobe'; + +interfaceConfig.DEFAULT_LOGO_URL = '/images/politictalk/pgLogo.svg'; +interfaceConfig.DEFAULT_WELCOME_PAGE_LOGO_URL = '/images/politictalk/pgLogo.svg'; +interfaceConfig.SHOW_JITSI_WATERMARK = false; +interfaceConfig.SHOW_BRAND_WATERMARK = true; +interfaceConfig.BRAND_WATERMARK_LINK = 'https://parallelglobe.io/politictalk'; +interfaceConfig.SHOW_POWERED_BY = false; +interfaceConfig.MOBILE_APP_PROMO = false; + +interfaceConfig.DISPLAY_WELCOME_FOOTER = false; +interfaceConfig.DISPLAY_WELCOME_PAGE_CONTENT = false; +interfaceConfig.DISPLAY_WELCOME_PAGE_ADDITIONAL_CARD = false; +interfaceConfig.DISPLAY_WELCOME_PAGE_TOOLBAR_ADDITIONAL_CONTENT = false; + +interfaceConfig.JITSI_WATERMARK_LINK = ''; +interfaceConfig.SUPPORT_URL = ''; +// END POLITICTALK INTERFACE OVERRIDES diff --git a/local/README.md b/local/README.md index 78b0e1d..2c2e05b 100644 --- a/local/README.md +++ b/local/README.md @@ -31,7 +31,7 @@ The browser may warn about a self-signed certificate. That is expected for local ## After Editing Overrides -When you edit `local/custom-config.js`, `local/custom-interface_config.js`, or local assets, run: +When you edit `local/custom-config.js`, `local/custom-interface_config.js`, `web/title.html`, `web/plugin.head.html`, or local assets, run: ```bash ./scripts/local-jitsi-sync.sh @@ -47,11 +47,30 @@ When you edit `local/custom-config.js`, `local/custom-interface_config.js`, or l ## What This Tests - PoliticTalk branding assets -- prejoin behavior +- PoliticTalk browser title, favicon, and in-meeting logo +- direct room entry after the PgPlatform prejoin/auth step +- host-only room creation and participant-only Jitsi roles when JWT auth is enabled - audio-only policy - muted microphone/camera startup +- host-controlled participant unmute through Jitsi AV moderation - toolbar restrictions - chat, polls, raise hand, fullscreen, noise suppression - E2EE UI availability Final production verification still happens on the VPS because real WebRTC networking, domain, HTTPS, and future JWT auth depend on the server environment. + +## Optional JWT Auth Check + +Local Docker Jitsi can be switched into JWT mode when you want to test token-only joins: + +```bash +ENABLE_POLITICTALK_JWT_AUTH=1 \ +POLITICTALK_JITSI_JWT_APP_ID=politictalk-local \ +POLITICTALK_JITSI_JWT_APP_SECRET=replace-with-local-secret \ +POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET=politictalk-local-lifecycle-secret \ +./scripts/local-jitsi-setup.sh +./scripts/local-jitsi-start.sh +``` + +Use the same app id and secret in `pgapi/.env` so `/events/:id/join-link` returns a URL with `?jwt=...`. +Use the same callback secret in `pgapi/.env` as `POLITICTALK_JITSI_LIFECYCLE_SECRET`. diff --git a/local/custom-config.js b/local/custom-config.js index 643e330..b587b89 100644 --- a/local/custom-config.js +++ b/local/custom-config.js @@ -4,6 +4,9 @@ config.defaultLogoUrl = '/images/politictalk/pgLogo.svg'; config.dynamicBrandingUrl = '/images/politictalk/branding.json'; +config.welcomePage = config.welcomePage || {}; +config.welcomePage.disabled = false; +config.welcomePage.customUrl = 'http://localhost:3000/politictalk'; config.startAudioOnly = true; config.startAudioMuted = 0; @@ -25,8 +28,8 @@ config.enableNoisyMicDetection = true; config.disableRemoteMute = false; config.prejoinConfig = { - enabled: true, - hideDisplayName: false, + enabled: false, + hideDisplayName: true, hideExtraJoinButtons: ['no-audio', 'by-phone'], preCallTestEnabled: false, preCallTestICEUrl: '', diff --git a/local/custom-interface_config.js b/local/custom-interface_config.js index 524490f..416115e 100644 --- a/local/custom-interface_config.js +++ b/local/custom-interface_config.js @@ -6,10 +6,11 @@ interfaceConfig.APP_NAME = 'PoliticTalk'; interfaceConfig.NATIVE_APP_NAME = 'PoliticTalk'; interfaceConfig.PROVIDER_NAME = 'ParallelGlobe'; +interfaceConfig.DEFAULT_LOGO_URL = '/images/politictalk/pgLogo.svg'; interfaceConfig.DEFAULT_WELCOME_PAGE_LOGO_URL = '/images/politictalk/pgLogo.svg'; interfaceConfig.SHOW_JITSI_WATERMARK = false; interfaceConfig.SHOW_BRAND_WATERMARK = true; -interfaceConfig.BRAND_WATERMARK_LINK = 'https://parallelglobe.io/'; +interfaceConfig.BRAND_WATERMARK_LINK = 'https://parallelglobe.io/politictalk'; interfaceConfig.SHOW_POWERED_BY = false; interfaceConfig.MOBILE_APP_PROMO = false; diff --git a/local/docker-compose.override.yml b/local/docker-compose.override.yml index 999b481..aae75e8 100644 --- a/local/docker-compose.override.yml +++ b/local/docker-compose.override.yml @@ -2,3 +2,9 @@ services: web: volumes: - ../../assets/public/politictalk:/usr/share/jitsi-meet/images/politictalk:ro + - ../../web/title.html:/usr/share/jitsi-meet/title.html:ro + - ../../web/plugin.head.html:/usr/share/jitsi-meet/plugin.head.html:ro + prosody: + environment: + - POLITICTALK_ROOM_INACTIVE_CALLBACK_URL=${POLITICTALK_ROOM_INACTIVE_CALLBACK_URL:-http://host.docker.internal:9000/events/politictalk/jitsi/room-inactive} + - POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET=${POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET:-politictalk-local-lifecycle-secret} diff --git a/prosody-plugins/mod_politictalk_roles.lua b/prosody-plugins/mod_politictalk_roles.lua new file mode 100644 index 0000000..f1d40fe --- /dev/null +++ b/prosody-plugins/mod_politictalk_roles.lua @@ -0,0 +1,329 @@ +local st = require "util.stanza"; +local http = require "net.http"; +local jid = require "util.jid"; +local json = require "util.json"; +local util = module:require "util"; +local is_admin = util.is_admin; + +local ROLE_JOINING = module:shared("politictalk/roles/joining"); +local ROOM_INACTIVE_CALLBACK_URL = module:get_option_string( + "politictalk_room_inactive_callback_url", + os.getenv("POLITICTALK_ROOM_INACTIVE_CALLBACK_URL") +); +local ROOM_INACTIVE_CALLBACK_SECRET = module:get_option_string( + "politictalk_room_inactive_callback_secret", + os.getenv("POLITICTALK_ROOM_INACTIVE_CALLBACK_SECRET") +); + +module:log("info", "Loaded PoliticTalk JWT role enforcement"); + +local function get_user(session) + return session and session.jitsi_meet_context_user or nil; +end + +local function get_user_id(session) + local user = get_user(session); + return user and user.id or nil; +end + +local function is_moderator_session(session) + local user = get_user(session); + local moderator = user and user.moderator; + + return moderator == true or moderator == "true"; +end + +local function table_contains(values, value) + if not values or not value then + return false; + end + + for _, current in ipairs(values) do + if current == value then + return true; + end + end + + return false; +end + +local function add_unique(values, value) + if not value then + return values or {}; + end + + values = values or {}; + + if not table_contains(values, value) then + table.insert(values, value); + end + + return values; +end + +local function remove_value(values, value) + if not values or not value then + return values or {}; + end + + local filtered = {}; + + for _, current in ipairs(values) do + if current ~= value then + table.insert(filtered, current); + end + end + + return filtered; +end + +local function ensure_room_data(room) + if not room then + return false; + end + + room._data = room._data or {}; + room._data.moderators = room._data.moderators or {}; + room._data.participants = room._data.participants or {}; + room._data.politictalk_host_jids = room._data.politictalk_host_jids or {}; + room._data.politictalk_jid_user_ids = room._data.politictalk_jid_user_ids or {}; + -- Enables Jitsi AV moderation automatically: hosts can speak, participants + -- must be explicitly allowed by a host before they can unmute. + room._data.av_can_unmute = false; + + return true; +end + +local function update_room_role_lists(room, user_id, is_moderator) + if not room or not user_id then + return; + end + + ensure_room_data(room); + + if is_moderator then + room._data.moderators = add_unique(room._data.moderators, user_id); + room._data.participants = remove_value(room._data.participants, user_id); + else + room._data.participants = add_unique(room._data.participants, user_id); + room._data.moderators = remove_value(room._data.moderators, user_id); + end + + module:fire_event("room-metadata-changed", { room = room; }); +end + +local function mark_host(room, occupant, user_id) + if not room or not occupant or not occupant.bare_jid or not user_id then + return; + end + + ensure_room_data(room); + room._data.politictalk_host_jids = add_unique(room._data.politictalk_host_jids, occupant.bare_jid); + room._data.politictalk_jid_user_ids[occupant.bare_jid] = user_id; +end + +local function mark_participant(room, occupant, user_id) + if not room or not occupant or not occupant.bare_jid or not user_id then + return; + end + + ensure_room_data(room); + room._data.politictalk_jid_user_ids[occupant.bare_jid] = user_id; +end + +local function unmark_occupant(room, occupant) + if not room or not occupant or not occupant.bare_jid then + return nil; + end + + ensure_room_data(room); + + local user_id = room._data.politictalk_jid_user_ids[occupant.bare_jid]; + room._data.politictalk_jid_user_ids[occupant.bare_jid] = nil; + room._data.politictalk_host_jids = remove_value(room._data.politictalk_host_jids, occupant.bare_jid); + + return user_id; +end + +local function has_active_host(room) + if not room then + return false; + end + + ensure_room_data(room); + + for _, occupant in room:each_occupant() do + if table_contains(room._data.politictalk_host_jids, occupant.bare_jid) then + return true; + end + end + + return false; +end + +local function deny_join(event, reason) + if event.origin and event.stanza then + event.origin.send(st.error_reply(event.stanza, "cancel", "not-allowed", reason)); + end + + return true; +end + +local function get_room_meeting_code(room) + if not room or not room.jid then + return nil; + end + + return jid.split(room.jid); +end + +local function notify_room_inactive(room, reason) + if + not ROOM_INACTIVE_CALLBACK_URL + or ROOM_INACTIVE_CALLBACK_URL == "" + or not ROOM_INACTIVE_CALLBACK_SECRET + or ROOM_INACTIVE_CALLBACK_SECRET == "" + then + module:log("warn", "Skipping PoliticTalk inactive-room callback because callback URL or secret is missing"); + return; + end + + local meeting_code = get_room_meeting_code(room); + if not meeting_code then + module:log("warn", "Skipping PoliticTalk inactive-room callback because room meeting code is missing"); + return; + end + + ensure_room_data(room); + if room._data.politictalk_inactive_notified then + return; + end + room._data.politictalk_inactive_notified = true; + + http.request(ROOM_INACTIVE_CALLBACK_URL, { + method = "POST"; + headers = { + ["Content-Type"] = "application/json"; + ["X-PoliticTalk-Jitsi-Secret"] = ROOM_INACTIVE_CALLBACK_SECRET; + }; + body = json.encode({ + meetingCode = meeting_code; + reason = reason or "host_left"; + roomJid = room.jid; + }); + }, function(_, code) + if code and code >= 200 and code < 300 then + module:log("info", "Marked PoliticTalk room inactive in PgApi: %s", tostring(meeting_code)); + else + module:log( + "warn", + "Failed to mark PoliticTalk room inactive in PgApi: room=%s status=%s", + tostring(meeting_code), + tostring(code) + ); + end + end); +end + +module:hook("muc-room-pre-create", function(event) + if event.stanza and event.stanza.attr and is_admin(event.stanza.attr.from) then + return; + end + + if is_moderator_session(event.origin) then + return; + end + + module:log("warn", "Blocking non-host from creating PoliticTalk room: %s", tostring(event.stanza and event.stanza.attr.to)); + return deny_join(event, "PoliticTalk room is waiting for the host"); +end, 90); + +module:hook("muc-occupant-pre-join", function(event) + local session = event.origin; + local room = event.room; + local occupant = event.occupant; + local user_id = get_user_id(session); + local is_moderator = is_moderator_session(session); + local occupant_jid = occupant and occupant.bare_jid; + + if occupant_jid and is_admin(occupant_jid) then + return; + end + + if not occupant_jid or not user_id then + module:log("warn", "Blocking participant without PoliticTalk JWT user id from room: %s", tostring(room and room.jid)); + return deny_join(event, "PoliticTalk identity is required"); + end + + if not is_moderator and not has_active_host(room) then + module:log("warn", "Blocking participant %s from room without host: %s", tostring(user_id), tostring(room and room.jid)); + return deny_join(event, "PoliticTalk room is waiting for the host"); + end + + ROLE_JOINING[occupant_jid] = { + is_moderator = is_moderator; + user_id = user_id; + }; + + update_room_role_lists(room, user_id, is_moderator); +end, 80); + +module:hook("muc-occupant-joined", function(event) + local room = event.room; + local occupant = event.occupant; + local occupant_jid = occupant and occupant.bare_jid; + local role = occupant_jid and ROLE_JOINING[occupant_jid] or nil; + + if occupant_jid then + ROLE_JOINING[occupant_jid] = nil; + end + + if not role then + return; + end + + if role.is_moderator then + mark_host(room, occupant, role.user_id); + update_room_role_lists(room, role.user_id, true); + room:set_affiliation(true, occupant.bare_jid, "owner"); + module:log( + "info", + "PoliticTalk host joined as moderator with AV moderation: %s room=%s", + tostring(role.user_id), + tostring(room.jid) + ); + else + mark_participant(room, occupant, role.user_id); + update_room_role_lists(room, role.user_id, false); + room:set_affiliation(true, occupant.bare_jid, "member"); + module:log("info", "PoliticTalk participant joined as member: %s room=%s", tostring(role.user_id), tostring(room.jid)); + end +end, 1); + +module:hook("muc-occupant-left", function(event) + local room = event.room; + local occupant = event.occupant; + local was_host = + room and occupant and room._data and table_contains(room._data.politictalk_host_jids, occupant.bare_jid); + local user_id = unmark_occupant(room, occupant) or get_user_id(event.origin); + + if room and user_id then + ensure_room_data(room); + room._data.moderators = remove_value(room._data.moderators, user_id); + room._data.participants = remove_value(room._data.participants, user_id); + end + + if was_host and not has_active_host(room) then + module:log("info", "Destroying PoliticTalk room after host left: %s", tostring(room.jid)); + notify_room_inactive(room, "host_left"); + room:destroy(nil, "The host has left the PoliticTalk room"); + end +end, -20); + +module:hook("muc-room-destroyed", function(event) + local room = event.room; + + if room and room._data and room._data.politictalk_host_jids then + notify_room_inactive(room, "room_destroyed"); + end +end, -30); diff --git a/scripts/deploy-vps.sh b/scripts/deploy-vps.sh index 4e007d2..33466c3 100755 --- a/scripts/deploy-vps.sh +++ b/scripts/deploy-vps.sh @@ -10,12 +10,26 @@ BACKUP_DIR="$BACKUP_ROOT/$STAMP" CONFIG_SRC="$JITSI_DIR/config/$DOMAIN-config.js" ASSETS_SRC="$JITSI_DIR/assets/public/politictalk" +PROSODY_PLUGINS_SRC="$JITSI_DIR/prosody-plugins" +TITLE_SRC="$JITSI_DIR/web/title.html" +PLUGIN_HEAD_SRC="$JITSI_DIR/web/plugin.head.html" +INTERFACE_SRC="$JITSI_DIR/interface_config/politictalk-overrides.js" NGINX_SRC="$JITSI_DIR/nginx/$DOMAIN.conf" CONFIG_DEST="/etc/jitsi/meet/$DOMAIN-config.js" ASSETS_DEST="/etc/jitsi/meet/public/politictalk" +WEB_IMAGES_DEST="/usr/share/jitsi-meet/images/politictalk" +PROSODY_PLUGINS_DEST="/usr/share/jitsi-meet/prosody-plugins" +TITLE_DEST="/usr/share/jitsi-meet/title.html" +PLUGIN_HEAD_DEST="/usr/share/jitsi-meet/plugin.head.html" +INTERFACE_DEST="/usr/share/jitsi-meet/interface_config.js" NGINX_DEST="/etc/nginx/sites-available/$DOMAIN.conf" +INTERFACE_MARKER_START="// BEGIN POLITICTALK INTERFACE OVERRIDES" +INTERFACE_MARKER_END="// END POLITICTALK INTERFACE OVERRIDES" +PLUGIN_MARKER_START="" +PLUGIN_MARKER_END="" + if [[ "$(id -u)" -ne 0 ]]; then echo "Run this script with sudo on the VPS." exit 1 @@ -31,6 +45,31 @@ if [[ ! -d "$ASSETS_SRC" ]]; then exit 1 fi +if [[ ! -d "$PROSODY_PLUGINS_SRC" ]]; then + echo "Missing Prosody plugins source: $PROSODY_PLUGINS_SRC" + exit 1 +fi + +if [[ ! -f "$TITLE_SRC" ]]; then + echo "Missing title source: $TITLE_SRC" + exit 1 +fi + +if [[ ! -f "$PLUGIN_HEAD_SRC" ]]; then + echo "Missing plugin head source: $PLUGIN_HEAD_SRC" + exit 1 +fi + +if [[ ! -f "$INTERFACE_SRC" ]]; then + echo "Missing interface source: $INTERFACE_SRC" + exit 1 +fi + +if [[ ! -f "$INTERFACE_DEST" ]]; then + echo "Missing Jitsi interface config destination: $INTERFACE_DEST" + exit 1 +fi + install -d -m 0755 "$BACKUP_DIR" if [[ -f "$CONFIG_DEST" ]]; then @@ -42,16 +81,72 @@ if [[ -d "$ASSETS_DEST" ]]; then cp -a "$ASSETS_DEST" "$BACKUP_DIR/public/" fi +if [[ -d "$WEB_IMAGES_DEST" ]]; then + install -d -m 0755 "$BACKUP_DIR/usr-share-jitsi-meet/images" + cp -a "$WEB_IMAGES_DEST" "$BACKUP_DIR/usr-share-jitsi-meet/images/" +fi + +if [[ -f "$TITLE_DEST" ]]; then + install -d -m 0755 "$BACKUP_DIR/usr-share-jitsi-meet" + cp -a "$TITLE_DEST" "$BACKUP_DIR/usr-share-jitsi-meet/" +fi + +if [[ -f "$PLUGIN_HEAD_DEST" ]]; then + install -d -m 0755 "$BACKUP_DIR/usr-share-jitsi-meet" + cp -a "$PLUGIN_HEAD_DEST" "$BACKUP_DIR/usr-share-jitsi-meet/" +fi + +if [[ -f "$INTERFACE_DEST" ]]; then + install -d -m 0755 "$BACKUP_DIR/usr-share-jitsi-meet" + cp -a "$INTERFACE_DEST" "$BACKUP_DIR/usr-share-jitsi-meet/" +fi + +if [[ -d "$PROSODY_PLUGINS_DEST" ]]; then + install -d -m 0755 "$BACKUP_DIR/usr-share-jitsi-meet/prosody-plugins" + find "$PROSODY_PLUGINS_DEST" -maxdepth 1 -name 'mod_politictalk_*.lua' -type f -exec cp -a {} "$BACKUP_DIR/usr-share-jitsi-meet/prosody-plugins/" \; +fi + if [[ "${DEPLOY_NGINX:-0}" == "1" && -f "$NGINX_DEST" ]]; then cp -a "$NGINX_DEST" "$BACKUP_DIR/" fi install -d -m 0755 "$ASSETS_DEST" +install -d -m 0755 "$WEB_IMAGES_DEST" install -m 0644 "$CONFIG_SRC" "$CONFIG_DEST" +install -m 0644 "$TITLE_SRC" "$TITLE_DEST" find "$ASSETS_SRC" -maxdepth 1 -type f -print0 | while IFS= read -r -d '' file; do install -m 0644 "$file" "$ASSETS_DEST/" + install -m 0644 "$file" "$WEB_IMAGES_DEST/" done +install -d -m 0755 "$PROSODY_PLUGINS_DEST" +find "$PROSODY_PLUGINS_SRC" -maxdepth 1 -name 'mod_politictalk_*.lua' -type f -print0 | while IFS= read -r -d '' file; do + install -m 0644 "$file" "$PROSODY_PLUGINS_DEST/" +done + +tmp_plugin_head="$(mktemp)" +awk -v start="$PLUGIN_MARKER_START" -v end="$PLUGIN_MARKER_END" ' + $0 == start { skip = 1; next } + $0 == end { skip = 0; next } + !skip { print } +' "$PLUGIN_HEAD_DEST" > "$tmp_plugin_head" +printf '\n%s\n' "$PLUGIN_MARKER_START" >> "$tmp_plugin_head" +cat "$PLUGIN_HEAD_SRC" >> "$tmp_plugin_head" +printf '\n%s\n' "$PLUGIN_MARKER_END" >> "$tmp_plugin_head" +install -m 0644 "$tmp_plugin_head" "$PLUGIN_HEAD_DEST" +rm -f "$tmp_plugin_head" + +tmp_interface="$(mktemp)" +awk -v start="$INTERFACE_MARKER_START" -v end="$INTERFACE_MARKER_END" ' + $0 == start { skip = 1; next } + $0 == end { skip = 0; next } + !skip { print } +' "$INTERFACE_DEST" > "$tmp_interface" +printf '\n' >> "$tmp_interface" +cat "$INTERFACE_SRC" >> "$tmp_interface" +install -m 0644 "$tmp_interface" "$INTERFACE_DEST" +rm -f "$tmp_interface" + if [[ "${DEPLOY_NGINX:-0}" == "1" ]]; then if [[ ! -f "$NGINX_SRC" ]]; then echo "Missing nginx source: $NGINX_SRC" diff --git a/scripts/local-jitsi-restart-web.sh b/scripts/local-jitsi-restart-web.sh index e64d9f0..eace129 100755 --- a/scripts/local-jitsi-restart-web.sh +++ b/scripts/local-jitsi-restart-web.sh @@ -13,4 +13,4 @@ fi "$SCRIPT_DIR/local-jitsi-sync.sh" cd "$STACK_DIR" -docker compose restart web +docker compose up -d --force-recreate web diff --git a/scripts/local-jitsi-setup.sh b/scripts/local-jitsi-setup.sh index 713f282..1e3b9e1 100755 --- a/scripts/local-jitsi-setup.sh +++ b/scripts/local-jitsi-setup.sh @@ -67,7 +67,7 @@ set_env HTTP_PORT "$HTTP_PORT" set_env HTTPS_PORT "$HTTPS_PORT" set_env PUBLIC_URL "$PUBLIC_URL" -set_env ENABLE_PREJOIN_PAGE 1 +set_env ENABLE_PREJOIN_PAGE 0 set_env ENABLE_WELCOME_PAGE 1 set_env ENABLE_NOISY_MIC_DETECTION 1 set_env ENABLE_NO_AUDIO_DETECTION 1 @@ -86,6 +86,37 @@ set_env DYNAMIC_BRANDING_URL /images/politictalk/branding.json set_env TOOLBAR_BUTTONS microphone,chat,raisehand,fullscreen,noisesuppression,participants-pane,hangup set_env HIDE_PREMEETING_BUTTONS microphone,camera,select-background,invite,settings set_env HIDE_PREJOIN_EXTRA_BUTTONS no-audio,by-phone +set_env XMPP_MUC_MODULES politictalk_roles + +set_env ENABLE_AUTH 0 +set_env AUTH_TYPE internal +set_env ENABLE_GUESTS 0 +set_env JICOFO_ENABLE_AUTH 0 +set_env ENABLE_AUTO_OWNER 1 +set_env ENABLE_MODERATOR_CHECKS 0 +set_env WAIT_FOR_HOST_DISABLE_AUTO_OWNERS false + +if [[ "${ENABLE_POLITICTALK_JWT_AUTH:-0}" == "1" ]]; then + if [[ -z "${POLITICTALK_JITSI_JWT_APP_ID:-}" || -z "${POLITICTALK_JITSI_JWT_APP_SECRET:-}" ]]; then + echo "Set POLITICTALK_JITSI_JWT_APP_ID and POLITICTALK_JITSI_JWT_APP_SECRET before enabling local JWT auth." + exit 1 + fi + + set_env ENABLE_AUTH 1 + set_env AUTH_TYPE jwt + set_env ENABLE_GUESTS 0 + set_env JWT_APP_ID "$POLITICTALK_JITSI_JWT_APP_ID" + set_env JWT_APP_SECRET "$POLITICTALK_JITSI_JWT_APP_SECRET" + set_env JWT_ACCEPTED_ISSUERS "$POLITICTALK_JITSI_JWT_APP_ID" + set_env JWT_ACCEPTED_AUDIENCES "${POLITICTALK_JITSI_JWT_AUDIENCE:-jitsi}" + set_env JWT_ALLOW_EMPTY 0 + set_env JWT_ENABLE_DOMAIN_VERIFICATION 0 + set_env JICOFO_AUTH_TYPE jwt + set_env JICOFO_ENABLE_AUTH 1 + set_env ENABLE_AUTO_OWNER 0 + set_env ENABLE_MODERATOR_CHECKS 1 + set_env WAIT_FOR_HOST_DISABLE_AUTO_OWNERS true +fi mkdir -p "$CONFIG_DIR"/{web,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb,jigasi,jibri} diff --git a/scripts/local-jitsi-sync.sh b/scripts/local-jitsi-sync.sh index 74dd080..e27860d 100755 --- a/scripts/local-jitsi-sync.sh +++ b/scripts/local-jitsi-sync.sh @@ -17,5 +17,9 @@ mkdir -p "$CONFIG_DIR"/{web,transcripts,prosody/config,prosody/prosody-plugins-c cp "$LOCAL_DIR/custom-config.js" "$CONFIG_DIR/web/custom-config.js" cp "$LOCAL_DIR/custom-interface_config.js" "$CONFIG_DIR/web/custom-interface_config.js" cp "$LOCAL_DIR/docker-compose.override.yml" "$STACK_DIR/docker-compose.override.yml" +find "$CONFIG_DIR/prosody/prosody-plugins-custom" -maxdepth 1 -name 'mod_politictalk_*.lua' -type f -delete +find "$ROOT_DIR/prosody-plugins" -maxdepth 1 -name 'mod_politictalk_*.lua' -type f -print0 | while IFS= read -r -d '' file; do + cp "$file" "$CONFIG_DIR/prosody/prosody-plugins-custom/" +done echo "Synced PoliticTalk local overrides into Docker Jitsi runtime." diff --git a/templates/auth-roadmap.md b/templates/auth-roadmap.md index 62fae5b..bc14ab6 100644 --- a/templates/auth-roadmap.md +++ b/templates/auth-roadmap.md @@ -5,7 +5,7 @@ These notes are intentionally not deployed yet. They describe the local files an ## Target Flow ```text -PgPlatform calendar event +PgPlatform PoliticTalk event -> PgPlatform PoliticTalk prejoin page -> PgApi verifies logged-in user, event ownership/invite/access, and event time window -> PgApi returns a short-lived Jitsi JWT @@ -13,21 +13,22 @@ PgPlatform calendar event -> Jitsi VPS validates JWT through Prosody token auth ``` -## Local Files To Add Later +## Local Files ```text jitsi/templates/prosody-token-auth.cfg.lua.example -jitsi/templates/jicofo-token-auth.conf.example -jitsi/templates/jitsi-token-auth.env.example +jitsi/templates/pgapi-politictalk-jwt.env.example ``` Only templates should be committed. Real JWT secrets must stay in VPS-only files. ## Platform Changes To Add Later -- PgApi endpoint to issue Jitsi JWTs for valid PoliticTalk event access. +- PgApi `join-link` issues Jitsi JWTs when token-auth env values are present. - JWT claims for room, user display name, email/id, moderator flag, expiry, and feature permissions. -- PgPlatform prejoin route that shows event title and immutable platform user name. +- PgPlatform prejoin route shows event title and immutable platform user name. - PgPlatform embedded Jitsi room using the IFrame API. +- Participants must wait on the PgPlatform prejoin page until the host starts the room. +- Jitsi auto-owner must be disabled once JWT auth is enabled so moderator status comes only from PgApi JWT claims. - Moderator-only controls for audio moderation and poll permissions. - Event end-time enforcement through JWT expiry and/or iframe hangup. diff --git a/templates/pgapi-politictalk-jwt.env.example b/templates/pgapi-politictalk-jwt.env.example new file mode 100644 index 0000000..4045c04 --- /dev/null +++ b/templates/pgapi-politictalk-jwt.env.example @@ -0,0 +1,10 @@ +# Add these to pgapi/.env only when the Jitsi VPS has token auth enabled. +# The app id and secret must match the Prosody token-auth config on the VPS. + +POLITICTALK_MEETING_BASE_URL=https://politictalk.parallelglobe.io +POLITICTALK_JITSI_JWT_APP_ID=politictalk +POLITICTALK_JITSI_JWT_APP_SECRET=replace-with-a-long-random-secret +POLITICTALK_JITSI_JWT_AUDIENCE=jitsi +POLITICTALK_JITSI_JWT_SUBJECT=politictalk.parallelglobe.io +POLITICTALK_JITSI_JWT_TTL_SECONDS=21600 +POLITICTALK_JITSI_LIFECYCLE_SECRET=replace-with-a-second-long-random-secret diff --git a/templates/prosody-token-auth.cfg.lua.example b/templates/prosody-token-auth.cfg.lua.example new file mode 100644 index 0000000..a51a35e --- /dev/null +++ b/templates/prosody-token-auth.cfg.lua.example @@ -0,0 +1,35 @@ +-- Reference snippet for the PoliticTalk VPS. +-- Apply this manually inside: +-- /etc/prosody/conf.avail/politictalk.parallelglobe.io.cfg.lua +-- +-- Do not commit real app secrets. The app_id/app_secret values must match +-- POLITICTALK_JITSI_JWT_APP_ID and POLITICTALK_JITSI_JWT_APP_SECRET in PgApi. + +VirtualHost "politictalk.parallelglobe.io" + authentication = "token" + app_id = "POLITICTALK_JITSI_JWT_APP_ID" + app_secret = "POLITICTALK_JITSI_JWT_APP_SECRET" + allow_empty_token = false + enable_domain_verification = false + +Component "conference.politictalk.parallelglobe.io" "muc" + politictalk_room_inactive_callback_url = "https://api.parallelglobe.is/events/politictalk/jitsi/room-inactive" + politictalk_room_inactive_callback_secret = "POLITICTALK_JITSI_LIFECYCLE_SECRET" + + modules_enabled = { + -- keep the existing modules already present in the VPS file + "token_verification"; + "politictalk_roles"; + } + +-- Also set the equivalent Jicofo options so only PgApi JWT moderator +-- claims can create room moderators: +-- +-- /etc/jitsi/jicofo/jicofo.conf +-- +-- jicofo { +-- conference { +-- enable-auto-owner = false +-- enable-moderator-checks = true +-- } +-- } diff --git a/web/plugin.head.html b/web/plugin.head.html new file mode 100644 index 0000000..b6fa775 --- /dev/null +++ b/web/plugin.head.html @@ -0,0 +1,114 @@ + + + diff --git a/web/title.html b/web/title.html new file mode 100644 index 0000000..f1426f1 --- /dev/null +++ b/web/title.html @@ -0,0 +1,9 @@ +