diff --git a/.gitignore b/.gitignore index d2d29bd333c7..af5a7cbde34a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,6 @@ cache/* artifacts/* bbb-presentation-video.zip bbb-presentation-video +bbb-core-api/gen bbb-graphql-actions-adapter-server/ bigbluebutton-html5/public/locales/index.json diff --git a/akka-bbb-apps/build.sbt b/akka-bbb-apps/build.sbt index e7c4b6f2326b..7df1a895285f 100755 --- a/akka-bbb-apps/build.sbt +++ b/akka-bbb-apps/build.sbt @@ -6,6 +6,7 @@ import com.typesafe.sbt.SbtNativePackager.autoImport._ enablePlugins(JavaServerAppPackaging) enablePlugins(UniversalPlugin) enablePlugins(DebianPlugin) +enablePlugins(PekkoGrpcPlugin) version := "0.0.4" @@ -44,6 +45,8 @@ testOptions in Test += Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/sc Seq(Revolver.settings: _*) lazy val bbbAppsAkka = (project in file(".")).settings(name := "bbb-apps-akka", libraryDependencies ++= Dependencies.runtime).settings(compileSettings) +PB.protoSources in Compile += baseDirectory.value / "../bbb-common-grpc/src/main/proto" + // See https://github.com/scala-ide/scalariform // Config file is in ./.scalariform.conf scalariformAutoformat := true diff --git a/akka-bbb-apps/project/plugins.sbt b/akka-bbb-apps/project/plugins.sbt index db53b2e38e60..310877aa9f8c 100755 --- a/akka-bbb-apps/project/plugins.sbt +++ b/akka-bbb-apps/project/plugins.sbt @@ -9,3 +9,7 @@ addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.15") addSbtPlugin("net.vonbuchholtz" % "sbt-dependency-check" % "0.2.9") addSbtPlugin("org.scalastyle" %% "scalastyle-sbt-plugin" % "1.0.0") + +addSbtPlugin("org.apache.pekko" % "pekko-grpc-sbt-plugin" % "1.0.1") + +ThisBuild / libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always \ No newline at end of file diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/Boot.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/Boot.scala index 0a85bcd0bf3a..0d7eebc27fe1 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/Boot.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/Boot.scala @@ -1,8 +1,9 @@ package org.bigbluebutton -import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.actor.{ActorRef, ActorSystem} import org.apache.pekko.event.Logging import org.apache.pekko.http.scaladsl.Http +import org.apache.pekko.http.scaladsl.model.{HttpRequest, HttpResponse} import org.apache.pekko.stream.ActorMaterializer import org.bigbluebutton.common2.redis.{MessageSender, RedisConfig, RedisPublisher} import org.bigbluebutton.core._ @@ -12,7 +13,10 @@ import org.bigbluebutton.core2.AnalyticsActor import org.bigbluebutton.core2.FromAkkaAppsMsgSenderActor import org.bigbluebutton.endpoint.redis.{AppsRedisSubscriberActor, ExportAnnotationsActor, GraphqlConnectionsActor, LearningDashboardActor, RedisRecorderActor} import org.bigbluebutton.common2.bus.IncomingJsonMessageBus -import org.bigbluebutton.service.{HealthzService, MeetingInfoActor, MeetingInfoService} +import org.bigbluebutton.service.{BbbCoreServiceImpl, HealthzService, MeetingInfoActor, MeetingInfoService} +import org.bigbluebutton.protos._ + +import scala.concurrent.{ExecutionContext, Future} object Boot extends App with SystemConfiguration { @@ -115,4 +119,19 @@ object Boot extends App with SystemConfiguration { ) val bindingFuture = Http().bindAndHandle(apiService.routes, httpHost, httpPort) + + new GrpcServer(system, bbbActor, grpcHost, grpcPort).run() } + +class GrpcServer(system: ActorSystem, bbbActor: ActorRef, host: String, port: Int) { + def run(): Future[Http.ServerBinding] = { + implicit val sys: ActorSystem = system + implicit val ec: ExecutionContext = sys.dispatcher + implicit val bbb: ActorRef = bbbActor + + val service: HttpRequest => Future[HttpResponse] = BbbCoreServiceHandler(new BbbCoreServiceImpl()) + val binding = Http().newServerAt(host, port).bind(service) + binding.foreach { binding => println(s"gRPC server bound to ${binding.localAddress}")} + binding + } +} \ No newline at end of file diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/SystemConfiguration.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/SystemConfiguration.scala index 9a32f958da9a..14463326a691 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/SystemConfiguration.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/SystemConfiguration.scala @@ -92,4 +92,7 @@ trait SystemConfiguration { val httpHost = config.getString("http.interface") // Grab the "port" parameter from the http config val httpPort = config.getInt("http.port") + + val grpcHost = config.getString("grpc.interface") + val grpcPort = config.getInt("grpc.port") } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala index 6cc031931020..f7631de770d2 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/BigBlueButtonActor.scala @@ -70,6 +70,9 @@ class BigBlueButtonActor( def receive = { // Internal messages case msg: DestroyMeetingInternalMsg => handleDestroyMeeting(msg) + case msg: IsMeetingRunning => handleIsMeetingRunning(sender(), msg) + case msg: GetMeeting => handleGetMeeting(sender(), msg) + case msg: GetMeetings => handleGetMeetings(sender(), msg) // 2x messages case msg: BbbCommonEnvCoreMsg => handleBbbCommonEnvCoreMsg(msg) @@ -205,4 +208,29 @@ class BigBlueButtonActor( } } + private def handleIsMeetingRunning(sender: ActorRef, msg: IsMeetingRunning): Unit = { + RunningMeetings.findWithId(meetings, msg.meetingId) match { + case Some(_) => sender ! true + case None => + RunningMeetings.findWithExtId(meetings, msg.meetingId) match { + case Some(_) => sender ! true + case None => sender ! false + } + } + } + + private def handleGetMeeting(sender: ActorRef, msg: GetMeeting): Unit = { + RunningMeetings.findWithId(meetings, msg.meetingId) match { + case Some(m) => sender ! Some(m) + case None => + RunningMeetings.findWithExtId(meetings, msg.meetingId) match { + case Some(m) => sender ! Some(m) + case None => sender ! None + } + } + } + + private def handleGetMeetings(sender: ActorRef, msg: GetMeetings): Unit = { + sender ! RunningMeetings.meetingsMap(meetings) + } } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala index f058250478f1..d6dc37a82849 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/api/InMessages.scala @@ -131,3 +131,16 @@ case class UserClosedAllGraphqlConnectionsInternalMsg(userId: String) extends In * @param userId */ case class UserEstablishedGraphqlConnectionInternalMsg(userId: String) extends InMessage + +// DeskShare +case class DeskShareStartedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage +case class DeskShareStoppedRequest(conferenceName: String, callerId: String, callerIdName: String) extends InMessage +case class DeskShareRTMPBroadcastStartedRequest(conferenceName: String, streamname: String, videoWidth: Int, videoHeight: Int, timestamp: String) extends InMessage +case class DeskShareRTMPBroadcastStoppedRequest(conferenceName: String, streamname: String, videoWidth: Int, videoHeight: Int, timestamp: String) extends InMessage +case class DeskShareGetDeskShareInfoRequest(conferenceName: String, requesterID: String, replyTo: String) extends InMessage + +// gRPC messages +case class IsMeetingRunning(meetingId: String) extends InMessage +case class GetMeeting(meetingId: String) extends InMessage +case class GetMeetings() extends InMessage +case class GetMeetingInfo() extends InMessage \ No newline at end of file diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserJoinMeetingReqMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserJoinMeetingReqMsgHdlr.scala index 80278fa920d7..f165c9b9f837 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserJoinMeetingReqMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/users/UserJoinMeetingReqMsgHdlr.scala @@ -46,6 +46,7 @@ trait UserJoinMeetingReqMsgHdlr extends HandlerHelpers { } private def handleSuccessfulUserJoin(msg: UserJoinMeetingReqMsg, regUser: RegisteredUser) = { + if (Users2x.numUsers(liveMeeting.users2x) == 0) meetingEndTime = 0 val newState = userJoinMeeting(outGW, msg.body.authToken, msg.body.clientType, liveMeeting, state) updateParentMeetingWithNewListOfUsers() notifyPreviousUsersWithSameExtId(regUser) diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala index 5bc7ed781860..174bc31bea76 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/apps/voice/UserLeftVoiceConfEvtMsgHdlr.scala @@ -54,5 +54,7 @@ trait UserLeftVoiceConfEvtMsgHdlr { if (liveMeeting.props.meetingProp.isBreakout) { BreakoutHdlrHelpers.updateParentMeetingWithUsers(liveMeeting, eventBus) } + + if (Users2x.numUsers(liveMeeting.users2x) == 0) meetingEndTime = System.currentTimeMillis() } } diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala index 35bc14170cbf..f9489245d900 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core/running/MeetingActor.scala @@ -98,7 +98,9 @@ class MeetingActor( with SyncGetMeetingInfoRespMsgHdlr with ClientToServerLatencyTracerMsgHdlr with ValidateConnAuthTokenSysMsgHdlr - with UserActivitySignCmdMsgHdlr { + with UserActivitySignCmdMsgHdlr + + with GetMeetingInfoMsgHdlr { object CheckVoiceRecordingInternalMsg object SyncVoiceUserStatusInternalMsg @@ -176,6 +178,8 @@ class MeetingActor( // Send new 2x message val msgEvent = MsgBuilder.buildMeetingCreatedEvtMsg(liveMeeting.props.meetingProp.intId, liveMeeting.props) + val meetingStartTme = System.currentTimeMillis() + var meetingEndTime = 0L outGW.send(msgEvent) //Insert meeting into the database @@ -255,17 +259,19 @@ class MeetingActor( //============================= // 2x messages - case msg: BbbCommonEnvCoreMsg => handleBbbCommonEnvCoreMsg(msg) + case msg: BbbCommonEnvCoreMsg => handleBbbCommonEnvCoreMsg(msg) // Handling RegisterUserReqMsg as it is forwarded from BBBActor and // its type is not BbbCommonEnvCoreMsg - case m: RegisterUserReqMsg => usersApp.handleRegisterUserReqMsg(m) - case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m) - case m: GetRunningMeetingStateReqMsg => handleGetRunningMeetingStateReqMsg(m) - case m: ValidateConnAuthTokenSysMsg => handleValidateConnAuthTokenSysMsg(m) + case m: RegisterUserReqMsg => usersApp.handleRegisterUserReqMsg(m) + case m: GetAllMeetingsReqMsg => handleGetAllMeetingsReqMsg(m) + case m: GetRunningMeetingStateReqMsg => handleGetRunningMeetingStateReqMsg(m) + case m: ValidateConnAuthTokenSysMsg => handleValidateConnAuthTokenSysMsg(m) // Meeting - case m: DestroyMeetingSysCmdMsg => handleDestroyMeetingSysCmdMsg(m) + case m: DestroyMeetingSysCmdMsg => + meetingEndTime = System.currentTimeMillis() + handleDestroyMeetingSysCmdMsg(m) //====================================== @@ -280,6 +286,9 @@ class MeetingActor( state = handleUserEstablishedGraphqlConnectionInternalMsg(msg, state) updateModeratorsPresence() + // Internal gRPC messages + case msg: GetMeetingInfo => sender() ! handleGetMeetingInfo() + case msg: ExtendMeetingDuration => handleExtendMeetingDuration(msg) case msg: SendTimeRemainingAuditInternalMsg => if (!liveMeeting.props.meetingProp.isBreakout) { @@ -398,7 +407,9 @@ class MeetingActor( private def handleMessageThatAffectsInactivity(msg: BbbCommonEnvCoreMsg): Unit = { msg.core match { - case m: EndMeetingSysCmdMsg => handleEndMeeting(m, state) + case m: EndMeetingSysCmdMsg => + meetingEndTime = System.currentTimeMillis() + handleEndMeeting(m, state) // Users case m: ValidateAuthTokenReqMsg => state = usersApp.handleValidateAuthTokenReqMsg(m, state) @@ -973,6 +984,7 @@ class MeetingActor( Users2x.numUsers(liveMeeting.users2x) == 0 && !state.expiryTracker.lastUserLeftOnInMs.isDefined) { log.info("Setting meeting no more users. meetingId=" + props.meetingProp.intId) + meetingEndTime = System.currentTimeMillis() val tracker = state.expiryTracker.setLastUserLeftOn(TimeUtil.timeNowInMs()) state.update(tracker) } else { diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/RunningMeetings.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/RunningMeetings.scala index d413488208a3..9b416974ac6e 100755 --- a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/RunningMeetings.scala +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/RunningMeetings.scala @@ -3,11 +3,17 @@ package org.bigbluebutton.core2 import org.bigbluebutton.core.db.MeetingDAO import org.bigbluebutton.core.running.RunningMeeting +import scala.collection.immutable.VectorMap + object RunningMeetings { def findWithId(meetings: RunningMeetings, id: String): Option[RunningMeeting] = { meetings.toVector.find(m => m.props.meetingProp.intId == id) } + def findWithExtId(meetings: RunningMeetings, id: String): Option[RunningMeeting] = { + meetings.toVector.find(m => m.props.meetingProp.extId == id) + } + def add(meetings: RunningMeetings, meeting: RunningMeeting): RunningMeeting = { meetings.save(meeting) meeting @@ -25,6 +31,10 @@ object RunningMeetings { meetings.toVector } + def meetingsMap(meetings: RunningMeetings): VectorMap[String, RunningMeeting] = { + meetings.getMeetings + } + def findMeetingWithVoiceConfId(meetings: RunningMeetings, voiceConfId: String): Option[RunningMeeting] = { meetings.toVector.find(m => { m.props.voiceProp.voiceConf == voiceConfId }) } @@ -32,10 +42,12 @@ object RunningMeetings { } class RunningMeetings { - private var meetings = new collection.immutable.HashMap[String, RunningMeeting] + private var meetings: VectorMap[String, RunningMeeting] = VectorMap.empty private def toVector: Vector[RunningMeeting] = meetings.values.toVector + private def getMeetings: VectorMap[String, RunningMeeting] = meetings + private def save(meeting: RunningMeeting): RunningMeeting = { meetings += meeting.props.meetingProp.intId -> meeting meeting diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/message/handlers/GetMeetingInfoMsgHdlr.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/message/handlers/GetMeetingInfoMsgHdlr.scala new file mode 100644 index 000000000000..22cf128da782 --- /dev/null +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/core2/message/handlers/GetMeetingInfoMsgHdlr.scala @@ -0,0 +1,76 @@ +package org.bigbluebutton.core2.message.handlers + +import org.bigbluebutton.core.models.{ RegisteredUsers, Users2x, VoiceUsers } +import org.bigbluebutton.core.models.VoiceUsers.findAllListenOnlyVoiceUsers +import org.bigbluebutton.core.models.Webcams.findAll +import org.bigbluebutton.core.running.MeetingActor +import org.bigbluebutton.core2.MeetingStatus2x +import org.bigbluebutton.core2.MeetingStatus2x.hasAuthedUserJoined +import org.bigbluebutton.protos.{ BreakoutInfo, DurationInfo, MeetingInfo, ParticipantInfo, User } + +trait GetMeetingInfoMsgHdlr { + this: MeetingActor => + + def handleGetMeetingInfo(): MeetingInfo = { + val users = for { + u <- Users2x.findAll(liveMeeting.users2x) + } yield { + User( + userId = u.intId, + fullName = u.name, + role = u.role, + isPresenter = u.presenter, + isListeningOnly = findAllListenOnlyVoiceUsers(liveMeeting.voiceUsers).exists(v => v.intId == u.intId), + hasJoinedVoice = VoiceUsers.findAllNonListenOnlyVoiceUsers(liveMeeting.voiceUsers).exists(v => v.intId == u.intId), + hasVideo = findAll(liveMeeting.webcams).exists(w => w.userId == u.intId), + clientType = u.clientType, + customData = RegisteredUsers.findWithUserId(u.intId, liveMeeting.registeredUsers).get.customParameters + ) + } + + val durationInfo = DurationInfo( + createTime = liveMeeting.props.durationProps.createdTime, + createdOn = liveMeeting.props.durationProps.createdDate, + duration = liveMeeting.props.durationProps.duration, + startTime = meetingStartTme, + endTime = meetingEndTime, + isRunning = MeetingStatus2x.hasMeetingEnded(liveMeeting.status), + hasBeenForciblyEnded = false + ) + + val lc = findAllListenOnlyVoiceUsers(liveMeeting.voiceUsers).length + val participantInfo = ParticipantInfo( + hasUserJoined = hasAuthedUserJoined(liveMeeting.status), + participantCount = Users2x.findAll(liveMeeting.users2x).length, + listenerCount = lc, + voiceParticipantCount = VoiceUsers.findAll(liveMeeting.voiceUsers).length - lc, + videoCount = findAll(liveMeeting.webcams).length, + maxUsers = liveMeeting.props.usersProp.maxUsers, + moderatorCount = Users2x.findAll(liveMeeting.users2x).count(u => u.role.equalsIgnoreCase("moderator")) + ) + + val breakoutInfo = BreakoutInfo( + isBreakout = liveMeeting.props.meetingProp.isBreakout, + parentMeetingId = liveMeeting.props.breakoutProps.parentId, + sequence = liveMeeting.props.breakoutProps.sequence, + freeJoin = liveMeeting.props.breakoutProps.freeJoin + ) + + MeetingInfo( + meetingName = liveMeeting.props.meetingProp.name, + meetingExtId = liveMeeting.props.meetingProp.extId, + meetingIntId = liveMeeting.props.meetingProp.intId, + voiceBridge = liveMeeting.props.voiceProp.voiceConf, + dialNumber = liveMeeting.props.voiceProp.dialNumber, + attendeePw = liveMeeting.props.password.viewerPass, + moderatorPw = liveMeeting.props.password.moderatorPass, + recording = liveMeeting.props.recordProp.record, + users = users, + metadata = liveMeeting.props.metadataProp.metadata, + breakoutRooms = if (state.breakout.isDefined) state.breakout.get.getRooms().map(_.name).toList else List(), + durationInfo = Some(durationInfo), + participantInfo = Some(participantInfo), + breakoutInfo = Some(breakoutInfo) + ) + } +} diff --git a/akka-bbb-apps/src/main/scala/org/bigbluebutton/service/BbbCoreServiceImpl.scala b/akka-bbb-apps/src/main/scala/org/bigbluebutton/service/BbbCoreServiceImpl.scala new file mode 100644 index 000000000000..57e2f7a2461d --- /dev/null +++ b/akka-bbb-apps/src/main/scala/org/bigbluebutton/service/BbbCoreServiceImpl.scala @@ -0,0 +1,89 @@ +package org.bigbluebutton.service + +import com.google.rpc.Code +import org.apache.pekko.NotUsed +import org.apache.pekko.actor.ActorRef +import org.apache.pekko.grpc.GrpcServiceException +import org.apache.pekko.pattern.ask +import org.apache.pekko.stream.Materializer +import org.apache.pekko.stream.scaladsl.Source +import org.apache.pekko.util.Timeout +import org.bigbluebutton.core.api.{ GetMeeting, GetMeetingInfo, GetMeetings, IsMeetingRunning } +import org.bigbluebutton.core.running.RunningMeeting +import org.bigbluebutton.protos._ + +import scala.collection.immutable.VectorMap +import scala.concurrent.Future +import scala.concurrent.duration.DurationInt + +class BbbCoreServiceImpl(implicit materializer: Materializer, bbbActor: ActorRef) extends BbbCoreService { + + import materializer.executionContext + + override def isMeetingRunning(in: MeetingRunningRequest): Future[MeetingRunningResponse] = { + implicit val timeout: Timeout = 3.seconds + + // Ask the BigBlueButton actor if a meeting with the given ID is currently running. + (bbbActor ? IsMeetingRunning(in.meetingId)).mapTo[Boolean].map(msg => MeetingRunningResponse(msg)) + } + + override def getMeetingInfo(in: MeetingInfoRequest): Future[MeetingInfoResponse] = { + implicit val timeout: Timeout = 3.seconds + + // Ask the BigBlueButton actor to try to retrieve a RunningMeeting with the given ID. + (bbbActor ? GetMeeting(in.meetingId)).mapTo[Option[RunningMeeting]].flatMap { + // If there is a meeting running with the given ID then ask its MeetingActor for the meeting's information. + // Map the MeetingInfo to a MeetingInfoResponse. + case Some(runningMeeting) => (runningMeeting.actorRef ? GetMeetingInfo()).mapTo[MeetingInfo].map(msg => MeetingInfoResponse(Some(msg))) + + // If no meeting with the provided ID is running then return an error + case None => Future.failed(GrpcServiceException(Code.NOT_FOUND, "notFound", Seq(new ErrorResponse("notFound", "A meeting with that ID does not exist")))) + } + } + + override def listMeetings(in: ListMeetingsRequest): Future[ListMeetingsResponse] = ??? + + override def getMeetingsStream(in: GetMeetingsStreamRequest): Source[MeetingInfoResponse, NotUsed] = { + implicit val timeout: Timeout = 3.seconds + + // Ask the BigBlueButton actor for the collection of RunningMeetings. + val runningMeetingsFuture: Future[VectorMap[String, RunningMeeting]] = (bbbActor ? GetMeetings()).mapTo[VectorMap[String, RunningMeeting]] + + // Create a source using the returned collection of RunningMeetings. + Source.future(runningMeetingsFuture).flatMapConcat { runningMeetings: VectorMap[String, RunningMeeting] => + // Consumers of this API can pass an optional meetingId argument indicating that the stream should begin from the corresponding RunningMeeting. + // Check if this argument has been provided. If not then use the entire collection of RunningMeetings. + val meetingsToReturn = if (Option(in.meetingId).forall(_.isBlank)) runningMeetings else { + // A meetingId argument has been given. Lookup the index of the corresponding RunningMeeting. + val startIndex = runningMeetings.keys.indexOf(in.meetingId) + startIndex match { + // No RunningMeeting exists with the provided meetingId so return an empty map. + case -1 => VectorMap.empty + + // A RunningMeeting exists with the given meetingId. + // Slice the RunningMeetings starting from that RunningMeeting's index to the end of the original RunningMeetings collection. + case index => runningMeetings.slice(index, runningMeetings.size) + } + } + + // If there are no RunningMeetings, either because none are running or an invalid meetingId was provided, then return an error. + if (meetingsToReturn.isEmpty) Source.failed(GrpcServiceException(Code.NOT_FOUND, "notFound", Seq(new ErrorResponse("notFound", "No meetings were found")))) + else { + // Create a source using the final collection of RunningMeetings that should be returned. + // Attempt to map every element of the stream, i.e. each RunningMeeting, to a MeetingInfoResponse. + // Each mapping is done asynchronously but the order of the emitted elements is maintained based on the order of the collection of RunningMeetings. + Source(meetingsToReturn.toList).mapAsync(parallelism = 4) { case (_, runningMeeting) => + // Ask the RunningMeeting's MeetingActor for the meeting's information. + // Map the MeetingInfo to a MeetingInfoResponse. + (runningMeeting.actorRef ? GetMeetingInfo()).mapTo[MeetingInfo].map(meetingInfo => MeetingInfoResponse(Option(meetingInfo))).recover { + // If an error occurs during the mapping of one of the RunningMeetings then recover the stream with an empty MeetingInfoResponse. + case ex: Throwable => MeetingInfoResponse(None) + } + } + } + }.recoverWith { + // If an error occurs with the stream itself then recover by emitting a single empty MeetingInfoResponse. + case ex: Throwable => Source.single(MeetingInfoResponse(None)) + } + } +} diff --git a/akka-bbb-apps/src/universal/conf/application.conf b/akka-bbb-apps/src/universal/conf/application.conf index ec5046bbb74f..80e9c0c2580c 100755 --- a/akka-bbb-apps/src/universal/conf/application.conf +++ b/akka-bbb-apps/src/universal/conf/application.conf @@ -25,6 +25,14 @@ pekko { # Set to 1 for as fair as possible. throughput = 512 } + + http { + server { + preview { + enable-http2 = "on" + } + } + } } client { @@ -82,6 +90,11 @@ http { port = ${?PORT} } +grpc { + interface = "127.0.0.1" + port = 9000 +} + services { telizeHost = "www.telize.com" telizePort = 80 diff --git a/bbb-common-grpc/src/main/proto/bbb-core/bbb-core.proto b/bbb-common-grpc/src/main/proto/bbb-core/bbb-core.proto new file mode 100644 index 000000000000..89fe9442fda7 --- /dev/null +++ b/bbb-common-grpc/src/main/proto/bbb-core/bbb-core.proto @@ -0,0 +1,61 @@ +syntax = "proto3"; + +import "common/meeting.proto"; + +package org.bigbluebutton.protos; + +message MeetingRunningRequest { + // The ID, either internal or external, of the meeting to be found. + string meeting_id = 1; +} + +message MeetingRunningResponse { + // A boolean indicating whether the meeting is running or not. + // Value will be false if the meeting does not exist. + bool is_running = 1; +} + +message MeetingInfoRequest { + // The ID, either internal or external, of the meeting to be found. + string meeting_id = 1; +} + +message MeetingInfoResponse { + // Information describing the current state of the meeting. + MeetingInfo meeting_info = 1; +} + +message ListMeetingsRequest { + // The maximum number of meetings to return. The service may return fewer than this value. + // If unspecified, at most 50 meetings will be returned. + // The maximum value is 100; values above 100 will be coerced to 100. + int32 page_size = 1; + + // Token received from the previous `ListMeetings` call. + // Provide this to retrieve the subsequent page. + string page_token = 2; + + // Number of meetings to be skipped. + int32 skip = 3; +} + +message ListMeetingsResponse { + repeated MeetingInfo meetings = 1; + + // A token that can be sent as `page_token` to retrieve the next page. + // If this field is empty, there are no subsequent pages + string next_page_token = 2; +} + +message GetMeetingsStreamRequest { + // The ID, either internal or external, of the meeting to start the stream from. + // If unspecified, the stream will start from the first meeting. + string meeting_id = 1; +} + +service BbbCoreService { + rpc isMeetingRunning(MeetingRunningRequest) returns (MeetingRunningResponse); + rpc getMeetingInfo(MeetingInfoRequest) returns (MeetingInfoResponse); + rpc listMeetings(ListMeetingsRequest) returns (ListMeetingsResponse); + rpc getMeetingsStream(GetMeetingsStreamRequest) returns (stream MeetingInfoResponse); +} \ No newline at end of file diff --git a/bbb-common-grpc/src/main/proto/common/error.proto b/bbb-common-grpc/src/main/proto/common/error.proto new file mode 100644 index 000000000000..1ce15ce7bb28 --- /dev/null +++ b/bbb-common-grpc/src/main/proto/common/error.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +package org.bigbluebutton.protos; + +message ErrorResponse { + string key = 1; + string message = 2; +} \ No newline at end of file diff --git a/bbb-common-grpc/src/main/proto/common/meeting.proto b/bbb-common-grpc/src/main/proto/common/meeting.proto new file mode 100644 index 000000000000..9fd912d268b6 --- /dev/null +++ b/bbb-common-grpc/src/main/proto/common/meeting.proto @@ -0,0 +1,59 @@ +syntax = "proto3"; + +package org.bigbluebutton.protos; + +message User { + string user_id = 1; + string full_name = 2; + string role = 3; + bool is_presenter = 4; + bool is_listening_only = 5; + bool has_joined_voice = 6; + bool has_video = 7; + string clientType = 8; + map custom_data = 9; +} + +message DurationInfo { + int64 create_time = 1; + string created_on = 2; + int32 duration = 3; + int64 start_time = 4; + int64 end_time = 5; + bool is_running = 6; + bool has_been_forcibly_ended = 7; +} + +message ParticipantInfo { + bool has_user_joined = 1; + int32 participant_count = 2; + int32 listener_count = 3; + int32 voice_participant_count = 4; + int32 video_count = 5; + int32 max_users = 6; + int32 moderator_count = 7; +} + +message BreakoutInfo { + bool is_breakout = 1; + string parent_meeting_id = 2; + int32 sequence = 3; + bool free_join = 4; +} + +message MeetingInfo { + string meeting_name = 1; + string meeting_ext_id = 2; + string meeting_int_id = 3; + string voice_bridge = 6; + string dial_number = 7; + string attendee_pw = 8; + string moderator_pw = 9; + bool recording = 10; + repeated User users = 11; + map metadata = 12; + repeated string breakout_rooms = 13; + DurationInfo duration_info = 14; + ParticipantInfo participant_info = 15; + BreakoutInfo breakout_info = 16; +} \ No newline at end of file diff --git a/bbb-core-api/cmd/api/handlers.go b/bbb-core-api/cmd/api/handlers.go new file mode 100644 index 000000000000..f86963abbaac --- /dev/null +++ b/bbb-core-api/cmd/api/handlers.go @@ -0,0 +1,203 @@ +package main + +import ( + "context" + "io" + "log" + "net/http" + "time" + + bbbcore "github.com/bigbluebutton/bigbluebutton/bbb-core-api/gen/bbb-core" + "github.com/bigbluebutton/bigbluebutton/bbb-core-api/internal/model" + "github.com/bigbluebutton/bigbluebutton/bbb-core-api/internal/validation" +) + +func (app *Config) isMeetingRunning(w http.ResponseWriter, r *http.Request) { + log.Println("Handling isMeetingRunning request") + + params := r.URL.Query() + var payload model.Response + + ok, key, msg := app.isChecksumValid(r, "isMeetingRunning") + if !ok { + payload.ReturnCode = model.ReturnCodeFailure + payload.MessageKey = key + payload.Message = msg + app.writeXML(w, http.StatusAccepted, payload) + return + } + + meetingId := params.Get("meetingID") + ok, key, msg = validation.IsMeetingIdValid(meetingId) + if !ok { + payload.ReturnCode = model.ReturnCodeFailure + payload.MessageKey = key + payload.Message = msg + app.writeXML(w, http.StatusAccepted, payload) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + res, err := app.BbbCore.IsMeetingRunning(ctx, &bbbcore.MeetingRunningRequest{ + MeetingId: meetingId, + }) + if err != nil { + log.Println(err) + app.writeXML(w, http.StatusAccepted, model.GrpcErrorToErrorResp(err)) + return + } + + payload = model.Response{ + ReturnCode: model.ReturnCodeSuccess, + Running: &res.IsRunning, + } + + app.writeXML(w, http.StatusAccepted, payload) +} + +func (app *Config) getMeetingInfo(w http.ResponseWriter, r *http.Request) { + log.Println("Handling getMeetingInfo request") + + params := r.URL.Query() + var errorPayload model.Response + + ok, key, msg := app.isChecksumValid(r, "getMeetingInfo") + if !ok { + errorPayload.ReturnCode = model.ReturnCodeFailure + errorPayload.MessageKey = key + errorPayload.Message = msg + app.writeXML(w, http.StatusAccepted, errorPayload) + return + } + + meetingId := params.Get("meetingID") + ok, key, msg = validation.IsMeetingIdValid(meetingId) + if !ok { + errorPayload.ReturnCode = model.ReturnCodeFailure + errorPayload.MessageKey = key + errorPayload.Message = msg + app.writeXML(w, http.StatusAccepted, errorPayload) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + res, err := app.BbbCore.GetMeetingInfo(ctx, &bbbcore.MeetingInfoRequest{ + MeetingId: meetingId, + }) + if err != nil { + log.Println(err) + app.writeXML(w, http.StatusAccepted, model.GrpcErrorToErrorResp(err)) + return + } + + users := make([]model.User, 0, len(res.MeetingInfo.Users)) + for _, u := range res.MeetingInfo.Users { + user := model.GrpcUserToRespUser(u) + users = append(users, user) + } + + metadata := model.MapToMapData(res.MeetingInfo.Metadata, "metadata") + + payload := model.GetMeetingInfoResponse{ + ReturnCode: model.ReturnCodeSuccess, + MeetingName: res.MeetingInfo.MeetingName, + MeetingId: res.MeetingInfo.MeetingExtId, + InternalMeetingId: res.MeetingInfo.MeetingIntId, + CreateTime: res.MeetingInfo.DurationInfo.CreateTime, + CreateDate: res.MeetingInfo.DurationInfo.CreatedOn, + VoiceBridge: res.MeetingInfo.VoiceBridge, + DialNumber: res.MeetingInfo.DialNumber, + AttendeePW: res.MeetingInfo.AttendeePw, + ModeratorPW: res.MeetingInfo.ModeratorPw, + Running: res.MeetingInfo.DurationInfo.IsRunning, + Duration: res.MeetingInfo.DurationInfo.Duration, + HasUserJoined: res.MeetingInfo.ParticipantInfo.HasUserJoined, + Recording: res.MeetingInfo.Recording, + HasBeenForciblyEnded: res.MeetingInfo.DurationInfo.HasBeenForciblyEnded, + StartTime: res.MeetingInfo.DurationInfo.StartTime, + EndTime: res.MeetingInfo.DurationInfo.EndTime, + ParticipantCount: res.MeetingInfo.ParticipantInfo.ParticipantCount, + ListenerCount: res.MeetingInfo.ParticipantInfo.ListenerCount, + VoiceParticipantCount: res.MeetingInfo.ParticipantInfo.VoiceParticipantCount, + VideoCount: res.MeetingInfo.ParticipantInfo.VideoCount, + MaxUsers: res.MeetingInfo.ParticipantInfo.MaxUsers, + ModeratorCount: res.MeetingInfo.ParticipantInfo.ModeratorCount, + Users: model.Users{Users: users}, + Metadata: metadata, + IsBreakout: res.MeetingInfo.BreakoutInfo.IsBreakout, + BreakoutRooms: model.BreakoutRooms{Breakout: res.MeetingInfo.BreakoutRooms}, + } + + app.writeXML(w, http.StatusAccepted, payload) +} + +func (app *Config) getMeetings(w http.ResponseWriter, r *http.Request) { + log.Println("Handling getMeetings request") + + params := r.URL.Query() + var payload model.Response + + ok, key, msg := app.isChecksumValid(r, "getMeetings") + if !ok { + payload.ReturnCode = model.ReturnCodeFailure + payload.MessageKey = key + payload.Message = msg + app.writeXML(w, http.StatusAccepted, payload) + return + } + + meetingId := params.Get("meetingID") + if meetingId != "" { + ok, key, msg = validation.IsMeetingIdValid(meetingId) + if !ok { + payload.ReturnCode = model.ReturnCodeFailure + payload.MessageKey = key + payload.Message = msg + app.writeXML(w, http.StatusAccepted, payload) + return + } + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + stream, err := app.BbbCore.GetMeetingsStream(ctx, &bbbcore.GetMeetingsStreamRequest{ + MeetingId: meetingId, + }) + if err != nil { + log.Println(err) + app.writeXML(w, http.StatusAccepted, model.GrpcErrorToErrorResp(err)) + } + + meetings := make([]model.Meeting, 0) + for { + res, err := stream.Recv() + if err == io.EOF { + break + } + if err != nil { + log.Println(err) + err = app.writeXML(w, http.StatusAccepted, model.GrpcErrorToErrorResp(err)) + if err != nil { + log.Println(err) + } + return + } + if res.MeetingInfo != nil { + meetings = append(meetings, model.MeetingInfoToMeeting(res.MeetingInfo)) + } + } + + payload = model.Response{ + ReturnCode: model.ReturnCodeSuccess, + Meetings: &model.Meetings{ + Meetings: meetings, + }, + } + + app.writeXML(w, http.StatusAccepted, payload) +} diff --git a/bbb-core-api/cmd/api/helpers.go b/bbb-core-api/cmd/api/helpers.go new file mode 100644 index 000000000000..1658404757c0 --- /dev/null +++ b/bbb-core-api/cmd/api/helpers.go @@ -0,0 +1,113 @@ +package main + +import ( + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "encoding/xml" + "hash" + "log" + "net/http" + "strings" + + "github.com/bigbluebutton/bigbluebutton/bbb-core-api/internal/model" +) + +func (app *Config) writeXML(w http.ResponseWriter, status int, data any, headers ...http.Header) error { + xml, err := xml.Marshal(data) + if err != nil { + return err + } + + if len(headers) > 0 { + for key, value := range headers[0] { + w.Header()[key] = value + } + } + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(status) + + _, err = w.Write(xml) + if err != nil { + return err + } + + return nil +} + +func (app *Config) isChecksumValid(r *http.Request, apiCall string) (bool, string, string) { + params := r.URL.Query() + + if app.Security.Salt == "" { + log.Println("Security is disabled in this service. Make sure this is intentional.") + return true, "", "" + } + + checksum := params.Get("checksum") + if checksum == "" { + return false, model.ChecksumErrorKey, model.ChecksumErrorMsg + } + + queryString := r.URL.RawQuery + queryWithoutChecksum := app.removeQueryParam(queryString, "checksum") + log.Printf("Query string after checksum removed [%s]\n", queryWithoutChecksum) + + data := apiCall + queryWithoutChecksum + app.Security.Salt + var createdChecksum string + + switch checksumLength := len(checksum); checksumLength { + case 40: + _, ok := app.ChecksumAlgorithms["sha-1"] + if ok { + createdChecksum = app.generateHashString(data, sha1.New()) + log.Println("SHA-1", createdChecksum) + } + case 64: + _, ok := app.ChecksumAlgorithms["sha-256"] + if ok { + createdChecksum = app.generateHashString(data, sha256.New()) + log.Println("SHA-256", createdChecksum) + } + case 96: + _, ok := app.ChecksumAlgorithms["sha-384"] + if ok { + createdChecksum = app.generateHashString(data, sha512.New384()) + log.Println("SHA-384", createdChecksum) + } + case 128: + _, ok := app.ChecksumAlgorithms["sha-512"] + if ok { + createdChecksum = app.generateHashString(data, sha512.New()) + log.Println("SHA-512", createdChecksum) + } + default: + log.Println("No algorithm could be found that matches the provided checksum length") + } + + if createdChecksum == "" || createdChecksum != checksum { + log.Printf("checksumError: Query string checksum failed. Our: [%s], Client: [%s]", createdChecksum, checksum) + return false, model.ChecksumErrorKey, model.ChecksumErrorMsg + } + + return true, "", "" +} + +func (app *Config) generateHashString(data string, h hash.Hash) string { + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func (app *Config) removeQueryParam(queryString, param string) string { + entries := strings.Split(queryString, "&") + var newEntries []string + + for _, entry := range entries { + if kv := strings.SplitN(entry, "=", 2); kv[0] != param { + newEntries = append(newEntries, entry) + } + } + + return strings.Join(newEntries, "&") +} diff --git a/bbb-core-api/cmd/api/main.go b/bbb-core-api/cmd/api/main.go new file mode 100644 index 000000000000..c622465ea0ef --- /dev/null +++ b/bbb-core-api/cmd/api/main.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + + bbbcore "github.com/bigbluebutton/bigbluebutton/bbb-core-api/gen/bbb-core" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "gopkg.in/yaml.v3" +) + +type Config struct { + BbbCore bbbcore.BbbCoreServiceClient `yaml:"-"` + ChecksumAlgorithms map[string]struct{} `yaml:"-"` + Server struct { + Host string `yaml:"host"` + Port string `yaml:"port"` + Grpc struct { + Host string `yaml:"host"` + Port string `yaml:"port"` + } `yaml:"grpc"` + } `yaml:"server"` + Security struct { + Salt string `yaml:"salt"` + Checksum struct { + Algorithms []string `yaml:"algorithms"` + } `yaml:"checksum"` + } `yaml:"security"` +} + +const retryPolicy = `{ + "methodConfig": [{ + "name": [{"service": "org.bigbluebutton.protos.BbbCoreService"}], + "waitForReady": true, + + "retryPolicy": { + "MaxAttempts": 5, + "InitialBackoff": ".01s", + "MaxBackoff": ".1s", + "BackoffMultiplier": 2.0, + "RetryableStatusCodes": [ "UNAVAILABLE" ] + } + }] +}` + +func main() { + app := parseConfiguration() + target := fmt.Sprintf("%s:%s", app.Server.Grpc.Host, app.Server.Grpc.Port) + + log.Println("Establishing connection to akka-apps through gRPC at", target) + conn, err := grpc.Dial(target, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithDefaultServiceConfig(retryPolicy)) + if err != nil { + log.Panicln(err) + return + } + defer conn.Close() + + client := bbbcore.NewBbbCoreServiceClient(conn) + app.BbbCore = client + + address := fmt.Sprintf("%s:%s", app.Server.Host, app.Server.Port) + log.Printf("Starting bbb-core-api at %s\n", address) + srv := &http.Server{ + Addr: address, + Handler: app.routes(), + } + + err = srv.ListenAndServe() + if err != nil { + log.Panicln(err) + } +} + +func parseConfiguration() *Config { + log.Println("Parsing server configuration") + + f, err := os.Open("config.yml") + if err != nil { + log.Println(err) + return nil + } + defer f.Close() + + var app Config + decoder := yaml.NewDecoder(f) + err = decoder.Decode(&app) + if err != nil { + log.Println(err) + return nil + } + + checksumAlgorithms := make(map[string]struct{}) + for _, algorithm := range app.Security.Checksum.Algorithms { + checksumAlgorithms[algorithm] = struct{}{} + } + app.ChecksumAlgorithms = checksumAlgorithms + + return &app +} diff --git a/bbb-core-api/cmd/api/routes.go b/bbb-core-api/cmd/api/routes.go new file mode 100644 index 000000000000..517d2f790525 --- /dev/null +++ b/bbb-core-api/cmd/api/routes.go @@ -0,0 +1,32 @@ +package main + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" +) + +func (app *Config) routes() http.Handler { + mux := chi.NewRouter() + + mux.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowedHeaders: []string{"Accept", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 300, + })) + + mux.Use(middleware.Heartbeat("/ping")) + + mux.Get("/isMeetingRunning", app.isMeetingRunning) + + mux.Get("/getMeetingInfo", app.getMeetingInfo) + + mux.Get("/getMeetings", app.getMeetings) + + return mux +} diff --git a/bbb-core-api/config.yml b/bbb-core-api/config.yml new file mode 100644 index 000000000000..5665aad1ebde --- /dev/null +++ b/bbb-core-api/config.yml @@ -0,0 +1,15 @@ +server: + host: localhost + port: 9100 + grpc: + host: localhost + port: 9000 + +security: + salt: 330a8b08c3b4c61533e1d0c5ce1ac88f + checksum: + algorithms: + - sha-1 + - sha-256 + - sha-384 + - sha-512 \ No newline at end of file diff --git a/bbb-core-api/go.mod b/bbb-core-api/go.mod new file mode 100644 index 000000000000..10d192e776e6 --- /dev/null +++ b/bbb-core-api/go.mod @@ -0,0 +1,17 @@ +module github.com/bigbluebutton/bigbluebutton/bbb-core-api + +go 1.20 + +require ( + github.com/go-chi/chi v1.5.5 // indirect + github.com/go-chi/chi/v5 v5.0.12 // indirect + github.com/go-chi/cors v1.2.1 // indirect + github.com/golang/protobuf v1.5.3 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 // indirect + google.golang.org/grpc v1.62.1 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/bbb-core-api/go.sum b/bbb-core-api/go.sum new file mode 100644 index 000000000000..cbede4bad866 --- /dev/null +++ b/bbb-core-api/go.sum @@ -0,0 +1,31 @@ +github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE= +github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= +github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20240123012728-ef4313101c80 h1:KAeGQVN3M9nD0/bQXnr/ClcEMJ968gUXJQ9pwfSynuQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80 h1:AjyfHzEPEFp/NpvfN5g+KDla3EMojjhRVZc1i7cj+oM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240123012728-ef4313101c80/go.mod h1:PAREbraiVEVGVdTZsVWjSbbTtSyGbAgIIvni8a8CD5s= +google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk= +google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bbb-core-api/internal/model/responses.go b/bbb-core-api/internal/model/responses.go new file mode 100644 index 000000000000..92b00673ec1c --- /dev/null +++ b/bbb-core-api/internal/model/responses.go @@ -0,0 +1,236 @@ +package model + +import ( + "encoding/xml" + + common "github.com/bigbluebutton/bigbluebutton/bbb-core-api/gen/common" + "google.golang.org/grpc/status" +) + +const ( + ReturnCodeSuccess string = "SUCCESS" + ReturnCodeFailure string = "FAILED" + + ChecksumErrorKey string = "checksumError" + ChecksumErrorMsg string = "Checksums do not match" + + MeetingIdMissingErrorKey string = "missingParamMeetingID" + MeetingIdMissingErrorMsg string = "You must provide a meeting ID" + + MeetingIdLengthErrorKey string = "validationError" + MeetingIdLengthErrorMsg string = "Meeting ID must be between 2 and 256 characters" + + MeetingIdFormatErrorKey string = "validationError" + MeetingIdFormatErrorMsg string = "Meeting ID cannot contain ','" +) + +type Response struct { + XMLName xml.Name `xml:"response"` + ReturnCode string `xml:"returncode"` + MessageKey string `xml:"messageKey,omitempty"` + Message string `xml:"message,omitempty"` + Running *bool `xml:"running,omitempty"` + Meetings *Meetings `xml:"meetings,omitempty"` +} + +type MapData struct { + Data map[string]string + TagName string +} + +type Meetings struct { + Meetings []Meeting `xml:"meetings"` +} + +type Users struct { + Users []User `xml:"attendee"` +} + +type User struct { + XMLName xml.Name `xml:"attendee"` + UserId string `xml:"userID"` + FullName string `xml:"fullName"` + Role string `xml:"role"` + IsPresenter bool `xml:"isPresenter"` + IsListeningOnly bool `xml:"isListeningOnly"` + HasJoinedVoice bool `xml:"hasJoinedVoice"` + HasVideo bool `xml:"hasVideo"` + ClientType string `xml:"clientType"` + CustomData MapData +} + +type BreakoutRooms struct { + XMLName xml.Name `xml:"breakoutRooms"` + Breakout []string `xml:"breakout"` +} + +type Meeting struct { + XMLName xml.Name `xml:"meeting"` + MeetingName string `xml:"meetingName"` + MeetingId string `xml:"meetingID"` + InternalMeetingId string `xml:"internalMeetingID"` + CreateTime int64 `xml:"createTime"` + CreateDate string `xml:"createDate"` + VoiceBridge string `xml:"voiceBridge"` + DialNumber string `xml:"dialNumber"` + AttendeePW string `xml:"attendeePW"` + ModeratorPW string `xml:"moderatorPW"` + Running bool `xml:"running"` + Duration int32 `xml:"duration"` + HasUserJoined bool `xml:"hasUserJoined"` + Recording bool `xml:"recording"` + HasBeenForciblyEnded bool `xml:"hasBeenForciblyEnded"` + StartTime int64 `xml:"startTime"` + EndTime int64 `xml:"endTime"` + ParticipantCount int32 `xml:"participantCount"` + ListenerCount int32 `xml:"listenerCount"` + VoiceParticipantCount int32 `xml:"voiceParticipantCount"` + VideoCount int32 `xml:"videoCount"` + MaxUsers int32 `xml:"maxUsers"` + ModeratorCount int32 `xml:"moderatorCount"` + Users Users `xml:"attendees"` + Metadata MapData + IsBreakout bool `xml:"isBreakout"` + BreakoutRooms BreakoutRooms `xml:"breakoutRooms"` +} + +type GetMeetingInfoResponse struct { + XMLName xml.Name `xml:"response"` + ReturnCode string `xml:"returncode"` + MeetingName string `xml:"meetingName"` + MeetingId string `xml:"meetingID"` + InternalMeetingId string `xml:"internalMeetingID"` + CreateTime int64 `xml:"createTime"` + CreateDate string `xml:"createDate"` + VoiceBridge string `xml:"voiceBridge"` + DialNumber string `xml:"dialNumber"` + AttendeePW string `xml:"attendeePW"` + ModeratorPW string `xml:"moderatorPW"` + Running bool `xml:"running"` + Duration int32 `xml:"duration"` + HasUserJoined bool `xml:"hasUserJoined"` + Recording bool `xml:"recording"` + HasBeenForciblyEnded bool `xml:"hasBeenForciblyEnded"` + StartTime int64 `xml:"startTime"` + EndTime int64 `xml:"endTime"` + ParticipantCount int32 `xml:"participantCount"` + ListenerCount int32 `xml:"listenerCount"` + VoiceParticipantCount int32 `xml:"voiceParticipantCount"` + VideoCount int32 `xml:"videoCount"` + MaxUsers int32 `xml:"maxUsers"` + ModeratorCount int32 `xml:"moderatorCount"` + Users Users `xml:"attendees"` + Metadata MapData + IsBreakout bool `xml:"isBreakout"` + BreakoutRooms BreakoutRooms +} + +func (m MapData) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + tagName := "metadata" + if m.TagName != "" { + tagName = m.TagName + } + + start.Name = xml.Name{Local: tagName} + tokens := []xml.Token{start} + + for k, v := range m.Data { + t := xml.StartElement{Name: xml.Name{Local: k}} + tokens = append(tokens, t, xml.CharData(v), xml.EndElement{Name: t.Name}) + } + + tokens = append(tokens, xml.EndElement{Name: start.Name}) + + for _, t := range tokens { + if err := e.EncodeToken(t); err != nil { + return err + } + } + + return e.Flush() +} + +func MapToMapData(data map[string]string, tagName string) MapData { + return MapData{Data: data, TagName: tagName} +} + +func GrpcUserToRespUser(u *common.User) User { + customData := MapToMapData(u.CustomData, "customdata") + + user := User{ + UserId: u.UserId, + FullName: u.FullName, + Role: u.Role, + IsPresenter: u.IsPresenter, + IsListeningOnly: u.IsListeningOnly, + HasJoinedVoice: u.HasJoinedVoice, + HasVideo: u.HasVideo, + ClientType: u.ClientType, + CustomData: customData, + } + + return user +} + +func MeetingInfoToMeeting(m *common.MeetingInfo) Meeting { + metadata := MapToMapData(m.Metadata, "metadata") + + users := make([]User, 0, len(m.Users)) + for _, u := range m.Users { + user := GrpcUserToRespUser(u) + users = append(users, user) + } + + meeting := Meeting{ + MeetingName: m.MeetingName, + MeetingId: m.MeetingExtId, + InternalMeetingId: m.MeetingIntId, + CreateTime: m.DurationInfo.CreateTime, + CreateDate: m.DurationInfo.CreatedOn, + VoiceBridge: m.VoiceBridge, + DialNumber: m.DialNumber, + AttendeePW: m.AttendeePw, + ModeratorPW: m.ModeratorPw, + Running: m.DurationInfo.IsRunning, + Duration: m.DurationInfo.Duration, + HasUserJoined: m.ParticipantInfo.HasUserJoined, + Recording: m.Recording, + HasBeenForciblyEnded: m.DurationInfo.HasBeenForciblyEnded, + StartTime: m.DurationInfo.StartTime, + EndTime: m.DurationInfo.EndTime, + ParticipantCount: m.ParticipantInfo.ParticipantCount, + ListenerCount: m.ParticipantInfo.ListenerCount, + VoiceParticipantCount: m.ParticipantInfo.VoiceParticipantCount, + VideoCount: m.ParticipantInfo.VideoCount, + MaxUsers: m.ParticipantInfo.MaxUsers, + ModeratorCount: m.ParticipantInfo.ModeratorCount, + Users: Users{Users: users}, + Metadata: metadata, + IsBreakout: m.BreakoutInfo.IsBreakout, + BreakoutRooms: BreakoutRooms{Breakout: m.BreakoutRooms}, + } + + return meeting +} + +func GrpcErrorToErrorResp(err error) Response { + st, ok := status.FromError(err) + if ok { + for _, detail := range st.Details() { + switch t := detail.(type) { + case *common.ErrorResponse: + return Response{ + ReturnCode: ReturnCodeFailure, + MessageKey: t.Key, + Message: t.Message, + } + } + } + } + + return Response{ + ReturnCode: ReturnCodeFailure, + MessageKey: "error", + Message: "A unknown error occurred", + } +} diff --git a/bbb-core-api/internal/validation/validation.go b/bbb-core-api/internal/validation/validation.go new file mode 100644 index 000000000000..1aab387d9972 --- /dev/null +++ b/bbb-core-api/internal/validation/validation.go @@ -0,0 +1,25 @@ +package validation + +import ( + "regexp" + + "github.com/bigbluebutton/bigbluebutton/bbb-core-api/internal/model" +) + +func IsMeetingIdValid(meetingId string) (bool, string, string) { + if meetingId == "" { + return false, model.MeetingIdMissingErrorKey, model.MeetingIdMissingErrorMsg + } + + if len(meetingId) < 2 || len(meetingId) > 256 { + return false, model.MeetingIdLengthErrorKey, model.MeetingIdLengthErrorMsg + } + + r, _ := regexp.Compile("^[^,]+$") + + if !r.MatchString(meetingId) { + return false, model.MeetingIdFormatErrorKey, model.MeetingIdFormatErrorMsg + } + + return true, "", "" +} diff --git a/bbb-core-api/proto-gen.sh b/bbb-core-api/proto-gen.sh new file mode 100755 index 000000000000..c5c600132825 --- /dev/null +++ b/bbb-core-api/proto-gen.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +PROTO_DIR="../bbb-common-grpc/src/main/proto" +OUT_DIR="./gen" +BASE_PACKAGE="github.com/bigbluebutton/bigbluebutton/bbb-core-api/gen" + +mkdir -p ${OUT_DIR} + +find "${PROTO_DIR}" -name "*.proto" -exec protoc \ + --proto_path="${PROTO_DIR}" \ + --go_out="${OUT_DIR}" \ + --go_opt=paths=source_relative \ + --go_opt=Mcommon/error.proto=${BASE_PACKAGE}/common \ + --go_opt=Mcommon/meeting.proto=${BASE_PACKAGE}/common \ + --go_opt=Mbbb-core/bbb-core.proto=${BASE_PACKAGE}/bbbcore \ + --go-grpc_out="${OUT_DIR}" \ + --go-grpc_opt=paths=source_relative \ + --go-grpc_opt=Mcommon/error.proto=${BASE_PACKAGE}/common \ + --go-grpc_opt=Mcommon/meeting.proto=${BASE_PACKAGE}/common \ + --go-grpc_opt=Mbbb-core/bbb-core.proto=${BASE_PACKAGE}/bbbcore \ + '{}' \; \ No newline at end of file diff --git a/bbb-core-api/run-dev.sh b/bbb-core-api/run-dev.sh new file mode 100755 index 000000000000..8221fb8bab47 --- /dev/null +++ b/bbb-core-api/run-dev.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +go run ./cmd/api \ No newline at end of file