From e5efb05a01e361df15ba2ddfe0cb4c93f8678d07 Mon Sep 17 00:00:00 2001 From: Leonardo Crauss Daronco Date: Wed, 7 Dec 2016 16:20:10 -0200 Subject: [PATCH] Add endpoint to detect new recordings and update the interface The endpoint receives events from BigBlueButton via webhooks or scripts in the record and playback workflow. For now it only treats the event for when a recording is ready. When it happens, it uses action cable to update the interface dynamically with the new recording. --- .../javascripts/channels/recording_update.js | 15 +++- app/assets/javascripts/recordings.coffee | 8 +- app/assets/javascripts/shared.js | 6 +- app/controllers/bbb_controller.rb | 78 ++++++++++++++++++- app/jobs/recording_created_job.rb | 26 +++++++ app/jobs/recording_deletes_job.rb | 2 +- app/jobs/recording_updates_job.rb | 2 +- app/lib/bbb_api.rb | 51 +++++++++++- app/views/layouts/application.html.erb | 4 + config/locales/en-us.yml | 1 + config/routes.rb | 1 + 11 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 app/jobs/recording_created_job.rb diff --git a/app/assets/javascripts/channels/recording_update.js b/app/assets/javascripts/channels/recording_update.js index 1b13b5bb..2b517ccd 100644 --- a/app/assets/javascripts/channels/recording_update.js +++ b/app/assets/javascripts/channels/recording_update.js @@ -26,23 +26,32 @@ var recordings = Recordings.getInstance(); var table = recordings.table.api(); - var row = table.row("#"+data.record_id); + var row = table.row("#"+data.id); if (data.action === 'update') { var rowData = row.data(); rowData.published = data.published; rowData.listed = data.listed; - table.row("#"+data.record_id).data(rowData); + table.row("#"+data.id).data(rowData); recordings.draw(); var status = data.published ? (data.listed ? 'published' : 'unlisted') : 'unpublished'; showAlert(I18n['recording_'+status], 4000); + } else if (data.action === 'delete') { row.remove(); recordings.draw(); - showAlert(I18n.recording_deleted, 4000); + + } else if (data.action === 'create') { + if (row.length == 0) { + data.duration = data.length; + table.rows.add([data]); + recordings.draw(); + showAlert(I18n.recording_created, 4000); + } + } } }); diff --git a/app/assets/javascripts/recordings.coffee b/app/assets/javascripts/recordings.coffee index d6ee8d3d..0c9cc382 100644 --- a/app/assets/javascripts/recordings.coffee +++ b/app/assets/javascripts/recordings.coffee @@ -166,13 +166,13 @@ class @Recordings listed = btn.data('visibility') == "published" btn.prop('disabled', true) + + data = { published: published.toString() } + data["meta_" + GreenLight.META_LISTED] = listed.toString(); $.ajax({ method: 'PATCH', url: url+'/recordings/'+id, - data: { - published: published.toString(), - "meta_greenlight-listed": listed.toString() - } + data: data }).done((data) -> ).fail((data) -> diff --git a/app/assets/javascripts/shared.js b/app/assets/javascripts/shared.js index 854679a5..166ef5ad 100644 --- a/app/assets/javascripts/shared.js +++ b/app/assets/javascripts/shared.js @@ -34,6 +34,7 @@ var loopJoin = function() { }); } +var alertTimeout = null; var showAlert = function(html, timeout_delay) { if (!html) { return; @@ -43,11 +44,12 @@ var showAlert = function(html, timeout_delay) { $('#alerts').html($('.alert-template').html()); if (timeout_delay) { - setTimeout(function() { + clearTimeout(alertTimeout); + alertTimeout = setTimeout(function() { $('#alerts > .alert').alert('close'); }, timeout_delay); } -} +}; var displayRoomURL = function() { $('.meeting-url').val(Meeting.getInstance().getURL()); diff --git a/app/controllers/bbb_controller.rb b/app/controllers/bbb_controller.rb index ae2272a7..1bf33933 100644 --- a/app/controllers/bbb_controller.rb +++ b/app/controllers/bbb_controller.rb @@ -20,6 +20,9 @@ class BbbController < ApplicationController 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 + before_action :validate_checksum, only: :callback + # GET /:resource/:id/join def join if params[:name].blank? @@ -39,7 +42,9 @@ class BbbController < ApplicationController user_is_moderator: true } end - options[:meeting_logout_url] = "#{request.base_url}/#{params[:resource]}/#{params[:id]}" + base_url = "#{request.base_url}/#{params[:resource]}/#{params[:id]}" + options[:meeting_logout_url] = base_url + options[:hook_url] = "#{base_url}/callback" bbb_res = bbb_join_url( params[:id], @@ -55,6 +60,20 @@ class BbbController < ApplicationController end end + # POST /:resource/:id/callback + # Endpoint for webhook calls from BigBlueButton + def callback + begin + data = JSON.parse(read_body(request)) + treat_callback_event(data["event"]) + 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 + render head(:ok) + end + end + # DELETE /rooms/:id/end def end load_and_authorize_room_owner! @@ -130,4 +149,61 @@ class BbbController < ApplicationController @response = response render status: @status end + + def read_body(request) + request.body.read.force_encoding("UTF-8") + end + + def treat_callback_event(event) + + # a recording is ready + if event['header']['name'] == "publish_ended" + token = event['payload']['metadata'][META_TOKEN] + record_id = event['payload']['meeting_id'] + + # 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 we wouldn't need this + rec_info = bbb_get_recordings(token, record_id) + rec_info = rec_info[:recordings].first + RecordingCreatedJob.perform_later(token, parse_recording_for_view(rec_info)) + end + + render head(:ok) && return + 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"] + data = read_body(request) + callback_url = uri_remove_param(request.url, "checksum") + + checksum_str = "#{callback_url}#{data}#{secret}" + calculated_checksum = Digest::SHA1.hexdigest(checksum_str) + + if calculated_checksum != checksum + logger.error "Checksum did not match. Calculated: #{calculated_checksum}, received: #{checksum}" + + # respond with 200 anyway so BigBlueButton knows the hook call was ok + # but abort execution + render head(:ok) && return + 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 diff --git a/app/jobs/recording_created_job.rb b/app/jobs/recording_created_job.rb new file mode 100644 index 00000000..c38370d8 --- /dev/null +++ b/app/jobs/recording_created_job.rb @@ -0,0 +1,26 @@ +# 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 RecordingCreatedJob < ApplicationJob + include BbbApi + + queue_as :default + + def perform(room, recording) + ActionCable.server.broadcast "#{room}_recording_updates_channel", + { action: 'create' }.merge(recording) + end +end diff --git a/app/jobs/recording_deletes_job.rb b/app/jobs/recording_deletes_job.rb index 624ada8f..74df9abd 100644 --- a/app/jobs/recording_deletes_job.rb +++ b/app/jobs/recording_deletes_job.rb @@ -28,7 +28,7 @@ class RecordingDeletesJob < ApplicationJob if !bbb_res[:recordings] || bbb_res[:messageKey] == 'noRecordings' ActionCable.server.broadcast "#{room}_recording_updates_channel", action: 'delete', - record_id: record_id + id: record_id break end sleep sleep_time diff --git a/app/jobs/recording_updates_job.rb b/app/jobs/recording_updates_job.rb index 6c21a6f0..9dafbb2f 100644 --- a/app/jobs/recording_updates_job.rb +++ b/app/jobs/recording_updates_job.rb @@ -24,7 +24,7 @@ class RecordingUpdatesJob < ApplicationJob recording = bbb_res[:recordings].first ActionCable.server.broadcast "#{room}_recording_updates_channel", action: 'update', - record_id: record_id, + id: record_id, published: recording[:published], listed: bbb_is_recording_listed(recording) end diff --git a/app/lib/bbb_api.rb b/app/lib/bbb_api.rb index a12657fb..e1690485 100644 --- a/app/lib/bbb_api.rb +++ b/app/lib/bbb_api.rb @@ -15,7 +15,9 @@ # with BigBlueButton; if not, see . module BbbApi - META_LISTED = "greenlight-listed" + META_LISTED = "gl-listed" + META_TOKEN = "gl-token" + META_HOOK_URL = "gl-webhooks-callback-url" def bbb_endpoint Rails.configuration.bigbluebutton_endpoint || '' @@ -53,7 +55,7 @@ module BbbApi # See if the meeting is running begin - bbb_meeting_info = bbb.get_meeting_info( meeting_id, nil ) + bbb_meeting_info = bbb.get_meeting_info(meeting_id, nil) rescue BigBlueButton::BigBlueButtonException => exc # This means that is not created @@ -72,9 +74,14 @@ module BbbApi logoutURL: logout_url, moderatorPW: moderator_password, attendeePW: viewer_password, - "meta_#{BbbApi::META_LISTED}": false + "meta_#{BbbApi::META_LISTED}": false, + "meta_#{BbbApi::META_TOKEN}": meeting_token } + meeting_options.merge!( + { "meta_#{BbbApi::META_HOOK_URL}": options[:hook_url] } + ) if options[:hook_url] + # Create the meeting bbb.create_meeting(options[:meeting_name], meeting_id, meeting_options) @@ -110,7 +117,7 @@ module BbbApi options[:recordID] = record_id end if meeting_id - options[:meetingID] = (Digest::SHA1.hexdigest(Rails.application.secrets[:secret_key_base]+meeting_id)).to_s + options[:meetingID] = bbb_meeting_id(meeting_id) end res = bbb_safe_execute :get_recordings, options @@ -210,6 +217,42 @@ module BbbApi recording[:metadata][BbbApi::META_LISTED.to_sym] == "true" end + # Parses a recording as returned by getRecordings and returns it + # as an object as expected by the views. + # TODO: this is almost the same done by jbuilder templates (bbb/recordings), + # how to reuse them? + def parse_recording_for_view(recording) + recording[:previews] ||= [] + previews = recording[:previews].map do |preview| + { + url: preview[:content], + width: preview[:width], + height: preview[:height], + alt: preview[:alt] + } + end + recording[:playbacks] ||= [] + playbacks = recording[:playbacks].map do |playback| + { + type: playback[:type], + type_i18n: t(playback[:type]), + url: playback[:url], + previews: previews + } + end + { + id: recording[:recordID], + name: recording[:name], + published: recording[:published], + end_time: recording[:endTime].to_s, + start_time: recording[:startTime].to_s, + length: recording[:length], + listed: recording[:listed], + playbacks: playbacks, + previews: previews + } + end + def success_join_res(join_url) { returncode: true, diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index b038e670..e13b10ea 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -79,4 +79,8 @@ with BigBlueButton; if not, see . diff --git a/config/locales/en-us.yml b/config/locales/en-us.yml index b44ff5f5..86365cbe 100644 --- a/config/locales/en-us.yml +++ b/config/locales/en-us.yml @@ -48,6 +48,7 @@ en-US: no_recordings: No Recordings no_recordings_yet: No Recordings (Yet!) publish_recording: Publish recording + recording_created: A recording was created recording_deleted: Recording was deleted recording_published: Recording was published recording_unlisted: Recording was unlisted diff --git a/config/routes.rb b/config/routes.rb index 47af0695..5c89477d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,7 @@ Rails.application.routes.draw do get '/:resource/:id/join', to: 'bbb#join', as: :bbb_join, defaults: {format: 'json'} get '/:resource/:id/wait', to: 'landing#wait_for_moderator' get '/:resource/:id/session_status_refresh', to: 'landing#session_status_refresh' + post '/:resource/:id/callback', to: 'bbb#callback' #, defaults: {format: 'json'} delete '/rooms/:id/end', to: 'bbb#end', defaults: {format: 'json'} get '/rooms/:id/recordings', to: 'bbb#recordings', defaults: {format: 'json'} patch '/rooms/:id/recordings/:record_id', to: 'bbb#update_recordings', defaults: {format: 'json'}