canvas-lms/app/models/big_blue_button_conference.rb

315 lines
11 KiB
Ruby

# frozen_string_literal: true
#
# Copyright (C) 2011 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
require 'nokogiri'
class BigBlueButtonConference < WebConference
include ActionDispatch::Routing::PolymorphicRoutes
include CanvasRails::Application.routes.url_helpers
after_destroy :end_meeting
after_destroy :delete_all_recordings
user_setting_field :record, {
name: ->{ t('recording_setting', 'Recording') },
description: ->{ t('recording_setting_enabled_description', 'Enable recording for this conference') },
type: :boolean,
default: false,
visible: ->{ WebConference.config(class_name: BigBlueButtonConference.to_s)[:recording_enabled] },
}
user_setting_field :scheduled_date, {
name: ->{ t('Scheduled Date') },
description: ->{ t('Enable recording for this conference') },
type: :date,
default: false,
visible: false
}
def initiate_conference
return conference_key if conference_key && !retouch?
unless self.conference_key
self.conference_key = "instructure_#{self.feed_code}".gsub(/[^a-zA-Z0-9_]/, "_")
chars = ('a'..'z').to_a + ('0'..'9').to_a
# create user/admin passwords for this conference. we may want to show
# the admin passwords in the ui in case moderators need them for any
# admin-specific functionality within the BBB ui (or we could provide
# ui for them to specify the password/key)
settings[:user_key] = 8.times.map{ chars[chars.size * rand] }.join
settings[:admin_key] = 8.times.map{ chars[chars.size * rand] }.join until settings[:admin_key] && settings[:admin_key] != settings[:user_key]
end
settings[:record] &&= config[:recording_enabled]
settings[:domain] ||= config[:domain] # save the domain
current_host = URI(settings[:default_return_url] || "http://www.instructure.com").host
send_request(:create, {
:meetingID => conference_key,
:name => title,
:voiceBridge => format("%020d", self.global_id),
:attendeePW => settings[:user_key],
:moderatorPW => settings[:admin_key],
:logoutURL => (settings[:default_return_url] || "http://www.instructure.com"),
:record => settings[:record] ? "true" : "false",
:welcome => settings[:record] ? t("This conference may be recorded.") : "",
"meta_canvas-recording-ready-user" => recording_ready_user,
"meta_canvas-recording-ready-url" => recording_ready_url(current_host)
}) or return nil
@conference_active = true
save
conference_key
end
def recording_ready_user
if self.grants_right?(self.user, :create)
"#{self.user['name']} <#{self.user.email}>"
end
end
def recording_ready_url(current_host = nil)
polymorphic_url([:api_v1, context, :conferences, :recording_ready],
conference_id: self.id,
protocol: HostUrl.protocol,
host: HostUrl.context_host(context, current_host))
end
def conference_status
if (result = send_request(:isMeetingRunning, :meetingID => conference_key)) && result[:running] == 'true'
:active
else
:closed
end
end
def admin_join_url(user, _return_to = "http://www.instructure.com")
join_url(user, :admin)
end
def participant_join_url(user, _return_to = "http://www.instructure.com")
join_url(user)
end
def recordings
fetch_recordings.map do |recording|
recording_formats(recording)
end
end
def recording(recording_id = nil)
unless recording_id.nil?
recording = fetch_recordings.find{ |r| r[:recordID]==recording_id }
recording_formats(recording) if recording
end
end
def recording_formats(recording)
recording_formats = recording.fetch(:playback, []).map do |format|
show_to_students = !!format[:length] || format[:type] == "notes" # either is an actual recording or shared notes
format.merge(:show_to_students => show_to_students)
end
{
recording_id: recording[:recordID],
title: recording[:name],
duration_minutes: filter_duration(recording_formats),
playback_url: nil,
playback_formats: recording_formats,
created_at: recording[:startTime].to_i,
}
end
def delete_recording(recording_id)
return { deleted: false } if recording_id.nil?
response = send_request(:deleteRecordings, recordID: recording_id)
{ deleted: response.present? && response[:deleted].casecmp('true') == 0 }
end
def delete_all_recordings
fetch_recordings.map do |recording|
delete_recording recording[:recordID]
end
end
def close
end_meeting
super
end
attr_writer :loaded_recordings
# we can use the same API method with multiple meeting ids to load all the recordings up in one go
# instead of making a bunch of individual calls
def self.preload_recordings(conferences)
filtered_conferences = conferences.select{|c| c.conference_key && c.settings[:record]}
return unless filtered_conferences.any?
fallback_conferences, current_conferences = filtered_conferences.partition{|c| c.use_fallback_config?}
fetch_and_preload_recordings(fallback_conferences, use_fallback_config: true) if fallback_conferences.any?
fetch_and_preload_recordings(current_conferences, use_fallback_config: false) if current_conferences.any?
end
def self.fetch_and_preload_recordings(conferences, use_fallback_config: false)
# have a limit so we don't send a ridiculously long URL over
limit = Setting.get("big_blue_button_preloaded_recordings_limit", "50").to_i
conferences.each_slice(limit) do |sliced_conferences|
meeting_ids = sliced_conferences.map(&:conference_key).join(",")
response = send_request(:getRecordings,
{:meetingID => meeting_ids},
use_fallback_config: use_fallback_config)
result = response[:recordings] if response
result = [] if result.is_a?(String)
grouped_result = Array(result).group_by{|r| r[:meetingID]}
sliced_conferences.each do |c|
c.loaded_recordings = grouped_result[c.conference_key] || []
end
end
end
def use_fallback_config?
# use the fallback config (if possible) if it wasn't created with the current config
self.class.config[:use_fallback] &&
self.settings[:domain] != self.class.config[:domain]
end
private
def retouch?
# If we've queried the room status recently, use that result to determine if
# we need to recreate it.
unless @conference_active.nil?
return !@conference_active
end
# BBB removes chat rooms that have been idle fairly quickly.
# There's no harm in "creating" a room that already exists; the api will
# just return the room info. So we'll just go ahead and recreate it
# to make sure we don't accidentally redirect people to an inactive room.
true
end
def join_url(user, type = :user)
generate_request :join,
:fullName => user.short_name,
:meetingID => conference_key,
:password => settings[(type == :user ? :user_key : :admin_key)],
:userID => user.id
end
def end_meeting
response = send_request(:end, {
:meetingID => conference_key,
:password => settings[(type == :user ? :user_key : :admin_key)],
})
response[:ended] if response
end
def fetch_recordings
@loaded_recordings ||= begin
if conference_key && settings[:record]
response = send_request(:getRecordings, {
:meetingID => conference_key,
})
result = response[:recordings] if response
result = [] if result.is_a?(String)
Array(result)
else
[]
end
end
end
def generate_request(*args)
self.class.generate_request(*args)
end
def self.fallback_config
Canvas::Plugin.find(:big_blue_button_fallback).settings&.with_indifferent_access
end
def self.generate_request(action, options, use_fallback_config: false)
config_to_use = (use_fallback_config && fallback_config.presence) || config
query_string = options.to_query
query_string << ("&checksum=" + Digest::SHA1.hexdigest(action.to_s + query_string + config_to_use[:secret_dec]))
"https://#{config_to_use[:domain]}/bigbluebutton/api/#{action}?#{query_string}"
end
def send_request(action, options)
self.class.send_request(action, options, use_fallback_config: use_fallback_config?)
end
def self.send_request(action, options, use_fallback_config: false)
url_str = generate_request(action, options, use_fallback_config: use_fallback_config)
http_response = nil
Canvas.timeout_protection("big_blue_button") do
logger.debug "big blue button api call: #{url_str}"
http_response = CanvasHttp.get(url_str, redirect_limit: 5)
end
case http_response
when Net::HTTPSuccess
response = xml_to_hash(http_response.body)
if response[:returncode] == 'SUCCESS'
return response
else
logger.error "big blue button api error #{response[:message]} (#{response[:messageKey]})"
end
else
logger.error "big blue button http error #{http_response}"
end
nil
rescue
logger.error "big blue button unhandled exception #{$ERROR_INFO}"
nil
end
def self.xml_to_hash(xml_string)
doc = Nokogiri::XML(xml_string)
# assumes the top level value will be a hash
xml_to_value(doc.root)
end
def self.xml_to_value(node)
child_elements = node.element_children
# if there are no children at all, then this is an empty node
if node.children.empty?
nil
# If no child_elements, this is probably a text node, so just return its content
elsif child_elements.empty?
node.content
# The BBB API follows the pattern where a plural element (ie <bars>)
# contains many singular elements (ie <bar>) and nothing else. Detect this
# and return an array to be assigned to the plural element.
# It excludes the playback node as all of them may be showing different content.
elsif node.name.singularize == child_elements.first.name || node.name == "playback"
child_elements.map { |child| xml_to_value(child) }
# otherwise, make a hash of the child elements
else
child_elements.each_with_object({}) do |child, hash|
hash[child.name.to_sym] = xml_to_value(child)
end
end
end
def filter_duration(recording_formats)
# This is a filter to take the duration from any of the playback formats that include a value in length.
# As not all the formats are the actual recording, identify the first one that has :length <> nil
recording_formats.each do |recording_format|
return recording_format[:length].to_i if recording_format[:length].present?
end
end
end