diff --git a/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java b/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java index 554938e3b0bb841aba44f92270ea4e38c959b0ad..00506598398feaf6b6fa7892f4b0cbc4d08649e3 100755 --- a/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java +++ b/bbb-common-message/src/main/java/org/bigbluebutton/common2/redis/RedisStorageService.java @@ -19,8 +19,10 @@ package org.bigbluebutton.common2.redis; +import java.util.HashMap; import java.util.Map; +import com.sun.org.apache.xpath.internal.operations.Bool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +31,7 @@ import io.lettuce.core.RedisClient; import io.lettuce.core.RedisURI; import io.lettuce.core.api.StatefulRedisConnection; import io.lettuce.core.api.sync.RedisCommands; +import org.apache.commons.codec.digest.DigestUtils; public class RedisStorageService extends RedisAwareCommunicator { @@ -56,6 +59,37 @@ public class RedisStorageService extends RedisAwareCommunicator { log.info("RedisStorageService Stopped"); } + public String generateSingleUseCaptionToken(String recordId, String caption, Long expirySeconds) { + Map<String, String> data = new HashMap<String, String>(); + data.put("recordId", recordId); + data.put("caption", caption); + + String token = DigestUtils.sha1Hex(recordId + caption + System.currentTimeMillis()); + String key = "captions:" + token + ":singleusetoken"; + RedisCommands<String, String> commands = connection.sync(); + commands.multi(); + commands.hmset(key, data); + commands.expire(key, expirySeconds); + commands.exec(); + + return token; + } + + public Boolean validateSingleUseCaptionToken(String token, String recordId, String caption) { + String key = "captions:" + token + ":singleusetoken"; + RedisCommands<String, String> commands = connection.sync(); + Boolean keyExist = commands.exists(key) == 1; + if (keyExist) { + Map <String, String> data = commands.hgetall(key); + if (data.get("recordId").equals(recordId) && data.get("caption").equals(caption)) { + commands.del(key); + return true; + } + } + + return false; + } + public void recordMeetingInfo(String meetingId, Map<String, String> info) { recordMeeting(Keys.MEETING_INFO + meetingId, info); } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java index e239f97f905e3837e2f1578d84ec0227808d6687..715757404985418eb1a4a282d0eb789aae1a9fdd 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/ApiParams.java @@ -48,6 +48,7 @@ public class ApiParams { public static final String PARENT_MEETING_ID = "parentMeetingID"; public static final String PASSWORD = "password"; public static final String RECORD = "record"; + public static final String RECORD_ID = "recordID"; public static final String REDIRECT = "redirect"; public static final String SEQUENCE = "sequence"; public static final String VOICE_BRIDGE = "voiceBridge"; diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java index bdc8b9798dbe48469830e14945614bddc82e8b9d..ac0540fdcaf41c417dc1377b4f227bedc844c6a9 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/MeetingService.java @@ -403,21 +403,48 @@ public class MeetingService implements MessageListener { return null; } + public Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token) { + return recordingService.validateTextTrackSingleUseToken(recordId, caption, token); + } + public String getRecordingTextTracks(String recordId) { return recordingService.getRecordingTextTracks(recordId); } public String putRecordingTextTrack(String recordId, String kind, String lang, File file, String label, - String origFilename, String trackId) { + String origFilename, String trackId, String contentType, String tempFilename) { + + Map<String, Object> logData = new HashMap<>(); + logData.put("recordId", recordId); + logData.put("kind", kind); + logData.put("lang", lang); + logData.put("label", label); + logData.put("origFilename", origFilename); + logData.put("contentType", contentType); + logData.put("tempFilename", tempFilename); + logData.put("logCode", "recording_captions_uploaded"); + logData.put("description", "Captions for recording uploaded."); + + Gson gson = new Gson(); + String logStr = gson.toJson(logData); + log.info(" --analytics-- data={}", logStr); UploadedTrack track = new UploadedTrack(recordId, kind, lang, label, origFilename, file, trackId, - getCaptionTrackInboxDir()); + getCaptionTrackInboxDir(), contentType, tempFilename); return recordingService.putRecordingTextTrack(track); } public String getCaptionTrackInboxDir() { return recordingService.getCaptionTrackInboxDir(); - } + } + + public String getCaptionsDir() { + return recordingService.getCaptionsDir(); + } + + public boolean isRecordingExist(String recordId) { + return recordingService.isRecordingExist(recordId); + } public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters) { return recordingService.getRecordings2x(idList, states, metadataFilters); diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java index 946d34c81b53985da561e04df5b5e56235dfbacd..69c86878aa576dc0535ea1e4401b61d0a3d1a425 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/RecordingService.java @@ -54,6 +54,8 @@ public class RecordingService { private String recordStatusDir; private String captionsDir; private String presentationBaseDir; + private String defaultServerUrl; + private String defaultTextTrackUrl; private void copyPresentationFile(File presFile, File dlownloadableFile) { try { @@ -168,8 +170,12 @@ public class RecordingService { return recs; } + public Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token) { + return recordingServiceHelper.validateTextTrackSingleUseToken(recordId, caption, token); + } + public String getRecordingTextTracks(String recordId) { - return recordingServiceHelper.getRecordingTextTracks(recordId, captionsDir); + return recordingServiceHelper.getRecordingTextTracks(recordId, captionsDir, getCaptionFileUrlDirectory()); } public String putRecordingTextTrack(UploadedTrack track) { @@ -242,6 +248,16 @@ public class RecordingService { return ids; } + public boolean isRecordingExist(String recordId) { + List<String> publishList = getAllRecordingIds(publishedDir); + List<String> unpublishList = getAllRecordingIds(unpublishedDir); + if (publishList.contains(recordId) || unpublishList.contains(recordId)) { + return true; + } + + return false; + } + public boolean existAnyRecording(List<String> idList) { List<String> publishList = getAllRecordingIds(publishedDir); List<String> unpublishList = getAllRecordingIds(unpublishedDir); @@ -374,6 +390,14 @@ public class RecordingService { presentationBaseDir = dir; } + public void setDefaultServerUrl(String url) { + defaultServerUrl = url; + } + + public void setDefaultTextTrackUrl(String url) { + defaultTextTrackUrl = url; + } + public void setPublishedDir(String dir) { publishedDir = dir; } @@ -662,7 +686,16 @@ public class RecordingService { return baseDir; } - public String getCaptionTrackInboxDir() { - return captionsDir + File.separatorChar + "inbox"; - } + public String getCaptionTrackInboxDir() { + return captionsDir + File.separatorChar + "inbox"; + } + + public String getCaptionsDir() { + return captionsDir; + } + + public String getCaptionFileUrlDirectory() { + return defaultTextTrackUrl + "/textTrack/"; + } + } diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java index 6c7f045e43f3d063af610925f1f4b8c20026bca7..8b52e5f81f4f0050b9a6e5ff41e1bf71d049afe7 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api/util/RecordingMetadataReaderHelper.java @@ -16,8 +16,12 @@ public class RecordingMetadataReaderHelper { private RecordingServiceGW recordingServiceGW; - public String getRecordingTextTracks(String recordId, String captionsDir) { - return recordingServiceGW.getRecordingTextTracks(recordId, captionsDir); + public Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token) { + return recordingServiceGW.validateTextTrackSingleUseToken(recordId, caption, token); + } + + public String getRecordingTextTracks(String recordId, String captionsDir, String captionsBaseUrl) { + return recordingServiceGW.getRecordingTextTracks(recordId, captionsDir, captionsBaseUrl); } public String putRecordingTextTrack(UploadedTrack track) { diff --git a/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java b/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java index 987d9f96b59a20cc3d1801e0cddc3cea864b8c05..82c20372f32cad318dddbfb69c9953b801d58c2d 100755 --- a/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java +++ b/bbb-common-web/src/main/java/org/bigbluebutton/api2/RecordingServiceGW.java @@ -13,6 +13,7 @@ public interface RecordingServiceGW { String getRecordings2x(ArrayList<RecordingMetadata> recs); Option<RecordingMetadata> getRecordingMetadata(File xml); boolean saveRecordingMetadata(File xml, RecordingMetadata metadata); - String getRecordingTextTracks(String recordId, String captionsDir); + boolean validateTextTrackSingleUseToken(String recordId, String caption, String token); + String getRecordingTextTracks(String recordId, String captionsDir, String captionBasUrl); String putRecordingTextTrack(UploadedTrack track); } diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala index 383a329bc6884e3dcf4d470f5d3fa3a41687fc47..1dc38356bc1b2a9d513afe4581d7e2a78b31373a 100755 --- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala +++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/BbbWebApiGWApp.scala @@ -38,6 +38,19 @@ class BbbWebApiGWApp( val redisPass = if (redisPassword != "") Some(redisPassword) else None val redisConfig = RedisConfig(redisHost, redisPort, redisPass, redisExpireKey) + + var redisStorage = new RedisStorageService() + redisStorage.setHost(redisConfig.host) + redisStorage.setPort(redisConfig.port) + val redisPassStr = redisConfig.password match { + case Some(pass) => pass + case None => "" + } + redisStorage.setPassword(redisPassStr) + redisStorage.setExpireKey(redisConfig.expireKey) + redisStorage.setClientName("BbbWebRedisStore") + redisStorage.start() + private val redisPublisher = new RedisPublisher(system, "BbbWebPub", redisConfig) private val msgSender: MessageSender = new MessageSender(redisPublisher) @@ -274,4 +287,13 @@ class BbbWebApiGWApp( msgToAkkaAppsEventBus.publish(MsgToAkkaApps(toAkkaAppsChannel, event)) } } + +/*** Caption API ***/ + def generateSingleUseCaptionToken(recordId: String, caption: String, expirySeconds: Long): String = { + redisStorage.generateSingleUseCaptionToken(recordId, caption, expirySeconds) + } + + def validateSingleUseCaptionToken(token: String, meetingId: String, caption: String): Boolean = { + redisStorage.validateSingleUseCaptionToken(token, meetingId, caption) + } } diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala index cb337dc63ca0443abdbb99a1ffcb961d31a1878b..874baee5c5ccc73ddab84ae530c740d7a4afb96f 100755 --- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala +++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/domain/TextTracks.scala @@ -12,21 +12,25 @@ case class UploadedTrack( origFilename: String, track: File, trackId: String, - inboxDir: String + inboxDir: String, + contentType: String, + tempFilename: String ) case class UploadedTrackInfo( - recordId: String, - kind: String, - lang: String, - label: String, - origFilename: String + record_id: String, + kind: String, + lang: String, + label: String, + original_filename: String, + content_type: String, + temp_filename: String ) case class Track( + href: String, kind: String, - lang: String, label: String, - source: String, - href: String + lang: String, + source: String ) case class GetRecTextTracksResult( returncode: String, diff --git a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala index 6b078329a4eb14a269ee086ceb6f2ce8cb897701..b23f4693fdef501061d4b80c4345d6f364f2598f 100755 --- a/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala +++ b/bbb-common-web/src/main/scala/org/bigbluebutton/api2/util/RecMetaXmlHelper.scala @@ -4,23 +4,27 @@ import java.io.{ File, FileOutputStream, FileWriter, IOException } import java.nio.channels.Channels import java.nio.charset.StandardCharsets import java.util +import java.nio.file.{ Files, Paths } import com.google.gson.Gson import org.bigbluebutton.api.domain.RecordingMetadata -import org.bigbluebutton.api2.RecordingServiceGW +import org.bigbluebutton.api2.{ BbbWebApiGWApp, RecordingServiceGW } import org.bigbluebutton.api2.domain._ import scala.xml.{ Elem, PrettyPrinter, XML } import scala.collection.JavaConverters._ import scala.collection.mutable.{ Buffer, ListBuffer, Map } import scala.collection.Iterable - import java.io.IOException import java.nio.charset.Charset import java.nio.file.Files import java.nio.file.Paths -class RecMetaXmlHelper extends RecordingServiceGW with LogHelper { +import com.google.gson.internal.LinkedTreeMap + +import scala.util.Try + +class RecMetaXmlHelper(gw: BbbWebApiGWApp) extends RecordingServiceGW with LogHelper { val SUCCESS = "SUCCESS" val FAILED = "FAILED" @@ -188,19 +192,43 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper { } } - def getRecordingTextTracks(recordId: String, captionsDir: String): String = { + def validateTextTrackSingleUseToken(recordId: String, caption: String, token: String): Boolean = { + gw.validateSingleUseCaptionToken(token, recordId, caption) + } + + def getRecordingsCaptionsJson(recordId: String, captionsDir: String, captionBaseUrl: String): String = { val gson = new Gson() var returnResponse: String = "" val captionsFilePath = captionsDir + File.separatorChar + recordId + File.separatorChar + CAPTIONS_FILE readCaptionJsonFile(captionsFilePath, StandardCharsets.UTF_8) match { case Some(captions) => - val ctracks = gson.fromJson(captions, classOf[util.ArrayList[Track]]) - val result1 = GetRecTextTracksResult(SUCCESS, ctracks) - val response1 = GetRecTextTracksResp(result1) - val respText1 = gson.toJson(response1) + val ctracks = gson.fromJson(captions, classOf[java.util.List[LinkedTreeMap[String, String]]]) + + val list = new util.ArrayList[Track]() + val it = ctracks.iterator() - returnResponse = respText1 + while (it.hasNext()) { + val mapTrack = it.next() + val caption = mapTrack.get("kind") + "_" + mapTrack.get("lang") + ".vtt" + val singleUseToken = gw.generateSingleUseCaptionToken(recordId, caption, 60 * 60) + + list.add(new Track( + // captionBaseUrl contains the '/' so no need to put one before singleUseToken + href = captionBaseUrl + singleUseToken + '/' + recordId + '/' + caption, + kind = mapTrack.get("kind"), + label = mapTrack.get("label"), + lang = mapTrack.get("lang"), + source = mapTrack.get("source") + )) + } + val textTracksResult = GetRecTextTracksResult(SUCCESS, list) + + val textTracksResponse = GetRecTextTracksResp(textTracksResult) + val textTracksJson = gson.toJson(textTracksResponse) + // parse(textTracksJson).transformField{case JField(x, v) if x == "value" && v == JString("Company")=> JField("value1",JString("Company1"))} + + returnResponse = textTracksJson case None => val resFailed = GetRecTextTracksResultFailed(FAILED, "noCaptionsFound", "No captions found for " + recordId) val respFailed = GetRecTextTracksRespFailed(resFailed) @@ -212,6 +240,21 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper { returnResponse } + def getRecordingTextTracks(recordId: String, captionsDir: String, captionBaseUrl: String): String = { + val gson = new Gson() + var returnResponse: String = "" + val recordingPath = captionsDir + File.separatorChar + recordId + if (!Files.exists(Paths.get(recordingPath))) { + val resFailed = GetRecTextTracksResultFailed(FAILED, "noRecordings", "No recording found for " + recordId) + val respFailed = GetRecTextTracksRespFailed(resFailed) + returnResponse = gson.toJson(respFailed) + } else { + returnResponse = getRecordingsCaptionsJson(recordId, captionsDir, captionBaseUrl) + } + + returnResponse + } + def saveCaptionsFile(captionsDir: String, captionsTracks: String): Boolean = { val path = captionsDir + File.separatorChar + CAPTIONS_FILE val fileWriter = new FileWriter(path) @@ -232,18 +275,25 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper { } } + def mv(oldName: String, newName: String) = + Try(new File(oldName).renameTo(new File(newName))).getOrElse(false) + def saveTrackInfoFile(trackInfoJson: String, trackInfoFilePath: String): Boolean = { + // Need to create intermediate file to prevent race where the file is processed before + // contents have been written. + val tempTrackInfoFilePath = trackInfoFilePath + ".tmp" + var result = false - val fileWriter = new FileWriter(trackInfoFilePath) + val fileWriter = new FileWriter(tempTrackInfoFilePath) try { fileWriter.write(trackInfoJson) result = true } catch { case ioe: IOException => - logger.info("Failed to write caption.json {}", trackInfoFilePath) + logger.info("Failed to write caption.json {}", tempTrackInfoFilePath) result = false case ex: Exception => - logger.info("Exception while writing {}", trackInfoFilePath) + logger.info("Exception while writing {}", tempTrackInfoFilePath) logger.info("Exception details: {}", ex.getMessage) result = false } finally { @@ -251,6 +301,11 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper { fileWriter.close() } + if (result) { + // Rename so that the captions processor will pick up the uploaded captions. + result = mv(tempTrackInfoFilePath, trackInfoFilePath) + } + result } @@ -258,11 +313,13 @@ class RecMetaXmlHelper extends RecordingServiceGW with LogHelper { val trackInfoFilePath = track.inboxDir + File.separatorChar + track.trackId + "-track.json" val trackInfo = new UploadedTrackInfo( - recordId = track.recordId, + record_id = track.recordId, kind = track.kind, lang = track.lang, label = track.label, - origFilename = track.origFilename + original_filename = track.origFilename, + temp_filename = track.tempFilename, + content_type = track.contentType ) val gson = new Gson() diff --git a/bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests.scala b/bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests .scala similarity index 100% rename from bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests.scala rename to bbb-common-web/src/test/scala/org/bigbluebutton/api2/util/RecMetaXmlHelperTests .scala diff --git a/bigbluebutton-config/bin/bbb-record b/bigbluebutton-config/bin/bbb-record index 74bf7301202df18dbd6c237648783e86e19b7d7f..b2c2cbd338e7c58a0247df07f79f4469f60d30e8 100755 --- a/bigbluebutton-config/bin/bbb-record +++ b/bigbluebutton-config/bin/bbb-record @@ -35,6 +35,7 @@ # 2016-07-02 FFD Updates for 1.1-beta # 2016-10-17 GTR Stricter rule for detection of recording directories names # 2017-04-28 FFD Updated references to systemd processing units +# 2019-05-13 GTR Delete caption files #set -e #set -x @@ -372,6 +373,10 @@ if [ $DELETE ]; then rm -rf /var/log/bigbluebutton/$type/*$MEETING_ID* done + rm -rf /var/bigbluebutton/captions/$MEETING_ID* + rm -f /var/bigbluebutton/inbox/$MEETING_ID*.json + rm -f /var/bigbluebutton/inbox/$MEETING_ID*.txt + rm -rf /var/bigbluebutton/recording/raw/$MEETING_ID* rm -rf /usr/share/red5/webapps/video/streams/$MEETING_ID @@ -402,7 +407,9 @@ if [ $DELETEALL ]; then done rm -rf /var/bigbluebutton/recording/raw/* - + + rm -f /var/bigbluebutton/captions/inbox/* + find /usr/share/red5/webapps/video/streams -name "*.flv" -exec rm '{}' \; find /usr/share/red5/webapps/video-broadcast/streams -name "*.flv" -exec rm '{}' \; rm -f /var/bigbluebutton/screenshare/*.flv @@ -411,6 +418,7 @@ if [ $DELETEALL ]; then for meeting in $(ls /var/bigbluebutton | grep "^[0-9a-f]\{40\}-[[:digit:]]\{13\}$"); do echo "deleting: $meeting" rm -rf /var/bigbluebutton/$meeting + rm -rf /var/bigbluebutton/captions/$meeting done fi diff --git a/bigbluebutton-web/bbb-web.nginx b/bigbluebutton-web/bbb-web.nginx old mode 100644 new mode 100755 index 6b0e66f12994163171dea8062f7a21d9bd175d2d..b8b331c2994724bc03eadbb52f4ca43152b77046 --- a/bigbluebutton-web/bbb-web.nginx +++ b/bigbluebutton-web/bbb-web.nginx @@ -73,4 +73,46 @@ proxy_set_header Content-Length ""; proxy_set_header X-Original-URI $request_uri; } + + location ~ "^/bigbluebutton\/textTrack\/(?<textTrackToken>[a-zA-Z0-9]+)\/(?<recordId>[a-zA-Z0-9_-]+)\/(?<textTrack>.+)$" { + # Workaround IE refusal to set cookies in iframe + add_header P3P 'CP="No P3P policy available"'; + + # Allow 30M uploaded presentation document. + client_max_body_size 30m; + client_body_buffer_size 128k; + + proxy_connect_timeout 90; + proxy_send_timeout 90; + proxy_read_timeout 90; + + proxy_buffer_size 4k; + proxy_buffers 4 32k; + proxy_busy_buffers_size 64k; + proxy_temp_file_write_size 64k; + + include fastcgi_params; + + proxy_request_buffering off; + + # Send a sub-request to allow bbb-web to refuse before loading + auth_request /bigbluebutton/textTrack/validateAuthToken; + + default_type text/plain; + alias /var/bigbluebutton/captions/$recordId/$textTrack; + + } + + location = /bigbluebutton/textTrack/validateAuthToken { + internal; + proxy_pass http://127.0.0.1:8090; + proxy_redirect default; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_set_header X-textTrack-token $textTrackToken; + proxy_set_header X-textTrack-recordId $recordId; + proxy_set_header X-textTrack-track $textTrack; + proxy_set_header X-Original-URI $request_uri; + } + } diff --git a/bigbluebutton-web/build.gradle b/bigbluebutton-web/build.gradle index 03b25c8d1b34ba814e23e7644a74297f8caeb783..5dfb4f6434b5c3c4def4d98ee58b70f040281f97 100755 --- a/bigbluebutton-web/build.gradle +++ b/bigbluebutton-web/build.gradle @@ -84,6 +84,9 @@ dependencies { compile "org.libreoffice:ridl:5.4.2" compile "org.libreoffice:juh:5.4.2" compile "org.libreoffice:jurt:5.4.2" + // https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload + compile group: 'commons-fileupload', name: 'commons-fileupload', version: '1.4' + //--- BigBlueButton Dependencies End console "org.grails:grails-console" profile "org.grails.profiles:web" diff --git a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties index 3b5e9ee7f27128125d617dac56a32035fbecce15..34630aa569ddd5472213598889229df543c9ccdd 100755 --- a/bigbluebutton-web/grails-app/conf/bigbluebutton.properties +++ b/bigbluebutton-web/grails-app/conf/bigbluebutton.properties @@ -201,7 +201,8 @@ keepEvents=false #---------------------------------------------------- # This URL is where the BBB client is accessible. When a user sucessfully # enters a name and password, she is redirected here to load the client. -bigbluebutton.web.serverURL=http://ritz-20.blindside-dev.com +# Do not commit changes to this field. +bigbluebutton.web.serverURL=https://bigbluebutton.example.com #---------------------------------------------------- @@ -326,3 +327,5 @@ lockSettingsLockOnJoin=true lockSettingsLockOnJoinConfigurable=false allowDuplicateExtUserid=true + +defaultTextTrackUrl=${bigbluebutton.web.serverURL}/bigbluebutton diff --git a/bigbluebutton-web/grails-app/conf/spring/resources.xml b/bigbluebutton-web/grails-app/conf/spring/resources.xml index 8fa2f6571a724c00141033d9749529bad47727f7..a5320caf48f1e312bff6dcbfdc8ccd7695849c58 100755 --- a/bigbluebutton-web/grails-app/conf/spring/resources.xml +++ b/bigbluebutton-web/grails-app/conf/spring/resources.xml @@ -71,6 +71,7 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>. </bean> <bean id="recordingServiceGW" class="org.bigbluebutton.api2.util.RecMetaXmlHelper"> + <constructor-arg index="0" ref="bbbWebApiGWApp"/> </bean> <bean id="presDownloadService" class="org.bigbluebutton.presentation.PresentationUrlDownloadService" @@ -89,6 +90,8 @@ with BigBlueButton; if not, see <http://www.gnu.org/licenses/>. <property name="captionsDir" value="${captionsDir}"/> <property name="recordingServiceHelper" ref="recordingServiceHelper"/> <property name="presentationBaseDir" value="${presentationDir}"/> + <property name="defaultServerUrl" value="${bigbluebutton.web.serverURL}"/> + <property name="defaultTextTrackUrl" value="${defaultTextTrackUrl}"/> </bean> <bean id="configServiceHelper" class="org.bigbluebutton.api.ClientConfigServiceHelperImp"/> diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy index 5b871c37f20e227fbed2df35e50db7dc1dab4b49..10d573ce34a7026da877fe57d62a894146f7c503 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/UrlMappings.groovy @@ -83,8 +83,12 @@ class UrlMappings { action = [GET: 'guestWaitHandler'] } + "/bigbluebutton/textTrack/validateAuthToken"(controller: "recording") { + action = [GET: 'checkTextTrackAuthToken'] + } + "/bigbluebutton/api/getRecordingTextTracks"(controller: "recording") { - action = [GET: 'getRecordingTextTracks'] + action = [GET: 'getRecordingTextTracksHandler', POST: 'getRecordingTextTracksHandler'] } "/bigbluebutton/api/putRecordingTextTrack"(controller: "recording") { diff --git a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy index a3f01d80c608be846a6c0e29711e7cb3a428b256..88ba14f09ad6967a95c14eef332267be00cfb907 100755 --- a/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy +++ b/bigbluebutton-web/grails-app/controllers/org/bigbluebutton/web/controllers/RecordingController.groovy @@ -1,41 +1,95 @@ package org.bigbluebutton.web.controllers -import org.bigbluebutton.api.MeetingService; -import org.bigbluebutton.api.ParamsProcessorUtil; -import org.apache.commons.lang.StringUtils; -import org.bigbluebutton.api.ApiErrors; +import grails.web.context.ServletContextHolder +import groovy.json.JsonBuilder +import org.bigbluebutton.api.MeetingService +import org.bigbluebutton.api.ParamsProcessorUtil +import org.bigbluebutton.api.util.ResponseBuilder +import org.bigbluebutton.api.ApiErrors +import org.bigbluebutton.api.ApiParams +import org.apache.commons.lang3.StringUtils +import org.json.JSONArray +import org.springframework.web.multipart.commons.CommonsMultipartFile +import org.apache.commons.lang.LocaleUtils class RecordingController { private static final String CONTROLLER_NAME = 'RecordingController' + protected static final String RESP_CODE_SUCCESS = 'SUCCESS' + protected static final String RESP_CODE_FAILED = 'FAILED' + protected static Boolean REDIRECT_RESPONSE = true - MeetingService meetingService; + MeetingService meetingService ParamsProcessorUtil paramsProcessorUtil + ResponseBuilder responseBuilder = initResponseBuilder() - def getRecordingTextTracks = { + def initResponseBuilder = { + String protocol = this.getClass().getResource("").getProtocol() + if (Objects.equals(protocol, "jar")) { + // Application running inside a JAR file + responseBuilder = new ResponseBuilder(getClass().getClassLoader(), "/WEB-INF/freemarker") + } else if (Objects.equals(protocol, "file")) { + // Application unzipped and running outside a JAR file + String templateLoc = ServletContextHolder.servletContext.getRealPath("/WEB-INF/freemarker") + // We should never have a null `templateLoc` + responseBuilder = new ResponseBuilder(new File(templateLoc)) + } + } + + def checkTextTrackAuthToken = { + try { + def textTrackToken = request.getHeader("x-textTrack-token") + def textTrackRecordId = request.getHeader("x-textTrack-recordId") + def textTrackTrack = request.getHeader("x-textTrack-track") + + def isValid = false + if (textTrackToken != null && + textTrackRecordId != null && + textTrackTrack != null) { + isValid = meetingService.validateTextTrackSingleUseToken(textTrackRecordId, textTrackTrack, textTrackToken) + } + response.addHeader("Cache-Control", "no-cache") + response.contentType = 'plain/text' + if (isValid) { + response.setStatus(200) + response.outputStream << 'authorized' + } else { + response.setStatus(401) + response.outputStream << 'unauthorized' + } + } catch (IOException e) { + log.error("Error while checking text track token.\n" + e.getMessage()) + response.setStatus(401) + response.outputStream << 'unauthorized' + } + } + + /****************************************************** + * GET RECORDING TEXT TRACKS API + ******************************************************/ + def getRecordingTextTracksHandler = { String API_CALL = "getRecordingTextTracks" log.debug CONTROLLER_NAME + "#${API_CALL}" // BEGIN - backward compatibility if (StringUtils.isEmpty(params.checksum)) { - respondWithError("paramError", "Missing param checksum.") + invalid("checksumError", "You did not pass the checksum security check") return } if (StringUtils.isEmpty(params.recordID)) { - respondWithError("paramError", "Missing param recordID."); + invalid("missingParamRecordID", "You must specify a recordID.") return } - String recordId = StringUtils.strip(params.recordID) - - // Do we agree on the checksum? If not, complain. - //if (! paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) { - // respondWithError("checksumError", "You did not pass the checksum security check.") - // return - //} - - String result = meetingService.getRecordingTextTracks(recordId) + if (!paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) { + invalid("checksumError", "You did not pass the checksum security check") + return + } + // END - backward compatibility + + String recId = StringUtils.strip(params.recordID) + String result = meetingService.getRecordingTextTracks(recId) response.addHeader("Cache-Control", "no-cache") withFormat { @@ -49,19 +103,21 @@ class RecordingController { response.addHeader("Cache-Control", "no-cache") withFormat { json { - render(contentType: "application/json") { - response() { - returncode = "FAILED" - messageKey = errorKey - messsage = errorMessage - } + log.debug "Rendering as json" + def builder = new JsonBuilder() + builder.response { + returncode RESP_CODE_FAILED + messageKey errorKey + message errorMessage } + render(contentType: "application/json", text: builder.toPrettyString()) } } } def putRecordingTextTrack = { - log.debug CONTROLLER_NAME + "#putRecordingTextTrack" + String API_CALL = "putRecordingTextTrack" + log.debug CONTROLLER_NAME + "#${API_CALL}" // BEGIN - backward compatibility if (StringUtils.isEmpty(params.checksum)) { @@ -70,26 +126,57 @@ class RecordingController { } if (StringUtils.isEmpty(params.recordID)) { - respondWithError("paramError", "Missing param recordID."); + respondWithError("paramError", "Missing param recordID.") return } String recordId = StringUtils.strip(params.recordID) + log.debug("Captions for recordID: " + recordId) + + if (!paramsProcessorUtil.isChecksumSame(API_CALL, params.checksum, request.getQueryString())) { + invalid("checksumError", "You did not pass the checksum security check") + return + } + + if (!meetingService.isRecordingExist(recordId)) { + respondWithError("noRecordings", "No recording was found for " + recordId) + return + } if (StringUtils.isEmpty(params.kind)) { - respondWithError("paramError", "Missing param kind."); + respondWithError("paramError", "Missing param kind.") return } String captionsKind = StringUtils.strip(params.kind) + log.debug("Captions kind: " + captionsKind) + def isAllowedKind = captionsKind in ['subtitles', 'captions'] + if (!isAllowedKind) { + respondWithError("invalidKind", "Invalid kind parameter, expected='subtitles|captions' actual=" + captionsKind) + return + } + + Locale locale if (StringUtils.isEmpty(params.lang)) { - respondWithError("paramError", "Missing param lang."); + respondWithError("paramError", "Missing param lang.") + return + } + + String paramsLang = StringUtils.strip(params.lang) + log.debug("Captions lang: " + paramsLang) + + + try { + locale = LocaleUtils.toLocale(paramsLang) + log.debug("Captions locale: " + locale.toLanguageTag()) + } catch (IllegalArgumentException e) { + respondWithError("invalidLang", "Malformed lang param, received=" + paramsLang) return } - String captionsLang = StringUtils.strip(params.lang) - String captionsLabel = captionsLang + String captionsLang = locale.toLanguageTag() + String captionsLabel = locale.getDisplayLanguage() if (!StringUtils.isEmpty(params.label)) { captionsLabel = StringUtils.strip(params.label) @@ -97,15 +184,22 @@ class RecordingController { def uploadedCaptionsFile = request.getFile('file') if (uploadedCaptionsFile && !uploadedCaptionsFile.empty) { + CommonsMultipartFile contentType = uploadedCaptionsFile.contentType + def fileContentType = null + if (contentType != null) { + fileContentType = contentType.getContentType() + } + log.debug("Captions content type: " + fileContentType) def origFilename = uploadedCaptionsFile.getOriginalFilename() def trackId = recordId + "-" + System.currentTimeMillis() - def captionsFilePath = meetingService.getCaptionTrackInboxDir() + File.separatorChar + trackId + "-track.txt" + def tempFilename = trackId + "-track.txt" + def captionsFilePath = meetingService.getCaptionTrackInboxDir() + File.separatorChar + tempFilename def captionsFile = new File(captionsFilePath) uploadedCaptionsFile.transferTo(captionsFile) String result = meetingService.putRecordingTextTrack(recordId, captionsKind, - captionsLang, captionsFile, captionsLabel, origFilename, trackId) + captionsLang, captionsFile, captionsLabel, origFilename, trackId, fileContentType, tempFilename) response.addHeader("Cache-Control", "no-cache") withFormat { @@ -118,16 +212,78 @@ class RecordingController { response.addHeader("Cache-Control", "no-cache") withFormat { json { - render(contentType: "application/json") { - response = { - returncode = "FAILED" - messageKey = "empty_uploaded_text_track" - message = "Empty uploaded text track." - } + def builder = new JsonBuilder() + builder.response { + returncode RESP_CODE_FAILED + messageKey = "empty_uploaded_text_track" + message = "Empty uploaded text track." } + render(contentType: "application/json", text: builder.toPrettyString()) } } } } + + private void invalid(key, msg, redirectResponse = false) { + // Note: This xml scheme will be DEPRECATED. + log.debug CONTROLLER_NAME + "#invalid " + msg + if (redirectResponse) { + ArrayList<Object> errors = new ArrayList<Object>() + Map<String, String> errorMap = new LinkedHashMap<String, String>() + errorMap.put("key", key) + errorMap.put("message", msg) + errors.add(errorMap) + + JSONArray errorsJSONArray = new JSONArray(errors) + log.debug "JSON Errors {}", errorsJSONArray.toString() + + respondWithRedirect(errorsJSONArray) + } else { + response.addHeader("Cache-Control", "no-cache") + withFormat { + xml { + render(text: responseBuilder.buildError(key, msg, RESP_CODE_FAILED), contentType: "text/xml") + } + json { + log.debug "Rendering as json" + def builder = new JsonBuilder() + builder.response { + returncode RESP_CODE_FAILED + messageKey key + message msg + } + render(contentType: "application/json", text: builder.toPrettyString()) + } + } + } + } + + private void respondWithRedirect(errorsJSONArray) { + String logoutUrl = paramsProcessorUtil.getDefaultLogoutUrl() + URI oldUri = URI.create(logoutUrl) + + if (!StringUtils.isEmpty(params.logoutURL)) { + try { + oldUri = URI.create(params.logoutURL) + } catch (Exception e) { + // Do nothing, the variable oldUri was already initialized + } + } + + String newQuery = oldUri.getQuery() + + if (newQuery == null) { + newQuery = "errors=" + } else { + newQuery += "&" + "errors=" + } + newQuery += errorsJSONArray + + URI newUri = new URI(oldUri.getScheme(), oldUri.getAuthority(), oldUri.getPath(), newQuery, oldUri.getFragment()) + + log.debug "Constructed logout URL {}", newUri.toString() + redirect(url: newUri) + } + } \ No newline at end of file diff --git a/record-and-playback/.rubocop.yml b/record-and-playback/.rubocop.yml new file mode 100644 index 0000000000000000000000000000000000000000..f244a1aeb3b76fdb0dcd6e70ca8ce30960415c4e --- /dev/null +++ b/record-and-playback/.rubocop.yml @@ -0,0 +1,12 @@ +AllCops: + TargetRubyVersion: 2.3 # System ruby on Ubuntu 16.04 +Layout/AlignHash: + EnforcedHashRocketStyle: [ key, table ] +Metrics: + Enabled: false +Style/AsciiComments: + AllowedChars: [ © ] +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: consistent_comma +Style/TrailingCommaInHashLiteral: + EnforcedStyleForMultiline: consistent_comma diff --git a/record-and-playback/core/Gemfile b/record-and-playback/core/Gemfile index 035c7f8924d165767e6b73c80d2101d813740824..375fc602601ec1320e211cd15e94792a75b502d3 100644 --- a/record-and-playback/core/Gemfile +++ b/record-and-playback/core/Gemfile @@ -17,17 +17,24 @@ # -source "http://rubygems.org" +source "https://rubygems.org" -gem "redis" -gem "nokogiri" -gem "loofah" -gem "rubyzip" +gem "absolute_time" gem "builder" -gem "trollop", "2.1.3" -gem "open4" gem "fastimage" -gem "absolute_time" -gem "jwt" -gem "java_properties" gem "fnv" +gem "java_properties" +gem "journald-logger" +gem "jwt" +gem "locale" +gem "loofah" +gem "nokogiri" +gem "open4" +gem "rb-inotify" +gem "redis" +gem "rubyzip" +gem "trollop", "2.1.3" + +group :test, optional: true do + gem "rubocop", "~> 0.71.0" +end diff --git a/record-and-playback/core/Gemfile.lock b/record-and-playback/core/Gemfile.lock index c056c89ec66729236cb3d294ff971f6524470a6a..42f6e5f60a974efa5138a328bed36da2d8ab6548 100644 --- a/record-and-playback/core/Gemfile.lock +++ b/record-and-playback/core/Gemfile.lock @@ -1,23 +1,45 @@ GEM - remote: http://rubygems.org/ + remote: https://rubygems.org/ specs: absolute_time (1.0.0) + ast (2.4.0) builder (3.2.3) crass (1.0.4) fastimage (2.1.5) + ffi (1.11.1) fnv (0.2.0) + jaro_winkler (1.5.2) java_properties (0.0.4) - jwt (2.1.0) + journald-logger (2.0.4) + journald-native (~> 1.0) + journald-native (1.0.11) + jwt (2.2.1) + locale (2.1.2) loofah (2.2.3) crass (~> 1.0.2) nokogiri (>= 1.5.9) mini_portile2 (2.4.0) - nokogiri (1.10.1) + nokogiri (1.10.3) mini_portile2 (~> 2.4.0) open4 (1.3.4) - redis (4.1.0) - rubyzip (1.2.2) + parallel (1.17.0) + parser (2.6.3.0) + ast (~> 2.4.0) + rainbow (3.0.0) + rb-inotify (0.10.0) + ffi (~> 1.0) + redis (4.1.2) + rubocop (0.71.0) + jaro_winkler (~> 1.5.1) + parallel (~> 1.10) + parser (>= 2.6) + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 1.4.0, < 1.7) + ruby-progressbar (1.10.1) + rubyzip (1.2.3) trollop (2.1.3) + unicode-display_width (1.6.0) PLATFORMS ruby @@ -28,11 +50,15 @@ DEPENDENCIES fastimage fnv java_properties + journald-logger jwt + locale loofah nokogiri open4 + rb-inotify redis + rubocop (~> 0.71.0) rubyzip trollop (= 2.1.3) diff --git a/record-and-playback/core/lib/recordandplayback/events_archiver.rb b/record-and-playback/core/lib/recordandplayback/events_archiver.rb index 708b24d9ba292ca86bd9a0475f4e2ea19ff99d88..27a19041f12080a7abaeb3bbb8d37f6d05d8cf8e 100755 --- a/record-and-playback/core/lib/recordandplayback/events_archiver.rb +++ b/record-and-playback/core/lib/recordandplayback/events_archiver.rb @@ -28,7 +28,7 @@ require 'yaml' require 'fileutils' module BigBlueButton - $bbb_props = YAML::load(File.open('../../core/scripts/bigbluebutton.yml')) + $bbb_props = YAML::load(File.open(File.expand_path('../../../scripts/bigbluebutton.yml', __FILE__))) $recording_dir = $bbb_props['recording_dir'] $raw_recording_dir = "#{$recording_dir}/raw" diff --git a/record-and-playback/core/lib/recordandplayback/generators/captions.rb b/record-and-playback/core/lib/recordandplayback/generators/captions.rb new file mode 100644 index 0000000000000000000000000000000000000000..892764f76bf32faeef6a24fd92e4d33893fb0ddd --- /dev/null +++ b/record-and-playback/core/lib/recordandplayback/generators/captions.rb @@ -0,0 +1,49 @@ +# Copyright © 2019 BigBlueButton Inc. and by respective authors. +# +# This file is part of BigBlueButton open source conferencing system. +# +# BigBlueButton 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 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 <http://www.gnu.org/licenses/>. + +require File.expand_path('../../edl', __FILE__) + +module BigBlueButton + + # Convert a caption file in some format to WebVTT. + # + # content_type is optional - if provided it should be the mime type of + # the caption file format. Automatic probing will be performed if it is + # null. + # + # Returns true on success, and false on failure. If conversion fails, the + # out_filename file will be deleted. + def self.convert_caption_webvtt(in_filename, content_type, out_filename) + ffmpeg_cmd = [*FFMPEG] + # Input. For now ignore content type and use only automatic probing. + ffmpeg_cmd += ["-i", in_filename] + # Select only the first subtitle track. This makes ffmpeg error out if + # it doesn't find a subtitle track. + ffmpeg_cmd += ["-map", "0:s"] + # Output. + ffmpeg_cmd += ["-f", "webvtt", out_filename] + Dir.chdir(File.dirname(out_filename)) do + exitstatus = exec_ret(*ffmpeg_cmd) + return true if exitstatus == 0 + end + + # FFmpeg creates the output file even if conversion fails. Clean it up. + FileUtils.rm_f(out_filename) + return false + end + +end diff --git a/record-and-playback/core/scripts/bigbluebutton.yml b/record-and-playback/core/scripts/bigbluebutton.yml index f121f70e8049156c08fe1f401c7d9d9f8191c6ca..c5dd4006a34f92f76d7feb35199b9b25652e959d 100755 --- a/record-and-playback/core/scripts/bigbluebutton.yml +++ b/record-and-playback/core/scripts/bigbluebutton.yml @@ -25,6 +25,7 @@ log_dir: /var/log/bigbluebutton events_dir: /var/bigbluebutton/events recording_dir: /var/bigbluebutton/recording published_dir: /var/bigbluebutton/published +captions_dir: /var/bigbluebutton/captions playback_host: 127.0.0.1 playback_protocol: http diff --git a/record-and-playback/core/scripts/rap-caption-inbox.rb b/record-and-playback/core/scripts/rap-caption-inbox.rb new file mode 100755 index 0000000000000000000000000000000000000000..58a7a7ba5137e43ef1d60c374feedb45a867ea40 --- /dev/null +++ b/record-and-playback/core/scripts/rap-caption-inbox.rb @@ -0,0 +1,166 @@ +#!/usr/bin/ruby +# frozen_string_literal: true + +# Copyright © 2019 BigBlueButton Inc. and by respective authors. +# +# This file is part of the BigBlueButton open source conferencing system. +# +# BigBlueButton 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 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 <https://www.gnu.org/licenses/>. + +require 'rubygems' +require 'bundler/setup' + +require File.expand_path('../lib/recordandplayback', __dir__) + +require 'journald/logger' +require 'locale' +require 'rb-inotify' +require 'yaml' + +# Read configuration and set up logger + +props = File.open(File.expand_path('bigbluebutton.yml', __dir__)) do |bbb_yml| + YAML.safe_load(bbb_yml) +end + +logger = Journald::Logger.new('bbb-rap-caption-inbox') +BigBlueButton.logger = logger + +captions_dir = props['captions_dir'] +unless captions_dir + logger.error('captions_dir was not defined in bigbluebutton.yml') + exit(1) +end +captions_inbox_dir = File.join(captions_dir, 'inbox') + +# Internal error classes + +# Base class for internal errors +class CaptionError < StandardError +end + +# Indicates that uploaded caption files are invalid (unrecoverable) +class InvalidCaptionError < CaptionError +end + +# Implementation + +caption_file_notify = proc do |json_filename| + # There's a possible race condition where we can be notified twice for a new + # file. That's fine, just do nothing the second time. + return unless File.exist?(json_filename) + + logger.info("Found new caption index file #{json_filename}") + + # TODO: Rather than do anything directly in this script, it should create a + # queue job (resque?) that does the actual work. + + captions_work_base = File.join(props['recording_dir'], 'caption', 'inbox') + new_caption_info = File.open(json_filename) { |file| JSON.parse(file.read) } + record_id = new_caption_info['record_id'] + logger.tag(record_id: record_id) do + begin + # Read the existing captions index file + # TODO: This is racy if multiple tools are editing the captions.json file + index_filename = File.join(captions_dir, record_id, 'captions.json') + captions_info = + begin + File.open(index_filename) { |file| JSON.parse(file.read) } + rescue StandardError + # No captions file or cannot be read, assume none present + [] + end + + temp_filename = new_caption_info['temp_filename'] + raise InvalidCaptionError, 'Temp filename is blank' if temp_filename.nil? || temp_filename.empty? + + src_filename = File.join(captions_inbox_dir, temp_filename) + + langtag = Locale::Tag::Rfc.parse(new_caption_info['lang']) + raise InvalidCaptionError, 'Language tag is not well-formed' unless langtag + + # Remove the info for an existing matching track, and add the new one + captions_info.delete_if do |caption_info| + caption_info['lang'] == new_caption_info['lang'] && + caption_info['kind'] == new_caption_info['kind'] + end + captions_info << { + 'kind' => new_caption_info['kind'], + 'label' => new_caption_info['label'], + 'lang' => langtag.to_s, + 'source' => 'upload', + } + + captions_work = File.join(captions_work_base, record_id) + FileUtils.mkdir_p(captions_work) + dest_filename = "#{new_caption_info['kind']}_#{new_caption_info['lang']}.vtt" + tmp_dest = File.join(captions_work, dest_filename) + final_dest_dir = File.join(captions_dir, record_id) + final_dest = File.join(final_dest_dir, dest_filename) + + # Convert the received caption file to WebVTT + ffmpeg_cmd = [ + 'ffmpeg', '-y', '-v', 'warning', '-nostats', '-nostdin', + '-i', src_filename, '-map', '0:s', + '-f', 'webvtt', tmp_dest, + ] + ret = BigBlueButton.exec_ret(*ffmpeg_cmd) + raise InvalidCaptionError, 'FFmpeg could not read input' unless ret.zero? + + FileUtils.mkdir_p(final_dest_dir) + FileUtils.mv(tmp_dest, final_dest) + + # Finally, save the updated index file that references the new caption + File.open(index_filename, 'w') do |file| + file.write(JSON.pretty_generate(captions_info)) + end + + Dir.glob(File.expand_path('captions/*', __dir__)) do |caption_script| + next unless File.file?(caption_script) && File.executable?(caption_script) + + logger.info("Running caption integration script #{caption_script}") + ret = BigBlueButton.exec_ret(caption_script, '--record-id', record_id) + logger.warn('Caption integration script failed') unless ret.zero? + end + + logger.info('Removing files from inbox directory') + FileUtils.rm_f(src_filename) if src_filename + FileUtils.rm_f(json_filename) + rescue InvalidCaptionError => e + logger.exception(e) + + logger.info('Deleting invalid files from inbox directory') + FileUtils.rm_f(src_filename) if src_filename + FileUtils.rm_f(json_filename) + ensure + FileUtils.rm_rf(File.join(captions_work_base, record_id)) + end + end +end + +logger.info("Setting up inotify watch on #{captions_inbox_dir}") +notifier = INotify::Notifier.new +notifier.watch(captions_inbox_dir, :moved_to, :create) do |event| + next unless event.name.end_with?('-track.json') + + caption_file_notify.call(event.absolute_name) +end + +logger.info('Checking for missed/skipped caption files') +Dir.glob(File.join(captions_inbox_dir, '*-track.json')).each do |filename| + caption_file_notify.call(filename) +end + +logger.info('Waiting for new caption files...') +notifier.run diff --git a/record-and-playback/core/scripts/rap-process-worker.rb b/record-and-playback/core/scripts/rap-process-worker.rb index 24fd8a7cfe672744daee368598ef6a7a360e9905..4f062b9f8d41e7eef1026bf2daebd24019d01c65 100755 --- a/record-and-playback/core/scripts/rap-process-worker.rb +++ b/record-and-playback/core/scripts/rap-process-worker.rb @@ -45,6 +45,12 @@ def process_archived_meetings(recording_dir) step_succeeded = true + # Generate captions + ret = BigBlueButton.exec_ret('ruby', 'utils/captions.rb', '-m', meeting_id) + if ret != 0 + BigBlueButton.logger.warn("Failed to generate caption files #{ret}") + end + # Iterate over the list of recording processing scripts to find available # types. For now, we look for the ".rb" extension - TODO other scripting # languages? diff --git a/record-and-playback/core/scripts/utils/captions.rb b/record-and-playback/core/scripts/utils/captions.rb new file mode 100644 index 0000000000000000000000000000000000000000..5df148f90abbcb0e0bfac28643585c5352eb0c7f --- /dev/null +++ b/record-and-playback/core/scripts/utils/captions.rb @@ -0,0 +1,103 @@ +# Set encoding to utf-8 +# encoding: UTF-8 + +# +# BigBlueButton open source conferencing system - http://www.bigbluebutton.org/ +# +# Copyright (c) 2019 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 <http://www.gnu.org/licenses/>. +# + +# For DEVELOPMENT +# Allows us to run the script manually +# require File.expand_path('../../../../core/lib/recordandplayback', __FILE__) + +# For PRODUCTION +require File.expand_path('../../../lib/recordandplayback', __FILE__) + +require 'rubygems' +require 'trollop' +require 'yaml' +require 'json' + +opts = Trollop::options do + opt :meeting_id, "Meeting id to archive", :type => String +end + +meeting_id = opts[:meeting_id] + +# This script lives in scripts/archive/steps while properties.yaml lives in scripts/ +props = YAML::load(File.open('../../core/scripts/bigbluebutton.yml')) + +recording_dir = props['recording_dir'] +raw_archive_dir = "#{recording_dir}/raw/#{meeting_id}" +BigBlueButton.logger.info("Setting process dir") +BigBlueButton.logger.info("setting captions dir") +captions_dir = props['captions_dir'] + +log_dir = props['log_dir'] + +target_dir = "#{recording_dir}/process/presentation/#{meeting_id}" + +# Generate captions.json for API +def create_api_captions_file(captions_meeting_dir) + BigBlueButton.logger.info("Generating closed captions for API") + + captions = JSON.load(File.new("#{captions_meeting_dir}/captions_playback.json")) + captions_json = [] + captions.each do |track| + caption = {} + caption[:kind] = :captions + caption[:label] = track['localeName'] + caption[:lang] = track['locale'] + caption[:source] = :live + captions_json << caption + end + + File.open("#{captions_meeting_dir}/captions.json", "w") do |f| + f.write(captions_json.to_json) + end +end + +if not FileTest.directory?(target_dir) + + captions_meeting_dir = "#{captions_dir}/#{meeting_id}" + + FileUtils.mkdir_p "#{log_dir}/presentation" + logger = Logger.new("#{log_dir}/presentation/process-#{meeting_id}.log", 'daily') + BigBlueButton.logger = logger + BigBlueButton.logger.info("Processing script captions.rb") + FileUtils.mkdir_p target_dir + + begin + BigBlueButton.logger.info("Generating closed captions") + FileUtils.mkdir_p captions_meeting_dir + ret = BigBlueButton.exec_ret('utils/gen_webvtt', '-i', raw_archive_dir, '-o', captions_meeting_dir) + if ret != 0 + raise "Generating closed caption files failed" + end + + FileUtils.cp("#{captions_meeting_dir}/captions.json", "#{captions_meeting_dir}/captions_playback.json") + create_api_captions_file(captions_meeting_dir) + FileUtils.rm "#{captions_meeting_dir}/captions_playback.json" + + rescue Exception => e + BigBlueButton.logger.error(e.message) + e.backtrace.each do |traceline| + BigBlueButton.logger.error(traceline) + end + exit 1 + end + +end diff --git a/record-and-playback/core/systemd/bbb-rap-caption-inbox.service b/record-and-playback/core/systemd/bbb-rap-caption-inbox.service new file mode 100644 index 0000000000000000000000000000000000000000..2796163c81de831c9ad72ae49caa54a55b526a7c --- /dev/null +++ b/record-and-playback/core/systemd/bbb-rap-caption-inbox.service @@ -0,0 +1,9 @@ +[Unit] +Description=BigBlueButton recording caption upload handler + +[Service] +Type=simple +ExecStart=/usr/local/bigbluebutton/core/scripts/rap-caption-inbox.rb +User=bigbluebutton +Slice=bbb_record_core.slice +Restart=on-failure diff --git a/record-and-playback/deploy.sh b/record-and-playback/deploy.sh index 1e43a5840aa2d3d55616f48200f852989a70e9a0..a29d8c9d1a76b0db2d06036758b7d8f0d0e006e5 100755 --- a/record-and-playback/deploy.sh +++ b/record-and-playback/deploy.sh @@ -40,21 +40,65 @@ function deploy_format() { deploy_format "presentation" -sudo mkdir -p /var/bigbluebutton/events/ -sudo mkdir -p /var/bigbluebutton/playback/ -sudo mkdir -p /var/bigbluebutton/recording/raw/ -sudo mkdir -p /var/bigbluebutton/recording/process/ -sudo mkdir -p /var/bigbluebutton/recording/publish/ -sudo mkdir -p /var/bigbluebutton/recording/status/ended/ -sudo mkdir -p /var/bigbluebutton/recording/status/recorded/ -sudo mkdir -p /var/bigbluebutton/recording/status/archived/ -sudo mkdir -p /var/bigbluebutton/recording/status/processed/ -sudo mkdir -p /var/bigbluebutton/recording/status/sanity/ +CAPTIONS_DIR=/var/bigbluebutton/captions/ +if [ ! -d "$CAPTIONS_DIR" ]; then + sudo mkdir -p $CAPTIONS_DIR +fi + +EVENTS_DIR=/var/bigbluebutton/events/ +if [ ! -d "$EVENTS_DIR" ]; then + sudo mkdir -p $EVENTS_DIR +fi + +PLAYBACK_DIR=/var/bigbluebutton/playback/ +if [ ! -d "$PLAYBACK_DIR" ]; then + sudo mkdir -p $PLAYBACK_DIR +fi + +REC_RAW_DIR=/var/bigbluebutton/recording/raw/ +if [ ! -d "$REC_RAW_DIR" ]; then + sudo mkdir -p $REC_RAW_DIR +fi + +REC_PROC_DIR=/var/bigbluebutton/recording/process/ +if [ ! -d "$REC_PROC_DIR" ]; then + sudo mkdir -p $REC_PROC_DIR +fi + +REC_PUB_DIR=/var/bigbluebutton/recording/publish/ +if [ ! -d "$REC_PUB_DIR" ]; then + sudo mkdir -p $REC_PUB_DIR +fi + +REC_STATUS_ENDED_DIR=/var/bigbluebutton/recording/status/ended/ +if [ ! -d "$REC_STATUS_ENDED_DIR" ]; then + sudo mkdir -p $REC_STATUS_ENDED_DIR +fi + +REC_STATUS_RECORDED_DIR=/var/bigbluebutton/recording/status/recorded/ +if [ ! -d "$REC_STATUS_RECORDED_DIR" ]; then + sudo mkdir -p $REC_STATUS_RECORDED_DIR +fi + +REC_STATUS_ARCHIVED_DIR=/var/bigbluebutton/recording/status/archived/ +if [ ! -d "$REC_STATUS_ARCHIVED_DIR" ]; then + sudo mkdir -p $REC_STATUS_ARCHIVED_DIR +fi + +REC_STATUS_PROCESSED_DIR=/var/bigbluebutton/recording/status/processed/ +if [ ! -d "$REC_STATUS_PROCESSED_DIR" ]; then + sudo mkdir -p $REC_STATUS_PROCESSED_DIR +fi + +REC_STATUS_SANITY_DIR=/var/bigbluebutton/recording/status/sanity/ +if [ ! -d "$REC_STATUS_SANITY_DIR" ]; then + sudo mkdir -p $REC_STATUS_SANITY_DIR +fi sudo mv /usr/local/bigbluebutton/core/scripts/*.nginx /etc/bigbluebutton/nginx/ sudo service nginx reload sudo chown -R bigbluebutton:bigbluebutton /var/bigbluebutton/ /var/log/bigbluebutton/ sudo chown -R red5:red5 /var/bigbluebutton/screenshare/ -cd /usr/local/bigbluebutton/core/ -sudo bundle install \ No newline at end of file +#cd /usr/local/bigbluebutton/core/ +#sudo bundle install \ No newline at end of file diff --git a/record-and-playback/presentation/scripts/caption/presentation b/record-and-playback/presentation/scripts/caption/presentation new file mode 100755 index 0000000000000000000000000000000000000000..8f991b760ee26fe18084128f97b6838dba44381d --- /dev/null +++ b/record-and-playback/presentation/scripts/caption/presentation @@ -0,0 +1,140 @@ +#!/usr/bin/ruby +# frozen_string_literal: true + +# Copyright © 2019 BigBlueButton Inc. and by respective authors. +# +# This file is part of the BigBlueButton open source conferencing system. +# +# BigBlueButton 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 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 <https://www.gnu.org/licenses/>. + +require 'rubygems' +require 'bundler/setup' + +require 'optparse' +require 'journald/logger' + +# Parse command-line options + +recording_id = nil +OptionParser.new do |opts| + opts.on('--record-id=REC', 'Recording ID to copy captions into') do |rec| + recording_id = rec + end +end.parse! + +unless recording_id + warn('--record-id was not specified') + exit(1) +end + +# Read configuration and set up logger + +props = File.open(File.expand_path('../bigbluebutton.yml', __dir__)) do |bbb_yml| + YAML.safe_load(bbb_yml) +end +presentation_props = File.open(File.expand_path('../presentation.yml', __dir__)) do |pres_yml| + YAML.safe_load(pres_yml) +end + +logger = Journald::Logger.new('caption/presentation') +logger.tag(record_id: recording_id) + +captions_dir = props['captions_dir'] +publish_dir = presentation_props['publish_dir'] + +unless captions_dir + logger.error('captions_dir was not defined in bigbluebutton.yml') + exit(1) +end + +unless File.directory?(File.join(publish_dir, recording_id)) + logger.error('Published recording directory for this recording does not exist') + exit(0) +end + +# Load the captions index file and recording playback caption list + +begin + logger.info('Loading recording playback captions list') + playback_captions_path = File.join(publish_dir, recording_id, 'captions.json') + playback_captions = File.open(playback_captions_path) do |json| + JSON.parse(json.read) + end +rescue Errno::ENOENT + logger.info("Playback doesn't have a captions.json - old playback format version?") + logger.info('Triggering recording reprocessing.') + archive_done_file = '' + File.open(archive_done_file, 'w') do |archive_done| + archive_done.write('Reprocessing for captions') + end + # Treat this as success, the captions will be integrated during reprocessing + exit(0) +end + +# Captions index file +begin + logger.info('Loading captions index file') + captions_path = File.join(captions_dir, recording_id, 'captions.json') + captions = File.open(captions_path) do |json| + JSON.parse(json.read) + end +rescue Errno::ENOENT + captions = [] +end + +# Copy the new caption files over the existing ones in the playback + +captions.each do |caption| + kind = caption['kind'] + lang = caption['lang'] + label = caption['label'] + + # TODO: the presentation playback format needs to be updated to support + # kind != captions + next unless kind == 'captions' + + logger.info("Copying file for kind=#{kind} lang=#{lang}") + dest_kind = case kind + when 'captions' then 'caption' + else kind + end + + FileUtils.cp( + File.join(captions_dir, recording_id, "#{kind}_#{lang}.vtt"), + File.join(publish_dir, recording_id, "#{dest_kind}_#{lang}.vtt") + ) + + # Remove any existing matching tracks from the playback captions list... + playback_captions.reject! do |playback_caption| + playback_caption['locale'] == lang && ( + playback_caption['kind'] == kind || + (playback_caption['kind'].nil? && kind == 'captions') + ) + end + # ...and add the new one. + playback_captions << { + 'kind' => kind, + 'locale' => lang, + 'localeName' => label, + } +end + +# Save the updated playback captions list + +logger.info('Saving updated playback captions list') +# Sort the list by label so the selection menu looks nice +playback_captions.sort { |a, b| a['localeName'] <=> b['localeName'] } +File.open(playback_captions_path, 'w') do |json| + json.write(JSON.pretty_generate(playback_captions)) +end diff --git a/record-and-playback/presentation/scripts/process/presentation.rb b/record-and-playback/presentation/scripts/process/presentation.rb index d7d594eb839720174d67b132c752fec34a297419..4a8e140547fbdb8666a9146210004f5403eabc6b 100755 --- a/record-and-playback/presentation/scripts/process/presentation.rb +++ b/record-and-playback/presentation/scripts/process/presentation.rb @@ -47,6 +47,10 @@ recording_dir = props['recording_dir'] raw_archive_dir = "#{recording_dir}/raw/#{meeting_id}" log_dir = props['log_dir'] +BigBlueButton.logger.info("setting captions dir") +captions_dir = props['captions_dir'] +captions_meeting_dir = "#{captions_dir}/#{meeting_id}" + target_dir = "#{recording_dir}/process/presentation/#{meeting_id}" if not FileTest.directory?(target_dir) FileUtils.mkdir_p "#{log_dir}/presentation" @@ -198,11 +202,22 @@ if not FileTest.directory?(target_dir) FileUtils.cp_r("#{pres_dir}/thumbnails", "#{target_pres_dir}/thumbnails") end - BigBlueButton.logger.info("Generating closed captions") - ret = BigBlueButton.exec_ret('utils/gen_webvtt', '-i', raw_archive_dir, '-o', target_dir) - if ret != 0 - raise "Generating closed caption files failed" + BigBlueButton.logger.info("Copying closed captions") + + captions = JSON.load(File.new("#{captions_meeting_dir}/captions.json")) + captions_json = [] + captions.each do |track| + caption = {} + caption[:localeName] = track['label'] + caption[:locale] = track['lang'] + captions_json << caption + FileUtils.cp("#{captions_meeting_dir}/caption_" + track['lang'] + ".vtt", target_dir) end + + File.open("#{target_dir}/captions.json", "w") do |f| + f.write(captions_json.to_json) + end + captions = JSON.load(File.new("#{target_dir}/captions.json", 'r')) if not presentation_text.empty? diff --git a/record-and-playback/presentation/scripts/publish/presentation.rb b/record-and-playback/presentation/scripts/publish/presentation.rb index f347eb339429b1de4729c164194bb95e6064810b..072b2f3d69362d647fe5a910502ba4eb5bdff006 100755 --- a/record-and-playback/presentation/scripts/publish/presentation.rb +++ b/record-and-playback/presentation/scripts/publish/presentation.rb @@ -1185,6 +1185,9 @@ begin $process_dir = "#{recording_dir}/process/presentation/#{$meeting_id}" BigBlueButton.logger.info("setting publish dir") publish_dir = $presentation_props['publish_dir'] + BigBlueButton.logger.info("setting captions dir") + captions_dir = bbb_props['captions_dir'] + captions_meeting_dir = "#{captions_dir}/#{$meeting_id}" BigBlueButton.logger.info("setting playback url info") playback_protocol = bbb_props['playback_protocol'] playback_host = bbb_props['playback_host'] @@ -1225,6 +1228,21 @@ begin BigBlueButton.logger.info("Copied audio.ogg file") end + BigBlueButton.logger.info("Copying caption files to #{target_dir}") + captions = JSON.load(File.new("#{captions_meeting_dir}/captions.json")) + captions_json = [] + captions.each do |track| + caption = {} + caption[:localeName] = track['label'] + caption[:locale] = track['lang'] + captions_json << caption + FileUtils.cp("#{captions_meeting_dir}/caption_" + track['lang'] + ".vtt", target_dir) + end + + File.open("#{target_dir}/captions.json", "w") do |f| + f.write(captions_json.to_json) + end + if File.exist?("#{$process_dir}/captions.json") BigBlueButton.logger.info("Copying caption files") FileUtils.cp("#{$process_dir}/captions.json", package_dir)