# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/.
#
# Copyright (c) 2016 BigBlueButton Inc. and by respective authors (see below).
#
# This program is free software; you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free Software
# Foundation; either version 3.0 of the License, or (at your option) any later
# version.
#
# BigBlueButton 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 Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with BigBlueButton; if not, see .
class BbbController < ApplicationController
include BbbApi
before_action :authorize_recording_owner!, only: [:update_recordings, :delete_recordings]
before_action :load_and_authorize_room_owner!, only: [:end]
skip_before_action :verify_authenticity_token, only: :callback
# GET /:resource/:id/join
# GET /:resource/:room_id/:id/join
def join
if params[:name].blank?
return render_bbb_response(
messageKey: "missing_parameter",
message: "user name was not included",
status: :unprocessable_entity
)
elsif params[:name].size > user_name_limit
return render_bbb_response(
messageKey: "invalid_parameter",
message: "user name is too long",
status: :unprocessable_entity
)
elsif params[:id].size > meeting_name_limit
return render_bbb_response(
messageKey: "invalid_parameter",
message: "meeting name is too long",
status: :unprocessable_entity
)
else
if params[:room_id]
user = User.find_by encrypted_id: params[:room_id]
if !user
return render_bbb_response(
messageKey: "not_found",
message: "User Not Found",
status: :not_found
)
end
meeting_id = "#{params[:room_id]}-#{params[:id]}"
meeting_name = params[:id]
meeting_path = "#{URI.encode(params[:room_id])}/#{URI.encode(params[:id]).gsub('/','%2F')}"
else
user = User.find_by encrypted_id: params[:id]
meeting_id = params[:id]
meeting_path = URI.encode(meeting_id).gsub('/','%2F')
end
options = if user
{
wait_for_moderator: true,
meeting_recorded: true,
meeting_name: meeting_name,
room_owner: params[:room_id],
user_is_moderator: current_user == user
}
else
{
user_is_moderator: true
}
end
base_url = "#{request.base_url}#{relative_root}/#{params[:resource]}/#{meeting_path}"
options[:meeting_logout_url] = base_url
options[:hook_url] = "#{base_url}/callback"
options[:moderator_message] = t('moderator_default_message', url: "#{base_url}")
bbb_res = bbb_join_url(
meeting_id,
params[:name],
options
)
# the user can join the meeting
if user
if bbb_res[:returncode] && current_user == user
JoinMeetingJob.perform_later(user, params[:id], base_url)
WaitingList.empty(options[:room_owner], options[:meeting_name])
# the user can't join because they are on mobile and HTML5 is not enabled.
elsif bbb_res[:messageKey] == 'unable_to_join'
NotifyUserCantJoinJob.perform_later(user.encrypted_id, params[:id], params[:name])
# user will be waiting for a moderator
else
NotifyUserWaitingJob.perform_later(user.encrypted_id, params[:id], params[:name])
end
end
render_bbb_response bbb_res, bbb_res[:response]
end
end
# POST /:resource/:room_id/:id/callback
# Endpoint for webhook calls from BigBlueButton
def callback
# respond with 200 anyway so BigBlueButton knows the hook call was ok
# but abort execution
head(:ok) && return unless validate_checksum
begin
event = params['event']
data = event.is_a?(String) ? JSON.parse(params['event']) : event
treat_callback_event(data)
rescue Exception => e
logger.error "Error parsing webhook data. Data: #{data}, exception: #{e.inspect}"
# respond with 200 anyway so BigBlueButton knows the hook call was ok
head(:ok) && return
end
end
# DELETE /rooms/:room_id/:id/end
def end
load_and_authorize_room_owner!
bbb_res = bbb_end_meeting "#{@user.encrypted_id}-#{params[:id]}"
if bbb_res[:returncode] || bbb_res[:status] == :not_found
EndMeetingJob.perform_later(@user.encrypted_id, params[:id])
bbb_res[:status] = :ok
end
render_bbb_response bbb_res
end
# GET /rooms/:room_id/recordings
# GET /rooms/:room_id/:id/recordings
def recordings
load_room!
# bbb_res = bbb_get_recordings "#{@user.encrypted_id}-#{params[:id]}"
options = { "meta_room-id": @user.encrypted_id }
if params[:id]
options["meta_meeting-name"] = params[:id]
end
bbb_res = bbb_get_recordings(options)
render_bbb_response bbb_res, bbb_res[:recordings]
end
# PATCH /rooms/:room_id/recordings/:record_id
# PATCH /rooms/:room_id/:id/recordings/:record_id
def update_recordings
published = params[:published] == 'true'
metadata = params.select{ |k, v| k.match(/^meta_/) }
bbb_res = bbb_update_recordings(params[:record_id], published, metadata)
if bbb_res[:returncode]
RecordingUpdatesJob.perform_later(@user.encrypted_id, params[:record_id])
end
render_bbb_response bbb_res
end
# DELETE /rooms/:room_id/recordings/:record_id
# DELETE /rooms/:room_id/:id/recordings/:record_id
def delete_recordings
recording = bbb_get_recordings({recordID: params[:record_id]})[:recordings].first
bbb_res = bbb_delete_recordings(params[:record_id])
if bbb_res[:returncode]
RecordingDeletesJob.perform_later(@user.encrypted_id, params[:record_id], recording[:metadata][:"meeting-name"])
end
render_bbb_response bbb_res
end
# POST /rooms/:room_id/recordings/:record_id
# POST /rooms/:room_id/:id/recordings/:record_id
def youtube_publish
# If we can't get the client, then they don't have a Youtube account.
begin
client = Yt::Account.new(access_token: current_user.token)
video = client.upload_video(get_webcams_url(params[:record_id]),
title: params[:video_title],
description: t('youtube_description', url: 'https://bigbluebutton.org/'),
privacy_status: params[:privacy_status])
rescue => e
errors = e.response_body['error']['errors']
# Many complications, start by getting them to refresh their token.
if errors.length > 1
redirect_url = user_login_url
else
error = errors[0]
if error['message'] == "Unauthorized"
redirect_url = 'https://m.youtube.com/create_channel'
else
# In this case, they don't have a youtube channel connected to their account, so prompt to create one.
redirect_url = user_login_url
end
end
end
render json: {:url => redirect_url}
end
# POST /rooms/:room_id/recordings/can_upload
def can_upload
# The recording is uploadable if it contains webcam data and they are logged in through Google.
if Rails.configuration.enable_youtube_uploading == false then
uploadable = 'uploading_disabled'
elsif current_user.provider != 'google'
uploadable = 'invalid_provider'
else
uploadable = (Faraday.head(get_webcams_url(params[:rec_id])).status == 200 && current_user.provider == 'google').to_s
end
render json: {:uploadable => uploadable}
end
def get_webcams_url(recording_id)
uri = URI.parse(Rails.configuration.bigbluebutton_endpoint)
uri.scheme + '://' + uri.host + '/presentation/' + recording_id + '/video/webcams.webm'
end
private
def load_room!
@user = User.find_by encrypted_id: params[:room_id]
if !@user
render head(:not_found) && return
end
end
def load_and_authorize_room_owner!
load_room!
if !current_user || current_user != @user
render head(:unauthorized) && return
end
end
def authorize_recording_owner!
load_and_authorize_room_owner!
recordings = bbb_get_recordings({recordID: params[:record_id]})[:recordings]
recordings.each do |recording|
if recording[:recordID] == params[:record_id]
return true
end
end
render head(:not_found) && return
end
def render_bbb_response(bbb_res, response={})
@messageKey = bbb_res[:messageKey]
@message = bbb_res[:message]
@status = bbb_res[:status]
@response = response
render status: @status
end
def read_body(request)
request.body.read.force_encoding("UTF-8")
end
def treat_callback_event(event)
# Check if the event is a BigBlueButton 2.0 event.
if event.has_key?('envelope')
eventName = (event.present? && event['envelope'].present?) ? event['envelope']['name'] : nil
else # The event came from BigBlueButton 1.1 (or earlier).
eventName = (event.present? && event['header'].present?) ? event['header']['name'] : nil
end
# a recording is ready
if eventName == "publish_ended"
if event['payload'] && event['payload']['metadata'] && event['payload']['meeting_id']
token = event['payload']['metadata'][META_TOKEN]
room_id = event['payload']['metadata']['room-id']
record_id = event['payload']['meeting_id']
duration_data = event['payload']['duration']
# the webhook event doesn't have all the data we need, so we need
# to send a getRecordings anyway
# TODO: if the webhooks included all data in the event we wouldn't need this
rec_info = bbb_get_recordings({recordID: record_id})
rec_info = rec_info[:recordings].first
RecordingCreatedJob.perform_later(token, room_id, parse_recording_for_view(rec_info))
rec_info[:duration] = duration_data.to_json
# send an email to the owner of this recording, if defined
if Rails.configuration.mail_notifications
owner = User.find_by(encrypted_id: room_id)
RecordingReadyEmailJob.perform_later(owner, parse_recording_for_view(rec_info)) if owner.present?
end
else
logger.error "Bad format for event #{event}, won't process"
end
elsif eventName == "meeting_created_message" || eventName == "MeetingCreatedEvtMsg"
# Fire an Actioncable event that updates _previously_joined for the client.
actioncable_event('create')
elsif eventName == "meeting_destroyed_event" || eventName == "MeetingEndedEvtMsg"
actioncable_event('destroy')
# Since the meeting is destroyed we have no way get the callback url to remove the meeting, so we must build it.
remove_url = build_callback_url(params[:id], params[:room_id])
# Remove webhook for the meeting.
webhook_remove(remove_url)
elsif eventName == "user_joined_message"
actioncable_event('join', {user_id: event['payload']['user']['extern_userid'], user: event['payload']['user']['name'], role: event['payload']['user']['role']})
elsif eventName == "UserJoinedMeetingEvtMsg"
actioncable_event('join', {user_id: event['core']['body']['intId'], user: event['core']['body']['name'], role: event['core']['body']['role']})
elsif eventName == "user_left_message"
actioncable_event('leave', {user_id: event['payload']['user']['extern_userid']})
elsif eventName == "UserLeftMeetingEvtMsg"
actioncable_event('leave', {user_id: event['core']['body']['intId']})
else
logger.info "Callback event will not be treated. Event name: #{eventName}"
end
render head(:ok) && return
end
def build_callback_url(id, room_id)
"#{request.base_url}#{relative_root}/rooms/#{room_id}/#{URI.encode(id)}/callback"
end
def actioncable_event(method, data = {})
data = {method: method, meeting: params[:id], room: params[:room_id]}.merge(data)
ActionCable.server.broadcast('refresh_meetings', data)
end
# Validates the checksum received in a callback call.
# If the checksum doesn't match, renders an ok and aborts execution.
def validate_checksum
secret = ENV['BIGBLUEBUTTON_SECRET']
checksum = params["checksum"]
return false unless checksum
# Message is only encoded if it comes from the bbb-webhooks node application.
# The post process script does not encode it's response body.
begin
# Decode and break the body into parts.
parts = URI.decode_www_form(read_body(request))
# Convert the data into the correct checksum format, replace ruby hash arrows.
converted_data = {parts[0][0]=>parts[0][1],parts[1][0]=>parts[1][1].to_i}.to_s.gsub!('=>', ':')
# Manually remove the space between the two elements.
converted_data[converted_data.rindex("timestamp") - 2] = ''
callback_url = uri_remove_param(request.original_url, "checksum")
checksum_str = "#{callback_url}#{converted_data}#{secret}"
rescue
# Data was not recieved encoded (sent from post process script).
data = read_body(request)
callback_url = uri_remove_param(request.original_url, "checksum")
checksum_str = "#{callback_url}#{data}#{secret}"
end
calculated_checksum = Digest::SHA1.hexdigest(checksum_str)
if calculated_checksum != checksum
logger.error "Checksum did not match. Calculated: #{calculated_checksum}, received: #{checksum}"
false
else
true
end
end
# Removes parameters from an URI
def uri_remove_param(uri, params = nil)
return uri unless params
params = Array(params)
uri_parsed = URI.parse(uri)
return uri unless uri_parsed.query
new_params = uri_parsed.query.gsub(/&/, '&').split('&').reject { |q| params.include?(q.split('=').first) }
uri = uri.split('?').first
if new_params.count > 0
"#{uri}?#{new_params.join('&')}"
else
uri
end
end
end