diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/app/assets/locales/de.json b/app/assets/locales/de.json index f3cc73234b..597f1a8bf0 100644 --- a/app/assets/locales/de.json +++ b/app/assets/locales/de.json @@ -17,6 +17,7 @@ "close": "Schließen", "delete": "Löschen", "copy": "Einladungslink kopieren", + "copy_voice_bridge": "Telefoneinwahl kopieren", "copy_viewer_code": "Zugangscode für Zuhörerende kopieren", "copy_moderator_code": "Zugangscode für Moderation kopieren", "or": "oder", @@ -125,7 +126,13 @@ "meeting_invitation": "Du wurdest zur Teilnahme eingeladen", "meeting_not_started": "Die Konferenz hat noch nicht begonnen", "join_meeting_automatically": "Beitritt erfolgt automatisch, sobald die Konferenz beginnt", - "recording_consent": "Ich nehme zur Kenntnis, dass die Sitzung aufgezeichnet werden kann. Dies wird meine Stimme und mein Bild einschließen, wenn dies aktiviert ist." + "recording_consent": "Ich nehme zur Kenntnis, dass die Sitzung aufgezeichnet werden kann. Dies wird meine Stimme und mein Bild einschließen, wenn dies aktiviert ist.", + "invite_to_meeting": "{{ name }} lädt Sie ein, an einem Meeting teilzunehmen.", + "join_by_url": "An Besprechung per URL teilnehmen", + "alternative_options": "Alternative Optionen zur Teilnahme", + "download_ics": ".ics-Kalenderdatei herunterladen", + "share_meeting": "Raum teilen", + "join_by_phone": "mit Telefoneinwahl beitreten" }, "presentation": { "presentation": "Präsentation", @@ -403,6 +410,7 @@ "access_code_generated": "Zugangscode generiert.", "access_code_deleted": "Zugriffcode wurde gelöscht.", "copied_meeting_url": "Die URL der Konferenz wurde kopiert. Der Link kann verwendet werden, um an der Konferenz teilzunehmen.", + "copied_voice_bridge": "Die Telefonnummer und der Pin wurden kopiert. Diese können genutzt werden um an der Konferenz teilzunehmen.", "copied_viewer_code": "Zugangscode für Zuhörende wurde in die Zwischenablage kopiert.", "copied_moderator_code": "Zugangscode für Moderation wurde in die Zwischenablage kopiert." }, diff --git a/app/assets/locales/en.json b/app/assets/locales/en.json index 665d511926..6af67a1843 100644 --- a/app/assets/locales/en.json +++ b/app/assets/locales/en.json @@ -18,6 +18,7 @@ "close": "Close", "delete": "Delete", "copy": "Copy Join Link", + "copy_voice_bridge": "Copy Phone Dialup", "copy_viewer_code": "Copy Viewer Code", "copy_moderator_code": "Copy Moderator Code", "or": "Or", @@ -126,7 +127,13 @@ "meeting_invitation": "You have been invited to join", "meeting_not_started": "The meeting has not started yet", "join_meeting_automatically": "You will automatically join when the meeting starts", - "recording_consent": "I acknowledge that this session may be recorded. This may include my voice and video if enabled." + "recording_consent": "I acknowledge that this session may be recorded. This may include my voice and video if enabled.", + "invite_to_meeting": "{{ name }} invites you to join a meeting.", + "join_by_url": "Join room by URL", + "alternative_options": "Alternative options to join", + "download_ics": "Download .ics calender file", + "share_meeting": "Share Room", + "join_by_phone": "join with telephone dial-up" }, "presentation": { "presentation": "Presentation", @@ -412,6 +419,7 @@ "access_code_generated": "A new access code has been generated.", "access_code_deleted": "The access code has been deleted.", "copied_meeting_url": "The meeting URL has been copied. The link can be used to join the meeting.", + "copied_voice_bridge": "The phone number and pin have been copied. They can be used to join the conference.", "copied_viewer_code": "The viewer access code has been copied.", "copied_moderator_code": "The moderator access code has been copied." }, diff --git a/app/controllers/api/v1/rooms_controller.rb b/app/controllers/api/v1/rooms_controller.rb index 6588804587..15eec964b5 100644 --- a/app/controllers/api/v1/rooms_controller.rb +++ b/app/controllers/api/v1/rooms_controller.rb @@ -93,7 +93,7 @@ def create room = Room.new(name: room_params[:name], user_id: room_params[:user_id]) if room.save - logger.info "room(friendly_id):#{room.friendly_id} created for user(id):#{room.user_id}" + logger.info "room(friendly_id):#{room.friendly_id} created for user(id):#{room.user_id} with voice brige: #{room.voice_bridge}" render_data status: :created else render_error errors: room.errors.to_a, status: :bad_request @@ -160,7 +160,7 @@ def find_room end def room_params - params.require(:room).permit(:name, :user_id, :presentation) + params.require(:room).permit(:name, :user_id, :voice_bridge, :presentation) end end end diff --git a/app/javascript/components/rooms/RoomCard.jsx b/app/javascript/components/rooms/RoomCard.jsx index 5d62c1b4bb..915a7d010b 100644 --- a/app/javascript/components/rooms/RoomCard.jsx +++ b/app/javascript/components/rooms/RoomCard.jsx @@ -18,7 +18,7 @@ import React, { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button, Card, Stack } from 'react-bootstrap'; import PropTypes from 'prop-types'; -import { DocumentDuplicateIcon, LinkIcon } from '@heroicons/react/24/outline'; +import { DocumentDuplicateIcon, LinkIcon, PhoneIcon } from '@heroicons/react/24/outline'; import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../../contexts/auth/AuthProvider'; @@ -41,28 +41,33 @@ export default function RoomCard({ room }) { toast.success(t('toast.success.room.copied_meeting_url')); } + function copyVoiceBridge(voiceBridge, voiceBridgePhoneNumber) { + navigator.clipboard.writeText(`Tel.: ${voiceBridgePhoneNumber} Pin: ${voiceBridge}`); + toast.success(t('toast.success.room.copied_voice_bridge')); + } + return (
- { room?.shared_owner + {room?.shared_owner ? : }
- { room?.online + {room?.online && }
- { room.name } - { room.shared_owner && ( + {room.name} + {room.shared_owner && ( { t('room.shared_by') } {' '} { room.shared_owner } )} - { room.last_session ? ( - { t('room.last_session', { localizedTime }) } + {room.last_session ? ( + {t('room.last_session', { localizedTime })} ) : ( - { t('room.no_last_session') } + {t('room.no_last_session')} )}
@@ -73,9 +78,17 @@ export default function RoomCard({ room }) { > + {typeof room.voice_bridge_phone_number !== 'undefined' && ( + + )} + {!isRoomLoading && typeof room.voice_bridge_phone_number !== 'undefined' && ( + + )} { (roomSettings?.data?.glModeratorAccessCode || roomSettings?.data?.glViewerAccessCode) && ( = max_pins # Check if pins are left + + # Pick random pin + id = SecureRandom.random_number(10.pow(pin_len) - 1) + # Ensure uniqueness and if not take next free pin + id = ((id + 1) % max_pins) + 10.pow(pin_len - 1) while Room.exists?(voice_bridge: id) || id < 10.pow(pin_len - 1) + + self.voice_bridge = id + end + def scan_presentation_for_virus return if !virus_scan? || !attachment_changes['presentation'] diff --git a/app/serializers/current_room_serializer.rb b/app/serializers/current_room_serializer.rb index d4e8e98785..515747c704 100644 --- a/app/serializers/current_room_serializer.rb +++ b/app/serializers/current_room_serializer.rb @@ -23,6 +23,9 @@ class CurrentRoomSerializer < ApplicationSerializer attribute :last_session, if: -> { object.last_session } + attribute :voice_bridge, if: -> { Rails.application.config.voice_bridge_phone_number } + attribute :voice_bridge_phone_number, if: -> { Rails.application.config.voice_bridge_phone_number } + def presentation_name presentation_file_name(object) end @@ -34,4 +37,8 @@ def thumbnail def owner_name object.user.name end + + def voice_bridge_phone_number + Rails.application.config.voice_bridge_phone_number + end end diff --git a/app/serializers/room_serializer.rb b/app/serializers/room_serializer.rb index c236a94ed8..6f5adddb1b 100644 --- a/app/serializers/room_serializer.rb +++ b/app/serializers/room_serializer.rb @@ -20,8 +20,14 @@ class RoomSerializer < ApplicationSerializer attributes :id, :name, :friendly_id, :online, :participants, :last_session attribute :shared_owner, if: -> { object.shared } + attribute :voice_bridge, if: -> { Rails.application.config.voice_bridge_phone_number } + attribute :voice_bridge_phone_number, if: -> { Rails.application.config.voice_bridge_phone_number } def shared_owner object.user.name end + + def voice_bridge_phone_number + Rails.application.config.voice_bridge_phone_number + end end diff --git a/app/services/meeting_starter.rb b/app/services/meeting_starter.rb index 78e8c6298a..fa8e2082a2 100644 --- a/app/services/meeting_starter.rb +++ b/app/services/meeting_starter.rb @@ -30,7 +30,8 @@ def initialize(room:, base_url:, current_user:, provider:) def call # TODO: amir - Check the legitimately of the action. - options = RoomSettingsGetter.new(room_id: @room.id, provider: @room.user.provider, current_user: @current_user, only_bbb_options: true).call + options = RoomSettingsGetter.new(room_id: @room.id, provider: @room.user.provider, current_user: @current_user, only_bbb_options: true, + voice_bridge: @room.voice_bridge).call viewer_code = RoomSettingsGetter.new( room_id: @room.id, provider: @room.user.provider, diff --git a/app/services/room_settings_getter.rb b/app/services/room_settings_getter.rb index 1d033d41aa..e57d28efaf 100644 --- a/app/services/room_settings_getter.rb +++ b/app/services/room_settings_getter.rb @@ -23,13 +23,14 @@ class RoomSettingsGetter # Hash(` => {'true' => , 'false' => })` SPECIAL_OPTIONS = { 'guestPolicy' => { 'true' => 'ASK_MODERATOR', 'false' => 'ALWAYS_ACCEPT' } }.freeze - def initialize(room_id:, provider:, current_user:, settings: [], show_codes: false, only_enabled: false, only_bbb_options: false) + def initialize(room_id:, provider:, current_user:, settings: [], show_codes: false, only_enabled: false, only_bbb_options: false, voice_bridge: nil) @current_user = current_user @room_id = room_id @only_bbb_options = only_bbb_options # When used only BBB options (not prefixed with 'gl') will be returned. @only_enabled = only_enabled # When used only optional and force enabled options will be returned. @show_codes = show_codes # When used access code values will be returned. @settings = settings # When given only the settings contained in the Array will be returned. + @voice_bridge = voice_bridge # Fetching only rooms configs that are not optional to overwrite the settings values. @rooms_configs = MeetingOption.joins(:rooms_configurations) @@ -55,6 +56,8 @@ def call infer_codes(room_settings:, access_codes:) # Access codes should map their forced values as intended. infer_can_record(room_settings:) if room_settings['record'] && @rooms_configs['record'].nil? + set_voice_brige(room_settings:) + room_settings end @@ -98,4 +101,10 @@ def infer_can_record(room_settings:) room_settings['record'] = 'false' end + + def set_voice_brige(room_settings:) + return if @voice_bridge.nil? + + room_settings['voiceBridge'] = @voice_bridge.to_s + end end diff --git a/config/application.rb b/config/application.rb index 73f70e7416..8a4ebbb94c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -84,6 +84,10 @@ class Application < Rails::Application config.relative_url_root = '/' if config.relative_url_root.blank? I18n.load_path += Dir[Rails.root.join('config/locales/*.{rb,yml}').to_s] + + config.voice_bridge_phone_number = ENV.fetch('VOICE_BRIDGE_PHONE_NUMBER', nil) + config.sip_pin_length = ENV.fetch('SIP_PIN_LENGTH', 5) + config.i18n.fallbacks = %i[en] config.i18n.enforce_available_locales = false diff --git a/db/data/20230328124724_populate_voice_brige_for_existing_rooms.rb b/db/data/20230328124724_populate_voice_brige_for_existing_rooms.rb new file mode 100644 index 0000000000..9982b88f6e --- /dev/null +++ b/db/data/20230328124724_populate_voice_brige_for_existing_rooms.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class PopulateVoiceBrigeForExistingRooms < ActiveRecord::Migration[7.0] + def up + return if Rails.application.config.voice_bridge_phone_number.nil? + + pin_len = Rails.application.config.sip_pin_length + max_pins = 10.pow(pin_len) - 10.pow(pin_len - 1) - 1 + + raise 'The db contains to many rooms to assign each one a unique voice_bridge' if Room.all.length > max_pins + + Room.where(voice_bridge: nil).each do |room| + id = SecureRandom.random_number(max_pins) + 10.pow(pin_len - 1) + + while Room.exists?(voice_bridge: id) + id += 1 + id = 10.pow(pin_len - 1) if id > max_pins + end + + room.update(voice_bridge: id) + end + end + + def down + # rubocop:disable Rails/SkipsModelValidations + Room.update_all(voice_bridge: nil) + # rubocop:enable Rails/SkipsModelValidations + end +end diff --git a/db/migrate/20230321125010_add_voice_brige_to_romms.rb b/db/migrate/20230321125010_add_voice_brige_to_romms.rb new file mode 100644 index 0000000000..829e427e07 --- /dev/null +++ b/db/migrate/20230321125010_add_voice_brige_to_romms.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddVoiceBrigeToRomms < ActiveRecord::Migration[7.0] + def change + add_column :rooms, :voice_bridge, :integer, null: true, default: nil + add_index :rooms, :voice_bridge + end +end diff --git a/db/schema.rb b/db/schema.rb index 4f439f27d1..71ed36b4c5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -43,9 +43,6 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end - create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t| - end - create_table "formats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "recording_id" t.string "recording_type", null: false @@ -132,9 +129,11 @@ t.datetime "updated_at", null: false t.integer "recordings_processing", default: 0 t.boolean "online", default: false + t.integer "voice_bridge" t.index ["friendly_id"], name: "index_rooms_on_friendly_id", unique: true t.index ["meeting_id"], name: "index_rooms_on_meeting_id", unique: true t.index ["user_id"], name: "index_rooms_on_user_id" + t.index ["voice_bridge"], name: "index_rooms_on_voice_bridge" end create_table "rooms_configurations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/sample.env b/sample.env index d90f6dea9b..e5873d9f58 100644 --- a/sample.env +++ b/sample.env @@ -98,6 +98,13 @@ LOG_LEVEL=info # RAILS_LOG_REMOTE_PORT=99999 # RAILS_LOG_REMOTE_TAG=greenlight-v3 +# Define the phone number for the voice bridge. +# This number is not sent to bbb and is only displayed in the Greenlight UI, but it should match the number in the bbb instance to avoid user confusion. +# If this number is defined, each newly created room will be assigned a static voiceBridge pin. +#VOICE_BRIDGE_PHONE_NUMBER= +# Define the length of the PIN for SIP if not set PIN length is 5 (sufficient for small to medium BBB servers < 90000 rooms) +#SIP_PIN_LENGTH= + ## ClamAV Virus Scanning # If you have ClamAV installed on the same machine as your Greenlight deployment, you can enable automatic virus scanning # for presentations, avatars, and BrandingImage. If a malicious file is detected, the user will be informed and asked diff --git a/spec/factories/room_factory.rb b/spec/factories/room_factory.rb index e94153021a..7258496f58 100644 --- a/spec/factories/room_factory.rb +++ b/spec/factories/room_factory.rb @@ -21,6 +21,6 @@ user name { Faker::Educator.course_name } last_session { nil } - # meeting_id & friendly_id are set automatically using before_validation + # meeting_id & friendly_id & voice_brige are set automatically using before_validation end end diff --git a/spec/models/room_spec.rb b/spec/models/room_spec.rb index 691d47f7e4..cb695a6ba8 100644 --- a/spec/models/room_spec.rb +++ b/spec/models/room_spec.rb @@ -30,7 +30,7 @@ it { is_expected.to validate_length_of(:name).is_at_least(1).is_at_most(255) } it { is_expected.to validate_numericality_of(:recordings_processing).only_integer.is_greater_than_or_equal_to(0) } - # Can't test validation on friendly_id and meeting_id due to before_validations + # Can't test validation on friendly_id, meeting_id and voice_brige due to before_validations context 'presentation validations' do it 'fails if the presentation is not a valid extension' do @@ -84,6 +84,16 @@ expect { duplicate_room.meeting_id = room.meeting_id }.to change { duplicate_room.valid? }.to false end end + + describe '#set_voice_brige' do + it 'sets a rooms voice_brige before creating' do + if Rails.application.config.voice_bridge_phone_number.nil? + expect(room.voice_bridge).to be_nil + else + expect(room.voice_bridge).to be_present + end + end + end end describe 'before_save' do diff --git a/spec/services/meeting_starter_spec.rb b/spec/services/meeting_starter_spec.rb index 4fb0464bb0..1ba3ee5542 100644 --- a/spec/services/meeting_starter_spec.rb +++ b/spec/services/meeting_starter_spec.rb @@ -67,7 +67,7 @@ expect(RoomSettingsGetter) .to receive(:new) - .with(room_id: room.id, provider: 'greenlight', current_user: user, only_bbb_options: true) + .with(room_id: room.id, provider: 'greenlight', current_user: user, only_bbb_options: true, voice_bridge: room.voice_bridge) expect(room_setting_getter_service) .to receive(:call) diff --git a/tmp/.keep b/tmp/.keep deleted file mode 100644 index e69de29bb2..0000000000