Merge remote-tracking branch 'upstream/v2.6.x-release' into demo-page-removal

This commit is contained in:
GuiLeme 2022-07-13 11:16:51 -03:00
commit bdb9c6857f
496 changed files with 19847 additions and 19534 deletions

View File

@ -14,4 +14,4 @@ contact_links:
about: Issue tracker for the Greenlight frontend
- name: Commercial Support
url: https://bigbluebutton.org/commercial-support
abount: List of companies offering commercial BigBlueButton support
about: List of companies offering commercial BigBlueButton support

View File

@ -16,9 +16,11 @@ jobs:
- run: ./build/setup.sh bbb-apps-akka
- run: ./build/setup.sh bbb-config
- run: ./build/setup.sh bbb-etherpad
- run: ./build/setup.sh bbb-export-annotations
- run: ./build/setup.sh bbb-freeswitch-core
- run: ./build/setup.sh bbb-freeswitch-sounds
- run: ./build/setup.sh bbb-fsesl-akka
- run: ./build/setup.sh bbb-html5-nodejs
- run: ./build/setup.sh bbb-html5
- run: ./build/setup.sh bbb-learning-dashboard
- run: ./build/setup.sh bbb-libreoffice-docker
@ -53,11 +55,9 @@ jobs:
sudo sh -c '
mkdir /root/bbb-ci-ssl/
cd /root/bbb-ci-ssl/
openssl rand -base64 48 > /root/bbb-ci-ssl/bbb-dev-ca.pass ;
chmod 600 /root/bbb-ci-ssl/bbb-dev-ca.pass ;
openssl genrsa -des3 -out bbb-dev-ca.key -passout file:/root/bbb-ci-ssl/bbb-dev-ca.pass 2048 ;
openssl req -x509 -new -nodes -key bbb-dev-ca.key -sha256 -days 1460 -passin file:/root/bbb-ci-ssl/bbb-dev-ca.pass -out bbb-dev-ca.crt -subj "/C=CA/ST=BBB/L=BBB/O=BBB/OU=BBB/CN=BBB-DEV" ;
'
- name: Trust CA
@ -67,7 +67,6 @@ jobs:
sudo cp /root/bbb-ci-ssl/bbb-dev-ca.crt /usr/local/share/ca-certificates/bbb-dev/
sudo chmod 644 /usr/local/share/ca-certificates/bbb-dev/bbb-dev-ca.crt
sudo update-ca-certificates
'
- name: Generate certificate
run: |
@ -84,9 +83,7 @@ jobs:
[alt_names]
DNS.1 = bbb-ci.test
EOF
openssl req -nodes -newkey rsa:2048 -keyout bbb-ci.test.key -out bbb-ci.test.csr -subj "/C=CA/ST=BBB/L=BBB/O=BBB/OU=BBB/CN=bbb-ci.test" -addext "subjectAltName = DNS:bbb-ci.test"
openssl req -nodes -newkey rsa:2048 -keyout bbb-ci.test.key -out bbb-ci.test.csr -subj "/C=CA/ST=BBB/L=BBB/O=BBB/OU=BBB/CN=bbb-ci.test" -addext "subjectAltName = DNS:bbb-ci.test"
openssl x509 -req -in bbb-ci.test.csr -CA bbb-dev-ca.crt -CAkey bbb-dev-ca.key -CAcreateserial -out bbb-ci.test.crt -days 825 -sha256 -passin file:/root/bbb-ci-ssl/bbb-dev-ca.pass -extfile bbb-ci.test.ext
cd
@ -95,7 +92,6 @@ jobs:
cat /root/bbb-ci-ssl/bbb-ci.test.crt > /local/certs/fullchain.pem
cat /root/bbb-ci-ssl/bbb-dev-ca.crt >> /local/certs/fullchain.pem
cat /root/bbb-ci-ssl/bbb-ci.test.key > /local/certs/privkey.pem
'
- name: Setup local repository
run: |
@ -115,25 +111,32 @@ jobs:
- name: Install BBB
run: |
sudo sh -c '
cd /root/ && wget -q https://ubuntu.bigbluebutton.org/bbb-install-2.5.sh -O bbb-install.sh
cat bbb-install.sh | sed "s|> /etc/apt/sources.list.d/bigbluebutton.list||g" | bash -s -- -v focal-25-dev -s bbb-ci.test -d /certs/
cd /root/ && wget -q https://ubuntu.bigbluebutton.org/bbb-install-2.6.sh -O bbb-install.sh
cat bbb-install.sh | sed "s|> /etc/apt/sources.list.d/bigbluebutton.list||g" | bash -s -- -v focal-26-dev -s bbb-ci.test -d /certs/
bbb-conf --salt bbbci
echo "NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bbb-dev/bbb-dev-ca.crt" >> /usr/share/meteor/bundle/bbb-html5-with-roles.conf
bbb-conf --restart
'
- name: Run tests
- name: Install test dependencies
working-directory: ./bigbluebutton-tests/playwright
run: |
sh -c '
echo "Teste"
cd /home/runner/work/bigbluebutton/bigbluebutton/bigbluebutton-tests/playwright/
echo "
BBB_URL=\"https://bbb-ci.test/bigbluebutton/api\"
BBB_SECRET=\"bbbci\"
DEBUG_MODE=\"\"
" > .env
npm install
npx playwright install-deps
npx playwright install
export NODE_TLS_REJECT_UNAUTHORIZED='0'
npx playwright test --project=chromium --grep @ci
'
sh -c '
npm install
npx playwright install-deps
npx playwright install
'
- name: Run tests
working-directory: ./bigbluebutton-tests/playwright
env:
NODE_EXTRA_CA_CERTS: /usr/local/share/ca-certificates/bbb-dev/bbb-dev-ca.crt
ACTIONS_RUNNER_DEBUG: true
BBB_URL: https://bbb-ci.test/bigbluebutton/api
BBB_SECRET: bbbci
run: npm run test-ci
- if: always()
uses: actions/upload-artifact@v3
with:
name: tests-report
path: |
bigbluebutton-tests/playwright/playwright-report
bigbluebutton-tests/playwright/test-results

View File

@ -11,7 +11,7 @@ stages:
# define which docker image to use for builds
default:
image: gitlab.senfcall.de:5050/senfcall-public/docker-bbb-build:v2022-03-30
image: gitlab.senfcall.de:5050/senfcall-public/docker-bbb-build:v2022-07-12
# This stage uses git to find out since when each package has been unmodified.
# it then checks an API endpoint on the package server to find out for which of

View File

@ -1,28 +0,0 @@
dist: focal
language: node_js
node_js: 16
env:
- JOB_TYPE=linter
- JOB_TYPE=acceptance_tests
global:
- BBB_SERVER_URL=http://localhost/bigbluebutton/api
jobs:
allow_failures:
- env: JOB_TYPE=linter
include:
- stage: "Linter"
name: "ESLint"
env: JOB_TYPE=linter
- stage: "Tests"
name: "Acceptance Tests"
env: JOB_TYPE=acceptance_tests
script:
- travis_wait 30 bash ./build_script.sh $JOB_TYPE
after_script:
- docker stop $docker
- docker rm $docker
- docker rmi b2

View File

@ -6,13 +6,14 @@ We actively support BigBlueButton through the community forums and through secur
| Version | Supported |
| ------- | ------------------ |
| 2.2.x (or earlier) | :x: |
| 2.3.x | :white_check_mark: |
| 2.3.x (or earlier) | :x: |
| 2.4.x   | :white_check_mark: |
| 2.5.x   | :white_check_mark: |
| 2.6.x   | :x: |
We have released 2.4 to the community and are going to support both 2.4 and 2.3 together for the coming months (while we're actively developing the next release). Also, BigBlueButton 2.2 is running on Ubuntu 16.04 which is now end of life.
We have released 2.5 to the community and are going to support both 2.4 and 2.5 together for the coming months (while we're actively developing the next release). Also, BigBlueButton 2.3 is now end of life.
As such, we recommend that all administrators deploy 2.4 going forward. You'll find [many improvements](https://docs.bigbluebutton.org/2.4/new.html) in this newer version.
As such, we recommend that all administrators deploy 2.5 going forward. You'll find [many improvements](https://docs.bigbluebutton.org/2.5/new.html) in this newer version.
## Reporting a Vulnerability

View File

@ -11,7 +11,7 @@ import org.bigbluebutton.core.pubsub.senders.ReceivedJsonMsgHandlerActor
import org.bigbluebutton.core2.AnalyticsActor
import org.bigbluebutton.core2.FromAkkaAppsMsgSenderActor
import org.bigbluebutton.endpoint.redis.AppsRedisSubscriberActor
import org.bigbluebutton.endpoint.redis.RedisRecorderActor
import org.bigbluebutton.endpoint.redis.{ RedisRecorderActor, ExportAnnotationsActor }
import org.bigbluebutton.endpoint.redis.LearningDashboardActor
import org.bigbluebutton.common2.bus.IncomingJsonMessageBus
import org.bigbluebutton.service.{ HealthzService, MeetingInfoActor, MeetingInfoService }
@ -59,6 +59,11 @@ object Boot extends App with SystemConfiguration {
"redisRecorderActor"
)
val exportAnnotationsActor = system.actorOf(
ExportAnnotationsActor.props(system, redisConfig, healthzService),
"exportAnnotationsActor"
)
val learningDashboardActor = system.actorOf(
LearningDashboardActor.props(system, outGW),
"LearningDashboardActor"
@ -72,6 +77,7 @@ object Boot extends App with SystemConfiguration {
val analyticsActorRef = system.actorOf(AnalyticsActor.props(analyticsIncludeChat))
outBus2.subscribe(fromAkkaAppsMsgSenderActorRef, outBbbMsgMsgChannel)
outBus2.subscribe(redisRecorderActor, recordServiceMessageChannel)
outBus2.subscribe(exportAnnotationsActor, outBbbMsgMsgChannel)
outBus2.subscribe(analyticsActorRef, outBbbMsgMsgChannel)
bbbMsgBus.subscribe(analyticsActorRef, analyticsChannel)

View File

@ -75,6 +75,12 @@ object LockSettingsUtil {
}
}
def isMicrophoneSharingLocked(user: UserState, liveMeeting: LiveMeeting): Boolean = {
val permissions = MeetingStatus2x.getPermissions(liveMeeting.status)
user.role == Roles.VIEWER_ROLE && user.locked && permissions.disableMic
}
def isCameraBroadcastLocked(user: UserState, liveMeeting: LiveMeeting): Boolean = {
val permissions = MeetingStatus2x.getPermissions(liveMeeting.status)

View File

@ -70,11 +70,6 @@ trait SystemConfiguration {
lazy val fromBbbWebRedisChannel = Try(config.getString("redis.fromBbbWebRedisChannel")).getOrElse("from-bbb-web-redis-channel")
lazy val toAkkaTranscodeRedisChannel = Try(config.getString("redis.toAkkaTranscodeRedisChannel")).getOrElse("bigbluebutton:to-bbb-transcode:system")
lazy val fromAkkaTranscodeRedisChannel = Try(config.getString("redis.fromAkkaTranscodeRedisChannel")).getOrElse("bigbluebutton:from-bbb-transcode:system")
lazy val toAkkaTranscodeJsonChannel = Try(config.getString("eventBus.toAkkaTranscodeJsonChannel")).getOrElse("to-akka-transcode-json-channel")
lazy val fromAkkaTranscodeJsonChannel = Try(config.getString("eventBus.fromAkkaTranscodeJsonChannel")).getOrElse("from-akka-transcode-json-channel")
lazy val analyticsIncludeChat = Try(config.getBoolean("analytics.includeChat")).getOrElse(true)
// Grab the "interface" parameter from the http config

View File

@ -187,9 +187,6 @@ class BigBlueButtonActor(
val disconnectEvnt = MsgBuilder.buildDisconnectAllClientsSysMsg(msg.meetingId, "meeting-destroyed")
m2.outMsgRouter.send(disconnectEvnt)
val stopTranscodersCmd = MsgBuilder.buildStopMeetingTranscodersSysCmdMsg(msg.meetingId)
m2.outMsgRouter.send(stopTranscodersCmd)
log.info("Destroyed meetingId={}", msg.meetingId)
val destroyedEvent = MsgBuilder.buildMeetingDestroyedEvtMsg(msg.meetingId)
m2.outMsgRouter.send(destroyedEvent)

View File

@ -20,7 +20,7 @@ trait CreateDefaultPublicGroupChat {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, userId)
val envelope = BbbCoreEnvelope(GroupChatCreatedEvtMsg.NAME, routing)
val header = BbbClientMsgHeader(GroupChatCreatedEvtMsg.NAME, meetingId, userId)
val body = GroupChatCreatedEvtMsgBody(correlationId, gc.id, gc.createdBy, gc.name, gc.access, gc.users, msgs)
val body = GroupChatCreatedEvtMsgBody(correlationId, gc.id, gc.createdBy, gc.access, gc.users, msgs)
val event = GroupChatCreatedEvtMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}

View File

@ -62,7 +62,7 @@ trait CreateGroupChatReqMsgHdlr extends SystemConfiguration {
}
}
val gc = GroupChatApp.createGroupChat(msg.body.name, msg.body.access, createdBy, users, msgs)
val gc = GroupChatApp.createGroupChat(msg.body.access, createdBy, users, msgs)
sendMessages(msg, gc, liveMeeting, bus)
val groupChats = state.groupChats.add(gc)
@ -84,12 +84,12 @@ trait CreateGroupChatReqMsgHdlr extends SystemConfiguration {
BbbCoreEnvelope(name, routing)
}
def makeBody(chatId: String, name: String,
def makeBody(chatId: String,
access: String, correlationId: String,
createdBy: GroupChatUser, users: Vector[GroupChatUser],
msgs: Vector[GroupChatMsgToUser]): GroupChatCreatedEvtMsgBody = {
GroupChatCreatedEvtMsgBody(correlationId, chatId, createdBy,
name, access, users, msgs)
access, users, msgs)
}
val meetingId = liveMeeting.props.meetingProp.intId
@ -102,7 +102,7 @@ trait CreateGroupChatReqMsgHdlr extends SystemConfiguration {
val envelope = makeEnvelope(MessageTypes.DIRECT, GroupChatCreatedEvtMsg.NAME, meetingId, userId)
val header = makeHeader(GroupChatCreatedEvtMsg.NAME, meetingId, userId)
val body = makeBody(gc.id, gc.name, gc.access, correlationId, gc.createdBy, users, msgs)
val body = makeBody(gc.id, gc.access, correlationId, gc.createdBy, users, msgs)
val event = GroupChatCreatedEvtMsg(header, body)
val outEvent = BbbCommonEnvCoreMsg(envelope, event)
bus.outGW.send(outEvent)
@ -117,7 +117,7 @@ trait CreateGroupChatReqMsgHdlr extends SystemConfiguration {
meetingId, userId)
val header = makeHeader(GroupChatCreatedEvtMsg.NAME, meetingId, userId)
val body = makeBody(gc.id, gc.name, gc.access, correlationId, gc.createdBy, users, msgs)
val body = makeBody(gc.id, gc.access, correlationId, gc.createdBy, users, msgs)
val event = GroupChatCreatedEvtMsg(header, body)
val outEvent = BbbCommonEnvCoreMsg(envelope, event)

View File

@ -27,8 +27,8 @@ trait GetGroupChatsReqMsgHdlr {
val publicChats = state.groupChats.findAllPublicChats()
val privateChats = state.groupChats.findAllPrivateChatsForUser(msg.header.userId)
val pubChats = publicChats map (pc => GroupChatInfo(pc.id, pc.name, pc.access, pc.createdBy, pc.users))
val privChats = privateChats map (pc => GroupChatInfo(pc.id, pc.name, pc.access, pc.createdBy, pc.users))
val pubChats = publicChats map (pc => GroupChatInfo(pc.id, pc.access, pc.createdBy, pc.users))
val privChats = privateChats map (pc => GroupChatInfo(pc.id, pc.access, pc.createdBy, pc.users))
val allChats = pubChats ++ privChats

View File

@ -9,10 +9,10 @@ object GroupChatApp {
val MAIN_PUBLIC_CHAT = "MAIN-PUBLIC-GROUP-CHAT"
def createGroupChat(chatName: String, access: String, createBy: GroupChatUser,
def createGroupChat(access: String, createBy: GroupChatUser,
users: Vector[GroupChatUser], msgs: Vector[GroupChatMessage]): GroupChat = {
val gcId = GroupChatFactory.genId()
GroupChatFactory.create(gcId, chatName, access, createBy, users, msgs)
GroupChatFactory.create(gcId, access, createBy, users, msgs)
}
def toGroupChatMessage(sender: GroupChatUser, msg: GroupChatMsgFromUser): GroupChatMessage = {
@ -46,13 +46,15 @@ object GroupChatApp {
def createDefaultPublicGroupChat(): GroupChat = {
val createBy = GroupChatUser(SystemUser.ID)
GroupChatFactory.create(MAIN_PUBLIC_CHAT, MAIN_PUBLIC_CHAT, GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty)
GroupChatFactory.create(MAIN_PUBLIC_CHAT, GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty)
}
def createTestPublicGroupChat(state: MeetingState2x): MeetingState2x = {
val createBy = GroupChatUser(SystemUser.ID)
val defaultPubGroupChat = GroupChatFactory.create("TEST_GROUP_CHAT", "TEST_GROUP_CHAT",
GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty)
val defaultPubGroupChat = GroupChatFactory.create(
"TEST_GROUP_CHAT",
GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty
)
val groupChats = state.groupChats.add(defaultPubGroupChat)
state.update(groupChats)
}

View File

@ -1,6 +1,7 @@
package org.bigbluebutton.core.apps.groupchats
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.PermissionCheck
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.running.{ HandlerHelpers, LiveMeeting }
@ -43,17 +44,27 @@ trait SendGroupChatMessageMsgHdlr extends HandlerHelpers {
sender <- GroupChatApp.findGroupChatUser(msg.header.userId, liveMeeting.users2x)
chat <- state.groupChats.find(msg.body.chatId)
} yield {
val gcm = GroupChatApp.toGroupChatMessage(sender, msg.body.msg)
val gcs = GroupChatApp.addGroupChatMessage(chat, state.groupChats, gcm)
val chatIsPrivate = chat.access == GroupChatAccess.PRIVATE;
val userIsAParticipant = chat.users.filter(u => u.id == sender.id).length > 0;
val event = buildGroupChatMessageBroadcastEvtMsg(
liveMeeting.props.meetingProp.intId,
msg.header.userId, msg.body.chatId, gcm
)
if ((chatIsPrivate && userIsAParticipant) || !chatIsPrivate) {
val gcm = GroupChatApp.toGroupChatMessage(sender, msg.body.msg)
val gcs = GroupChatApp.addGroupChatMessage(chat, state.groupChats, gcm)
bus.outGW.send(event)
val event = buildGroupChatMessageBroadcastEvtMsg(
liveMeeting.props.meetingProp.intId,
msg.header.userId, msg.body.chatId, gcm
)
bus.outGW.send(event)
state.update(gcs)
} else {
val reason = "User isn't a participant of the chat"
PermissionCheck.ejectUserForFailedPermission(msg.header.meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
state
}
state.update(gcs)
}
newState match {

View File

@ -41,7 +41,7 @@ trait SyncGetGroupChatsInfoMsgHdlr {
val respMsg = buildSyncGetGroupChatMsgsRespMsg(msgs, pc.id)
bus.outGW.send(respMsg)
GroupChatInfo(pc.id, pc.name, pc.access, pc.createdBy, pc.users)
GroupChatInfo(pc.id, pc.access, pc.createdBy, pc.users)
})
// publishing a message with the group chat info

View File

@ -19,6 +19,7 @@ class PresentationPodHdlrs(implicit val context: ActorContext)
with PresentationPageCountErrorPubMsgHdlr
with PresentationUploadedFileTooLargeErrorPubMsgHdlr
with PresentationUploadTokenReqMsgHdlr
with PresentationWithAnnotationsMsgHdlr
with ResizeAndMovePagePubMsgHdlr
with SyncGetPresentationPodsMsgHdlr
with RemovePresentationPodPubMsgHdlr

View File

@ -0,0 +1,164 @@
package org.bigbluebutton.core.apps.presentationpod
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.apps.{ PermissionCheck, RightsManagementTrait }
import org.bigbluebutton.core.bus.MessageBus
import org.bigbluebutton.core.domain.MeetingState2x
import org.bigbluebutton.core.running.LiveMeeting
import org.bigbluebutton.core.util.RandomStringGenerator
import org.bigbluebutton.core.models.{ PresentationPod, PresentationPage, PresentationInPod }
import java.io.File
trait PresentationWithAnnotationsMsgHdlr extends RightsManagementTrait {
this: PresentationPodHdlrs =>
def buildStoreAnnotationsInRedisSysMsg(annotations: StoredAnnotations, liveMeeting: LiveMeeting): BbbCommonEnvCoreMsg = {
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
val envelope = BbbCoreEnvelope(StoreAnnotationsInRedisSysMsg.NAME, routing)
val body = StoreAnnotationsInRedisSysMsgBody(annotations)
val header = BbbCoreHeaderWithMeetingId(StoreAnnotationsInRedisSysMsg.NAME, liveMeeting.props.meetingProp.intId)
val event = StoreAnnotationsInRedisSysMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def buildStoreExportJobInRedisSysMsg(exportJob: ExportJob, liveMeeting: LiveMeeting): BbbCommonEnvCoreMsg = {
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
val envelope = BbbCoreEnvelope(StoreExportJobInRedisSysMsg.NAME, routing)
val body = StoreExportJobInRedisSysMsgBody(exportJob)
val header = BbbCoreHeaderWithMeetingId(StoreExportJobInRedisSysMsg.NAME, liveMeeting.props.meetingProp.intId)
val event = StoreExportJobInRedisSysMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def buildPresentationUploadTokenSysPubMsg(parentId: String, userId: String, presentationUploadToken: String, filename: String): BbbCommonEnvCoreMsg = {
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
val envelope = BbbCoreEnvelope(PresentationUploadTokenSysPubMsg.NAME, routing)
val header = BbbClientMsgHeader(PresentationUploadTokenSysPubMsg.NAME, parentId, userId)
val body = PresentationUploadTokenSysPubMsgBody("DEFAULT_PRESENTATION_POD", presentationUploadToken, filename, parentId)
val event = PresentationUploadTokenSysPubMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def getPresentationPagesForExport(pagesRange: List[Int], pageCount: Int, presId: String, currentPres: Option[PresentationInPod], liveMeeting: LiveMeeting, storeAnnotationPages: List[PresentationPageForExport] = List()): List[PresentationPageForExport] = {
pagesRange match {
case (pageNumber :: pages) => {
if (pageNumber >= 1 && pageNumber <= pageCount) {
val whiteboardId = s"${presId}/${pageNumber.toString}"
val presentationPage: PresentationPage = currentPres.get.pages(whiteboardId)
val xCamera: Double = presentationPage.xCamera
val yCamera: Double = presentationPage.yCamera
val zoom: Double = presentationPage.zoom
val whiteboardHistory: Array[AnnotationVO] = liveMeeting.wbModel.getHistory(whiteboardId)
val page = new PresentationPageForExport(pageNumber, xCamera, yCamera, zoom, whiteboardHistory)
getPresentationPagesForExport(pages, pageCount, presId, currentPres, liveMeeting, storeAnnotationPages :+ page)
} else {
getPresentationPagesForExport(pages, pageCount, presId, currentPres, liveMeeting, storeAnnotationPages)
}
}
case _ => storeAnnotationPages
}
}
def handle(m: MakePresentationWithAnnotationDownloadReqMsg, state: MeetingState2x, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
val meetingId = liveMeeting.props.meetingProp.intId
val userId = m.header.userId
val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting()
val currentPres: Option[PresentationInPod] = presentationPods.flatMap(_.getCurrentPresentation()).headOption
if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, userId)) {
val reason = "No permission to export presentation."
PermissionCheck.ejectUserForFailedPermission(meetingId, userId, reason, bus.outGW, liveMeeting)
} else if (currentPres.isEmpty) {
log.error(s"No presentation set in meeting ${meetingId}")
} else {
val jobType: String = "PresentationWithAnnotationDownloadJob"
val jobId: String = RandomStringGenerator.randomAlphanumericString(16);
val allPages: Boolean = m.body.allPages
val pageCount = currentPres.get.pages.size
val presId: String = m.body.presId match {
case "" => PresentationPodsApp.getAllPresentationPodsInMeeting(state).flatMap(_.getCurrentPresentation.map(_.id)).mkString
case _ => m.body.presId
}
val presLocation = List("var", "bigbluebutton", meetingId, meetingId, presId).mkString(File.separator, File.separator, "");
val pages: List[Int] = m.body.pages // Desired presentation pages for export
val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else pages
val exportJob: ExportJob = new ExportJob(jobId, jobType, "annotated_slides", presId, presLocation, allPages, pagesRange, meetingId, "");
val storeAnnotationPages: List[PresentationPageForExport] = getPresentationPagesForExport(pagesRange, pageCount, presId, currentPres, liveMeeting);
// Send Export Job to Redis
val job = buildStoreExportJobInRedisSysMsg(exportJob, liveMeeting)
bus.outGW.send(job)
// Send Annotations to Redis
val annotations = new StoredAnnotations(jobId, presId, storeAnnotationPages)
bus.outGW.send(buildStoreAnnotationsInRedisSysMsg(annotations, liveMeeting))
}
}
def handle(m: ExportPresentationWithAnnotationReqMsg, state: MeetingState2x, liveMeeting: LiveMeeting, bus: MessageBus): Unit = {
val meetingId = liveMeeting.props.meetingProp.intId
val userId = m.header.userId
val presentationPods: Vector[PresentationPod] = state.presentationPodManager.getAllPresentationPodsInMeeting()
val currentPres: Option[PresentationInPod] = presentationPods.flatMap(_.getCurrentPresentation()).headOption
if (permissionFailed(PermissionCheck.MOD_LEVEL, PermissionCheck.VIEWER_LEVEL, liveMeeting.users2x, userId)) {
val reason = "No permission to export presentation."
PermissionCheck.ejectUserForFailedPermission(meetingId, userId, reason, bus.outGW, liveMeeting)
} else if (currentPres.isEmpty) {
log.error(s"No presentation set in meeting ${meetingId}")
} else {
val jobId: String = RandomStringGenerator.randomAlphanumericString(16);
val jobType = "PresentationWithAnnotationExportJob"
val allPages: Boolean = m.body.allPages
val pageCount = currentPres.get.pages.size
val presId: String = PresentationPodsApp.getAllPresentationPodsInMeeting(state).flatMap(_.getCurrentPresentation.map(_.id)).mkString
val presLocation = List("var", "bigbluebutton", meetingId, meetingId, presId).mkString(File.separator, File.separator, "");
val parentMeetingId: String = m.body.parentMeetingId
val currentPage: PresentationPage = PresentationInPod.getCurrentPage(currentPres.get).get
val pagesRange: List[Int] = if (allPages) (1 to pageCount).toList else List(currentPage.num)
val presentationUploadToken: String = PresentationPodsApp.generateToken("DEFAULT_PRESENTATION_POD", userId)
// Set filename, checking if it is already in use
val meetingName: String = liveMeeting.props.meetingProp.name
val duplicatedCount = presentationPods.flatMap(_.getPresentationsByFilename(meetingName)).size
val filename = duplicatedCount match {
case 0 => meetingName
case _ => s"${meetingName}(${duplicatedCount})"
}
// Informs bbb-web about the token so that when we use it to upload the presentation, it is able to look it up in the list of tokens
bus.outGW.send(buildPresentationUploadTokenSysPubMsg(parentMeetingId, userId, presentationUploadToken, filename))
val exportJob: ExportJob = new ExportJob(jobId, jobType, filename, presId, presLocation, allPages, pagesRange, parentMeetingId, presentationUploadToken)
val storeAnnotationPages: List[PresentationPageForExport] = getPresentationPagesForExport(pagesRange, pageCount, presId, currentPres, liveMeeting);
// Send Export Job to Redis
val job = buildStoreExportJobInRedisSysMsg(exportJob, liveMeeting)
bus.outGW.send(job)
// Send Annotations to Redis
val annotations = new StoredAnnotations(jobId, presId, storeAnnotationPages)
bus.outGW.send(buildStoreAnnotationsInRedisSysMsg(annotations, liveMeeting))
}
}
}

View File

@ -25,8 +25,7 @@ trait GetScreenBroadcastPermissionReqMsgHdlr {
val meetingId = liveMeeting.props.meetingProp.intId
val reason = "No permission to share the screen."
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, outGW, liveMeeting)
} else if (!user.userLeftFlag.left
&& liveMeeting.props.meetingProp.intId == msg.body.meetingId
} else if (liveMeeting.props.meetingProp.intId == msg.body.meetingId
&& liveMeeting.props.voiceProp.voiceConf == msg.body.voiceConf) {
allowed = true
}

View File

@ -17,8 +17,7 @@ trait GetScreenSubscribePermissionReqMsgHdlr {
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
} yield {
if (!user.userLeftFlag.left
&& liveMeeting.props.meetingProp.intId == msg.body.meetingId
if (liveMeeting.props.meetingProp.intId == msg.body.meetingId
&& liveMeeting.props.voiceProp.voiceConf == msg.body.voiceConf
&& ScreenshareModel.getRTMPBroadcastingUrl(liveMeeting.screenshareModel) == msg.body.streamId) {
allowed = true

View File

@ -51,7 +51,7 @@ trait EjectUserFromMeetingCmdMsgHdlr extends RightsManagementTrait {
breakoutModel <- state.breakout
} yield {
breakoutModel.rooms.values.foreach { room =>
room.users.filter(u => u.id == ru.externId + "-" + room.sequence).foreach(user => {
room.users.filter(u => u.id == ru.id + "-" + room.sequence).foreach(user => {
eventBus.publish(BigBlueButtonEvent(room.id, EjectUserFromBreakoutInternalMsg(meetingId, room.id, user.id, ejectedBy, reason, EjectReasonCode.EJECT_USER, ban)))
})
}

View File

@ -1,35 +1,51 @@
package org.bigbluebutton.core2.message.handlers
package org.bigbluebutton.core.apps.voice
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.running.{ MeetingActor, OutMsgRouter }
import org.bigbluebutton.core.models.Users2x
import org.bigbluebutton.core2.message.senders.MsgBuilder
import org.bigbluebutton.core.running.{ LiveMeeting, MeetingActor, OutMsgRouter }
trait GetGlobalAudioPermissionReqMsgHdlr {
this: MeetingActor =>
val outGW: OutMsgRouter
def handleGetGlobalAudioPermissionReqMsg(msg: GetGlobalAudioPermissionReqMsg) {
var allowed = false
def handleGetGlobalAudioPermissionReqMsg(msg: GetGlobalAudioPermissionReqMsg): Unit = {
for {
user <- Users2x.findWithIntId(liveMeeting.users2x, msg.body.userId)
} yield {
if (!user.userLeftFlag.left
&& liveMeeting.props.meetingProp.intId == msg.body.meetingId
&& liveMeeting.props.voiceProp.voiceConf == msg.body.voiceConf) {
allowed = true
}
def broadcastEvent(
meetingId: String,
voiceConf: String,
userId: String,
sfuSessionId: String,
allowed: Boolean
): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(GetGlobalAudioPermissionRespMsg.NAME, routing)
val header = BbbClientMsgHeader(GetGlobalAudioPermissionRespMsg.NAME, meetingId, userId)
val body = GetGlobalAudioPermissionRespMsgBody(
meetingId,
voiceConf,
userId,
sfuSessionId,
allowed
)
val event = GetGlobalAudioPermissionRespMsg(header, body)
val eventMsg = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(eventMsg)
}
val event = MsgBuilder.buildGetGlobalAudioPermissionRespMsg(
val allowed = VoiceHdlrHelpers.isGlobalAudioSubscribeAllowed(
liveMeeting,
msg.body.meetingId,
msg.body.userId,
msg.body.voiceConf
)
broadcastEvent(
liveMeeting.props.meetingProp.intId,
liveMeeting.props.voiceProp.voiceConf,
msg.body.userId,
msg.body.sfuSessionId,
allowed
)
outGW.send(event)
}
}

View File

@ -0,0 +1,53 @@
package org.bigbluebutton.core.apps.voice
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.running.{ LiveMeeting, MeetingActor, OutMsgRouter }
trait GetMicrophonePermissionReqMsgHdlr {
this: MeetingActor =>
val liveMeeting: LiveMeeting
val outGW: OutMsgRouter
def handleGetMicrophonePermissionReqMsg(msg: GetMicrophonePermissionReqMsg): Unit = {
def broadcastEvent(
meetingId: String,
voiceConf: String,
userId: String,
sfuSessionId: String,
allowed: Boolean
): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(GetMicrophonePermissionRespMsg.NAME, routing)
val header = BbbClientMsgHeader(GetMicrophonePermissionRespMsg.NAME, meetingId, userId)
val body = GetMicrophonePermissionRespMsgBody(
meetingId,
voiceConf,
userId,
sfuSessionId,
allowed
)
val event = GetMicrophonePermissionRespMsg(header, body)
val eventMsg = BbbCommonEnvCoreMsg(envelope, event)
outGW.send(eventMsg)
}
val allowed = VoiceHdlrHelpers.isMicrophoneSharingAllowed(
liveMeeting,
msg.body.meetingId,
msg.body.userId,
msg.body.voiceConf,
msg.body.callerIdNum
)
broadcastEvent(
liveMeeting.props.meetingProp.intId,
liveMeeting.props.voiceProp.voiceConf,
msg.body.userId,
msg.body.sfuSessionId,
allowed
)
}
}

View File

@ -0,0 +1,53 @@
package org.bigbluebutton.core.apps.voice
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.core.models.{ Users2x, VoiceUsers }
import org.bigbluebutton.core.running.{ LiveMeeting }
import org.bigbluebutton.LockSettingsUtil
import org.bigbluebutton.SystemConfiguration
object VoiceHdlrHelpers extends SystemConfiguration {
def isGlobalAudioSubscribeAllowed(
liveMeeting: LiveMeeting,
meetingId: String,
userId: String,
voiceConf: String
): Boolean = {
Users2x.findWithIntId(liveMeeting.users2x, userId) match {
case Some(user) => (
applyPermissionCheck &&
liveMeeting.props.meetingProp.intId == meetingId &&
liveMeeting.props.voiceProp.voiceConf == voiceConf
)
case _ => false
}
}
def isMicrophoneSharingAllowed(
liveMeeting: LiveMeeting,
meetingId: String,
userId: String,
voiceConf: String,
callerIdNum: String
): Boolean = {
Users2x.findWithIntId(liveMeeting.users2x, userId) match {
case Some(user) => {
val microphoneSharingLocked = LockSettingsUtil.isMicrophoneSharingLocked(
user,
liveMeeting
)
val isCallerBanned = VoiceUsers.isCallerBanned(
callerIdNum,
liveMeeting.voiceUsers
)
(applyPermissionCheck &&
!isCallerBanned &&
!microphoneSharingLocked &&
liveMeeting.props.meetingProp.intId == meetingId &&
liveMeeting.props.voiceProp.voiceConf == voiceConf)
}
case _ => false
}
}
}

View File

@ -23,7 +23,6 @@ object CameraHdlrHelpers extends SystemConfiguration with RightsManagementTrait
(applyPermissionCheck &&
!camBroadcastLocked &&
!camCapReached &&
!user.userLeftFlag.left &&
streamId.startsWith(user.intId) &&
liveMeeting.props.meetingProp.intId == meetingId)
}
@ -43,7 +42,6 @@ object CameraHdlrHelpers extends SystemConfiguration with RightsManagementTrait
(applyPermissionCheck &&
!camSubscribeLocked &&
!user.userLeftFlag.left &&
liveMeeting.props.meetingProp.intId == meetingId)
}
case _ => false

View File

@ -5,9 +5,9 @@ import org.bigbluebutton.core.util.RandomStringGenerator
object GroupChatFactory {
def genId(): String = System.currentTimeMillis() + "-" + RandomStringGenerator.randomAlphanumericString(8)
def create(id: String, name: String, access: String, createdBy: GroupChatUser,
def create(id: String, access: String, createdBy: GroupChatUser,
users: Vector[GroupChatUser], msgs: Vector[GroupChatMessage]): GroupChat = {
new GroupChat(id, name, access, createdBy, users, msgs)
new GroupChat(id, access, createdBy, users, msgs)
}
}
@ -23,7 +23,7 @@ case class GroupChats(chats: collection.immutable.Map[String, GroupChat]) {
def getAllGroupChatsInMeeting(): Vector[GroupChat] = chats.values.toVector
}
case class GroupChat(id: String, name: String, access: String, createdBy: GroupChatUser,
case class GroupChat(id: String, access: String, createdBy: GroupChatUser,
users: Vector[GroupChatUser],
msgs: Vector[GroupChatMessage]) {
def findMsgWithId(id: String): Option[GroupChatMessage] = msgs.find(m => m.id == id)

View File

@ -3,6 +3,7 @@ package org.bigbluebutton.core.models
import org.bigbluebutton.common2.domain.PageVO
import org.bigbluebutton.core.models.PresentationInPod
import org.bigbluebutton.core.util.RandomStringGenerator
import org.bigbluebutton.common2.msgs.AnnotationVO
object PresentationPodFactory {
private def genId(): String = System.currentTimeMillis() + "-" + RandomStringGenerator.randomAlphanumericString(8)
@ -81,6 +82,9 @@ case class PresentationPod(id: String, currentPresenter: String,
def getPresentation(presentationId: String): Option[PresentationInPod] =
presentations.values find (p => p.id == presentationId)
def getPresentationsByFilename(filename: String): Iterable[PresentationInPod] =
presentations.values filter (p => p.name.startsWith(filename))
def setCurrentPresentation(presId: String): Option[PresentationPod] = {
var tempPod: PresentationPod = this
presentations.values foreach (curPres => { // unset previous current presentation

View File

@ -215,6 +215,8 @@ class ReceivedJsonMsgHandlerActor(
routeVoiceMsg[VoiceConfCallStateEvtMsg](envelope, jsonNode)
case GetGlobalAudioPermissionReqMsg.NAME =>
routeGenericMsg[GetGlobalAudioPermissionReqMsg](envelope, jsonNode)
case GetMicrophonePermissionReqMsg.NAME =>
routeGenericMsg[GetMicrophonePermissionReqMsg](envelope, jsonNode)
// Breakout rooms
case BreakoutRoomsListMsg.NAME =>
@ -304,6 +306,12 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[PdfConversionInvalidErrorSysPubMsg](envelope, jsonNode)
case AssignPresenterReqMsg.NAME =>
routeGenericMsg[AssignPresenterReqMsg](envelope, jsonNode)
case MakePresentationWithAnnotationDownloadReqMsg.NAME =>
routeGenericMsg[MakePresentationWithAnnotationDownloadReqMsg](envelope, jsonNode)
case ExportPresentationWithAnnotationReqMsg.NAME =>
routeGenericMsg[ExportPresentationWithAnnotationReqMsg](envelope, jsonNode)
case NewPresAnnFileAvailableMsg.NAME =>
routeGenericMsg[NewPresAnnFileAvailableMsg](envelope, jsonNode)
// Presentation Pods
case CreateNewPresentationPodPubMsg.NAME =>
@ -384,7 +392,6 @@ class ReceivedJsonMsgHandlerActor(
routeGenericMsg[UpdateExternalVideoPubMsg](envelope, jsonNode)
case StopExternalVideoPubMsg.NAME =>
routeGenericMsg[StopExternalVideoPubMsg](envelope, jsonNode)
case _ =>
log.error("Cannot route envelope name " + envelope.name)
// do nothing

View File

@ -31,4 +31,4 @@ trait AbstractPresentationRecordEvent extends RecordEvent {
object AbstractPresentationRecordEvent {
protected final val POD_ID = "podId"
}
}

View File

@ -0,0 +1,24 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 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/>.
*
*/
package org.bigbluebutton.core.record.events
trait AbstractPresentationWithAnnotations extends RecordEvent {
setModule("PRES-ANN")
}

View File

@ -45,4 +45,4 @@ object AbstractWhiteboardRecordEvent {
protected final val PRESENTATION = "presentation"
protected final val PAGE_NUM = "pageNumber"
protected final val WHITEBOARD_ID = "whiteboardId"
}
}

View File

@ -0,0 +1,65 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 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/>.
*
*/
package org.bigbluebutton.core.record.events
import org.bigbluebutton.common2.domain.SimpleVoteOutVO
import scala.collection.immutable.List
import scala.collection.Map
import scala.collection.mutable.ArrayBuffer
import spray.json._
import DefaultJsonProtocol._
class AddTldrawShapeWhiteboardRecordEvent extends AbstractWhiteboardRecordEvent {
import AddTldrawShapeWhiteboardRecordEvent._
implicit object AnyJsonFormat extends JsonFormat[Any] {
def write(x: Any) = x match {
case n: Int => JsNumber(n)
case s: String => JsString(s)
case d: Double => JsNumber(d)
case m: scala.collection.immutable.Map[String, _] => mapFormat[String, Any].write(m)
case l: List[_] => listFormat[Any].write(l)
case b: Boolean if b == true => JsTrue
case b: Boolean if b == false => JsFalse
}
def read(value: JsValue) = {}
}
setEvent("AddTldrawShapeEvent")
def setUserId(id: String) {
eventMap.put(USER_ID, id)
}
def setAnnotationId(id: String) {
eventMap.put(SHAPE_ID, id)
}
def addAnnotation(annotation: scala.collection.immutable.Map[String, Any]) {
eventMap.put(SHAPE_DATA, annotation.toJson.compactPrint)
}
}
object AddTldrawShapeWhiteboardRecordEvent {
protected final val USER_ID = "userId"
protected final val SHAPE_ID = "shapeId"
protected final val SHAPE_DATA = "shapeData"
}

View File

@ -0,0 +1,39 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 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/>.
*
*/
package org.bigbluebutton.core.record.events
class DeleteTldrawShapeRecordEvent extends AbstractWhiteboardRecordEvent {
import DeleteTldrawShapeRecordEvent._
setEvent("DeleteTldrawShapeEvent")
def setUserId(userId: String) {
eventMap.put(USER_ID, userId)
}
def setShapeId(shapeId: String) {
eventMap.put(SHAPE_ID, shapeId)
}
}
object DeleteTldrawShapeRecordEvent {
protected final val USER_ID = "userId"
protected final val SHAPE_ID = "shapeId"
}

View File

@ -32,23 +32,28 @@ class ResizeAndMoveSlideRecordEvent extends AbstractPresentationRecordEvent {
eventMap.put(ID, id)
}
def setXCamera(xCamera: Double) {
eventMap.put(X_CAMERA, xCamera.toString)
def setXOffset(offset: Double) {
eventMap.put(X_OFFSET, offset.toString)
}
def setYCamera(yCamera: Double) {
eventMap.put(Y_CAMERA, yCamera.toString)
def setYOffset(offset: Double) {
eventMap.put(Y_OFFSET, offset.toString)
}
def setZoom(zoom: Double) {
eventMap.put(ZOOM, zoom.toString)
def setWidthRatio(ratio: Double) {
eventMap.put(WIDTH_RATIO, ratio.toString)
}
def setHeightRatio(ratio: Double) {
eventMap.put(HEIGHT_RATIO, ratio.toString)
}
}
object ResizeAndMoveSlideRecordEvent {
protected final val PRES_NAME = "presentationName"
protected final val ID = "id"
protected final val X_CAMERA = "xCamera"
protected final val Y_CAMERA = "yCamera"
protected final val ZOOM = "zoom"
protected final val X_OFFSET = "xOffset"
protected final val Y_OFFSET = "yOffset"
protected final val WIDTH_RATIO = "widthRatio"
protected final val HEIGHT_RATIO = "heightRatio"
}

View File

@ -0,0 +1,48 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 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/>.
*
*/
package org.bigbluebutton.core.record.events
import org.bigbluebutton.common2.msgs.{ AnnotationVO, ExportJob, StoredAnnotations, PresentationPageForExport }
import org.bigbluebutton.common2.util.JsonUtil
class StoreAnnotationsInRedisPresAnnEvent extends AbstractPresentationWithAnnotations {
import StoreAnnotationsInRedisPresAnnEvent._
setEvent("StoreAnnotationsInRedisPresAnnEvent")
def setJobId(jobId: String) {
eventMap.put(JOB_ID, jobId)
}
def setPresId(presId: String) {
eventMap.put(PRES_ID, presId)
}
def setPages(pages: List[PresentationPageForExport]) {
eventMap.put(PAGES, JsonUtil.toJson(pages))
}
}
object StoreAnnotationsInRedisPresAnnEvent {
protected final val JOB_ID = "jobId"
protected final val PRES_ID = "presId"
protected final val PAGES = "pages"
}

View File

@ -0,0 +1,77 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 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/>.
*
*/
package org.bigbluebutton.core.record.events
import org.bigbluebutton.common2.msgs.{ AnnotationVO, ExportJob, StoredAnnotations, PresentationPageForExport }
import org.bigbluebutton.common2.util.JsonUtil
class StoreExportJobInRedisPresAnnEvent extends AbstractPresentationWithAnnotations {
import StoreExportJobInRedisPresAnnEvent._
setEvent("StoreExportJobInRedisPresAnnEvent")
def setJobId(jobId: String) {
eventMap.put(JOB_ID, jobId)
}
def setJobType(jobType: String) {
eventMap.put(JOB_TYPE, jobType)
}
def setFilename(filename: String) {
eventMap.put(FILENAME, filename)
}
def setPresId(presId: String) {
eventMap.put(PRES_ID, presId)
}
def setPresLocation(presLocation: String) {
eventMap.put(PRES_LOCATION, presLocation)
}
def setAllPages(allPages: String) {
eventMap.put(ALL_PAGES, allPages)
}
def setPages(pages: List[Int]) {
eventMap.put(PAGES, JsonUtil.toJson(pages))
}
def setParentMeetingId(parentMeetingId: String) {
eventMap.put(PARENT_MEETING_ID, parentMeetingId)
}
def setPresentationUploadToken(presentationUploadToken: String) {
eventMap.put(PRESENTATION_UPLOAD_TOKEN, presentationUploadToken)
}
}
object StoreExportJobInRedisPresAnnEvent {
protected final val JOB_ID = "jobId"
protected final val JOB_TYPE = "jobType"
protected final val FILENAME = "filename"
protected final val PRES_ID = "presId"
protected final val PRES_LOCATION = "presLocation"
protected final val ALL_PAGES = "allPages"
protected final val PAGES = "pages"
protected final val PARENT_MEETING_ID = "parentMeetingId"
protected final val PRESENTATION_UPLOAD_TOKEN = "presentationUploadToken"
}

View File

@ -0,0 +1,54 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2017 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/>.
*
*/
package org.bigbluebutton.core.record.events
class TldrawCameraChangedRecordEvent extends AbstractPresentationRecordEvent {
import TldrawCameraChangedRecordEvent._
setEvent("TldrawCameraChangedEvent")
def setPresentationName(name: String) {
eventMap.put(PRES_NAME, name)
}
def setId(id: String) {
eventMap.put(ID, id)
}
def setXCamera(xCamera: Double) {
eventMap.put(X_CAMERA, xCamera.toString)
}
def setYCamera(yCamera: Double) {
eventMap.put(Y_CAMERA, yCamera.toString)
}
def setZoom(zoom: Double) {
eventMap.put(ZOOM, zoom.toString)
}
}
object TldrawCameraChangedRecordEvent {
protected final val PRES_NAME = "presentationName"
protected final val ID = "id"
protected final val X_CAMERA = "xCamera"
protected final val Y_CAMERA = "yCamera"
protected final val ZOOM = "zoom"
}

View File

@ -80,6 +80,7 @@ class MeetingActor(
with MuteMeetingCmdMsgHdlr
with IsMeetingMutedReqMsgHdlr
with GetGlobalAudioPermissionReqMsgHdlr
with GetMicrophonePermissionReqMsgHdlr
with GetScreenBroadcastPermissionReqMsgHdlr
with GetScreenSubscribePermissionReqMsgHdlr
@ -467,6 +468,8 @@ class MeetingActor(
handleUserStatusVoiceConfEvtMsg(m)
case m: GetGlobalAudioPermissionReqMsg =>
handleGetGlobalAudioPermissionReqMsg(m)
case m: GetMicrophonePermissionReqMsg =>
handleGetMicrophonePermissionReqMsg(m)
// Layout
case m: GetCurrentLayoutReqMsg => handleGetCurrentLayoutReqMsg(m)
@ -497,6 +500,9 @@ class MeetingActor(
// Presentation
case m: PreuploadedPresentationsSysPubMsg => presentationApp2x.handle(m, liveMeeting, msgBus)
case m: AssignPresenterReqMsg => state = handlePresenterChange(m, state)
case m: MakePresentationWithAnnotationDownloadReqMsg => presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: ExportPresentationWithAnnotationReqMsg => presentationPodsApp.handle(m, state, liveMeeting, msgBus)
case m: NewPresAnnFileAvailableMsg => log.info("***** New PDF with annotations available.")
// Presentation Pods
case m: CreateNewPresentationPodPubMsg => state = presentationPodsApp.handle(m, state, liveMeeting, msgBus)

View File

@ -110,6 +110,11 @@ class AnalyticsActor(val includeChat: Boolean) extends Actor with ActorLogging {
case m: SetPresentationDownloadableEvtMsg => logMessage(msg)
//case m: PresentationPageConvertedSysMsg => logMessage(msg)
//case m: PresentationPageConvertedEventMsg => logMessage(msg)
// case m: StoreAnnotationsInRedisSysMsg => logMessage(msg)
// case m: StoreExportJobInRedisSysMsg => logMessage(msg)
case m: MakePresentationWithAnnotationDownloadReqMsg => logMessage(msg)
case m: ExportPresentationWithAnnotationReqMsg => logMessage(msg)
case m: NewPresAnnFileAvailableMsg => logMessage(msg)
case m: PresentationPageConversionStartedSysMsg => logMessage(msg)
case m: PresentationConversionEndedSysMsg => logMessage(msg)
case m: PresentationConversionRequestReceivedSysMsg => logMessage(msg)

View File

@ -347,16 +347,6 @@ object MsgBuilder {
BbbCommonEnvCoreMsg(envelope, event)
}
def buildStopMeetingTranscodersSysCmdMsg(meetingId: String): BbbCommonEnvCoreMsg = {
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
val envelope = BbbCoreEnvelope(StopMeetingTranscodersSysCmdMsg.NAME, routing)
val body = StopMeetingTranscodersSysCmdMsgBody()
val header = BbbCoreHeaderWithMeetingId(StopMeetingTranscodersSysCmdMsg.NAME, meetingId)
val event = StopMeetingTranscodersSysCmdMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def buildRecordingChapterBreakSysMsg(meetingId: String, timestamp: Long): BbbCommonEnvCoreMsg = {
val routing = collection.immutable.HashMap("sender" -> "bbb-apps-akka")
val envelope = BbbCoreEnvelope(RecordingChapterBreakSysMsg.NAME, routing)
@ -461,23 +451,6 @@ object MsgBuilder {
BbbCommonEnvCoreMsg(envelope, event)
}
def buildGetGlobalAudioPermissionRespMsg(
meetingId: String,
voiceConf: String,
userId: String,
sfuSessionId: String,
allowed: Boolean
): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, meetingId, userId)
val envelope = BbbCoreEnvelope(GetGlobalAudioPermissionRespMsg.NAME, routing)
val header = BbbClientMsgHeader(GetGlobalAudioPermissionRespMsg.NAME, meetingId, userId)
val body = GetGlobalAudioPermissionRespMsgBody(meetingId, voiceConf, userId, sfuSessionId, allowed)
val event = GetGlobalAudioPermissionRespMsg(header, body)
BbbCommonEnvCoreMsg(envelope, event)
}
def buildMeetingTimeRemainingUpdateEvtMsg(meetingId: String, timeLeftInSec: Long, timeUpdatedInMinutes: Int = 0): BbbCommonEnvCoreMsg = {
val routing = Routing.addMsgToClientRouting(MessageTypes.BROADCAST_TO_MEETING, meetingId, "not-used")
val envelope = BbbCoreEnvelope(MeetingTimeRemainingUpdateEvtMsg.NAME, routing)

View File

@ -0,0 +1,88 @@
package org.bigbluebutton.endpoint.redis
import scala.collection.immutable.StringOps
import scala.collection.JavaConverters._
import org.bigbluebutton.common2.msgs._
import org.bigbluebutton.common2.redis.{ RedisConfig, RedisStorageProvider }
import org.bigbluebutton.core.record.events.{ AbstractPresentationWithAnnotations, StoreAnnotationsInRedisPresAnnEvent, StoreExportJobInRedisPresAnnEvent }
import akka.actor.Actor
import akka.actor.ActorLogging
import akka.actor.ActorSystem
import akka.actor.Props
import org.bigbluebutton.service.HealthzService
import scala.concurrent.duration._
import scala.concurrent._
import ExecutionContext.Implicits.global
object ExportAnnotationsActor {
def props(
system: ActorSystem,
redisConfig: RedisConfig,
healthzService: HealthzService
): Props =
Props(
classOf[ExportAnnotationsActor],
system,
redisConfig,
healthzService
)
}
class ExportAnnotationsActor(
system: ActorSystem,
redisConfig: RedisConfig,
healthzService: HealthzService
)
extends RedisStorageProvider(
system,
"BbbAppsAkkaRecorder",
redisConfig
) with Actor with ActorLogging {
private def storePresentationAnnotations(session: String, message: java.util.Map[java.lang.String, java.lang.String], messageType: String): Unit = {
redis.storePresentationAnnotations(session, message, messageType)
}
def receive = {
//=============================
// 2x messages
case msg: BbbCommonEnvCoreMsg => handleBbbCommonEnvCoreMsg(msg)
case _ => // do nothing
}
private def handleBbbCommonEnvCoreMsg(msg: BbbCommonEnvCoreMsg): Unit = {
msg.core match {
case m: StoreAnnotationsInRedisSysMsg => handleStoreAnnotationsInRedisSysMsg(m)
case m: StoreExportJobInRedisSysMsg => handleStoreExportJobInRedisSysMsg(m)
case _ => // message not to be stored.
}
}
private def handleStoreAnnotationsInRedisSysMsg(msg: StoreAnnotationsInRedisSysMsg) {
val ev = new StoreAnnotationsInRedisPresAnnEvent()
ev.setJobId(msg.body.annotations.jobId)
ev.setPresId(msg.body.annotations.presId)
ev.setPages(msg.body.annotations.pages)
storePresentationAnnotations(msg.header.meetingId, ev.toMap.asJava, "PresAnn")
}
private def handleStoreExportJobInRedisSysMsg(msg: StoreExportJobInRedisSysMsg) {
val ev = new StoreExportJobInRedisPresAnnEvent()
ev.setJobId(msg.body.exportJob.jobId)
ev.setJobType(msg.body.exportJob.jobType)
ev.setFilename(msg.body.exportJob.filename)
ev.setPresId(msg.body.exportJob.presId)
ev.setPresLocation(msg.body.exportJob.presLocation)
ev.setAllPages(msg.body.exportJob.allPages.toString)
ev.setPages(msg.body.exportJob.pages)
ev.setParentMeetingId(msg.body.exportJob.parentMeetingId)
ev.setPresentationUploadToken(msg.body.exportJob.presUploadToken)
storePresentationAnnotations(msg.header.meetingId, ev.toMap.asJava, "ExportJob")
}
}

View File

@ -184,7 +184,7 @@ class RedisRecorderActor(
}
private def handleResizeAndMovePageEvtMsg(msg: ResizeAndMovePageEvtMsg) {
val ev = new ResizeAndMoveSlideRecordEvent()
val ev = new TldrawCameraChangedRecordEvent()
ev.setMeetingId(msg.header.meetingId)
ev.setPodId(msg.body.podId)
ev.setPresentationName(msg.body.presentationId)
@ -280,7 +280,7 @@ class RedisRecorderActor(
private def handleSendWhiteboardAnnotationsEvtMsg(msg: SendWhiteboardAnnotationsEvtMsg) {
msg.body.annotations.foreach(annotation => {
val ev = new AddShapeWhiteboardRecordEvent()
val ev = new AddTldrawShapeWhiteboardRecordEvent()
ev.setMeetingId(msg.header.meetingId)
ev.setPresentation(getPresentationId(annotation.wbId))
ev.setPageNumber(getPageNum(annotation.wbId))
@ -320,7 +320,7 @@ class RedisRecorderActor(
private def handleDeleteWhiteboardAnnotationsEvtMsg(msg: DeleteWhiteboardAnnotationsEvtMsg) {
msg.body.annotationsIds.foreach(annotationId => {
val ev = new UndoAnnotationRecordEvent()
val ev = new DeleteTldrawShapeRecordEvent()
ev.setMeetingId(msg.header.meetingId)
ev.setPresentation(getPresentationId(msg.body.whiteboardId))
ev.setPageNumber(getPageNum(msg.body.whiteboardId))

View File

@ -3,6 +3,7 @@ Description=BigBlueButton Apps (Akka)
Requires=network.target
Wants=redis-server.service
After=redis-server.service
PartOf=bigbluebutton.target
[Service]
Type=simple
@ -22,5 +23,4 @@ PermissionsStartOnly=true
LimitNOFILE=1024
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target bigbluebutton.target

View File

@ -7,10 +7,9 @@ class GroupsChatTests extends UnitSpec {
"A GroupChat" should "be able to add and remove user" in {
val gcId = "gc-id"
val chatName = "Public"
val userId = "uid-1"
val createBy = GroupChatUser("groupId")
val gc = GroupChatFactory.create(gcId, chatName, GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty)
val gc = GroupChatFactory.create(gcId, GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty)
val user = GroupChatUser(userId)
val gc2 = gc.add(user)
assert(gc2.users.size == 1)
@ -26,8 +25,7 @@ class GroupsChatTests extends UnitSpec {
"A GroupChat" should "be able to add, update, and remove msg" in {
val createBy = GroupChatUser("groupId")
val gcId = "gc-id"
val chatName = "Public"
val gc = GroupChatFactory.create(gcId, chatName, GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty)
val gc = GroupChatFactory.create(gcId, GroupChatAccess.PUBLIC, createBy, Vector.empty, Vector.empty)
val msgId1 = "msgid-1"
val ts = System.currentTimeMillis()
val hello = "Hello World!"

View File

@ -18,7 +18,7 @@ val compileSettings = Seq(
"-Xlint",
"-Ywarn-dead-code",
"-language:_",
"-target:jvm-1.8",
"-target:jvm-1.11",
"-encoding", "UTF-8"
),
javacOptions ++= List(

View File

@ -46,8 +46,8 @@ object Dependencies {
val apacheLang = "org.apache.commons" % "commons-lang3" % Versions.lang
val bbbCommons = "org.bigbluebutton" % "bbb-common-message_2.13" % Versions.bbbCommons excludeAll (
ExclusionRule(organization = "org.red5"))
val bbbCommons = "org.bigbluebutton" % "bbb-common-message_2.13" % Versions.bbbCommons
val bbbFseslClient = "org.bigbluebutton" % "bbb-fsesl-client" % Versions.bbbFsesl
}

View File

@ -3,6 +3,7 @@ Description=BigBlueButton FS-ESL (Akka)
Requires=network.target
Wants=redis-server.service
After=redis-server.service
PartOf= bigbluebutton.target
[Service]
Type=simple
@ -22,5 +23,5 @@ PermissionsStartOnly=true
LimitNOFILE=1024
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target bigbluebutton.target

View File

@ -21,6 +21,8 @@ package org.bigbluebutton.common2.redis;
import java.util.HashMap;
import java.util.Map;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import io.lettuce.core.api.sync.BaseRedisCommands;
import org.slf4j.Logger;
@ -112,6 +114,33 @@ public class RedisStorageService extends RedisAwareCommunicator {
commands.exec();
}
public void storePresentationAnnotations(String meetingId, Map<String, String> event, String msgType) {
RedisCommands<String, String> commands = connection.sync();
commands.multi();
switch (msgType) {
case "PresAnn": {
commands.hmset(event.get("jobId"), event);
break;
}
case "ExportJob": {
Gson gson = new Gson();
String exportJobAsJson = gson.toJson(event);
commands.rpush("exportJobs", exportJobAsJson.toString());
break;
}
default: {
log.error("Attempted to store PresentationAnnotations message of type: {}", clientName);
break;
}
}
commands.exec();
}
// @fixme: not used anywhere
public void removeMeeting(String meetingId) {
RedisCommands<String, String> commands = connection.sync();

View File

@ -49,7 +49,6 @@ public class MessageSender extends RedisAwareCommunicator {
connectionPool = ConnectionPoolSupport.createGenericObjectPool(() -> redisClient.connectPubSub(),
createPoolingConfig());
log.info("Redis org.bigbluebutton.red5.pubsub.message publisher starting!");
try {
sendMessage = true;
@ -84,7 +83,7 @@ public class MessageSender extends RedisAwareCommunicator {
RedisAsyncCommands<String, String> async = connection.async();
RedisFuture<Long> future = async.publish(channel, message);
} catch (Exception e) {
log.warn("Cannot publish the org.bigbluebutton.red5.pubsub.message to redis", e);
log.warn("Cannot publish the message to redis", e);
}
}
};

View File

@ -49,8 +49,6 @@ case class UsersProp(
case class MetadataProp(metadata: collection.immutable.Map[String, String])
case class ScreenshareProps(screenshareConf: String, red5ScreenshareIp: String, red5ScreenshareApp: String)
case class LockSettingsProps(
disableCam: Boolean,
disableMic: Boolean,

View File

@ -8,7 +8,7 @@ object GroupChatAccess {
case class GroupChatUser(id: String, name: String = "", role: String = "VIEWER")
case class GroupChatMsgFromUser(correlationId: String, sender: GroupChatUser, chatEmphasizedText: Boolean = false, message: String)
case class GroupChatMsgToUser(id: String, timestamp: Long, correlationId: String, sender: GroupChatUser, chatEmphasizedText: Boolean = false, message: String)
case class GroupChatInfo(id: String, name: String, access: String, createdBy: GroupChatUser, users: Vector[GroupChatUser])
case class GroupChatInfo(id: String, access: String, createdBy: GroupChatUser, users: Vector[GroupChatUser])
object OpenGroupChatWindowReqMsg { val NAME = "OpenGroupChatWindowReqMsg" }
case class OpenGroupChatWindowReqMsg(header: BbbClientMsgHeader, body: OpenGroupChatWindowReqMsgBody) extends StandardMsg
@ -36,14 +36,14 @@ case class GetGroupChatMsgsRespMsgBody(chatId: String, msgs: Vector[GroupChatMsg
object CreateGroupChatReqMsg { val NAME = "CreateGroupChatReqMsg" }
case class CreateGroupChatReqMsg(header: BbbClientMsgHeader, body: CreateGroupChatReqMsgBody) extends StandardMsg
case class CreateGroupChatReqMsgBody(correlationId: String, name: String, access: String,
case class CreateGroupChatReqMsgBody(correlationId: String, access: String,
users: Vector[String], msg: Vector[GroupChatMsgFromUser])
object GroupChatCreatedEvtMsg { val NAME = "GroupChatCreatedEvtMsg" }
case class GroupChatCreatedEvtMsg(header: BbbClientMsgHeader, body: GroupChatCreatedEvtMsgBody) extends BbbCoreMsg
case class GroupChatCreatedEvtMsgBody(correlationId: String, chatId: String, createdBy: GroupChatUser,
name: String, access: String,
users: Vector[GroupChatUser], msg: Vector[GroupChatMsgToUser])
access: String,
users: Vector[GroupChatUser], msg: Vector[GroupChatMsgToUser])
object DestroyGroupChatReqMsg { val NAME = "DestroyGroupChatReqMsg" }
case class DestroyGroupChatReqMsg(header: BbbClientMsgHeader, body: DestroyGroupChatReqMsgBody) extends StandardMsg

View File

@ -3,13 +3,25 @@ package org.bigbluebutton.common2.msgs
import org.bigbluebutton.common2.domain.PresentationVO
// ------------ client to akka-apps ------------
// ------------ client to akka-apps ------------
// ------------ bbb-common-web to akka-apps ------------
object PreuploadedPresentationsSysPubMsg { val NAME = "PreuploadedPresentationsSysPubMsg" }
case class PreuploadedPresentationsSysPubMsg(header: BbbClientMsgHeader, body: PreuploadedPresentationsSysPubMsgBody) extends StandardMsg
case class PreuploadedPresentationsSysPubMsgBody(presentations: Vector[PresentationVO])
object MakePresentationWithAnnotationDownloadReqMsg { val NAME = "MakePresentationWithAnnotationDownloadReqMsg" }
case class MakePresentationWithAnnotationDownloadReqMsg(header: BbbClientMsgHeader, body: MakePresentationWithAnnotationDownloadReqMsgBody) extends StandardMsg
case class MakePresentationWithAnnotationDownloadReqMsgBody(presId: String, allPages: Boolean, pages: List[Int])
object ExportPresentationWithAnnotationReqMsg { val NAME = "ExportPresentationWithAnnotationReqMsg" }
case class ExportPresentationWithAnnotationReqMsg(header: BbbClientMsgHeader, body: ExportPresentationWithAnnotationReqMsgBody) extends StandardMsg
case class ExportPresentationWithAnnotationReqMsgBody(parentMeetingId: String, allPages: Boolean)
object NewPresAnnFileAvailableMsg { val NAME = "NewPresAnnFileAvailableMsg" }
case class NewPresAnnFileAvailableMsg(header: BbbClientMsgHeader, body: NewPresAnnFileAvailableMsgBody) extends StandardMsg
case class NewPresAnnFileAvailableMsgBody(fileURI: String)
// ------------ bbb-common-web to akka-apps ------------
// ------------ akka-apps to client ------------

View File

@ -1,43 +0,0 @@
package org.bigbluebutton.common2.msgs
/* In Messages */
object StartProbingSysReqMsg { val NAME = "StartProbingSysReqMsg" }
case class StartProbingSysReqMsg(header: BbbCoreHeaderWithMeetingId, body: StartProbingSysReqMsgBody) extends BbbCoreMsg
case class StartProbingSysReqMsgBody(transcoderId: String, params: Map[String, String])
object StartTranscoderSysReqMsg { val NAME = "StartTranscoderSysReqMsg" }
case class StartTranscoderSysReqMsg(header: BbbCoreHeaderWithMeetingId, body: StartTranscoderSysReqMsgBody) extends BbbCoreMsg
case class StartTranscoderSysReqMsgBody(transcoderId: String, params: Map[String, String])
object StopTranscoderSysReqMsg { val NAME = "StopTranscoderSysReqMsg" }
case class StopTranscoderSysReqMsg(header: BbbCoreHeaderWithMeetingId, body: StopTranscoderSysReqMsgBody) extends BbbCoreMsg
case class StopTranscoderSysReqMsgBody(transcoderId: String)
object UpdateTranscoderSysReqMsg { val NAME = "UpdateTranscoderSysReqMsg" }
case class UpdateTranscoderSysReqMsg(header: BbbCoreHeaderWithMeetingId, body: UpdateTranscoderSysReqMsgBody) extends BbbCoreMsg
case class UpdateTranscoderSysReqMsgBody(transcoderId: String, params: Map[String, String])
object TranscoderStatusUpdateSysCmdMsg { val NAME = "TranscoderStatusUpdateSysCmdMsg" }
case class TranscoderStatusUpdateSysCmdMsg(header: BbbCoreHeaderWithMeetingId, body: TranscoderStatusUpdateSysCmdMsgBody) extends BbbCoreMsg
case class TranscoderStatusUpdateSysCmdMsgBody(transcoderId: String, params: Map[String, String])
object StopMeetingTranscodersSysCmdMsg { val NAME = "StopMeetingTranscodersSysCmdMsg" }
case class StopMeetingTranscodersSysCmdMsg(header: BbbCoreHeaderWithMeetingId, body: StopMeetingTranscodersSysCmdMsgBody) extends BbbCoreMsg
case class StopMeetingTranscodersSysCmdMsgBody()
/* Out Messages */
object StartProbingSysRespMsg { val NAME = "StartProbingSysRespMsg" }
case class StartProbingSysRespMsg(header: BbbCoreHeaderWithMeetingId, body: StartProbingSysRespMsgBody) extends BbbCoreMsg
case class StartProbingSysRespMsgBody(transcoderId: String, params: Map[String, String])
object StartTranscoderSysRespMsg { val NAME = "StartTranscoderSysRespMsg" }
case class StartTranscoderSysRespMsg(header: BbbCoreHeaderWithMeetingId, body: StartTranscoderSysRespMsgBody) extends BbbCoreMsg
case class StartTranscoderSysRespMsgBody(transcoderId: String, params: Map[String, String])
object StopTranscoderSysRespMsg { val NAME = "StopTranscoderSysRespMsg" }
case class StopTranscoderSysRespMsg(header: BbbCoreHeaderWithMeetingId, body: StopTranscoderSysRespMsgBody) extends BbbCoreMsg
case class StopTranscoderSysRespMsgBody(transcoderId: String)
object UpdateTranscoderSysRespMsg { val NAME = "UpdateTranscoderSysRespMsg" }
case class UpdateTranscoderSysRespMsg(header: BbbCoreHeaderWithMeetingId, body: UpdateTranscoderSysRespMsgBody) extends BbbCoreMsg
case class UpdateTranscoderSysRespMsgBody(transcoderId: String, params: Map[String, String])

View File

@ -310,7 +310,7 @@ case class UserJoinMeetingAfterReconnectReqMsg(header: BbbClientMsgHeader, body:
case class UserJoinMeetingAfterReconnectReqMsgBody(userId: String, authToken: String, clientType: String)
/**
* Sent from bbb-apps when user disconnects from Red5.
* Sent from client to bbb-akka to notify that a user is leaving
*/
object UserLeaveReqMsg { val NAME = "UserLeaveReqMsg" }
case class UserLeaveReqMsg(header: BbbClientMsgHeader, body: UserLeaveReqMsgBody) extends StandardMsg

View File

@ -561,3 +561,40 @@ case class GetGlobalAudioPermissionRespMsgBody(
sfuSessionId: String,
allowed: Boolean
)
/* Sent by bbb-webrtc-sfu to ask permission for a new microphone/full audio
* connection
* - callerIdNum: the session's callerId as assembled by the requester
* - sfuSessionId: the UUID for this request's session in bbb-webrtc-sfu.
* Used for response matching.
*/
object GetMicrophonePermissionReqMsg { val NAME = "GetMicrophonePermissionReqMsg" }
case class GetMicrophonePermissionReqMsg(
header: BbbClientMsgHeader,
body: GetMicrophonePermissionReqMsgBody
) extends StandardMsg
case class GetMicrophonePermissionReqMsgBody(
meetingId: String,
voiceConf: String,
userId: String,
callerIdNum: String,
sfuSessionId: String
)
/* Sent to bbb-webrtc-sfu as a response to GetMicrophonePermissionReqMsg
* - sfuSessionId: the UUID for this request's session in bbb-webrtc-sfu.
* Used for response matching.
* - allowed: whether session creation should be allowed.
*/
object GetMicrophonePermissionRespMsg { val NAME = "GetMicrophonePermissionRespMsg" }
case class GetMicrophonePermissionRespMsg(
header: BbbClientMsgHeader,
body: GetMicrophonePermissionRespMsgBody
) extends StandardMsg
case class GetMicrophonePermissionRespMsgBody(
meetingId: String,
voiceConf: String,
userId: String,
sfuSessionId: String,
allowed: Boolean
)

View File

@ -3,6 +3,32 @@ package org.bigbluebutton.common2.msgs
case class AnnotationVO(id: String, annotationInfo: scala.collection.immutable.Map[String, Any],
wbId: String, userId: String)
case class PresentationPageForExport(
page: Int,
xCamera: Double,
yCamera: Double,
zoom: Double,
annotations: Array[AnnotationVO],
)
case class StoredAnnotations(
jobId: String,
presId: String,
pages: List[PresentationPageForExport],
)
case class ExportJob(
jobId: String,
jobType: String,
filename: String,
presId: String,
presLocation: String,
allPages: Boolean,
pages: List[Int],
parentMeetingId: String,
presUploadToken: String,
)
// ------------ client to akka-apps ------------
object ClientToServerLatencyTracerMsg { val NAME = "ClientToServerLatencyTracerMsg" }
case class ClientToServerLatencyTracerMsg(header: BbbClientMsgHeader, body: ClientToServerLatencyTracerMsgBody) extends StandardMsg
@ -65,4 +91,12 @@ case class SendWhiteboardAnnotationsEvtMsgBody(whiteboardId: String, annotations
object DeleteWhiteboardAnnotationsEvtMsg { val NAME = "DeleteWhiteboardAnnotationsEvtMsg" }
case class DeleteWhiteboardAnnotationsEvtMsg(header: BbbClientMsgHeader, body: DeleteWhiteboardAnnotationsEvtMsgBody) extends BbbCoreMsg
case class DeleteWhiteboardAnnotationsEvtMsgBody(whiteboardId: String, annotationsIds: Array[String])
// ------------ akka-apps to client ------------
object StoreAnnotationsInRedisSysMsg { val NAME = "StoreAnnotationsInRedisSysMsg" }
case class StoreAnnotationsInRedisSysMsg(header: BbbCoreHeaderWithMeetingId, body: StoreAnnotationsInRedisSysMsgBody) extends BbbCoreMsg
case class StoreAnnotationsInRedisSysMsgBody(annotations: StoredAnnotations)
object StoreExportJobInRedisSysMsg { val NAME = "StoreExportJobInRedisSysMsg" }
case class StoreExportJobInRedisSysMsg(header: BbbCoreHeaderWithMeetingId, body: StoreExportJobInRedisSysMsgBody) extends BbbCoreMsg
case class StoreExportJobInRedisSysMsgBody(exportJob: ExportJob)

5
bbb-common-web/.env Normal file
View File

@ -0,0 +1,5 @@
POSTGRES_VERSION=14.1
POSTGRES_USER=bbb
POSTGRES_PASSWORD=bbb123
HOST_PORT=5432
CONTAINER_PORT=5432

View File

@ -105,3 +105,15 @@ libraryDependencies += "javax.validation" % "validation-api" % "2.0.1.Final"
libraryDependencies += "org.springframework.boot" % "spring-boot-starter-validation" % "2.5.1"
libraryDependencies += "org.glassfish" % "javax.el" % "3.0.1-b12"
libraryDependencies += "org.apache.httpcomponents" % "httpclient" % "4.5.13"
libraryDependencies ++= Seq(
"javax.validation" % "validation-api" % "2.0.1.Final",
"org.springframework.boot" % "spring-boot-starter-validation" % "2.6.1",
"org.springframework.data" % "spring-data-commons" % "2.6.1",
"org.glassfish" % "javax.el" % "3.0.1-b12",
"org.apache.httpcomponents" % "httpclient" % "4.5.13",
"org.postgresql" % "postgresql" % "42.2.16",
"org.hibernate" % "hibernate-core" % "5.6.1.Final",
"org.flywaydb" % "flyway-core" % "7.8.2",
"com.zaxxer" % "HikariCP" % "4.0.3"
)

View File

@ -0,0 +1,2 @@
#!/bin/bash
docker rm -f $(docker ps -aq)

View File

@ -0,0 +1,14 @@
version: '2'
services:
postgres:
image: postgres:${POSTGRES_VERSION}
container_name: postgres
environment:
- "TZ=UTC"
- "POSTGRES_USER=${POSTGRES_USER}"
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
ports:
- "${HOST_PORT}:${CONTAINER_PORT}"
volumes:
- "./src/main/java/db/migration:/docker-entrypoint-initdb.d"

31
bbb-common-web/hibernate-cfg.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/bash
. .env
mkdir -p ./src/main/resources
echo '<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<!-- JDBC Database connection settings -->
<property name="connection.driver_class">org.postgresql.Driver</property>
<property name="connection.url">jdbc:postgresql://localhost:'"$HOST_PORT"'/bbb</property>
<property name="connection.username">'"$POSTGRES_USER"'</property>
<property name="connection.password">'"$POSTGRES_PASSWORD"'</property>
<!-- JDBC connection pool settings -->
<property name="hibernate.connection.provider_class">com.zaxxer.hikari.hibernate.HikariConnectionProvider</property>
<property name="hibernate.hikari.minimumIdle">5</property>
<property name="hibernate.hikari.maximumPoolSize">10</property>
<property name="hibernate.hikari.idleTimeout">30000</property>
<!-- Select our SQL dialect -->
<property name="dialect">org.hibernate.dialect.PostgreSQL10Dialect</property>
<!-- Echo the SQL to stdout -->
<property name="show_sql">true</property>
<!-- Set the current session context -->
<property name="current_session_context_class">thread</property>
<property name="hibernate.show_sql">false</property>
<!-- format the sql nice -->
<property name="hibernate.format_sql">false</property>
<!-- show the hql as comment -->
<property name="use_sql_comments">false</property>
</session-factory>
</hibernate-configuration>' > ./src/main/resources/hibernate.cfg.xml

12
bbb-common-web/psql.sh Normal file
View File

@ -0,0 +1,12 @@
#!/bin/bash
. .env
echo "================== Help for psql ========================="
echo "\\dt : Describe the current database"
echo "\\d [table] : Describe a table"
echo "\\c : Connect to a database"
echo "\\h : help with SQL commands"
echo "\\? : help with psql commands"
echo "\\q : quit"
echo "Reset the database using the truncate_tables('$POSTGRES_USER') function"
echo "=================================================================="
docker exec -it postgres psql -U $POSTGRES_USER -d bbb

View File

@ -0,0 +1,75 @@
CREATE DATABASE bbb;
\c bbb;
CREATE TABLE IF NOT EXISTS recordings (
id BIGSERIAL PRIMARY KEY,
record_id VARCHAR(64),
meeting_id VARCHAR(256),
name VARCHAR(256),
published BOOLEAN,
participants INT,
state VARCHAR(256),
start_time timestamp,
end_time timestamp,
deleted_at timestamp,
publish_updated BOOLEAN DEFAULT FALSE,
protected BOOLEAN
);
CREATE UNIQUE INDEX index_recording_on_recording_id ON recordings (record_id);
CREATE INDEX index_recordings_on_meeting_id ON recordings(meeting_id);
CREATE TABLE IF NOT EXISTS metadata (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT,
key VARCHAR(256),
value VARCHAR(256),
CONSTRAINT fk_metadata_recording FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
CREATE UNIQUE INDEX index_metadata_on_recording_id_and_key ON metadata(recording_id, key);
CREATE TABLE IF NOT EXISTS playback_formats (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT,
format VARCHAR(64),
url VARCHAR(256),
length INT,
processing_time INT,
CONSTRAINT fk_playback_formats_recording FOREIGN KEY (recording_id) REFERENCES recordings(id)
);
CREATE UNIQUE INDEX index_playback_formats_on_recording_id_and_format ON playback_formats(recording_id, format);
CREATE TABLE IF NOT EXISTS thumbnails (
id BIGSERIAL PRIMARY KEY,
playback_format_id BIGINT,
height INT,
width INT,
alt VARCHAR(256),
url VARCHAR(256),
sequence INT,
CONSTRAINT fk_thumbnails_playback_formats FOREIGN KEY (playback_format_id) REFERENCES playback_formats(id)
);
CREATE INDEX index_thumbnails_on_playback_format_id ON thumbnails(playback_format_id);
CREATE TABLE IF NOT EXISTS callback_data (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT,
meeting_id VARCHAR(256),
callback_attributes TEXT,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL,
CONSTRAINT fk_callback_data_recordings FOREIGN KEY (recording_id) REFERENCES recordings(id)
);
CREATE OR REPLACE FUNCTION truncate_tables(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
SELECT tablename FROM pg_tables
WHERE tableowner = username AND schemaname = 'public';
BEGIN
FOR stmt IN statements LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(stmt.tablename) || ' CASCADE;';
END LOOP;
END;
$$ LANGUAGE plpgsql;

View File

@ -47,8 +47,8 @@ public class HTML5LoadBalancingService {
// Find nodejs processes associated with processing meeting events
// $ ps -u meteor -o pcpu,cmd= | grep NODEJS_BACKEND_INSTANCE_ID
// 1.1 /usr/share/node-v12.16.1-linux-x64/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=1
// 1.0 /usr/share/node-v12.16.1-linux-x64/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=2
// 1.1 /usr/lib/bbb-html5/node/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=1
// 1.0 /usr/lib/bbb-html5/node/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=2
public void scanHTML5processes() {
try {
this.list = new ArrayList<HTML5ProcessLine>();

View File

@ -79,6 +79,8 @@ import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.InputStream;
import org.springframework.data.domain.*;
public class MeetingService implements MessageListener {
private static Logger log = LoggerFactory.getLogger(MeetingService.class);
@ -574,8 +576,26 @@ public class MeetingService implements MessageListener {
return recordingService.isRecordingExist(recordId);
}
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters) {
return recordingService.getRecordings2x(idList, states, metadataFilters);
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, String page, String size) {
int p;
int s;
try {
p = Integer.parseInt(page);
} catch(NumberFormatException e) {
p = 0;
}
try {
s = Integer.parseInt(size);
} catch(NumberFormatException e) {
s = 25;
}
log.info("{} {}", p, s);
Pageable pageable = PageRequest.of(p, s);
return recordingService.getRecordings2x(idList, states, metadataFilters, pageable);
}
public boolean existsAnyRecording(List<String> idList) {

View File

@ -19,696 +19,37 @@
package org.bigbluebutton.api;
import org.bigbluebutton.api.messaging.messages.MakePresentationDownloadableMsg;
import org.bigbluebutton.api.model.entity.Recording;
import org.bigbluebutton.api2.domain.UploadedTrack;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.bigbluebutton.api.domain.Recording;
import org.bigbluebutton.api.domain.RecordingMetadata;
import org.bigbluebutton.api.messaging.messages.MakePresentationDownloadableMsg;
import org.bigbluebutton.api.util.RecordingMetadataReaderHelper;
import org.bigbluebutton.api2.domain.UploadedTrack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.*;
public class RecordingService {
private static Logger log = LoggerFactory.getLogger(RecordingService.class);
public interface RecordingService {
private static final Pattern PRESENTATION_ID_PATTERN = Pattern.compile("^[a-z0-9]{40}-[0-9]{13}\\.[0-9a-zA-Z]{3,4}$");
private static String processDir = "/var/bigbluebutton/recording/process";
private static String publishedDir = "/var/bigbluebutton/published";
private static String unpublishedDir = "/var/bigbluebutton/unpublished";
private static String deletedDir = "/var/bigbluebutton/deleted";
private RecordingMetadataReaderHelper recordingServiceHelper;
private String recordStatusDir;
private String captionsDir;
private String presentationBaseDir;
private String defaultServerUrl;
private String defaultTextTrackUrl;
Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token);
String getRecordingTextTracks(String recordId);
String putRecordingTextTrack(UploadedTrack track);
String getCaptionTrackInboxDir();
String getCaptionsDir();
boolean isRecordingExist(String recordId);
String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, Pageable pageable);
boolean existAnyRecording(List<String> idList);
boolean changeState(String recordingId, String state);
void updateMetaParams(List<String> recordIDs, Map<String,String> metaParams);
void startIngestAndProcessing(String meetingId);
void markAsEnded(String meetingId);
void kickOffRecordingChapterBreak(String meetingId, Long timestamp);
void processMakePresentationDownloadableMsg(MakePresentationDownloadableMsg msg);
File getDownloadablePresentationFile(String meetingId, String presId, String presFilename);
private void copyPresentationFile(File presFile, File dlownloadableFile) {
try {
FileUtils.copyFile(presFile, dlownloadableFile);
} catch (IOException ex) {
log.error("Failed to copy file: {}", ex);
}
default <T> Page<T> listToPage(List<T> list, Pageable pageable) {
int start = (int) pageable.getOffset();
int end = (int) (Math.min((start + pageable.getPageSize()), list.size()));
return new PageImpl<>(list.subList(start, end), pageable, list.size());
}
public void processMakePresentationDownloadableMsg(MakePresentationDownloadableMsg msg) {
try {
File presDir = Util.getPresentationDir(presentationBaseDir, msg.meetingId, msg.presId);
Util.makePresentationDownloadable(presDir, msg.presId, msg.downloadable);
} catch (IOException e) {
log.error("Failed to make presentation downloadable: {}", e);
}
}
public File getDownloadablePresentationFile(String meetingId, String presId, String presFilename) {
log.info("Find downloadable presentation for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
if (! Util.isPresFileIdValidFormat(presFilename)) {
log.error("Invalid presentation filename for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
return null;
}
String presFilenameExt = FilenameUtils.getExtension(presFilename);
File presDir = Util.getPresentationDir(presentationBaseDir, meetingId, presId);
File downloadMarker = Util.getPresFileDownloadMarker(presDir, presId);
if (presDir != null && downloadMarker != null && downloadMarker.exists()) {
String safePresFilename = presId.concat(".").concat(presFilenameExt);
File presFile = new File(presDir.getAbsolutePath() + File.separatorChar + safePresFilename);
if (presFile.exists()) {
return presFile;
}
log.error("Presentation file missing for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
return null;
}
log.error("Invalid presentation directory for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
return null;
}
public void kickOffRecordingChapterBreak(String meetingId, Long timestamp) {
String done = recordStatusDir + File.separatorChar + meetingId + "-" + timestamp + ".done";
File doneFile = new File(done);
if (!doneFile.exists()) {
try {
doneFile.createNewFile();
if (!doneFile.exists())
log.error("Failed to create {} file.", done);
} catch (IOException e) {
log.error("Exception occured when trying to create {} file", done);
}
} else {
log.error("{} file already exists.", done);
}
}
public void startIngestAndProcessing(String meetingId) {
String done = recordStatusDir + File.separatorChar + meetingId + ".done";
File doneFile = new File(done);
if (!doneFile.exists()) {
try {
doneFile.createNewFile();
if (!doneFile.exists())
log.error("Failed to create {} file.", done);
} catch (IOException e) {
log.error("Exception occured when trying to create {} file.", done);
}
} else {
log.error("{} file already exists.", done);
}
}
public void markAsEnded(String meetingId) {
String done = recordStatusDir + "/../ended/" + meetingId + ".done";
File doneFile = new File(done);
if (!doneFile.exists()) {
try {
doneFile.createNewFile();
if (!doneFile.exists())
log.error("Failed to create " + done + " file.");
} catch (IOException e) {
log.error("Exception occured when trying to create {} file.", done);
}
} else {
log.error(done + " file already exists.");
}
}
public List<RecordingMetadata> getRecordingsMetadata(List<String> recordIDs, List<String> states) {
List<RecordingMetadata> recs = new ArrayList<>();
Map<String, List<File>> allDirectories = getAllDirectories(states);
if (recordIDs.isEmpty()) {
for (Map.Entry<String, List<File>> entry : allDirectories.entrySet()) {
recordIDs.addAll(getAllRecordingIds(entry.getValue()));
}
}
for (String recordID : recordIDs) {
for (Map.Entry<String, List<File>> entry : allDirectories.entrySet()) {
List<File> _recs = getRecordingsForPath(recordID, entry.getValue());
for (File _rec : _recs) {
RecordingMetadata r = getRecordingMetadata(_rec);
if (r != null) {
recs.add(r);
}
}
}
}
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, getCaptionFileUrlDirectory());
}
public String putRecordingTextTrack(UploadedTrack track) {
return recordingServiceHelper.putRecordingTextTrack(track);
}
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters) {
List<RecordingMetadata> recsList = getRecordingsMetadata(idList, states);
ArrayList<RecordingMetadata> recs = filterRecordingsByMetadata(recsList, metadataFilters);
return recordingServiceHelper.getRecordings2x(recs);
}
private RecordingMetadata getRecordingMetadata(File dir) {
File file = new File(dir.getPath() + File.separatorChar + "metadata.xml");
return recordingServiceHelper.getRecordingMetadata(file);
}
public boolean recordingMatchesMetadata(RecordingMetadata recording, Map<String, String> metadataFilters) {
boolean matchesMetadata = true;
Map<String, String> recMeta = recording.getMeta();
for (Map.Entry<String, String> filter : metadataFilters.entrySet()) {
String metadataValue = recMeta.get(filter.getKey());
if ( metadataValue == null ) {
// The recording doesn't have metadata specified
matchesMetadata = false;
} else {
String filterValue = filter.getValue();
if( filterValue.charAt(0) == '%' && filterValue.charAt(filterValue.length()-1) == '%' && metadataValue.contains(filterValue.substring(1, filterValue.length()-1)) ){
// Filter value embraced by two wild cards
// AND the filter value is part of the metadata value
} else if( filterValue.charAt(0) == '%' && metadataValue.endsWith(filterValue.substring(1, filterValue.length())) ) {
// Filter value starts with a wild cards
// AND the filter value ends with the metadata value
} else if( filterValue.charAt(filterValue.length()-1) == '%' && metadataValue.startsWith(filterValue.substring(0, filterValue.length()-1)) ) {
// Filter value ends with a wild cards
// AND the filter value starts with the metadata value
} else if( metadataValue.equals(filterValue) ) {
// Filter value doesnt have wildcards
// AND the filter value is the same as metadata value
} else {
matchesMetadata = false;
}
}
}
return matchesMetadata;
}
public ArrayList<RecordingMetadata> filterRecordingsByMetadata(List<RecordingMetadata> recordings, Map<String, String> metadataFilters) {
ArrayList<RecordingMetadata> resultRecordings = new ArrayList<>();
for (RecordingMetadata entry : recordings) {
if (recordingMatchesMetadata(entry, metadataFilters))
resultRecordings.add(entry);
}
return resultRecordings;
}
private ArrayList<File> getAllRecordingsFor(String recordId) {
String[] format = getPlaybackFormats(publishedDir);
ArrayList<File> ids = new ArrayList<File>();
for (int i = 0; i < format.length; i++) {
List<File> recordings = getDirectories(publishedDir + File.separatorChar + format[i]);
for (int f = 0; f < recordings.size(); f++) {
if (recordId.equals(recordings.get(f).getName()))
ids.add(recordings.get(f));
}
}
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);
for (String id : idList) {
if (publishList.contains(id) || unpublishList.contains(id)) {
return true;
}
}
return false;
}
private List<String> getAllRecordingIds(String path) {
String[] format = getPlaybackFormats(path);
return getAllRecordingIds(path, format);
}
private List<String> getAllRecordingIds(String path, String[] format) {
List<String> ids = new ArrayList<>();
for (String aFormat : format) {
List<File> recordings = getDirectories(path + File.separatorChar + aFormat);
for (File recording : recordings) {
if (!ids.contains(recording.getName())) {
ids.add(recording.getName());
}
}
}
return ids;
}
private Set<String> getAllRecordingIds(List<File> recs) {
Set<String> ids = new HashSet<>();
Iterator<File> iterator = recs.iterator();
while (iterator.hasNext()) {
ids.add(iterator.next().getName());
}
return ids;
}
private List<File> getRecordingsForPath(String id, List<File> recordings) {
List<File> recs = new ArrayList<>();
Iterator<File> iterator = recordings.iterator();
while (iterator.hasNext()) {
File rec = iterator.next();
if (rec.getName().startsWith(id)) {
recs.add(rec);
}
}
return recs;
}
private static void deleteRecording(String id, String path) {
String[] format = getPlaybackFormats(path);
for (String aFormat : format) {
List<File> recordings = getDirectories(path + File.separatorChar + aFormat);
for (File recording : recordings) {
if (recording.getName().equals(id)) {
deleteDirectory(recording);
createDirectory(recording);
}
}
}
}
private static void createDirectory(File directory) {
if (!directory.exists())
directory.mkdirs();
}
private static void deleteDirectory(File directory) {
/**
* Go through each directory and check if it's not empty. We need to
* delete files inside a directory before a directory can be deleted.
**/
File[] files = directory.listFiles();
for (File file : files) {
if (file.isDirectory()) {
deleteDirectory(file);
} else {
file.delete();
}
}
// Now that the directory is empty. Delete it.
directory.delete();
}
private static List<File> getDirectories(String path) {
List<File> files = new ArrayList<>();
try {
DirectoryStream<Path> stream = Files.newDirectoryStream(FileSystems.getDefault().getPath(path));
Iterator<Path> iter = stream.iterator();
while (iter.hasNext()) {
Path next = iter.next();
files.add(next.toFile());
}
stream.close();
} catch (IOException e) {
e.printStackTrace();
}
return files;
}
private static String[] getPlaybackFormats(String path) {
System.out.println("Getting playback formats at " + path);
List<File> dirs = getDirectories(path);
String[] formats = new String[dirs.size()];
for (int i = 0; i < dirs.size(); i++) {
System.out.println("Playback format = " + dirs.get(i).getName());
formats[i] = dirs.get(i).getName();
}
return formats;
}
public void setRecordingStatusDir(String dir) {
recordStatusDir = dir;
}
public void setUnpublishedDir(String dir) {
unpublishedDir = dir;
}
public void setPresentationBaseDir(String dir) {
presentationBaseDir = dir;
}
public void setDefaultServerUrl(String url) {
defaultServerUrl = url;
}
public void setDefaultTextTrackUrl(String url) {
defaultTextTrackUrl = url;
}
public void setPublishedDir(String dir) {
publishedDir = dir;
}
public void setCaptionsDir(String dir) {
captionsDir = dir;
}
public void setRecordingServiceHelper(RecordingMetadataReaderHelper r) {
recordingServiceHelper = r;
}
private boolean shouldIncludeState(List<String> states, String type) {
boolean r = false;
if (!states.isEmpty()) {
if (states.contains("any")) {
r = true;
} else {
if (type.equals(Recording.STATE_PUBLISHED) && states.contains(Recording.STATE_PUBLISHED)) {
r = true;
} else if (type.equals(Recording.STATE_UNPUBLISHED) && states.contains(Recording.STATE_UNPUBLISHED)) {
r = true;
} else if (type.equals(Recording.STATE_DELETED) && states.contains(Recording.STATE_DELETED)) {
r = true;
} else if (type.equals(Recording.STATE_PROCESSING) && states.contains(Recording.STATE_PROCESSING)) {
r = true;
} else if (type.equals(Recording.STATE_PROCESSED) && states.contains(Recording.STATE_PROCESSED)) {
r = true;
}
}
} else {
if (type.equals(Recording.STATE_PUBLISHED) || type.equals(Recording.STATE_UNPUBLISHED)) {
r = true;
}
}
return r;
}
public boolean changeState(String recordingId, String state) {
boolean succeeded = false;
if (state.equals(Recording.STATE_PUBLISHED)) {
// It can only be published if it is unpublished
succeeded |= changeState(unpublishedDir, recordingId, state);
} else if (state.equals(Recording.STATE_UNPUBLISHED)) {
// It can only be unpublished if it is published
succeeded |= changeState(publishedDir, recordingId, state);
} else if (state.equals(Recording.STATE_DELETED)) {
// It can be deleted from any state
succeeded |= changeState(publishedDir, recordingId, state);
succeeded |= changeState(unpublishedDir, recordingId, state);
}
return succeeded;
}
private boolean changeState(String path, String recordingId, String state) {
boolean exists = false;
boolean succeeded = true;
String[] format = getPlaybackFormats(path);
for (String aFormat : format) {
List<File> recordings = getDirectories(path + File.separatorChar + aFormat);
for (File recording : recordings) {
if (recording.getName().equalsIgnoreCase(recordingId)) {
exists = true;
File dest;
if (state.equals(Recording.STATE_PUBLISHED)) {
dest = new File(publishedDir + File.separatorChar + aFormat);
succeeded &= publishRecording(dest, recordingId, recording, aFormat);
} else if (state.equals(Recording.STATE_UNPUBLISHED)) {
dest = new File(unpublishedDir + File.separatorChar + aFormat);
succeeded &= unpublishRecording(dest, recordingId, recording, aFormat);
} else if (state.equals(Recording.STATE_DELETED)) {
dest = new File(deletedDir + File.separatorChar + aFormat);
succeeded &= deleteRecording(dest, recordingId, recording, aFormat);
} else {
log.debug(String.format("State: %s, is not supported", state));
return false;
}
}
}
}
return exists && succeeded;
}
public boolean publishRecording(File destDir, String recordingId, File recordingDir, String format) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(recordingDir.getPath());
RecordingMetadata r = recordingServiceHelper.getRecordingMetadata(metadataXml);
if (r != null) {
if (!destDir.exists()) destDir.mkdirs();
try {
FileUtils.moveDirectory(recordingDir, new File(destDir.getPath() + File.separatorChar + recordingId));
r.setState(Recording.STATE_PUBLISHED);
r.setPublished(true);
File medataXmlFile = recordingServiceHelper.getMetadataXmlLocation(
destDir.getAbsolutePath() + File.separatorChar + recordingId);
// Process the changes by saving the recording into metadata.xml
return recordingServiceHelper.saveRecordingMetadata(medataXmlFile, r);
} catch (IOException e) {
log.error("Failed to publish recording : " + recordingId, e);
}
}
return false;
}
public boolean unpublishRecording(File destDir, String recordingId, File recordingDir, String format) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(recordingDir.getPath());
RecordingMetadata r = recordingServiceHelper.getRecordingMetadata(metadataXml);
if (r != null) {
if (!destDir.exists()) destDir.mkdirs();
try {
FileUtils.moveDirectory(recordingDir, new File(destDir.getPath() + File.separatorChar + recordingId));
r.setState(Recording.STATE_UNPUBLISHED);
r.setPublished(false);
File medataXmlFile = recordingServiceHelper.getMetadataXmlLocation(
destDir.getAbsolutePath() + File.separatorChar + recordingId);
// Process the changes by saving the recording into metadata.xml
return recordingServiceHelper.saveRecordingMetadata(medataXmlFile, r);
} catch (IOException e) {
log.error("Failed to unpublish recording : " + recordingId, e);
}
}
return false;
}
public boolean deleteRecording(File destDir, String recordingId, File recordingDir, String format) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(recordingDir.getPath());
RecordingMetadata r = recordingServiceHelper.getRecordingMetadata(metadataXml);
if (r != null) {
if (!destDir.exists()) destDir.mkdirs();
try {
FileUtils.moveDirectory(recordingDir, new File(destDir.getPath() + File.separatorChar + recordingId));
r.setState(Recording.STATE_DELETED);
r.setPublished(false);
File medataXmlFile = recordingServiceHelper.getMetadataXmlLocation(
destDir.getAbsolutePath() + File.separatorChar + recordingId);
// Process the changes by saving the recording into metadata.xml
return recordingServiceHelper.saveRecordingMetadata(medataXmlFile, r);
} catch (IOException e) {
log.error("Failed to delete recording : " + recordingId, e);
}
}
return false;
}
private List<File> getAllDirectories(String state) {
List<File> allDirectories = new ArrayList<>();
String dir = getDestinationBaseDirectoryName(state);
if ( dir != null ) {
String[] formats = getPlaybackFormats(dir);
for (String format : formats) {
allDirectories.addAll(getDirectories(dir + File.separatorChar + format));
}
}
return allDirectories;
}
private Map<String, List<File>> getAllDirectories(List<String> states) {
Map<String, List<File>> allDirectories = new HashMap<>();
if ( shouldIncludeState(states, Recording.STATE_PUBLISHED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_PUBLISHED);
allDirectories.put(Recording.STATE_PUBLISHED, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_UNPUBLISHED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_UNPUBLISHED);
allDirectories.put(Recording.STATE_UNPUBLISHED, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_DELETED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_DELETED);
allDirectories.put(Recording.STATE_DELETED, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_PROCESSING) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_PROCESSING);
allDirectories.put(Recording.STATE_PROCESSING, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_PROCESSED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_PROCESSED);
allDirectories.put(Recording.STATE_PROCESSED, listedDirectories);
}
return allDirectories;
}
public void updateMetaParams(List<String> recordIDs, Map<String,String> metaParams) {
// Define the directories used to lookup the recording
List<String> states = new ArrayList<>();
states.add(Recording.STATE_PUBLISHED);
states.add(Recording.STATE_UNPUBLISHED);
states.add(Recording.STATE_DELETED);
// Gather all the existent directories based on the states defined for the lookup
Map<String, List<File>> allDirectories = getAllDirectories(states);
// Retrieve the actual recording from the directories gathered for the lookup
for (String recordID : recordIDs) {
for (Map.Entry<String, List<File>> entry : allDirectories.entrySet()) {
List<File> recs = getRecordingsForPath(recordID, entry.getValue());
// Go through all recordings of all formats
for (File rec : recs) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(rec.getPath());
updateRecordingMetadata(metadataXml, metaParams, metadataXml);
}
}
}
}
public void updateRecordingMetadata(File srxMetadataXml, Map<String,String> metaParams, File destMetadataXml) {
RecordingMetadata rec = recordingServiceHelper.getRecordingMetadata(srxMetadataXml);
Map<String, String> recMeta = rec.getMeta();
if (rec != null && !recMeta.isEmpty()) {
for (Map.Entry<String,String> meta : metaParams.entrySet()) {
if ( !"".equals(meta.getValue()) ) {
// As it has a value, if the meta parameter exists update it, otherwise add it
recMeta.put(meta.getKey(), meta.getValue());
} else {
// As it doesn't have a value, if it exists delete it
if ( recMeta.containsKey(meta.getKey()) ) {
recMeta.remove(meta.getKey());
}
}
}
rec.setMeta(recMeta);
// Process the changes by saving the recording into metadata.xml
recordingServiceHelper.saveRecordingMetadata(destMetadataXml, rec);
}
}
private Map<String,File> indexRecordings(List<File> recs) {
Map<String,File> indexedRecs = new HashMap<>();
Iterator<File> iterator = recs.iterator();
while (iterator.hasNext()) {
File rec = iterator.next();
indexedRecs.put(rec.getName(), rec);
}
return indexedRecs;
}
private String getDestinationBaseDirectoryName(String state) {
return getDestinationBaseDirectoryName(state, false);
}
private String getDestinationBaseDirectoryName(String state, boolean forceDefault) {
String baseDir = null;
if ( state.equals(Recording.STATE_PROCESSING) || state.equals(Recording.STATE_PROCESSED) )
baseDir = processDir;
else if ( state.equals(Recording.STATE_PUBLISHED) )
baseDir = publishedDir;
else if ( state.equals(Recording.STATE_UNPUBLISHED) )
baseDir = unpublishedDir;
else if ( state.equals(Recording.STATE_DELETED) )
baseDir = deletedDir;
else if ( forceDefault )
baseDir = publishedDir;
return baseDir;
}
public String getCaptionTrackInboxDir() {
return captionsDir + File.separatorChar + "inbox";
}
public String getCaptionsDir() {
return captionsDir;
}
public String getCaptionFileUrlDirectory() {
return defaultTextTrackUrl + "/textTrack/";
}
}
}

View File

@ -0,0 +1,117 @@
package org.bigbluebutton.api.model.entity;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.Objects;
@Entity
@Table(name = "callback_data")
public class CallbackData {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "meeting_id")
private String meetingId;
@Column(name = "callback_attributes")
private String callbackAttributes;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "recording_id", referencedColumnName = "id")
private Recording recording;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getMeetingId() {
return meetingId;
}
public void setMeetingId(String meetingId) {
this.meetingId = meetingId;
}
public String getCallbackAttributes() {
return callbackAttributes;
}
public void setCallbackAttributes(String callbackAttributes) {
this.callbackAttributes = callbackAttributes;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public Recording getRecording() { return recording; }
public void setRecording(Recording recording) { this.recording = recording; }
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
CallbackData callbackData = (CallbackData) o;
return Objects.equals(this.id, callbackData.id) &&
Objects.equals(this.meetingId, callbackData.meetingId) &&
Objects.equals(this.callbackAttributes, callbackData.callbackAttributes) &&
Objects.equals(this.createdAt, callbackData.createdAt) &&
Objects.equals(this.updatedAt, callbackData.updatedAt);
}
@Override
public int hashCode() {
return Objects.hash(id, meetingId, callbackAttributes, createdAt, updatedAt);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class CallbackData {\n");
sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append(" meetingId: ").append(toIndentedString(meetingId)).append("\n");
sb.append(" callbackAttributes: ").append(toIndentedString(callbackAttributes)).append("\n");
sb.append(" createdAt: ").append(toIndentedString(createdAt)).append("\n");
sb.append(" updatedAt: ").append(toIndentedString(updatedAt)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,90 @@
package org.bigbluebutton.api.model.entity;
import javax.persistence.*;
import java.util.Objects;
@Entity
@Table(name = "metadata")
public class Metadata {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "key")
private String key;
@Column(name = "value")
private String value;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "recording_id", referencedColumnName = "id")
private Recording recording;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public Recording getRecording() { return recording; }
public void setRecording(Recording recording) { this.recording = recording; }
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Metadata metadata = (Metadata) o;
return Objects.equals(this.id, metadata.id) &&
Objects.equals(this.key, metadata.key) &&
Objects.equals(this.value, metadata.value);
}
@Override
public int hashCode() {
return Objects.hash(id, key, value);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class Metadata {\n");
sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append(" key: ").append(toIndentedString(key)).append("\n");
sb.append(" value: ").append(toIndentedString(value)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,140 @@
package org.bigbluebutton.api.model.entity;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
@Entity
@Table(name = "playback_formats")
public class PlaybackFormat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "format")
private String format;
@Column(name = "url")
private String url;
@Column(name = "length")
private Integer length;
@Column(name = "processing_time")
private Integer processingTime;
@OneToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "recording_id", referencedColumnName = "id")
private Recording recording;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "playbackFormat", fetch = FetchType.EAGER)
private Set<Thumbnail> thumbnails;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getFormat() {
return format;
}
public void setFormat(String format) {
this.format = format;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public Integer getLength() {
return length;
}
public void setLength(Integer length) {
this.length = length;
}
public Integer getProcessingTime() {
return processingTime;
}
public void setProcessingTime(Integer processingTime) {
this.processingTime = processingTime;
}
public Recording getRecording() {
return recording;
}
public void setRecording(Recording recording) {
this.recording = recording;
}
public Set<Thumbnail> getThumbnails() { return thumbnails; }
public void setThumbnails(Set<Thumbnail> thumbnails) { this.thumbnails = thumbnails; }
public void addThumbnail(Thumbnail thumbnail) {
if(thumbnails == null) {
thumbnails = new HashSet<>();
}
thumbnail.setPlaybackFormat(this);
thumbnails.add(thumbnail);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
PlaybackFormat format = (PlaybackFormat) o;
return Objects.equals(this.id, format.id) &&
Objects.equals(this.format, format.format) &&
Objects.equals(this.url, format.url) &&
Objects.equals(this.length, format.length) &&
Objects.equals(this.processingTime, format.processingTime) &&
Objects.equals(this.thumbnails, format.thumbnails);
}
@Override
public int hashCode() {
return Objects.hash(id, format, url, length, processingTime, thumbnails);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class PlaybackFormat {\n");
sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append(" format: ").append(toIndentedString(format)).append("\n");
sb.append(" url: ").append(toIndentedString(url)).append("\n");
sb.append(" length: ").append(toIndentedString(length)).append("\n");
sb.append(" processingTime: ").append(toIndentedString(processingTime)).append("\n");
sb.append(" thumbnails: ").append(toIndentedString(thumbnails)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,268 @@
package org.bigbluebutton.api.model.entity;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
@Entity
@Table(name = "recordings")
public class Recording {
public enum State {
STATE_PROCESSING("processing"),
STATE_PROCESSED("processed"),
STATE_PUBLISING("publishing"),
STATE_PUBLISHED("published"),
STATE_UNPUBLISING("unpublishing"),
STATE_UNPUBLISHED("unpublished"),
STATE_DELETING("deleting"),
STATE_DELETED("deleted");
private String value;
State(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "record_id")
private String recordId;
@Column(name = "meeting_id")
private String meetingId;
@Column(name = "name")
private String name;
@Column(name = "published")
private Boolean published;
@Column(name = "participants")
private Integer participants;
@Column(name = "state")
private String state;
@Column(name = "start_time")
private LocalDateTime startTime;
@Column(name = "end_time")
private LocalDateTime endTime;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "publish_updated")
private Boolean publishUpdated;
@Column(name = "protected")
private Boolean isProtected;
@OneToMany(mappedBy = "recording", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private Set<Metadata> metadata;
@OneToOne(mappedBy = "recording", cascade = CascadeType.ALL)
private PlaybackFormat format;
@OneToOne(mappedBy = "recording", cascade = CascadeType.ALL)
private CallbackData callbackData;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getRecordId() {
return recordId;
}
public void setRecordId(String recordId) {
this.recordId = recordId;
}
public String getMeetingId() {
return meetingId;
}
public void setMeetingId(String meetingId) {
this.meetingId = meetingId;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Boolean getPublished() {
return published;
}
public void setPublished(Boolean published) {
this.published = published;
}
public Integer getParticipants() {
return participants;
}
public void setParticipants(Integer participants) {
this.participants = participants;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public LocalDateTime getStartTime() {
return startTime;
}
public void setStartTime(LocalDateTime startTime) {
this.startTime = startTime;
}
public LocalDateTime getEndTime() {
return endTime;
}
public void setEndTime(LocalDateTime endTime) {
this.endTime = endTime;
}
public LocalDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(LocalDateTime deletedAt) {
this.deletedAt = deletedAt;
}
public Boolean getPublishUpdated() {
return publishUpdated;
}
public void setPublishUpdated(Boolean publishUpdated) {
this.publishUpdated = publishUpdated;
}
public Boolean getProtected() {
return isProtected;
}
public void setProtected(Boolean aProtected) {
isProtected = aProtected;
}
public Set<Metadata> getMetadata() { return metadata; }
public void setMetadata(Set<Metadata> metadata) { this.metadata = metadata; }
public void addMetadata(Metadata metadata) {
if(this.metadata == null) {
this.metadata = new HashSet<>();
}
metadata.setRecording(this);
this.metadata.add(metadata);
}
public PlaybackFormat getFormat() {
return format;
}
public void setFormat(PlaybackFormat format) {
this.format = format;
}
public CallbackData getCallbackData() {
return callbackData;
}
public void setCallbackData(CallbackData callbackData) {
this.callbackData = callbackData;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Recording recording = (Recording) o;
return Objects.equals(this.id, recording.id) &&
Objects.equals(this.recordId, recording.recordId) &&
Objects.equals(this.meetingId, recording.meetingId) &&
Objects.equals(this.name, recording.name) &&
Objects.equals(this.published, recording.published) &&
Objects.equals(this.participants, recording.participants) &&
Objects.equals(this.state, recording.state) &&
Objects.equals(this.startTime, recording.startTime) &&
Objects.equals(this.endTime, recording.endTime) &&
Objects.equals(this.deletedAt, recording.deletedAt) &&
Objects.equals(this.publishUpdated, recording.publishUpdated) &&
Objects.equals(this.isProtected, recording.isProtected) &&
Objects.equals(this.metadata, recording.metadata) &&
Objects.equals(this.format, recording.format) &&
Objects.equals(this.callbackData, recording.callbackData);
}
@Override
public int hashCode() {
return Objects.hash(id, recordId, meetingId, name, published, participants, state, startTime, endTime,
deletedAt, publishUpdated, isProtected, metadata, format, callbackData);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class Recording {\n");
sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append(" recordId: ").append(toIndentedString(recordId)).append("\n");
sb.append(" meetingId: ").append(toIndentedString(meetingId)).append("\n");
sb.append(" name: ").append(toIndentedString(name)).append("\n");
sb.append(" published: ").append(toIndentedString(published)).append("\n");
sb.append(" participants: ").append(toIndentedString(participants)).append("\n");
sb.append(" state: ").append(toIndentedString(state)).append("\n");
sb.append(" startTime: ").append(toIndentedString(startTime)).append("\n");
sb.append(" endTime: ").append(toIndentedString(endTime)).append("\n");
sb.append(" deletedAt: ").append(toIndentedString(deletedAt)).append("\n");
sb.append(" publishUpdated: ").append(toIndentedString(publishUpdated)).append("\n");
sb.append(" protected: ").append(toIndentedString(isProtected)).append("\n");
sb.append(" metadata: ").append(toIndentedString(metadata)).append("\n");
sb.append(" format: ").append(toIndentedString(format)).append("\n");
sb.append(" callBackData: ").append(toIndentedString(callbackData)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,132 @@
package org.bigbluebutton.api.model.entity;
import javax.persistence.*;
import java.util.Objects;
@Entity
@Table(name = "thumbnails")
public class Thumbnail implements Comparable<Thumbnail> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;
@Column(name = "height")
private Integer height;
@Column(name = "width")
private Integer width;
@Column(name = "alt")
private String alt;
@Column(name = "url")
private String url;
@Column(name = "sequence")
private Integer sequence;
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "playback_format_id", referencedColumnName = "id")
private PlaybackFormat playbackFormat;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public PlaybackFormat getPlaybackFormat() { return playbackFormat; }
public void setPlaybackFormat(PlaybackFormat playbackFormat) { this.playbackFormat = playbackFormat; }
public Integer getHeight() {
return height;
}
public void setHeight(Integer height) {
this.height = height;
}
public Integer getWidth() {
return width;
}
public void setWidth(Integer width) {
this.width = width;
}
public String getAlt() {
return alt;
}
public void setAlt(String alt) {
this.alt = alt;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public Integer getSequence() {
return sequence;
}
public void setSequence(Integer sequence) {
this.sequence = sequence;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Thumbnail thumbnail = (Thumbnail) o;
return Objects.equals(this.id, thumbnail.id) &&
Objects.equals(this.height, thumbnail.height) &&
Objects.equals(this.width, thumbnail.width) &&
Objects.equals(this.alt, thumbnail.alt) &&
Objects.equals(this.url, thumbnail.url);
}
@Override
public int hashCode() {
return Objects.hash(id, height, width, alt, url);
}
@Override
public int compareTo(Thumbnail t) {
return this.getSequence().compareTo(t.getSequence());
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class Thumbnail {\n");
sb.append(" Id: ").append(toIndentedString(id)).append("\n");
sb.append(" height: ").append(toIndentedString(height)).append("\n");
sb.append(" width: ").append(toIndentedString(width)).append("\n");
sb.append(" alt: ").append(toIndentedString(alt)).append("\n");
sb.append(" url: ").append(toIndentedString(url)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,19 @@
package org.bigbluebutton.api.service;
import org.bigbluebutton.api.model.entity.*;
import java.util.Collection;
import org.springframework.data.domain.*;
public interface XmlService {
String recordingsToXml(Collection<Recording> recordings);
String recordingToXml(Recording recording);
String metadataToXml(Metadata metadata);
String playbackFormatToXml(PlaybackFormat playbackFormat);
String thumbnailToXml(Thumbnail thumbnail);
String callbackDataToXml(CallbackData callbackData);
String constructResponseFromRecordingsXml(String xml);
String constructPaginatedResponse(Page<?> page, String response);
Recording xmlToRecording(String recordId, String xml);
}

View File

@ -0,0 +1,248 @@
package org.bigbluebutton.api.service.impl;
import org.bigbluebutton.api.RecordingService;
import org.bigbluebutton.api.messaging.messages.MakePresentationDownloadableMsg;
import org.bigbluebutton.api.model.entity.Metadata;
import org.bigbluebutton.api.model.entity.Recording;
import org.bigbluebutton.api.service.XmlService;
import org.bigbluebutton.api.util.DataStore;
import org.bigbluebutton.api.util.RecordingMetadataReaderHelper;
import org.bigbluebutton.api2.domain.UploadedTrack;
import java.io.File;
import java.util.*;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.*;
public class RecordingServiceDbImpl implements RecordingService {
private static final Logger logger = LoggerFactory.getLogger(RecordingServiceDbImpl.class);
private String processDir = "/var/bigbluebutton/recording/process";
private String publishedDir = "/var/bigbluebutton/published";
private String unpublishedDir = "/var/bigbluebutton/unpublished";
private String deletedDir = "/var/bigbluebutton/deleted";
private RecordingMetadataReaderHelper recordingServiceHelper;
private String recordStatusDir;
private String captionsDir;
private String presentationBaseDir;
private String defaultServerUrl;
private String defaultTextTrackUrl;
private DataStore dataStore;
private XmlService xmlService;
public RecordingServiceDbImpl() {
dataStore = DataStore.getInstance();
}
@Override
public Boolean validateTextTrackSingleUseToken(String recordId, String caption, String token) {
return null;
}
@Override
public String getRecordingTextTracks(String recordId) {
return null;
}
@Override
public String putRecordingTextTrack(UploadedTrack track) {
return null;
}
@Override
public String getCaptionTrackInboxDir() {
return null;
}
@Override
public String getCaptionsDir() {
return null;
}
@Override
public boolean isRecordingExist(String recordId) {
return dataStore.findRecordingByRecordId(recordId) != null;
}
@Override
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, Pageable pageable) {
logger.info("Retrieving all recordings");
Set<Recording> recordings = new HashSet<>();
recordings.addAll(dataStore.findAll(Recording.class));
Set<Recording> recordingsById = new HashSet<>();
for(String id: idList) {
List<Recording> r = dataStore.findRecordingsByMeetingId(id);
if(r == null || r.size() == 0) {
Recording recording = dataStore.findRecordingByRecordId(id);
if(recording != null) {
r = new ArrayList<>();
r.add(recording);
}
}
if(r != null) recordingsById.addAll(r);
}
logger.info("Filtering recordings by meeting ID");
if(recordingsById.size() > 0) {
recordings.retainAll(recordingsById);
}
logger.info("{} recordings remaining", recordings.size());
Set<Recording> recordingsByState = new HashSet<>();
for(String state: states) {
List<Recording> r = dataStore.findRecordingsByState(state);
if(r != null) recordingsByState.addAll(r);
}
logger.info("Filtering recordings by state");
if(recordingsByState.size() > 0) {
recordings.retainAll(recordingsByState);
}
logger.info("{} recordings remaining", recordings.size());
List<Metadata> metadata = new ArrayList<>();
for(Map.Entry<String, String> metadataFilter: metadataFilters.entrySet()) {
List<Metadata> m = dataStore.findMetadataByFilter(metadataFilter.getKey(), metadataFilter.getValue());
if(m != null) metadata.addAll(m);
}
Set<Recording> recordingsByMetadata = new HashSet<>();
for(Metadata m: metadata) {
recordingsByMetadata.add(m.getRecording());
}
logger.info("Filtering recordings by metadata");
if(recordingsByMetadata.size() > 0) {
recordings.retainAll(recordingsByMetadata);
}
logger.info("{} recordings remaining", recordings.size());
Page<Recording> recordingsPage = listToPage(new ArrayList<>(recordings), pageable);
String recordingsXml = xmlService.recordingsToXml(recordingsPage.getContent());
String response = xmlService.constructResponseFromRecordingsXml(recordingsXml);
return xmlService.constructPaginatedResponse(recordingsPage, response);
}
@Override
public boolean existAnyRecording(List<String> idList) {
for(String id: idList) {
if(dataStore.findRecordingByRecordId(id) != null) return true;
}
return false;
}
@Override
public boolean changeState(String recordingId, String state) {
if(Stream.of(Recording.State.values()).anyMatch(x -> x.getValue().equals(state))) {
Recording recording = dataStore.findRecordingByRecordId(recordingId);
if(recording != null) {
recording.setState(state);
dataStore.save(recording);
return true;
} else {
logger.error("A recording with ID {} does not exist", recordingId);
}
} else {
logger.error("State [{}] is not a valid state", state);
}
return false;
}
@Override
public void updateMetaParams(List<String> recordIDs, Map<String, String> metaParams) {
Set<Recording> recordings = new HashSet<>();
for(String id: recordIDs) {
Recording recording = dataStore.findRecordingByRecordId(id);
if(recording != null) recordings.add(recording);
}
for(Recording recording: recordings) {
Set<Metadata> metadata = recording.getMetadata();
for(Map.Entry<String, String> entry: metaParams.entrySet()) {
for(Metadata m: metadata) {
if(m.getKey().equals(entry.getKey())) {
m.setValue(entry.getValue());
} else {
Metadata newParam = new Metadata();
newParam.setKey(entry.getKey());
newParam.setValue(entry.getValue());
newParam.setRecording(recording);
recording.addMetadata(newParam);
}
}
}
dataStore.save(recording);
}
}
@Override
public void startIngestAndProcessing(String meetingId) {
}
@Override
public void markAsEnded(String meetingId) {
}
@Override
public void kickOffRecordingChapterBreak(String meetingId, Long timestamp) {
}
@Override
public void processMakePresentationDownloadableMsg(MakePresentationDownloadableMsg msg) {
}
@Override
public File getDownloadablePresentationFile(String meetingId, String presId, String presFilename) {
return null;
}
public void setRecordingStatusDir(String dir) {
recordStatusDir = dir;
}
public void setUnpublishedDir(String dir) {
unpublishedDir = dir;
}
public void setPresentationBaseDir(String dir) {
presentationBaseDir = dir;
}
public void setDefaultServerUrl(String url) {
defaultServerUrl = url;
}
public void setDefaultTextTrackUrl(String url) {
defaultTextTrackUrl = url;
}
public void setPublishedDir(String dir) {
publishedDir = dir;
}
public void setCaptionsDir(String dir) {
captionsDir = dir;
}
public void setRecordingServiceHelper(RecordingMetadataReaderHelper r) {
recordingServiceHelper = r;
}
public void setXmlService(XmlService xmlService) { this.xmlService = xmlService; }
}

View File

@ -0,0 +1,725 @@
/**
* BigBlueButton open source conferencing system - http://www.bigbluebutton.org/
*
* Copyright (c) 2012 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/>.
*
*/
package org.bigbluebutton.api.service.impl;
import java.io.File;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.bigbluebutton.api.Util;
import org.bigbluebutton.api.RecordingService;
import org.bigbluebutton.api.domain.Recording;
import org.bigbluebutton.api.domain.RecordingMetadata;
import org.bigbluebutton.api.messaging.messages.MakePresentationDownloadableMsg;
import org.bigbluebutton.api.service.XmlService;
import org.bigbluebutton.api.util.RecordingMetadataReaderHelper;
import org.bigbluebutton.api2.domain.UploadedTrack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.*;
public class RecordingServiceFileImpl implements RecordingService {
private static Logger log = LoggerFactory.getLogger(RecordingServiceFileImpl.class);
private static final Pattern PRESENTATION_ID_PATTERN = Pattern.compile("^[a-z0-9]{40}-[0-9]{13}\\.[0-9a-zA-Z]{3,4}$");
private static String processDir = "/var/bigbluebutton/recording/process";
private static String publishedDir = "/var/bigbluebutton/published";
private static String unpublishedDir = "/var/bigbluebutton/unpublished";
private static String deletedDir = "/var/bigbluebutton/deleted";
private RecordingMetadataReaderHelper recordingServiceHelper;
private XmlService xmlService;
private String recordStatusDir;
private String captionsDir;
private String presentationBaseDir;
private String defaultServerUrl;
private String defaultTextTrackUrl;
private void copyPresentationFile(File presFile, File dlownloadableFile) {
try {
FileUtils.copyFile(presFile, dlownloadableFile);
} catch (IOException ex) {
log.error("Failed to copy file: {}", ex);
}
}
public void processMakePresentationDownloadableMsg(MakePresentationDownloadableMsg msg) {
try {
File presDir = Util.getPresentationDir(presentationBaseDir, msg.meetingId, msg.presId);
Util.makePresentationDownloadable(presDir, msg.presId, msg.downloadable);
} catch (IOException e) {
log.error("Failed to make presentation downloadable: {}", e);
}
}
public File getDownloadablePresentationFile(String meetingId, String presId, String presFilename) {
log.info("Find downloadable presentation for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
if (! Util.isPresFileIdValidFormat(presFilename)) {
log.error("Invalid presentation filename for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
return null;
}
String presFilenameExt = FilenameUtils.getExtension(presFilename);
File presDir = Util.getPresentationDir(presentationBaseDir, meetingId, presId);
File downloadMarker = Util.getPresFileDownloadMarker(presDir, presId);
if (presDir != null && downloadMarker != null && downloadMarker.exists()) {
String safePresFilename = presId.concat(".").concat(presFilenameExt);
File presFile = new File(presDir.getAbsolutePath() + File.separatorChar + safePresFilename);
if (presFile.exists()) {
return presFile;
}
log.error("Presentation file missing for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
return null;
}
log.error("Invalid presentation directory for meetingId={} presId={} filename={}", meetingId, presId,
presFilename);
return null;
}
public void kickOffRecordingChapterBreak(String meetingId, Long timestamp) {
String done = recordStatusDir + File.separatorChar + meetingId + "-" + timestamp + ".done";
File doneFile = new File(done);
if (!doneFile.exists()) {
try {
doneFile.createNewFile();
if (!doneFile.exists())
log.error("Failed to create {} file.", done);
} catch (IOException e) {
log.error("Exception occured when trying to create {} file", done);
}
} else {
log.error("{} file already exists.", done);
}
}
public void startIngestAndProcessing(String meetingId) {
String done = recordStatusDir + File.separatorChar + meetingId + ".done";
File doneFile = new File(done);
if (!doneFile.exists()) {
try {
doneFile.createNewFile();
if (!doneFile.exists())
log.error("Failed to create {} file.", done);
} catch (IOException e) {
log.error("Exception occured when trying to create {} file.", done);
}
} else {
log.error("{} file already exists.", done);
}
}
public void markAsEnded(String meetingId) {
String done = recordStatusDir + "/../ended/" + meetingId + ".done";
File doneFile = new File(done);
if (!doneFile.exists()) {
try {
doneFile.createNewFile();
if (!doneFile.exists())
log.error("Failed to create " + done + " file.");
} catch (IOException e) {
log.error("Exception occured when trying to create {} file.", done);
}
} else {
log.error(done + " file already exists.");
}
}
public List<RecordingMetadata> getRecordingsMetadata(List<String> recordIDs, List<String> states) {
List<RecordingMetadata> recs = new ArrayList<>();
Map<String, List<File>> allDirectories = getAllDirectories(states);
if (recordIDs.isEmpty()) {
for (Map.Entry<String, List<File>> entry : allDirectories.entrySet()) {
recordIDs.addAll(getAllRecordingIds(entry.getValue()));
}
}
for (String recordID : recordIDs) {
for (Map.Entry<String, List<File>> entry : allDirectories.entrySet()) {
List<File> _recs = getRecordingsForPath(recordID, entry.getValue());
for (File _rec : _recs) {
RecordingMetadata r = getRecordingMetadata(_rec);
if (r != null) {
recs.add(r);
}
}
}
}
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, getCaptionFileUrlDirectory());
}
public String putRecordingTextTrack(UploadedTrack track) {
return recordingServiceHelper.putRecordingTextTrack(track);
}
public String getRecordings2x(List<String> idList, List<String> states, Map<String, String> metadataFilters, Pageable pageable) {
List<RecordingMetadata> recsList = getRecordingsMetadata(idList, states);
ArrayList<RecordingMetadata> recs = filterRecordingsByMetadata(recsList, metadataFilters);
Page<RecordingMetadata> recordingsPage = listToPage(recs, pageable);
String response = recordingServiceHelper.getRecordings2x(recs);
return xmlService.constructPaginatedResponse(recordingsPage, response);
}
private RecordingMetadata getRecordingMetadata(File dir) {
File file = new File(dir.getPath() + File.separatorChar + "metadata.xml");
return recordingServiceHelper.getRecordingMetadata(file);
}
public boolean recordingMatchesMetadata(RecordingMetadata recording, Map<String, String> metadataFilters) {
boolean matchesMetadata = true;
Map<String, String> recMeta = recording.getMeta();
for (Map.Entry<String, String> filter : metadataFilters.entrySet()) {
String metadataValue = recMeta.get(filter.getKey());
if ( metadataValue == null ) {
// The recording doesn't have metadata specified
matchesMetadata = false;
} else {
String filterValue = filter.getValue();
if( filterValue.charAt(0) == '%' && filterValue.charAt(filterValue.length()-1) == '%' && metadataValue.contains(filterValue.substring(1, filterValue.length()-1)) ){
// Filter value embraced by two wild cards
// AND the filter value is part of the metadata value
} else if( filterValue.charAt(0) == '%' && metadataValue.endsWith(filterValue.substring(1, filterValue.length())) ) {
// Filter value starts with a wild cards
// AND the filter value ends with the metadata value
} else if( filterValue.charAt(filterValue.length()-1) == '%' && metadataValue.startsWith(filterValue.substring(0, filterValue.length()-1)) ) {
// Filter value ends with a wild cards
// AND the filter value starts with the metadata value
} else if( metadataValue.equals(filterValue) ) {
// Filter value doesnt have wildcards
// AND the filter value is the same as metadata value
} else {
matchesMetadata = false;
}
}
}
return matchesMetadata;
}
public ArrayList<RecordingMetadata> filterRecordingsByMetadata(List<RecordingMetadata> recordings, Map<String, String> metadataFilters) {
ArrayList<RecordingMetadata> resultRecordings = new ArrayList<>();
for (RecordingMetadata entry : recordings) {
if (recordingMatchesMetadata(entry, metadataFilters))
resultRecordings.add(entry);
}
return resultRecordings;
}
private ArrayList<File> getAllRecordingsFor(String recordId) {
String[] format = getPlaybackFormats(publishedDir);
ArrayList<File> ids = new ArrayList<File>();
for (int i = 0; i < format.length; i++) {
List<File> recordings = getDirectories(publishedDir + File.separatorChar + format[i]);
for (int f = 0; f < recordings.size(); f++) {
if (recordId.equals(recordings.get(f).getName()))
ids.add(recordings.get(f));
}
}
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);
for (String id : idList) {
if (publishList.contains(id) || unpublishList.contains(id)) {
return true;
}
}
return false;
}
private List<String> getAllRecordingIds(String path) {
String[] format = getPlaybackFormats(path);
return getAllRecordingIds(path, format);
}
private List<String> getAllRecordingIds(String path, String[] format) {
List<String> ids = new ArrayList<>();
for (String aFormat : format) {
List<File> recordings = getDirectories(path + File.separatorChar + aFormat);
for (File recording : recordings) {
if (!ids.contains(recording.getName())) {
ids.add(recording.getName());
}
}
}
return ids;
}
private Set<String> getAllRecordingIds(List<File> recs) {
Set<String> ids = new HashSet<>();
Iterator<File> iterator = recs.iterator();
while (iterator.hasNext()) {
ids.add(iterator.next().getName());
}
return ids;
}
private List<File> getRecordingsForPath(String id, List<File> recordings) {
List<File> recs = new ArrayList<>();
Iterator<File> iterator = recordings.iterator();
while (iterator.hasNext()) {
File rec = iterator.next();
if (rec.getName().startsWith(id)) {
recs.add(rec);
}
}
return recs;
}
private static void deleteRecording(String id, String path) {
String[] format = getPlaybackFormats(path);
for (String aFormat : format) {
List<File> recordings = getDirectories(path + File.separatorChar + aFormat);
for (File recording : recordings) {
if (recording.getName().equals(id)) {
deleteDirectory(recording);
createDirectory(recording);
}
}
}
}
private static void createDirectory(File directory) {
if (!directory.exists())
directory.mkdirs();
}
private static void deleteDirectory(File directory) {
/**
* Go through each directory and check if it's not empty. We need to
* delete files inside a directory before a directory can be deleted.
**/
File[] files = directory.listFiles();
for (File file : files) {
if (file.isDirectory()) {
deleteDirectory(file);
} else {
file.delete();
}
}
// Now that the directory is empty. Delete it.
directory.delete();
}
private static List<File> getDirectories(String path) {
List<File> files = new ArrayList<>();
try {
DirectoryStream<Path> stream = Files.newDirectoryStream(FileSystems.getDefault().getPath(path));
Iterator<Path> iter = stream.iterator();
while (iter.hasNext()) {
Path next = iter.next();
files.add(next.toFile());
}
stream.close();
} catch (IOException e) {
e.printStackTrace();
}
return files;
}
private static String[] getPlaybackFormats(String path) {
System.out.println("Getting playback formats at " + path);
List<File> dirs = getDirectories(path);
String[] formats = new String[dirs.size()];
for (int i = 0; i < dirs.size(); i++) {
System.out.println("Playback format = " + dirs.get(i).getName());
formats[i] = dirs.get(i).getName();
}
return formats;
}
public void setRecordingStatusDir(String dir) {
recordStatusDir = dir;
}
public void setUnpublishedDir(String dir) {
unpublishedDir = dir;
}
public void setPresentationBaseDir(String dir) {
presentationBaseDir = dir;
}
public void setDefaultServerUrl(String url) {
defaultServerUrl = url;
}
public void setDefaultTextTrackUrl(String url) {
defaultTextTrackUrl = url;
}
public void setPublishedDir(String dir) {
publishedDir = dir;
}
public void setCaptionsDir(String dir) {
captionsDir = dir;
}
public void setRecordingServiceHelper(RecordingMetadataReaderHelper r) {
recordingServiceHelper = r;
}
public void setXmlService(XmlService xmlService) { this.xmlService = xmlService; }
private boolean shouldIncludeState(List<String> states, String type) {
boolean r = false;
if (!states.isEmpty()) {
if (states.contains("any")) {
r = true;
} else {
if (type.equals(Recording.STATE_PUBLISHED) && states.contains(Recording.STATE_PUBLISHED)) {
r = true;
} else if (type.equals(Recording.STATE_UNPUBLISHED) && states.contains(Recording.STATE_UNPUBLISHED)) {
r = true;
} else if (type.equals(Recording.STATE_DELETED) && states.contains(Recording.STATE_DELETED)) {
r = true;
} else if (type.equals(Recording.STATE_PROCESSING) && states.contains(Recording.STATE_PROCESSING)) {
r = true;
} else if (type.equals(Recording.STATE_PROCESSED) && states.contains(Recording.STATE_PROCESSED)) {
r = true;
}
}
} else {
if (type.equals(Recording.STATE_PUBLISHED) || type.equals(Recording.STATE_UNPUBLISHED)) {
r = true;
}
}
return r;
}
public boolean changeState(String recordingId, String state) {
boolean succeeded = false;
if (state.equals(Recording.STATE_PUBLISHED)) {
// It can only be published if it is unpublished
succeeded |= changeState(unpublishedDir, recordingId, state);
} else if (state.equals(Recording.STATE_UNPUBLISHED)) {
// It can only be unpublished if it is published
succeeded |= changeState(publishedDir, recordingId, state);
} else if (state.equals(Recording.STATE_DELETED)) {
// It can be deleted from any state
succeeded |= changeState(publishedDir, recordingId, state);
succeeded |= changeState(unpublishedDir, recordingId, state);
}
return succeeded;
}
private boolean changeState(String path, String recordingId, String state) {
boolean exists = false;
boolean succeeded = true;
String[] format = getPlaybackFormats(path);
for (String aFormat : format) {
List<File> recordings = getDirectories(path + File.separatorChar + aFormat);
for (File recording : recordings) {
if (recording.getName().equalsIgnoreCase(recordingId)) {
exists = true;
File dest;
if (state.equals(Recording.STATE_PUBLISHED)) {
dest = new File(publishedDir + File.separatorChar + aFormat);
succeeded &= publishRecording(dest, recordingId, recording, aFormat);
} else if (state.equals(Recording.STATE_UNPUBLISHED)) {
dest = new File(unpublishedDir + File.separatorChar + aFormat);
succeeded &= unpublishRecording(dest, recordingId, recording, aFormat);
} else if (state.equals(Recording.STATE_DELETED)) {
dest = new File(deletedDir + File.separatorChar + aFormat);
succeeded &= deleteRecording(dest, recordingId, recording, aFormat);
} else {
log.debug(String.format("State: %s, is not supported", state));
return false;
}
}
}
}
return exists && succeeded;
}
public boolean publishRecording(File destDir, String recordingId, File recordingDir, String format) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(recordingDir.getPath());
RecordingMetadata r = recordingServiceHelper.getRecordingMetadata(metadataXml);
if (r != null) {
if (!destDir.exists()) destDir.mkdirs();
try {
FileUtils.moveDirectory(recordingDir, new File(destDir.getPath() + File.separatorChar + recordingId));
r.setState(Recording.STATE_PUBLISHED);
r.setPublished(true);
File medataXmlFile = recordingServiceHelper.getMetadataXmlLocation(
destDir.getAbsolutePath() + File.separatorChar + recordingId);
// Process the changes by saving the recording into metadata.xml
return recordingServiceHelper.saveRecordingMetadata(medataXmlFile, r);
} catch (IOException e) {
log.error("Failed to publish recording : " + recordingId, e);
}
}
return false;
}
public boolean unpublishRecording(File destDir, String recordingId, File recordingDir, String format) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(recordingDir.getPath());
RecordingMetadata r = recordingServiceHelper.getRecordingMetadata(metadataXml);
if (r != null) {
if (!destDir.exists()) destDir.mkdirs();
try {
FileUtils.moveDirectory(recordingDir, new File(destDir.getPath() + File.separatorChar + recordingId));
r.setState(Recording.STATE_UNPUBLISHED);
r.setPublished(false);
File medataXmlFile = recordingServiceHelper.getMetadataXmlLocation(
destDir.getAbsolutePath() + File.separatorChar + recordingId);
// Process the changes by saving the recording into metadata.xml
return recordingServiceHelper.saveRecordingMetadata(medataXmlFile, r);
} catch (IOException e) {
log.error("Failed to unpublish recording : " + recordingId, e);
}
}
return false;
}
public boolean deleteRecording(File destDir, String recordingId, File recordingDir, String format) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(recordingDir.getPath());
RecordingMetadata r = recordingServiceHelper.getRecordingMetadata(metadataXml);
if (r != null) {
if (!destDir.exists()) destDir.mkdirs();
try {
FileUtils.moveDirectory(recordingDir, new File(destDir.getPath() + File.separatorChar + recordingId));
r.setState(Recording.STATE_DELETED);
r.setPublished(false);
File medataXmlFile = recordingServiceHelper.getMetadataXmlLocation(
destDir.getAbsolutePath() + File.separatorChar + recordingId);
// Process the changes by saving the recording into metadata.xml
return recordingServiceHelper.saveRecordingMetadata(medataXmlFile, r);
} catch (IOException e) {
log.error("Failed to delete recording : " + recordingId, e);
}
}
return false;
}
private List<File> getAllDirectories(String state) {
List<File> allDirectories = new ArrayList<>();
String dir = getDestinationBaseDirectoryName(state);
if ( dir != null ) {
String[] formats = getPlaybackFormats(dir);
for (String format : formats) {
allDirectories.addAll(getDirectories(dir + File.separatorChar + format));
}
}
return allDirectories;
}
private Map<String, List<File>> getAllDirectories(List<String> states) {
Map<String, List<File>> allDirectories = new HashMap<>();
if ( shouldIncludeState(states, Recording.STATE_PUBLISHED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_PUBLISHED);
allDirectories.put(Recording.STATE_PUBLISHED, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_UNPUBLISHED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_UNPUBLISHED);
allDirectories.put(Recording.STATE_UNPUBLISHED, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_DELETED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_DELETED);
allDirectories.put(Recording.STATE_DELETED, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_PROCESSING) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_PROCESSING);
allDirectories.put(Recording.STATE_PROCESSING, listedDirectories);
}
if ( shouldIncludeState(states, Recording.STATE_PROCESSED) ) {
List<File> listedDirectories = getAllDirectories(Recording.STATE_PROCESSED);
allDirectories.put(Recording.STATE_PROCESSED, listedDirectories);
}
return allDirectories;
}
public void updateMetaParams(List<String> recordIDs, Map<String,String> metaParams) {
// Define the directories used to lookup the recording
List<String> states = new ArrayList<>();
states.add(Recording.STATE_PUBLISHED);
states.add(Recording.STATE_UNPUBLISHED);
states.add(Recording.STATE_DELETED);
// Gather all the existent directories based on the states defined for the lookup
Map<String, List<File>> allDirectories = getAllDirectories(states);
// Retrieve the actual recording from the directories gathered for the lookup
for (String recordID : recordIDs) {
for (Map.Entry<String, List<File>> entry : allDirectories.entrySet()) {
List<File> recs = getRecordingsForPath(recordID, entry.getValue());
// Go through all recordings of all formats
for (File rec : recs) {
File metadataXml = recordingServiceHelper.getMetadataXmlLocation(rec.getPath());
updateRecordingMetadata(metadataXml, metaParams, metadataXml);
}
}
}
}
public void updateRecordingMetadata(File srxMetadataXml, Map<String,String> metaParams, File destMetadataXml) {
RecordingMetadata rec = recordingServiceHelper.getRecordingMetadata(srxMetadataXml);
Map<String, String> recMeta = rec.getMeta();
if (rec != null && !recMeta.isEmpty()) {
for (Map.Entry<String,String> meta : metaParams.entrySet()) {
if ( !"".equals(meta.getValue()) ) {
// As it has a value, if the meta parameter exists update it, otherwise add it
recMeta.put(meta.getKey(), meta.getValue());
} else {
// As it doesn't have a value, if it exists delete it
if ( recMeta.containsKey(meta.getKey()) ) {
recMeta.remove(meta.getKey());
}
}
}
rec.setMeta(recMeta);
// Process the changes by saving the recording into metadata.xml
recordingServiceHelper.saveRecordingMetadata(destMetadataXml, rec);
}
}
private Map<String,File> indexRecordings(List<File> recs) {
Map<String,File> indexedRecs = new HashMap<>();
Iterator<File> iterator = recs.iterator();
while (iterator.hasNext()) {
File rec = iterator.next();
indexedRecs.put(rec.getName(), rec);
}
return indexedRecs;
}
private String getDestinationBaseDirectoryName(String state) {
return getDestinationBaseDirectoryName(state, false);
}
private String getDestinationBaseDirectoryName(String state, boolean forceDefault) {
String baseDir = null;
if ( state.equals(Recording.STATE_PROCESSING) || state.equals(Recording.STATE_PROCESSED) )
baseDir = processDir;
else if ( state.equals(Recording.STATE_PUBLISHED) )
baseDir = publishedDir;
else if ( state.equals(Recording.STATE_UNPUBLISHED) )
baseDir = unpublishedDir;
else if ( state.equals(Recording.STATE_DELETED) )
baseDir = deletedDir;
else if ( forceDefault )
baseDir = publishedDir;
return baseDir;
}
public String getCaptionTrackInboxDir() {
return captionsDir + File.separatorChar + "inbox";
}
public String getCaptionsDir() {
return captionsDir;
}
public String getCaptionFileUrlDirectory() {
return defaultTextTrackUrl + "/textTrack/";
}
}

View File

@ -0,0 +1,594 @@
package org.bigbluebutton.api.service.impl;
import org.bigbluebutton.api.model.entity.*;
import org.bigbluebutton.api.service.XmlService;
import org.w3c.dom.CharacterData;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.StringWriter;
import java.lang.reflect.Field;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Node;
import org.springframework.data.domain.*;
import org.w3c.dom.NodeList;
public class XmlServiceImpl implements XmlService {
private static Logger logger = LoggerFactory.getLogger(XmlServiceImpl.class);
private DocumentBuilderFactory factory;
private DocumentBuilder builder;
@Override
public String recordingsToXml(Collection<Recording> recordings) {
logger.info("Converting {} recordings to xml", recordings.size());
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, "recordings", null);
document.appendChild(rootElement);
String xml;
Document secondDoc;
Node node;
for(Recording recording: recordings) {
xml = recordingToXml(recording);
secondDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
node = document.importNode(secondDoc.getDocumentElement(), true);
rootElement.appendChild(node);
}
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String recordingToXml(Recording recording) {
logger.info("Converting {} to xml", recording);
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document,"recording", null);
document.appendChild(rootElement);
appendFields(document, rootElement, recording, new String[] {"id", "metadata", "format", "callbackData"}, Type.CHILD);
Element meta = createElement(document, "meta", null);
rootElement.appendChild(meta);
String xml;
Document secondDoc;
Node node;
if(recording.getMetadata() != null) {
for(Metadata metadata: recording.getMetadata()) {
xml = metadataToXml(metadata);
secondDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
node = document.importNode(secondDoc.getDocumentElement(), true);
meta.appendChild(node);
}
}
if(recording.getFormat() != null) {
xml = playbackFormatToXml(recording.getFormat());
secondDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
node = document.importNode(secondDoc.getDocumentElement(), true);
rootElement.appendChild(node);
}
if(recording.getCallbackData() != null) {
xml = callbackDataToXml(recording.getCallbackData());
secondDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
node = document.importNode(secondDoc.getDocumentElement(), true);
rootElement.appendChild(node);
}
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String metadataToXml(Metadata metadata) {
logger.info("Converting {} to xml", metadata);
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, metadata.getKey(), metadata.getValue());
document.appendChild(rootElement);
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String playbackFormatToXml(PlaybackFormat playbackFormat) {
logger.info("Converting {} to xml", playbackFormat);
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, "playback", null);
document.appendChild(rootElement);
appendFields(document, rootElement, playbackFormat, new String[] {"id", "recording", "thumbnails"}, Type.CHILD);
if(playbackFormat.getThumbnails() != null && !playbackFormat.getThumbnails().isEmpty()) {
Element images = createElement(document, "images", null);
rootElement.appendChild(images);
List<Thumbnail> thumbnails = new ArrayList<>(playbackFormat.getThumbnails());
Collections.sort(thumbnails);
for(Thumbnail thumbnail: thumbnails) {
String xml = thumbnailToXml(thumbnail);
Document thumbnailDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
Node node = document.importNode(thumbnailDoc.getDocumentElement(), true);
images.appendChild(node);
}
}
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String thumbnailToXml(Thumbnail thumbnail) {
logger.info("Converting {} to xml", thumbnail);
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, "image", thumbnail.getUrl());
document.appendChild(rootElement);
appendFields(document, rootElement, thumbnail, new String[] {"id", "url", "playbackFormat"}, Type.ATTRIBUTE);
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String callbackDataToXml(CallbackData callbackData) {
logger.info("Converting {} to xml", callbackData);
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, "callback", null);
document.appendChild(rootElement);
appendFields(document, rootElement, callbackData, new String[] {"id", "recording"}, Type.CHILD);
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String constructResponseFromRecordingsXml(String xml) {
logger.info("Constructing response from recordings xml");
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, "response", null);
document.appendChild(rootElement);
Element returnCode = createElement(document, "returncode", "SUCCESS");
rootElement.appendChild(returnCode);
Document recordingsDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
Node recordingsNode = document.importNode(recordingsDoc.getDocumentElement(), true);
rootElement.appendChild(recordingsNode);
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
@Override
public String constructPaginatedResponse(Page<?> page, String response) {
logger.info("Constructing paginated response");
try {
setup();
if(response == null || response.equals("")) {
return null;
}
Document document = builder.parse(new ByteArrayInputStream(response.getBytes()));
Element rootElement = document.getDocumentElement();
Element pagination = createElement(document, "pagination", null);
String xml;
Document secondDoc;
Node node;
xml = pageableToXml(page.getPageable());
secondDoc = builder.parse(new ByteArrayInputStream(xml.getBytes()));
node = document.importNode(secondDoc.getDocumentElement(), true);
pagination.appendChild(node);
Element totalElements = createElement(document, "totalElements", String.valueOf(page.getTotalElements()));
pagination.appendChild(totalElements);
Element last = createElement(document, "last", String.valueOf(page.isLast()));
pagination.appendChild(last);
Element totalPages = createElement(document, "totalPages", String.valueOf(page.getTotalPages()));
pagination.appendChild(totalPages);
Element first = createElement(document, "first", String.valueOf(page.isFirst()));
pagination.appendChild(first);
Element empty = createElement(document, "empty", String.valueOf(!page.hasContent()));
pagination.appendChild(empty);
rootElement.appendChild(pagination);
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
private String pageableToXml(Pageable pageable) {
logger.info("Converting {} to xml", pageable);
try {
setup();
Document document = builder.newDocument();
Element rootElement = createElement(document, "pageable", null);
document.appendChild(rootElement);
Sort sort = pageable.getSort();
Element sortElement = createElement(document, "sort", null);
Element unsorted = createElement(document, "unsorted", String.valueOf(sort.isUnsorted()));
sortElement.appendChild(unsorted);
Element sorted = createElement(document, "sorted", String.valueOf(sort.isSorted()));
sortElement.appendChild(sorted);
Element empty = createElement(document, "empty", String.valueOf(sort.isEmpty()));
sortElement.appendChild(empty);
rootElement.appendChild(sortElement);
Element offset = createElement(document, "offset", String.valueOf(pageable.getOffset()));
rootElement.appendChild(offset);
Element pageSize = createElement(document, "pageSize", String.valueOf(pageable.getPageSize()));
rootElement.appendChild(pageSize);
Element pageNumber = createElement(document, "pageNumber", String.valueOf(pageable.getPageNumber()));
rootElement.appendChild(pageNumber);
Element paged = createElement(document, "paged", String.valueOf(pageable.isPaged()));
rootElement.appendChild(paged);
Element unpaged = createElement(document, "unpaged", String.valueOf(pageable.isUnpaged()));
rootElement.appendChild(unpaged);
String result = documentToString(document);
// logger.info("========== Result ==========");
// logger.info("{}", result);
// logger.info("============================");
return result;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
public Recording xmlToRecording(String recordId, String xml) {
try {
setup();
Document document = builder.parse(new ByteArrayInputStream(xml.getBytes()));
Recording recording = parseRecordingDocument(document);
if (recording.getRecordId() == null || recording.getRecordId().equals(""))
recording.setRecordId(recordId);
return recording;
} catch(Exception e) {
e.printStackTrace();
}
return null;
}
private Recording parseRecordingDocument(Document recordingDocument) {
String id = getNodeData(recordingDocument, "id");
String state = getNodeData(recordingDocument, "state");
String published = getNodeData(recordingDocument, "published");
String startTime = getNodeData(recordingDocument, "start_time");
String endTime = getNodeData(recordingDocument, "end_time");
String participants = getNodeData(recordingDocument, "participants");
String externalId = getNodeData(recordingDocument, "externalId");
String name = getNodeData(recordingDocument, "name");
if (tagExists(recordingDocument, "meeting")) {
Element meeting = (Element) recordingDocument.getElementsByTagName("meeting").item(0);
externalId = meeting.getAttribute("externalId");
name = meeting.getAttribute("name");
if (id == null || id.equals(""))
id = meeting.getAttribute("id");
}
Recording recording = new Recording();
recording.setRecordId(id);
recording.setMeetingId(externalId);
recording.setName(name);
recording.setPublished(Boolean.parseBoolean(published));
recording.setState(state);
try {
recording.setStartTime(
LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(startTime)), ZoneOffset.UTC));
recording
.setEndTime(LocalDateTime.ofInstant(Instant.ofEpochMilli(Long.parseLong(endTime)), ZoneOffset.UTC));
recording.setParticipants(Integer.parseInt(participants));
} catch (NumberFormatException e) {
}
parseMetadata(recordingDocument, recording);
PlaybackFormat playback = parsePlaybackFormat(recordingDocument);
recording.setFormat(playback);
playback.setRecording(recording);
logger.info("Finished constructing recording: {}", recording);
return recording;
}
private void parseMetadata(Document recordingDocument, Recording recording) {
Node meta = recordingDocument.getElementsByTagName("meta").item(0);
NodeList children = meta.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (!(node instanceof Element))
continue;
String key = node.getNodeName();
String value = node.getTextContent();
Metadata metadata = new Metadata();
metadata.setKey(key);
metadata.setValue(value);
logger.info("Finished constructing metadata: {}", metadata);
recording.addMetadata(metadata);
}
}
private PlaybackFormat parsePlaybackFormat(Document recordingDocument) {
PlaybackFormat playback = new PlaybackFormat();
String format = getNodeData(recordingDocument, "format");
playback.setFormat(format);
String url = getNodeData(recordingDocument, "link");
playback.setUrl(url);
String length = getNodeData(recordingDocument, "duration");
String processingTime = getNodeData(recordingDocument, "processingTime");
try {
playback.setLength(Integer.parseInt(length));
playback.setProcessingTime(Integer.parseInt(processingTime));
} catch (NumberFormatException e) {
}
NodeList images = recordingDocument.getElementsByTagName("image");
for (int i = 0; i < images.getLength(); i++) {
Element image = (Element) images.item(i);
String height = image.getAttribute("height");
String width = image.getAttribute("width");
String alt = image.getAttribute("alt");
String src = image.getTextContent();
Thumbnail thumbnail = new Thumbnail();
try {
thumbnail.setHeight(Integer.parseInt(height));
thumbnail.setWidth(Integer.parseInt(width));
} catch (NumberFormatException e) {
}
thumbnail.setAlt(alt);
thumbnail.setUrl(src);
thumbnail.setSequence(i);
logger.info("Finished constructing image: {}", image);
playback.addThumbnail(thumbnail);
}
logger.info("Finished constructing playback format: {}", playback);
return playback;
}
private void setup() throws ParserConfigurationException {
if(factory == null) factory = DocumentBuilderFactory.newInstance();
if(builder == null) builder = factory.newDocumentBuilder();
}
private Element createElement(Document document, String name, String value) {
Element element = document.createElement(name);
if(value != null) element.setTextContent(value);
return element;
}
public String documentToString(Document document) {
String output = null;
try {
TransformerFactory factory = TransformerFactory.newInstance();
Transformer transformer = factory.newTransformer();
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
transformer.setOutputProperty(OutputKeys.INDENT, "no");
StringWriter writer = new StringWriter();
transformer.transform(new DOMSource(document), new StreamResult(writer));
output = writer.toString();
} catch(Exception e) {
e.printStackTrace();
}
return output;
}
private void appendFields(Document document, Element parent, Object object, String[] ignoredFields, Type type) throws IllegalAccessException {
Field[] fields = object.getClass().getDeclaredFields();
for(Field field: fields) {
if(Arrays.stream(ignoredFields).anyMatch(field.getName()::equals)) continue;
field.setAccessible(true);
Object fieldValue = field.get(object);
if(fieldValue != null) {
if(fieldValue instanceof LocalDateTime) {
fieldValue = localDateTimeToEpoch((LocalDateTime) fieldValue);
}
switch(type) {
case CHILD:
Element child = createElement(document, field.getName(), fieldValue.toString());
parent.appendChild(child);
break;
case ATTRIBUTE:
parent.setAttribute(field.getName(), fieldValue.toString());
break;
}
}
}
}
private String localDateTimeToEpoch(LocalDateTime localDateTime) {
Instant instant = localDateTime.atZone(ZoneId.systemDefault()).toInstant();
return String.valueOf(instant.toEpochMilli());
}
private boolean tagExists(Document document, String tag) {
NodeList node = document.getElementsByTagName(tag);
if (node == null || node.getLength() == 0)
return false;
return true;
}
private String getNodeData(Document document, String tag) {
String data = null;
if (!tagExists(document, tag))
return data;
NodeList node = document.getElementsByTagName(tag);
Element element = (Element) node.item(0);
Node child = element.getFirstChild();
if (child instanceof CharacterData) {
CharacterData characterData = (CharacterData) child;
data = characterData.getData();
}
return data;
}
private enum Type {
CHILD,
ATTRIBUTE
}
}

View File

@ -0,0 +1,262 @@
package org.bigbluebutton.api.util;
import org.bigbluebutton.api.model.entity.*;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.Configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import javax.persistence.*;
import java.util.List;
public class DataStore {
private static final Logger logger = LoggerFactory.getLogger(DataStore.class);
private SessionFactory sessionFactory;
private static DataStore instance;
private DataStore() {
openConnection();
}
private void openConnection() {
sessionFactory = new Configuration()
.configure()
.addAnnotatedClass(Recording.class)
.addAnnotatedClass(Metadata.class)
.addAnnotatedClass(PlaybackFormat.class)
.addAnnotatedClass(Thumbnail.class)
.addAnnotatedClass(CallbackData.class)
.buildSessionFactory();
}
public static DataStore getInstance() {
if(instance == null) {
instance = new DataStore();
}
return instance;
}
public <T> void save(T entity) {
logger.info("Attempting to save {}", entity);
Session session = sessionFactory.openSession();
Transaction transaction = null;
try {
transaction = session.beginTransaction();
session.saveOrUpdate(entity);
transaction.commit();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
e.printStackTrace();
}
} finally {
session.close();
}
}
public <T> T find(String id, Class<T> entityClass) {
logger.info("Attempting to find {} with ID {}", entityClass.getSimpleName(), id);
Session session = sessionFactory.openSession();
Transaction transaction = null;
T result = null;
try {
transaction = session.beginTransaction();
result = session.find(entityClass, Long.parseLong(id));
transaction.commit();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
if(e instanceof NoResultException) logger.info("No result found.");
else e.printStackTrace();
}
} finally {
session.close();
}
return result;
}
public <T> List<T> findAll(Class<T> entityClass) {
logger.info("Attempting to fetch all {}", entityClass.getSimpleName());
Session session = sessionFactory.openSession();
Transaction transaction = null;
List<T> result = null;
try {
transaction = session.beginTransaction();
CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder();
CriteriaQuery<T> criteriaQuery = criteriaBuilder.createQuery(entityClass);
Root<T> root = criteriaQuery.from(entityClass);
CriteriaQuery<T> allEntities = criteriaQuery.select(root);
result = session.createQuery(allEntities).getResultList();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
if(e instanceof NoResultException) logger.info("No result found.");
else e.printStackTrace();
}
} finally {
session.close();
}
return result;
}
public Recording findRecordingByRecordId(String recordId) {
logger.info("Attempting to find recording with recordId {}", recordId);
Session session = sessionFactory.openSession();
Transaction transaction = null;
Recording result = null;
try {
transaction = session.beginTransaction();
CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder();
CriteriaQuery<Recording> criteriaQuery = criteriaBuilder.createQuery(Recording.class);
Root<Recording> recordingRoot = criteriaQuery.from(Recording.class);
criteriaQuery.where(criteriaBuilder.equal(recordingRoot.get("recordId"), recordId));
result = session.createQuery(criteriaQuery).getSingleResult();
transaction.commit();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
if(e instanceof NoResultException) logger.info("No result found.");
else e.printStackTrace();
}
} finally {
session.close();
}
return result;
}
public List<Recording> findRecordingsByMeetingId(String meetingId) {
logger.info("Attempting to find recordings with meetingID {}", meetingId);
Session session = sessionFactory.openSession();
Transaction transaction = null;
List<Recording> result = null;
try {
transaction = session.beginTransaction();
CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder();
CriteriaQuery<Recording> criteriaQuery = criteriaBuilder.createQuery(Recording.class);
Root<Recording> recordingRoot = criteriaQuery.from(Recording.class);
criteriaQuery.where(criteriaBuilder.equal(recordingRoot.get("meetingId"), meetingId));
result = session.createQuery(criteriaQuery).getResultList();
transaction.commit();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
if(e instanceof NoResultException) logger.info("No results found.");
else e.printStackTrace();
}
} finally {
session.close();
}
return result;
}
public List<Recording> findRecordingsByState(String state) {
logger.info("Attempting to find recordings with state {}", state);
Session session = sessionFactory.openSession();
Transaction transaction = null;
List<Recording> result = null;
try {
transaction = session.beginTransaction();
CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder();
CriteriaQuery<Recording> criteriaQuery = criteriaBuilder.createQuery(Recording.class);
Root<Recording> recordingRoot = criteriaQuery.from(Recording.class);
criteriaQuery.where(criteriaBuilder.equal(recordingRoot.get("state"), state));
result = session.createQuery(criteriaQuery).getResultList();
transaction.commit();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
if(e instanceof NoResultException) logger.info("No results found.");
else e.printStackTrace();
}
} finally {
session.close();
}
return result;
}
public List<Metadata> findMetadataByFilter(String key, String value) {
logger.info("Attempting to find metadata with key {} and value {}", key, value);
Session session = sessionFactory.openSession();
Transaction transaction = null;
List<Metadata> result = null;
try {
transaction = session.beginTransaction();
CriteriaBuilder criteriaBuilder = session.getCriteriaBuilder();
CriteriaQuery<Metadata> criteriaQuery = criteriaBuilder.createQuery(Metadata.class);
Root<Metadata> metadataRoot = criteriaQuery.from(Metadata.class);
Predicate predicateForKey = criteriaBuilder.equal(metadataRoot.get("key"), key);
Predicate predicateForValue = criteriaBuilder.equal(metadataRoot.get("value"), value);
criteriaQuery.where(criteriaBuilder.and(predicateForKey, predicateForValue));
result = session.createQuery(criteriaQuery).getResultList();
transaction.commit();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
if(e instanceof NoResultException) logger.info("No result found.");
else e.printStackTrace();
}
} finally {
session.close();
}
return result;
}
public <T> void delete(T entity) {
logger.info("Attempting to delete {}", entity);
Session session = sessionFactory.openSession();
Transaction transaction = null;
try {
transaction = session.beginTransaction();
session.delete(entity);
transaction.commit();
} catch(Exception e) {
if(transaction != null) {
transaction.rollback();
e.printStackTrace();
}
} finally {
session.close();
}
}
public void truncateTables() {
logger.info("Attempting to truncate tables");
List<Recording> recordings = findAll(Recording.class);
if(recordings != null) {
for(Recording recording: recordings) {
delete(recording);
}
}
}
}

View File

@ -27,8 +27,8 @@ public class HTML5ProcessLine {
public HTML5ProcessLine(String input) {
// $ ps -u meteor -o pcpu,cmd= | grep NODEJS_BACKEND_INSTANCE_ID
// 1.1 /usr/share/node-v12.16.1-linux-x64/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=1
// 1.0 /usr/share/node-v12.16.1-linux-x64/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=2
// 1.1 /usr/lib/bbb-html5/node/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=1
// 1.0 /usr/lib/bbb-html5/node/bin/node --max-old-space-size=2048 --max_semi_space_size=128 main.js NODEJS_BACKEND_INSTANCE_ID=2
String[] a = input.trim().split(" ");
this.percentageCPU = Double.parseDouble(a[0]);

View File

@ -0,0 +1,28 @@
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<!-- JDBC Database connection settings -->
<property name="connection.driver_class">org.postgresql.Driver</property>
<property name="connection.url">jdbc:postgresql://localhost:5432/bbb</property>
<property name="connection.username">bbb</property>
<property name="connection.password">bbb123</property>
<!-- JDBC connection pool settings -->
<property name="hibernate.connection.provider_class">com.zaxxer.hikari.hibernate.HikariConnectionProvider</property>
<property name="hibernate.hikari.minimumIdle">5</property>
<property name="hibernate.hikari.maximumPoolSize">10</property>
<property name="hibernate.hikari.idleTimeout">30000</property>
<!-- Select our SQL dialect -->
<property name="dialect">org.hibernate.dialect.PostgreSQL10Dialect</property>
<!-- Echo the SQL to stdout -->
<property name="show_sql">true</property>
<!-- Set the current session context -->
<property name="current_session_context_class">thread</property>
<property name="hibernate.show_sql">false</property>
<!-- format the sql nice -->
<property name="hibernate.format_sql">false</property>
<!-- show the hql as comment -->
<property name="use_sql_comments">false</property>
</session-factory>
</hibernate-configuration>

1
bbb-export-annotations/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,3 @@
const settings = require('./settings');
const config = settings;
module.exports = config;

View File

@ -0,0 +1,38 @@
{
"log": {
"level": "info"
},
"shared": {
"presDir": "/var/bigbluebutton",
"presAnnDropboxDir": "/tmp/pres-ann-dropbox"
},
"collector": {
"backgroundSlideDPI": 300,
"backgroundSlidePPI": 200
},
"process": {
"whiteboardTextEncoding": "utf-8",
"pointsPerInch": 72,
"pixelsPerInch": 96
},
"notifier": {
"pod_id": "DEFAULT_PRESENTATION_POD",
"is_downloadable": "false",
"msgName": "NewPresAnnFileAvailableMsg",
"protocol": "https",
"host": "localhost"
},
"bbbWeb": {
"host": "127.0.0.1",
"port": 8090
},
"redis": {
"host": "127.0.0.1",
"port": 6379,
"password": null,
"channels": {
"queue": "exportJobs",
"publish": "to-akka-apps-redis-channel"
}
}
}

View File

@ -0,0 +1,51 @@
const config = require('../../config');
const { level } = config.log;
const trace = level.toLowerCase() === 'trace';
const debug = trace || level.toLowerCase() === 'debug';
const date = () => new Date().toISOString();
const parse = (messages) => {
return messages.map(message => {
if (typeof message === 'object') return JSON.stringify(message);
return message;
});
};
module.exports = class Logger {
constructor(context) {
this.context = context;
}
trace(...messages) {
if (trace) {
console.log(date(), 'TRACE\t', `[${this.context}]`, ...parse(messages));
}
}
debug(...messages) {
if (debug) {
console.log(date(), 'DEBUG\t', `[${this.context}]`, ...parse(messages));
}
}
info(...messages) {
console.log(date(), 'INFO\t', `[${this.context}]`, ...parse(messages));
}
warn(...messages) {
if (debug) {
console.log(date(), 'WARN\t', `[${this.context}]`, ...parse(messages));
}
}
error(...messages) {
console.log(date(), 'ERROR\t', `[${this.context}]`, ...parse(messages));
}
fatal(...messages) {
console.log(date(), 'FATAL\t', `[${this.context}]`, ...parse(messages));
}
};

View File

@ -0,0 +1,62 @@
const Logger = require('./lib/utils/logger');
const config = require('./config');
const fs = require('fs');
const redis = require('redis');
const { commandOptions } = require('redis');
const { Worker } = require('worker_threads');
const path = require('path');
const logger = new Logger('presAnn Master');
logger.info("Running bbb-export-annotations");
const kickOffCollectorWorker = (jobId) => {
return new Promise((resolve, reject) => {
const worker = new Worker(path.join(__dirname, 'workers', 'collector.js'), { workerData: jobId });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0)
reject(new Error(`PresAnn Collector Worker stopped with exit code ${code}`));
})
})
}
(async () => {
const client = redis.createClient({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password
});
await client.connect();
client.on('error', (err) => logger.info('Redis Client Error', err));
async function waitForJobs () {
const queue = client.blPop(
commandOptions({ isolated: true }),
config.redis.channels.queue,
0
);
let job = await queue;
logger.info('Received job', job.element);
const exportJob = JSON.parse(job.element);
// Create folder in dropbox
let dropbox = path.join(config.shared.presAnnDropboxDir, exportJob.jobId);
fs.mkdirSync(dropbox, { recursive: true })
// Drop job into dropbox as JSON
fs.writeFile(path.join(dropbox, 'job'), job.element, function(err) {
if(err) { return logger.error(err); }
});
kickOffCollectorWorker(exportJob.jobId)
waitForJobs();
}
waitForJobs();
})();

745
bbb-export-annotations/package-lock.json generated Normal file
View File

@ -0,0 +1,745 @@
{
"name": "bbb-export-annotations",
"version": "0.0.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "bbb-export-annotations",
"version": "0.0.1",
"dependencies": {
"axios": "^0.26.0",
"form-data": "^4.0.0",
"perfect-freehand": "^1.0.16",
"probe-image-size": "^7.2.3",
"redis": "^4.0.3",
"sanitize-filename": "^1.6.3",
"xmlbuilder2": "^3.0.2"
}
},
"node_modules/@node-redis/bloom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@node-redis/bloom/-/bloom-1.0.1.tgz",
"integrity": "sha512-mXEBvEIgF4tUzdIN89LiYsbi6//EdpFA7L8M+DHCvePXg+bfHWi+ct5VI6nHUFQE5+ohm/9wmgihCH3HSkeKsw==",
"peerDependencies": {
"@node-redis/client": "^1.0.0"
}
},
"node_modules/@node-redis/client": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@node-redis/client/-/client-1.0.3.tgz",
"integrity": "sha512-IXNgOG99PHGL3NxN3/e8J8MuX+H08I+OMNmheGmZBXngE0IntaCQwwrd7NzmiHA+zH3SKHiJ+6k3P7t7XYknMw==",
"dependencies": {
"cluster-key-slot": "1.1.0",
"generic-pool": "3.8.2",
"redis-parser": "3.0.0",
"yallist": "4.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@node-redis/graph": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@node-redis/graph/-/graph-1.0.0.tgz",
"integrity": "sha512-mRSo8jEGC0cf+Rm7q8mWMKKKqkn6EAnA9IA2S3JvUv/gaWW/73vil7GLNwion2ihTptAm05I9LkepzfIXUKX5g==",
"peerDependencies": {
"@node-redis/client": "^1.0.0"
}
},
"node_modules/@node-redis/json": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@node-redis/json/-/json-1.0.2.tgz",
"integrity": "sha512-qVRgn8WfG46QQ08CghSbY4VhHFgaTY71WjpwRBGEuqGPfWwfRcIf3OqSpR7Q/45X+v3xd8mvYjywqh0wqJ8T+g==",
"peerDependencies": {
"@node-redis/client": "^1.0.0"
}
},
"node_modules/@node-redis/search": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@node-redis/search/-/search-1.0.2.tgz",
"integrity": "sha512-gWhEeji+kTAvzZeguUNJdMSZNH2c5dv3Bci8Nn2f7VGuf6IvvwuZDSBOuOlirLVgayVuWzAG7EhwaZWK1VDnWQ==",
"peerDependencies": {
"@node-redis/client": "^1.0.0"
}
},
"node_modules/@node-redis/time-series": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@node-redis/time-series/-/time-series-1.0.1.tgz",
"integrity": "sha512-+nTn6EewVj3GlUXPuD3dgheWqo219jTxlo6R+pg24OeVvFHx9aFGGiyOgj3vBPhWUdRZ0xMcujXV5ki4fbLyMw==",
"peerDependencies": {
"@node-redis/client": "^1.0.0"
}
},
"node_modules/@oozcitak/dom": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
"dependencies": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/url": "1.0.4",
"@oozcitak/util": "8.3.8"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/infra": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
"dependencies": {
"@oozcitak/util": "8.3.8"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/@oozcitak/url": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
"dependencies": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/@oozcitak/util": {
"version": "8.3.8",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==",
"engines": {
"node": ">=8.0"
}
},
"node_modules/@types/node": {
"version": "17.0.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.16.tgz",
"integrity": "sha512-ydLaGVfQOQ6hI1xK2A5nVh8bl0OGoIfYMxPWHqqYe9bTkWCfqiVvZoh2I/QF2sNSkZzZyROBoTefIEI+PB6iIA=="
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"node_modules/axios": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.0.tgz",
"integrity": "sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og==",
"dependencies": {
"follow-redirects": "^1.14.8"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
"integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/follow-redirects": {
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/generic-pool": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.8.2.tgz",
"integrity": "sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg==",
"engines": {
"node": ">= 4"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"node_modules/mime-db": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.34",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
"dependencies": {
"mime-db": "1.51.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/needle": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
"integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
"dependencies": {
"debug": "^3.2.6",
"iconv-lite": "^0.4.4",
"sax": "^1.2.4"
},
"bin": {
"needle": "bin/needle"
},
"engines": {
"node": ">= 4.4.x"
}
},
"node_modules/perfect-freehand": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.0.16.tgz",
"integrity": "sha512-D4+avUeR8CHSl2vaPbPYX/dNpSMRYO3VOFp7qSSc+LRkSgzQbLATVnXosy7VxtsSHEh1C5t8K8sfmo0zCVnfWQ=="
},
"node_modules/probe-image-size": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz",
"integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==",
"dependencies": {
"lodash.merge": "^4.6.2",
"needle": "^2.5.2",
"stream-parser": "~0.3.1"
}
},
"node_modules/redis": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.0.3.tgz",
"integrity": "sha512-SJMRXvgiQUYN0HaWwWv002J5ZgkhYXOlbLomzcrL3kP42yRNZ8Jx5nvLYhVpgmf10xcDpanFOxxJkphu2eyIFQ==",
"dependencies": {
"@node-redis/bloom": "1.0.1",
"@node-redis/client": "1.0.3",
"@node-redis/graph": "1.0.0",
"@node-redis/json": "1.0.2",
"@node-redis/search": "1.0.2",
"@node-redis/time-series": "1.0.1"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sanitize-filename": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
"integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
"dependencies": {
"truncate-utf8-bytes": "^1.0.0"
}
},
"node_modules/sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/stream-parser": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
"integrity": "sha1-FhhUhpRCACGhGC/wrxkRwSl2F3M=",
"dependencies": {
"debug": "2"
}
},
"node_modules/stream-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/stream-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
"integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=",
"dependencies": {
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
"integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E="
},
"node_modules/xmlbuilder2": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",
"integrity": "sha512-h4MUawGY21CTdhV4xm3DG9dgsqyhDkZvVJBx88beqX8wJs3VgyGQgAn5VreHuae6unTQxh115aMK5InCVmOIKw==",
"dependencies": {
"@oozcitak/dom": "1.15.10",
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8",
"@types/node": "*",
"js-yaml": "3.14.0"
},
"engines": {
"node": ">=12.0"
}
},
"node_modules/xmlbuilder2/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/xmlbuilder2/node_modules/js-yaml": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
},
"dependencies": {
"@node-redis/bloom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@node-redis/bloom/-/bloom-1.0.1.tgz",
"integrity": "sha512-mXEBvEIgF4tUzdIN89LiYsbi6//EdpFA7L8M+DHCvePXg+bfHWi+ct5VI6nHUFQE5+ohm/9wmgihCH3HSkeKsw==",
"requires": {}
},
"@node-redis/client": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@node-redis/client/-/client-1.0.3.tgz",
"integrity": "sha512-IXNgOG99PHGL3NxN3/e8J8MuX+H08I+OMNmheGmZBXngE0IntaCQwwrd7NzmiHA+zH3SKHiJ+6k3P7t7XYknMw==",
"requires": {
"cluster-key-slot": "1.1.0",
"generic-pool": "3.8.2",
"redis-parser": "3.0.0",
"yallist": "4.0.0"
}
},
"@node-redis/graph": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@node-redis/graph/-/graph-1.0.0.tgz",
"integrity": "sha512-mRSo8jEGC0cf+Rm7q8mWMKKKqkn6EAnA9IA2S3JvUv/gaWW/73vil7GLNwion2ihTptAm05I9LkepzfIXUKX5g==",
"requires": {}
},
"@node-redis/json": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@node-redis/json/-/json-1.0.2.tgz",
"integrity": "sha512-qVRgn8WfG46QQ08CghSbY4VhHFgaTY71WjpwRBGEuqGPfWwfRcIf3OqSpR7Q/45X+v3xd8mvYjywqh0wqJ8T+g==",
"requires": {}
},
"@node-redis/search": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@node-redis/search/-/search-1.0.2.tgz",
"integrity": "sha512-gWhEeji+kTAvzZeguUNJdMSZNH2c5dv3Bci8Nn2f7VGuf6IvvwuZDSBOuOlirLVgayVuWzAG7EhwaZWK1VDnWQ==",
"requires": {}
},
"@node-redis/time-series": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@node-redis/time-series/-/time-series-1.0.1.tgz",
"integrity": "sha512-+nTn6EewVj3GlUXPuD3dgheWqo219jTxlo6R+pg24OeVvFHx9aFGGiyOgj3vBPhWUdRZ0xMcujXV5ki4fbLyMw==",
"requires": {}
},
"@oozcitak/dom": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-1.15.10.tgz",
"integrity": "sha512-0JT29/LaxVgRcGKvHmSrUTEvZ8BXvZhGl2LASRUgHqDTC1M5g1pLmVv56IYNyt3bG2CUjDkc67wnyZC14pbQrQ==",
"requires": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/url": "1.0.4",
"@oozcitak/util": "8.3.8"
}
},
"@oozcitak/infra": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-1.0.8.tgz",
"integrity": "sha512-JRAUc9VR6IGHOL7OGF+yrvs0LO8SlqGnPAMqyzOuFZPSZSXI7Xf2O9+awQPSMXgIWGtgUf/dA6Hs6X6ySEaWTg==",
"requires": {
"@oozcitak/util": "8.3.8"
}
},
"@oozcitak/url": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-1.0.4.tgz",
"integrity": "sha512-kDcD8y+y3FCSOvnBI6HJgl00viO/nGbQoCINmQ0h98OhnGITrWR3bOGfwYCthgcrV8AnTJz8MzslTQbC3SOAmw==",
"requires": {
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8"
}
},
"@oozcitak/util": {
"version": "8.3.8",
"resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-8.3.8.tgz",
"integrity": "sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ=="
},
"@types/node": {
"version": "17.0.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.16.tgz",
"integrity": "sha512-ydLaGVfQOQ6hI1xK2A5nVh8bl0OGoIfYMxPWHqqYe9bTkWCfqiVvZoh2I/QF2sNSkZzZyROBoTefIEI+PB6iIA=="
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"axios": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.0.tgz",
"integrity": "sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og==",
"requires": {
"follow-redirects": "^1.14.8"
}
},
"cluster-key-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
"integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw=="
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"requires": {
"delayed-stream": "~1.0.0"
}
},
"debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"requires": {
"ms": "^2.1.1"
}
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
},
"follow-redirects": {
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
"integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w=="
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"generic-pool": {
"version": "3.8.2",
"resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.8.2.tgz",
"integrity": "sha512-nGToKy6p3PAbYQ7p1UlWl6vSPwfwU6TMSWK7TTu+WUY4ZjyZQGniGGt2oNVvyNSpyZYSB43zMXVLcBm08MTMkg=="
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"mime-db": {
"version": "1.51.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.51.0.tgz",
"integrity": "sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g=="
},
"mime-types": {
"version": "2.1.34",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.34.tgz",
"integrity": "sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A==",
"requires": {
"mime-db": "1.51.0"
}
},
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"needle": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz",
"integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==",
"requires": {
"debug": "^3.2.6",
"iconv-lite": "^0.4.4",
"sax": "^1.2.4"
}
},
"perfect-freehand": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.0.16.tgz",
"integrity": "sha512-D4+avUeR8CHSl2vaPbPYX/dNpSMRYO3VOFp7qSSc+LRkSgzQbLATVnXosy7VxtsSHEh1C5t8K8sfmo0zCVnfWQ=="
},
"probe-image-size": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.2.3.tgz",
"integrity": "sha512-HubhG4Rb2UH8YtV4ba0Vp5bQ7L78RTONYu/ujmCu5nBI8wGv24s4E9xSKBi0N1MowRpxk76pFCpJtW0KPzOK0w==",
"requires": {
"lodash.merge": "^4.6.2",
"needle": "^2.5.2",
"stream-parser": "~0.3.1"
}
},
"redis": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.0.3.tgz",
"integrity": "sha512-SJMRXvgiQUYN0HaWwWv002J5ZgkhYXOlbLomzcrL3kP42yRNZ8Jx5nvLYhVpgmf10xcDpanFOxxJkphu2eyIFQ==",
"requires": {
"@node-redis/bloom": "1.0.1",
"@node-redis/client": "1.0.3",
"@node-redis/graph": "1.0.0",
"@node-redis/json": "1.0.2",
"@node-redis/search": "1.0.2",
"@node-redis/time-series": "1.0.1"
}
},
"redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
},
"redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
"requires": {
"redis-errors": "^1.0.0"
}
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sanitize-filename": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.3.tgz",
"integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==",
"requires": {
"truncate-utf8-bytes": "^1.0.0"
}
},
"sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"stream-parser": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz",
"integrity": "sha1-FhhUhpRCACGhGC/wrxkRwSl2F3M=",
"requires": {
"debug": "2"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}
}
},
"truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
"integrity": "sha1-QFkjkJWS1W94pYGENLC3hInKXys=",
"requires": {
"utf8-byte-length": "^1.0.1"
}
},
"utf8-byte-length": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz",
"integrity": "sha1-9F8VDExm7uloGGUFq5P8u4rWv2E="
},
"xmlbuilder2": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-3.0.2.tgz",
"integrity": "sha512-h4MUawGY21CTdhV4xm3DG9dgsqyhDkZvVJBx88beqX8wJs3VgyGQgAn5VreHuae6unTQxh115aMK5InCVmOIKw==",
"requires": {
"@oozcitak/dom": "1.15.10",
"@oozcitak/infra": "1.0.8",
"@oozcitak/util": "8.3.8",
"@types/node": "*",
"js-yaml": "3.14.0"
},
"dependencies": {
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"js-yaml": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
"integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
}
}
},
"yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
}
}
}

View File

@ -0,0 +1,17 @@
{
"name": "bbb-export-annotations",
"version": "0.0.1",
"description": "BigBlueButton's Presentation Annotation Exporter",
"scripts": {
"start": "node master.js"
},
"dependencies": {
"axios": "^0.26.0",
"form-data": "^4.0.0",
"perfect-freehand": "^1.0.16",
"probe-image-size": "^7.2.3",
"redis": "^4.0.3",
"sanitize-filename": "^1.6.3",
"xmlbuilder2": "^3.0.2"
}
}

View File

@ -0,0 +1,110 @@
const Logger = require('../lib/utils/logger');
const config = require('../config');
const fs = require('fs');
const redis = require('redis');
const { Worker, workerData, parentPort } = require('worker_threads');
const path = require('path');
const probe = require('probe-image-size');
const { execSync } = require("child_process");
const jobId = workerData;
const logger = new Logger('presAnn Collector');
logger.info("Collecting job " + jobId);
const kickOffProcessWorker = (jobId) => {
return new Promise((resolve, reject) => {
const worker = new Worker('./workers/process.js', { workerData: jobId });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0)
reject(new Error(`PresAnn Process Worker stopped with exit code ${code}`));
})
})
}
const dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
// Takes the Job from the dropbox
let job = fs.readFileSync(path.join(dropbox, 'job'));
let exportJob = JSON.parse(job);
// Collect the annotations from Redis
(async () => {
const client = redis.createClient({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password
});
client.on('error', (err) => logger.info('Redis Client Error', err));
await client.connect();
let presAnn = await client.hGetAll(exportJob.jobId);
// Remove annotations from Redis
await client.DEL(jobId);
client.disconnect();
let annotations = JSON.stringify(presAnn);
let whiteboard = JSON.parse(annotations);
let pages = JSON.parse(whiteboard.pages);
fs.writeFile(path.join(dropbox, 'whiteboard'), annotations, function(err) {
if(err) { return logger.error(err); }
});
// Collect the Presentation Page files (PDF / PNG / JPEG) from the presentation directory
let presentationFile = path.join(exportJob.presLocation, exportJob.presId);
let pdfFile = `${presentationFile}.pdf`
if (fs.existsSync(pdfFile)) {
for (let p of pages) {
let pageNumber = p.page;
let svgFile = path.join(exportJob.presLocation, 'svgs', `slide${pageNumber}.svg`)
let outputFile = path.join(dropbox, `slide${pageNumber}`);
// CairoSVG doesn't handle transparent SVG and PNG embeds properly, e.g., in rasterized text.
// So textboxes may get a black background when downloading/exporting repeatedly.
// To avoid that, we take slides from the uploaded file, but later probe the dimensions from the SVG
// so it matches what was shown in the browser -- Tldraw unfortunately uses absolute coordinates.
let extract_png_from_pdf = [
'pdftocairo',
'-png',
'-f', pageNumber,
'-l', pageNumber,
'-r', config.collector.backgroundSlidePPI,
'-singlefile',
'-cropbox',
pdfFile, outputFile,
].join(' ')
execSync(extract_png_from_pdf);
fs.copyFileSync(svgFile, path.join(dropbox, `slide${pageNumber}.svg`));
}
}
// If PNG file already available
else if (fs.existsSync(`${presentationFile}.png`)) {
fs.copyFileSync(`${presentationFile}.png`, path.join(dropbox, 'slide1.png'));
}
// If JPEG file available
else if (fs.existsSync(`${presentationFile}.jpeg`)) {
fs.copyFileSync(`${presentationFile}.jpeg`, path.join(dropbox, 'slide1.jpeg'));
}
else {
return logger.error(`Could not find whiteboard presentation file for job ${exportJob.jobId}`);
}
kickOffProcessWorker(exportJob.jobId);
})()
parentPort.postMessage({ message: workerData })

View File

@ -0,0 +1,80 @@
const Logger = require('../lib/utils/logger');
const config = require('../config');
const fs = require('fs');
const FormData = require('form-data');
const redis = require('redis');
const axios = require('axios').default;
const path = require('path');
const { workerData, parentPort } = require('worker_threads')
const [jobType, jobId, filename] = workerData;
const logger = new Logger('presAnn Notifier Worker');
const dropbox = `${config.shared.presAnnDropboxDir}/${jobId}`
let job = fs.readFileSync(path.join(dropbox, 'job'));
let exportJob = JSON.parse(job);
async function notifyMeetingActor() {
const client = redis.createClient({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password
});
await client.connect();
client.on('error', (err) => logger.info('Redis Client Error', err));
let link = `${config.notifier.protocol}://${config.notifier.host}/bigbluebutton/presentation/${exportJob.parentMeetingId}/${exportJob.parentMeetingId}/${exportJob.presId}/pdf/${jobId}/${filename}.pdf`;
// Notify Meeting Actor of file availability by sending a message through Redis PubSub
const notification = {
envelope: {
name: config.notifier.msgName,
routing: {
sender: exportJob.module
},
timestamp: (new Date()).getTime(),
},
core: {
header: {
name: config.notifier.msgName,
meetingId: exportJob.parentMeetingId,
userId: ""
},
body: {
fileURI: link
},
}
}
logger.info(`Annotated PDF available at ${link}`);
await client.publish(config.redis.channels.publish, JSON.stringify(notification));
client.disconnect();
}
async function upload(exportJob) {
let callbackUrl = `http://${config.bbbWeb.host}:${config.bbbWeb.port}/bigbluebutton/presentation/${exportJob.presentationUploadToken}/upload`
let formData = new FormData();
formData.append('conference', exportJob.parentMeetingId);
formData.append('pod_id', config.notifier.pod_id);
formData.append('is_downloadable', config.notifier.is_downloadable);
formData.append('temporaryPresentationId', jobId);
formData.append('fileUpload', fs.createReadStream(`${exportJob.presLocation}/pdfs/${jobId}/${filename}.pdf`));
let res = await axios.post(callbackUrl, formData, { headers: formData.getHeaders() });
logger.info(`Upload of job ${exportJob.jobId} returned ${res.data}`);
}
if (jobType == 'PresentationWithAnnotationDownloadJob') {
notifyMeetingActor();
} else if (jobType == 'PresentationWithAnnotationExportJob') {
upload(exportJob);
} else {
logger.error(`Notifier received unknown job type ${jobType}`);
}
parentPort.postMessage({ message: workerData })

View File

@ -0,0 +1,843 @@
const Logger = require('../lib/utils/logger');
const config = require('../config');
const fs = require('fs');
const { create } = require('xmlbuilder2', { encoding: 'utf-8' });
const { execSync } = require("child_process");
const { Worker, workerData, parentPort } = require('worker_threads');
const path = require('path');
const sanitize = require('sanitize-filename');
const { getStroke, getStrokePoints } = require('perfect-freehand');
const probe = require('probe-image-size');
const jobId = workerData;
const logger = new Logger('presAnn Process Worker');
logger.info("Processing PDF for job " + jobId);
const kickOffNotifierWorker = (jobType, filename) => {
return new Promise((resolve, reject) => {
const worker = new Worker('./workers/notifier.js', { workerData: [jobType, jobId, filename] });
worker.on('message', resolve);
worker.on('error', reject);
worker.on('exit', (code) => {
if (code !== 0)
reject(new Error(`PresAnn Notifier Worker stopped with exit code ${code}`));
})
})
}
// General utilities for rendering SVGs resembling Tldraw as much as possible
function align_to_pango(alignment) {
switch (alignment) {
case 'start': return 'left'
case 'middle': return 'center'
case 'end': return 'right'
case 'justify': return 'justify'
default: return 'left'
}
}
function color_to_hex(color, isStickyNote = false, isFilled = false) {
if (isStickyNote) { color = `sticky-${color}` }
if (isFilled) { color = `fill-${color}` }
switch (color) {
case 'white': return '#1d1d1d'
case 'fill-white': return '#fefefe'
case 'sticky-white': return '#fddf8e'
case 'lightGray': return '#c6cbd1'
case 'fill-lightGray': return '#f1f2f3'
case 'sticky-lightGray': return '#dde0e3'
case 'gray': return '#788492'
case 'fill-gray': return '#e3e5e7'
case 'sticky-gray': return '#b3b9c1'
case 'black': return '#1d1d1d'
case 'fill-black': return '#d2d2d2'
case 'sticky-black': return '#fddf8e'
case 'green': return '#36b24d'
case 'fill-green': return '#d7eddb'
case 'sticky-green': return '#8ed29b'
case 'cyan': return '#0e98ad'
case 'fill-cyan': return '#d0e8ec'
case 'sticky-cyan': return '#78c4d0'
case 'blue': return '#1c7ed6'
case 'fill-blue': return '#d2e4f4'
case 'sticky-blue': return '#80b6e6'
case 'indigo': return '#4263eb'
case 'fill-indigo': return '#d9dff7'
case 'sticky-indigo': return '#95a7f2'
case 'violet': return '#7746f1'
case 'fill-violet': return '#e2daf8'
case 'sticky-violet': return '#b297f5'
case 'red': return '#ff2133'
case 'fill-red': return '#fbd3d6'
case 'sticky-red': return '#fd838d'
case 'orange': return '#ff9433'
case 'fill-orange': return '#fbe8d6'
case 'sticky-orange': return '#fdc28d'
case 'yellow': return '#ffc936'
case 'fill-yellow': return '#fbf1d7'
case 'sticky-yellow': return '#fddf8e'
default: return '#0d0d0d'
}
}
function determine_dasharray(dash, gap = 0) {
switch (dash) {
case 'dashed': return `stroke-linecap:butt;stroke-dasharray:${gap};`
case 'dotted': return `stroke-linecap:round;stroke-dasharray:${gap};`
default: return 'stroke-linejoin:round;stroke-linecap:round;'
}
}
function determine_font_from_family(family) {
switch (family) {
case 'script': return 'Caveat Brush'
case 'sans': return 'Source Sans Pro'
case 'serif': return 'Crimson Pro'
// Temporary workaround due to typo in messages
case 'erif': return 'Crimson Pro'
case 'mono': return 'Source Code Pro'
default: return 'Caveat Brush'
}
}
function rad_to_degree(angle) {
return angle * (180 / Math.PI);
}
// Convert pixels to points
function to_pt(px) {
return (px / config.process.pixelsPerInch) * config.process.pointsPerInch
}
// Convert points to pixels
function to_px(pt) {
return (pt / config.process.pointsPerInch) * config.process.pixelsPerInch
}
function render_textbox(textColor, font, fontSize, textAlign, text, id, textBoxWidth = null) {
fontSize = to_pt(fontSize);
// Sticky notes need automatic line wrapping: take width into account
let size = textBoxWidth ? `-size ${textBoxWidth}x` : ''
let pangoText = `pango:"<span font_family='${font}' font='${fontSize}' color='${textColor}'>${text}</span>"`
let justify = textAlign === 'justify'
textAlign = justify ? 'left' : textAlign
let commands = [
'convert',
'-encoding', `${config.process.whiteboardTextEncoding}`,
'-density', config.process.pixelsPerInch,
'-background', 'transparent',
size,
'-define', `pango:align=${textAlign}`,
'-define', `pango:justify=${justify}`,
'-define', 'pango:wrap=word-char',
pangoText,
path.join(dropbox, `text${id}.png`)
].join(' ')
execSync(commands);
}
function get_gap(dash, size) {
switch (dash) {
case 'dashed':
if (size == 'small') { return '8 8' }
else if (size == 'medium') { return '14 14' }
else { return '20 20' }
case 'dotted':
if (size == 'small') { return '0.1 8' }
else if (size == 'medium') { return '0.1 14' }
else { return '0.1 20' }
default: return '0'
}
}
function get_stroke_width(dash, size) {
switch (size) {
case 'small': if (dash === 'draw') { return 1 } else { return 4 };
case 'medium': if (dash === 'draw') { return 1.75 } else { return 6.25 };
case 'large': if (dash === 'draw') { return 2.5 } else { return 8.5 }
default: return 1;
}
}
function sortByKey(array, key, value) {
return array.sort(function (a, b) {
let [x, y] = [a[key][value], b[key][value]];
return x - y;
});
}
function text_size_to_px(size, scale = 1, isStickyNote = false) {
if (isStickyNote) { size = `sticky-${size}` }
switch (size) {
case 'sticky-small': return 24
case 'small': return 28 * scale
case 'sticky-medium': return 36
case 'medium': return 48 * scale
case 'sticky-large': return 48
case 'large': return 96 * scale
default: return 28 * scale
}
}
// Methods based on tldraw's utilities
function getPath(annotationPoints) {
// Gets inner path of a stroke outline
// For solid, dashed, and dotted types
let stroke = getStrokePoints(annotationPoints).map((strokePoint) => strokePoint.point);
let [max_x, max_y] = [0, 0];
let path = stroke.reduce(
(acc, [x0, y0], i, arr) => {
if (!arr[i + 1]) return acc
let [x1, y1] = arr[i + 1]
if (x1 >= max_x) { max_x = x1 }
if (y1 >= max_y) { max_y = y1 }
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2)
return acc
},
['M', ...stroke[0], 'Q']
)
path.join(' ');
return [path, max_x, max_y];
}
function getOutlinePath(annotationPoints) {
// Gets outline of a hand-drawn input, with pressure
let stroke = getStroke(annotationPoints, {
simulatePressure: true,
size: 8,
});
let [max_x, max_y] = [0, 0];
let path = stroke.reduce(
(acc, [x0, y0], i, arr) => {
let [x1, y1] = arr[(i + 1) % arr.length]
if (x1 >= max_x) { max_x = x1 }
if (y1 >= max_y) { max_y = y1 }
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2)
return acc;
},
['M', ...stroke[0], 'Q']
);
path.push('Z');
path.join(' ');
return [path, max_x, max_y];
}
function circleFromThreePoints(A, B, C) {
let [x1, y1] = A
let [x2, y2] = B
let [x3, y3] = C
let a = x1 * (y2 - y3) - y1 * (x2 - x3) + x2 * y3 - x3 * y2
let b =
(x1 * x1 + y1 * y1) * (y3 - y2) +
(x2 * x2 + y2 * y2) * (y1 - y3) +
(x3 * x3 + y3 * y3) * (y2 - y1)
let c =
(x1 * x1 + y1 * y1) * (x2 - x3) +
(x2 * x2 + y2 * y2) * (x3 - x1) +
(x3 * x3 + y3 * y3) * (x1 - x2)
let x = -b / (2 * a)
let y = -c / (2 * a)
return [x, y, Math.hypot(x - x1, y - y1)]
}
function distance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow((x2 - x1), 2) + Math.pow((y2 - y1), 2));
}
function getArcLength(C, r, A, B) {
let sweep = getSweep(C, A, B);
return r * (2 * Math.PI) * (sweep / (2 * Math.PI));
}
function getSweep(C, A, B) {
// Get angle between two vectors in radians
let a0 = Math.atan2(A[1] - C[1], A[0] - C[0]);
let a1 = Math.atan2(B[1] - C[1], B[0] - C[0]);
// Short distance between two angles
let max = Math.PI * 2
let da = (a1 - a0) % max
return ((2 * da) % max) - da
}
function intersectCircleCircle(c1, r1, c2, r2) {
let dx = c2[0] - c1[0];
let dy = c2[1] - c1[1];
let d = Math.sqrt(dx * dx + dy * dy);
let x = (d * d - r2 * r2 + r1 * r1) / (2 * d);
let y = Math.sqrt(r1 * r1 - x * x);
dx /= d
dy /= d
return [[c1[0] + dx * x - dy * y, c1[1] + dy * x + dx * y],
[c1[0] + dx * x + dy * y, c1[1] + dy * x - dx * y]]
}
function rotWith(A, C, r = 0) {
// Rotate a vector A around another vector C by r radians
if (r === 0) return A
let s = Math.sin(r)
let c = Math.cos(r)
let px = A[0] - C[0]
let py = A[1] - C[1]
let nx = px * c - py * s
let ny = px * s + py * c
return [nx + C[0], ny + C[1]]
}
function nudge(A, B, d) {
// Pushes a point A towards a point B by a given distance
if (A[0] === B[0] && A[1] === B[1]) return A
// B - A
let sub = [B[0] - A[0], B[1] - A[1]];
// Vector length
let len = Math.hypot(sub[0], sub[1]);
// Get unit vector
let unit = [sub[0] / len, sub[1] / len];
// Multiply by distance
let mul = [unit[0] * d, unit[1] * d];
return [A[0] + mul[0], A[1] + mul[1]]
}
function getCurvedArrowHeadPath(A, r1, C, r2, sweep) {
const phi = (1 + Math.sqrt(5)) / 2;
// Determine intersections between two circles
let ints = intersectCircleCircle(A, r1 * (phi - 1), C, r2)
if (!ints) {
logger.info('Could not find an intersection for the arrow head.')
return { left: A, right: A }
}
let int = sweep ? ints[0] : ints[1]
let left = int ? nudge(rotWith(int, A, Math.PI / 6), A, r1 * -0.382) : A
let right = int ? nudge(rotWith(int, A, -Math.PI / 6), A, r1 * -0.382) : A
return `M ${left} L ${A} ${right}`
}
// Methods to convert Akka message contents into SVG
function overlay_arrow(svg, annotation) {
let [x, y] = annotation.point;
let bend = annotation.bend;
let decorations = annotation.decorations;
let dash = annotation.style.dash;
dash = (dash == 'draw') ? 'solid' : dash // Use 'solid' thickness
let shapeColor = color_to_hex(annotation.style.color);
let sw = get_stroke_width(dash, annotation.style.size);
let gap = get_gap(dash, annotation.style.size);
let stroke_dasharray = determine_dasharray(dash, gap);
let [start_x, start_y] = annotation.handles.start.point;
let [end_x, end_y] = annotation.handles.end.point;
let [bend_x, bend_y] = annotation.handles.bend.point;
let line = [];
let arrowHead = [];
let arrowDistance = distance(start_x, start_y, end_x, end_y);
let arrowHeadLength = Math.min(arrowDistance / 3, 8 * sw);
let isStraightLine = parseFloat(bend).toFixed(3) == 0;
let angle = Math.atan2(end_y - start_y, end_x - start_x)
if (isStraightLine) {
// Draws a straight line / arrow
line.push(`M ${start_x} ${start_y} L ${end_x} ${end_y}`);
if (decorations.start || decorations.end) {
arrowHead.push(`M ${end_x} ${end_y}`);
arrowHead.push(`L ${end_x + arrowHeadLength * Math.cos(angle + (7 / 6) * Math.PI)} ${end_y + arrowHeadLength * Math.sin(angle + (7 / 6) * Math.PI)}`);
arrowHead.push(`M ${end_x} ${end_y}`);
arrowHead.push(`L ${end_x + arrowHeadLength * Math.cos(angle + (5 / 6) * Math.PI)} ${end_y + arrowHeadLength * Math.sin(angle + (5 / 6) * Math.PI)}`);
}
} else {
// Curved lines and arrows
let circle = circleFromThreePoints([start_x, start_y], [bend_x, bend_y], [end_x, end_y]);
let center = [circle[0], circle[1]]
let radius = circle[2]
let length = getArcLength(center, radius, [start_x, start_y], [end_x, end_y]);
line.push(`M ${start_x} ${start_y} A ${radius} ${radius} 0 0 ${length > 0 ? '1' : '0'} ${end_x} ${end_y}`);
if (decorations.start)
arrowHead.push(getCurvedArrowHeadPath([start_x, start_y], arrowHeadLength, center, radius, length < 0));
else if (decorations.end) {
arrowHead.push(getCurvedArrowHeadPath([end_x, end_y], arrowHeadLength, center, radius, length >= 0));
}
}
// The arrowhead is purposely not styled (e.g., dashed / dotted)
svg.ele('g', {
style: `stroke:${shapeColor};stroke-width:${sw};fill:none;`,
transform: `translate(${x} ${y})`
}).ele('path', {
'style': stroke_dasharray,
d: line.join(' '),
}).up()
.ele('path', {
d: arrowHead.join(' '),
}).up();
}
function overlay_draw(svg, annotation) {
let dash = annotation.style.dash;
let [path, max_x, max_y] = (dash == 'draw') ? getOutlinePath(annotation.points) : getPath(annotation.points);
if (!path.length) return;
let shapeColor = color_to_hex(annotation.style.color);
let rotation = rad_to_degree(annotation.rotation);
let thickness = get_stroke_width(dash, annotation.style.size);
let gap = get_gap(dash, annotation.style.size);
let [x, y] = annotation.point;
let stroke_dasharray = determine_dasharray(dash, gap);
let fill = (dash === 'draw') ? shapeColor : 'none';
let shapeFillColor = color_to_hex(`fill-${annotation.style.color}`)
let shapeTransform = `translate(${x} ${y}), rotate(${rotation} ${max_x / 2} ${max_y / 2})`
// Fill assuming solid, small pencil used when path start- and end points overlap
let shapeIsFilled =
annotation.style.isFilled &&
annotation.points.length > 3
&& Math.round(distance(
annotation.points[0][0],
annotation.points[0][1],
annotation.points[annotation.points.length - 1][0],
annotation.points[annotation.points.length - 1][1]
)) <= 2 * get_stroke_width('solid', 'small');
if (shapeIsFilled) {
svg.ele('path', {
style: `fill:${shapeFillColor};`,
d: getPath(annotation.points)[0] + 'Z',
transform: shapeTransform
}).up()
}
svg.ele('path', {
style: `stroke:${shapeColor};stroke-width:${thickness};fill:${fill};${stroke_dasharray}`,
d: path,
transform: shapeTransform
})
}
function overlay_ellipse(svg, annotation) {
let dash = annotation.style.dash;
dash = (dash == 'draw') ? 'solid' : dash // Use 'solid' thickness for draw type
let [x, y] = annotation.point; // Ellipse center coordinates
let [rx, ry] = annotation.radius;
let isFilled = annotation.style.isFilled;
let shapeColor = color_to_hex(annotation.style.color);
let fillColor = isFilled ? color_to_hex(annotation.style.color, false, isFilled) : 'none';
let rotation = rad_to_degree(annotation.rotation);
let sw = get_stroke_width(dash, annotation.style.size);
let gap = get_gap(dash, annotation.style.size);
let stroke_dasharray = determine_dasharray(dash, gap);
svg.ele('g', {
style: `stroke:${shapeColor};stroke-width:${sw};fill:${fillColor};${stroke_dasharray}`,
}).ele('ellipse', {
'cx': x + rx,
'cy': y + ry,
'rx': rx,
'ry': ry,
transform: `rotate(${rotation} ${x + rx} ${y + ry})`
}).up()
if (annotation.label) { overlay_shape_label(svg, annotation) }
}
function overlay_rectangle(svg, annotation) {
let dash = annotation.style.dash;
let rect_dash = (dash == 'draw') ? 'solid' : dash // Use 'solid' thickness for draw type
let [x, y] = annotation.point;
let [w, h] = annotation.size;
let isFilled = annotation.style.isFilled;
let shapeColor = color_to_hex(annotation.style.color);
let fillColor = isFilled ? color_to_hex(annotation.style.color, false, isFilled) : 'none';
let rotation = rad_to_degree(annotation.rotation);
let sw = get_stroke_width(rect_dash, annotation.style.size);
let gap = get_gap(dash, annotation.style.size);
let stroke_dasharray = determine_dasharray(dash, gap);
let rx = (dash == 'draw') ? Math.min(w / 4, sw * 2) : 0;
let ry = (dash == 'draw') ? Math.min(h / 4, sw * 2) : 0;
svg.ele('g', {
style: `stroke:${shapeColor};stroke-width:${sw};fill:${fillColor};${stroke_dasharray}`,
}).ele('rect', {
width: w,
height: h,
'rx': rx,
'ry': ry,
transform: `translate(${x} ${y}), rotate(${rotation} ${w / 2} ${h / 2})`
}).up()
if (annotation.label) { overlay_shape_label(svg, annotation) }
}
function overlay_shape_label(svg, annotation) {
let fontColor = color_to_hex(annotation.style.color);
let font = determine_font_from_family(annotation.style.font);
let fontSize = text_size_to_px(annotation.style.size, annotation.style.scale);
let textAlign = 'center';
let text = annotation.label;
let id = annotation.id;
let rotation = rad_to_degree(annotation.rotation);
let [shape_width, shape_height] = annotation.size
let [shape_x, shape_y] = annotation.point;
let x_offset = annotation.labelPoint[0]
let y_offset = annotation.labelPoint[1]
let label_center_x = shape_x + shape_width * x_offset
let label_center_y = shape_y + shape_height * y_offset
render_textbox(fontColor, font, fontSize, textAlign, text, id);
let dimensions = probe.sync(fs.readFileSync(path.join(dropbox, `text${id}.png`)));
let labelWidth = dimensions.width;
let labelHeight = dimensions.height;
svg.ele('g', {
transform: `rotate(${rotation} ${label_center_x} ${label_center_y})`
}).ele('image', {
x: label_center_x - (labelWidth * x_offset),
y: label_center_y - (labelHeight * y_offset),
width: labelWidth,
height: labelHeight,
'xlink:href': `file://${dropbox}/text${id}.png`,
}).up();
}
function overlay_sticky(svg, annotation) {
let backgroundColor = color_to_hex(annotation.style.color, true);
let fontSize = text_size_to_px(annotation.style.size, annotation.style.scale, true);
let rotation = rad_to_degree(annotation.rotation);
let font = determine_font_from_family(annotation.style.font);
let textAlign = align_to_pango(annotation.style.textAlign);
let [textBoxWidth, textBoxHeight] = annotation.size;
let [textBox_x, textBox_y] = annotation.point;
let textColor = "#0d0d0d" // For sticky notes
let text = annotation.text
let id = annotation.id;
render_textbox(textColor, font, fontSize, textAlign, text, id, textBoxWidth);
// Overlay transparent text image over empty sticky note
svg.ele('g', {
transform: `rotate(${rotation}, ${textBox_x + (textBoxWidth / 2)}, ${textBox_y + (textBoxHeight / 2)})`
}).ele('rect', {
x: textBox_x,
y: textBox_y,
width: textBoxWidth,
height: textBoxHeight,
fill: backgroundColor,
}).up()
.ele('image', {
x: textBox_x,
y: textBox_y,
width: textBoxWidth,
height: textBoxHeight,
'xlink:href': `file://${dropbox}/text${id}.png`,
}).up();
}
function overlay_triangle(svg, annotation) {
let dash = annotation.style.dash;
dash = (dash == 'draw') ? 'solid' : dash
let [x, y] = annotation.point;
let [w, h] = annotation.size;
let isFilled = annotation.style.isFilled;
let shapeColor = color_to_hex(annotation.style.color);
let fillColor = isFilled ? color_to_hex(annotation.style.color, false, isFilled) : 'none';
let rotation = rad_to_degree(annotation.rotation);
let sw = get_stroke_width(dash, annotation.style.size);
let gap = get_gap(dash, annotation.style.size);
let stroke_dasharray = determine_dasharray(dash, gap);
let points = `${w / 2} 0, ${w} ${h}, 0 ${h}, ${w / 2} 0`
svg.ele('g', {
style: `stroke:${shapeColor};stroke-width:${sw};fill:${fillColor};${stroke_dasharray}`,
}).ele('polygon', {
'points': points,
transform: `translate(${x}, ${y}), rotate(${rotation} ${w / 2} ${h / 2})`
}).up()
if (annotation.label) { overlay_shape_label(svg, annotation) }
}
function overlay_text(svg, annotation) {
let [textBoxWidth, textBoxHeight] = annotation.size;
let fontColor = color_to_hex(annotation.style.color);
let font = determine_font_from_family(annotation.style.font);
let fontSize = text_size_to_px(annotation.style.size, annotation.style.scale);
let textAlign = align_to_pango(annotation.style.textAlign);
let text = annotation.text
let id = annotation.id;
let rotation = rad_to_degree(annotation.rotation);
let [textBox_x, textBox_y] = annotation.point;
render_textbox(fontColor, font, fontSize, textAlign, text, id);
let rotation_x = textBox_x + (textBoxWidth / 2)
let rotation_y = textBox_y + (textBoxHeight / 2)
svg.ele('g', {
transform: `rotate(${rotation} ${rotation_x} ${rotation_y})`
}).ele('image', {
x: textBox_x,
y: textBox_y,
width: textBoxWidth,
height: textBoxHeight,
'xlink:href': `file://${dropbox}/text${id}.png`,
}).up();
}
function overlay_annotation(svg, currentAnnotation) {
if (currentAnnotation.childIndex >= 1) {
switch (currentAnnotation.type) {
case 'arrow':
overlay_arrow(svg, currentAnnotation);
break;
case 'draw':
overlay_draw(svg, currentAnnotation);
break;
case 'ellipse':
overlay_ellipse(svg, currentAnnotation);
break;
case 'rectangle':
overlay_rectangle(svg, currentAnnotation);
break;
case 'sticky':
overlay_sticky(svg, currentAnnotation);
break;
case 'triangle':
overlay_triangle(svg, currentAnnotation);
break;
case 'text':
overlay_text(svg, currentAnnotation);
break;
default:
logger.info(`Unknown annotation type ${currentAnnotation.type}.`);
}
}
}
function overlay_annotations(svg, currentSlideAnnotations) {
// Sort annotations by lowest child index
currentSlideAnnotations = sortByKey(currentSlideAnnotations, 'annotationInfo', 'childIndex');
for (let annotation of currentSlideAnnotations) {
switch (annotation.annotationInfo.type) {
case 'group':
// Get annotations that have this group as parent
let children = annotation.annotationInfo.children;
for (let childId of children) {
let childAnnotation = currentSlideAnnotations.find(ann => ann.id == childId);
overlay_annotation(svg, childAnnotation.annotationInfo);
}
break;
default:
// Add individual annotations if they don't belong to a group
if (annotation.annotationInfo.parentId % 1 === 0) {
overlay_annotation(svg, annotation.annotationInfo);
}
}
}
}
// Process the presentation pages and annotations into a PDF file
// 1. Get the job
const dropbox = path.join(config.shared.presAnnDropboxDir, jobId);
let job = fs.readFileSync(path.join(dropbox, 'job'));
let exportJob = JSON.parse(job);
// 2. Get the annotations
let annotations = fs.readFileSync(path.join(dropbox, 'whiteboard'));
let whiteboard = JSON.parse(annotations);
let pages = JSON.parse(whiteboard.pages);
let ghostScriptInput = ""
// 3. Convert annotations to SVG
for (let currentSlide of pages) {
let backgroundImagePath = path.join(dropbox, `slide${currentSlide.page}`);
let svgFileExists = fs.existsSync(`${backgroundImagePath}.svg`)
let backgroundFormat = fs.existsSync(`${backgroundImagePath}.png`) ? 'png' : 'jpeg'
// Output dimensions in pixels even if stated otherwise (pt)
// CairoSVG didn't like attempts to read the dimensions from a stream
// that would prevent loading file in memory
// Ideally, use dimensions provided by tldraw's background image asset
// (this is not yet always provided)
let dimensions = svgFileExists ? 
probe.sync(fs.readFileSync(`${backgroundImagePath}.svg`)) :
probe.sync(fs.readFileSync(`${backgroundImagePath}.${backgroundFormat}`));
let slideWidth = parseInt(dimensions.width, 10);
let slideHeight = parseInt(dimensions.height, 10);
// Create the SVG slide with the background image
let svg = create({ version: '1.0', encoding: 'UTF-8' })
.ele('svg', {
xmlns: 'http://www.w3.org/2000/svg',
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
width: `${slideWidth}px`,
height: `${slideHeight}px`,
})
.dtd({
pubID: '-//W3C//DTD SVG 1.1//EN',
sysID: 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'
})
.ele('image', {
'xlink:href': `file://${dropbox}/slide${currentSlide.page}.${backgroundFormat}`,
width: `${slideWidth}px`,
height: `${slideHeight}px`,
})
.up()
.ele('g', {
class: 'canvas'
});
// 4. Overlay annotations onto slides
overlay_annotations(svg, currentSlide.annotations)
svg = svg.end({ prettyPrint: true });
// Write annotated SVG file
let SVGfile = path.join(dropbox, `annotated-slide${currentSlide.page}.svg`)
let PDFfile = path.join(dropbox, `annotated-slide${currentSlide.page}.pdf`)
fs.writeFileSync(SVGfile, svg, function (err) {
if (err) { return logger.error(err); }
});
// Dimensions converted back to a pixel size which,
// when converted to points, will yield the desired
// dimension in pixels when read without conversion
// e.g. say Tldraw's canvas is 1920x1080 px.
// The background SVG dimensions are set to 1920x1080 pt (incorrect unit).
// So we read it in ignoring the unit as 1920x1080 px, making the position of the drawings match.
// Now we assume we had 1920x1080pt and resize to 2560x1440 px so that the SVG generates with the original "wrong" size.
let convertAnnotatedSlide = [
'cairosvg',
SVGfile,
'--output-width', to_px(slideWidth),
'--output-height', to_px(slideHeight),
'-o', PDFfile
].join(' ');
execSync(convertAnnotatedSlide);
ghostScriptInput += `${PDFfile} `
}
// Create PDF output directory if it doesn't exist
let output_dir = path.join(exportJob.presLocation, 'pdfs', jobId);
if (!fs.existsSync(output_dir)) { fs.mkdirSync(output_dir, { recursive: true }); }
let filename = sanitize(exportJob.filename.replace(/\s/g, '_'));
let mergePDFs = [
'gs',
'-dNOPAUSE',
'-sDEVICE=pdfwrite',
`-sOUTPUTFILE="${path.join(output_dir, `${filename}.pdf`)}"`,
`-dBATCH`,
ghostScriptInput,
].join(' ');
// Resulting PDF file is stored in the presentation dir
execSync(mergePDFs);
// Launch Notifier Worker depending on job type
logger.info(`Saved PDF at ${output_dir}/${jobId}/${filename}.pdf`);
kickOffNotifierWorker(exportJob.jobType, filename);
parentPort.postMessage({ message: workerData });

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,6 @@
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-intl": "^5.20.6",
"react-scripts": "^5.0.0",
"typescript": "^4.3.5",
"web-vitals": "^1.1.2"
},
@ -54,6 +53,7 @@
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"postcss": "^8.4.5",
"react-scripts": "^5.0.0",
"tailwindcss": "^3.0.11"
}
}

View File

@ -244,7 +244,7 @@ class UsersTable extends React.Component {
&nbsp;&nbsp;&nbsp;
<div className="inline-block">
<button
className="leading-none border-0 p-0 m-0 bg-none font-semibold truncate xl:max-w-sm max-w-xs cursor-pointer rounded-md focus:outline-none focus:ring ring-offset-0 focus:ring-gray-500 focus:ring-opacity-50"
className="leading-none border-0 p-0 m-0 bg-none font-semibold truncate xl:max-w-sm max-w-xs cursor-pointer focus:rounded focus:outline-none focus:ring ring-offset-0 focus:ring-gray-500 focus:ring-opacity-50 underline decoration-dotted decoration-from-font hover:opacity-75 focus:no-underline active:opacity-95"
type="button"
onClick={() => this.openUserModal(user)}
aria-label={`Open user details modal - ${user.name}`}

View File

@ -1 +1 @@
git clone --branch v1.2.0 --depth 1 https://github.com/bigbluebutton/bbb-pads bbb-pads
git clone --branch v1.2.1 --depth 1 https://github.com/bigbluebutton/bbb-pads bbb-pads

View File

@ -1 +1 @@
git clone --branch v4.0.0 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback
git clone --branch v5.0.0-alpha.1 --depth 1 https://github.com/bigbluebutton/bbb-playback bbb-playback

View File

@ -0,0 +1,48 @@
# BBB-Recording-Importer
Imports and parses recording metadata.xml files and stores the data in a Postgresql database
## How to use
1. In bbb-common-web
- Edit the .env file and set the environment variables
- Run the hibernate.cfg script to generate the hibernate config file
- Run "docker-compose up" to start up the docker container containing the Postgresql database
- Interact with the database using the psql script
2. In bbb-recording-imex
- Unit tests for parsing and persisting recording metadata can be found in src/test/java/org/bigbluebutton/recording/
- Edit the "metadataDirectory" variables in the test files to point to where the recording metadata can be found
- Run the unit tests using the command "mvn test"
- Use the deploy.sh script to compile the program
- Run the program with the recording-imex.sh script found in ~/usr/local/bin
- Use the --help option to see the usage
Usage: {-e|-i <persist>} [-s <id>] [PATH]
Import/export recording(s) to/from PATH. The default PATH is
/var/bigbluebutton/published/presentation
-e export recording(s)
-i <persist> import recording(s) and indicate if they should be persisted [true|false]
-s <id> ID of single recording to be imported/exported
Examples
~/usr/local/bin/recording-imex.sh -i true -s random-7739095 /var/bigbluebutton/published/presentation/1abbc41a2f2faf1d754dbd130fba9ae072c6e742-1652301432519/metadata.xml
~/usr/local/bin/recording-imex.sh -i true /var/bigbluebutton/published/presentation/
## Testing the new recording service
1. In bigbluebutton-web
- Edit the "recordingService" bean in /grails-app/conf/spring/resources.xml to use "org.bigbluebutton.api.service.impl.RecordingServiceDbImpl"
- Use "org.bigbluebutton.api.service.impl.RecordingServiceFileImpl" if you want to use the traditional file system service
2. In bbb-recording-imex
- Use the get-recordings.sh script to test the getRecordings endpoint on the recording API
- Edit the "SALT" variable to have the value of your security salt
- The script accepts arguments through the use of flags
- "-i" for the meetingID
- "-r" for the recordID(s)
- "-s" for the state(s)
- "-m" for the metadata

12
bbb-recording-imex/deploy.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
JAR_DIR="$HOME/usr/share/recording-imex"
JAR_NAME="bbb-recording-imex-1.0-SNAPSHOT-shaded.jar"
RUN_DIR="$HOME/usr/local/bin"
mkdir -p $JAR_DIR
mkdir -p $RUN_DIR
mvn package -Dmaven.test.skip
cp target/${JAR_NAME} $JAR_DIR
echo '#!/bin/bash
java -jar '${JAR_DIR}'/'${JAR_NAME} '"$@"'> ${RUN_DIR}/recording-imex.sh
chmod +x ${RUN_DIR}/recording-imex.sh

View File

@ -0,0 +1,50 @@
#!/bin/bash
while getopts i:r:s:m: flag
do
case "${flag}" in
i) MEETING_ID=${OPTARG};;
r) RECORD_ID=${OPTARG};;
s) STATE=${OPTARG};;
m) META=${OPTARG};;
esac
done
BASE_URL=""
SUBDIRECTORY="bigbluebutton/api/"
ENDPOINT="getRecordings"
QUERY=""
if ! [[ -z ${MEETING_ID+x} ]]; then QUERY+="meetingID=$MEETING_ID&"; fi
if ! [[ -z ${RECORD_ID+x} ]]; then QUERY+="recordID=$RECORD_ID&"; fi
if ! [[ -z ${STATE+x} ]]; then QUERY+="state=$STATE&"; fi
if ! [[ -z ${META+x} ]]; then QUERY+="meta=$META"; fi
echo "query: $QUERY"
INDEX=${#QUERY}-1
if [ "${QUERY:$INDEX:1}" = "&" ]; then QUERY=${QUERY:0:$INDEX}; fi
echo "query: $QUERY"
SALT=
DATA="$ENDPOINT$QUERY$SALT"
echo "data: $DATA"
CHECKSUM=$(echo -n $DATA | sha256sum)
CHECKSUM=${CHECKSUM:0:64}
echo "sha256 checksum: $CHECKSUM"
QUERY="?$QUERY"
if ! [[ ${#QUERY} -eq 1 ]]; then QUERY+="&"; fi
QUERY+="checksum=$CHECKSUM"
echo "query: $QUERY"
REQUEST="$BASE_URL$SUBDIRECTORY$ENDPOINT$QUERY"
echo "request: $REQUEST"
curl -s -X GET "$REQUEST"

111
bbb-recording-imex/pom.xml Executable file
View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.bigbluebutton</groupId>
<artifactId>bbb-recording-imex</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<shadedArtifactAttached>true</shadedArtifactAttached>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.bigbluebutton.RecordingApp</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<version>2.22.2</version>
</plugin>
<plugin>
<groupId>net.revelc.code.formatter</groupId>
<artifactId>formatter-maven-plugin</artifactId>
<version>2.16.0</version>
<executions>
<execution>
<goals>
<goal>format</goal>
</goals>
</execution>
</executions>
<configuration>
<compilerSource>1.8</compilerSource>
<compilerCompliance>1.8</compilerCompliance>
<compilerTargetPlatform>1.8</compilerTargetPlatform>
<lineEnding>LF</lineEnding>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.bigbluebutton</groupId>
<artifactId>bbb-common-web</artifactId>
<version>0.0.3-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.30</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.7.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.7.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-console-standalone</artifactId>
<version>1.8.2</version>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,224 @@
package org.bigbluebutton;
import java.io.Console;
import java.io.File;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.stream.IntStream;
public class RecordingApp {
public static void main(String[] args) {
if (args.length > 0) {
commandMode(args);
} else {
interactiveMode();
}
}
private static void commandMode(String[] args) {
int i = 0, j;
String arg;
char flag;
boolean export = false;
boolean persist = false;
String id = null;
String path;
while (i < args.length && args[i].startsWith("-")) {
arg = args[i++];
if (arg.equals("--help")) {
printUsage();
return;
}
for (j = 1; j < arg.length(); j++) {
flag = arg.charAt(j);
switch (flag) {
case 'e':
export = true;
break;
case 'i':
export = false;
if (i < args.length) {
String shouldPersist = args[i++];
if (shouldPersist.equalsIgnoreCase("true"))
persist = true;
else if (shouldPersist.equalsIgnoreCase("false"))
persist = false;
else {
System.out.println("Error: Could not parse persist argument");
return;
}
} else {
System.out.println("Error: Imports require an argument specifying if they should be persisted");
return;
}
break;
case 's':
if (i < args.length)
id = args[i++];
else {
System.out.println(
"Error: To import/export a single recording you must provide the recording ID");
}
break;
default:
System.out.println("Error: Illegal option " + flag);
}
}
}
if (i < args.length)
path = args[i];
else {
path = createDefaultDirectory();
if (path == null)
return;
}
executeCommands(export, persist, id, path);
}
private static void printUsage() {
System.out.println("Usage: {-e|-i <persist>} [-s <id>] [PATH]");
System.out.println("Import/export recording(s) to/from PATH. The default PATH is "
+ "\n/var/bigbluebutton/published/presentation");
System.out.println("-e export recording(s)");
System.out.println(
"-i <persist> import recording(s) and indicate if they should be persisted [true|false]");
System.out.println("-s <id> ID of single recording to be imported/exported");
}
private static String createDefaultDirectory() {
Path root = Paths.get(System.getProperty("user.dir")).getFileSystem().getRootDirectories().iterator().next();
String path = root.toAbsolutePath() + "var/bigbluebutton/published/presentation";
File directory = new File(path);
if (!directory.exists()) {
boolean created = directory.mkdirs();
if (!created) {
System.out.println("Error: Failed to create default presentation directory");
return null;
}
}
return path;
}
private static void executeCommands(boolean export, boolean persist, String id, String path) {
if (!export) {
RecordingImportHandler handler = RecordingImportHandler.getInstance();
if (id == null || id.isEmpty())
handler.importRecordings(path, persist);
else
handler.importRecording(path, id, persist);
} else {
RecordingExportHandler handler = RecordingExportHandler.getInstance();
if (id == null || id.isEmpty())
handler.exportRecordings(path);
else
handler.exportRecording(id, path);
}
}
private static void interactiveMode() {
System.out.println("Use this application to import and export recording metadata");
do {
int impex = getResponse("Are you importing or exporting recordings? (1-Import 2-Export 3-Quit) ",
new int[] { 1, 2, 3 }, "Please enter either 1, 2, or 3");
if (impex == 1) {
importRecordings();
} else if (impex == 2) {
exportRecordings();
} else {
break;
}
} while (true);
}
private static void importRecordings() {
RecordingImportHandler handler = RecordingImportHandler.getInstance();
int importIndividually = getResponse("Are you importing recordings individually? (1-Yes 2-No) ",
new int[] { 1, 2 }, "Please enter either 1 or 2");
int persist = getResponse("Should the imported recording(s) be persisted? (1-Yes 2-No) ", new int[] { 1, 2 },
"Please enter either 1 or 2");
boolean shouldPersist = persist == 1;
if (importIndividually == 1) {
do {
String path = getResponse(
"Please enter the path to the recording metadata.xml file (enter q to quit): ");
if (path.equalsIgnoreCase("q") || path.equalsIgnoreCase("quit"))
break;
String recordingId = getResponse("Please enter the ID of the recording: ");
handler.importRecording(path, recordingId, shouldPersist);
} while (true);
} else {
String path = getResponse("Please enter the path to the directory containing the metadata.xml files: ");
handler.importRecordings(path, shouldPersist);
}
}
private static void exportRecordings() {
RecordingExportHandler handler = RecordingExportHandler.getInstance();
int exportAll = getResponse("Do you want to export all recordings? (1-Yes 2-No) ", new int[] { 1, 2 },
"Please enter either 1 or 2");
String path = getResponse("Please enter the path to the directory that the recordings should be exported to: ");
if (exportAll == 1) {
handler.exportRecordings(path);
} else {
do {
String response = getResponse(
"Please enter the ID of the recording you would like to export (enter q to quit): ");
if (response.equalsIgnoreCase("q") || response.equalsIgnoreCase("quit"))
break;
handler.exportRecording(response, path);
} while (true);
}
}
private static int getResponse(String prompt, int[] options, String error) {
Console console = System.console();
String response;
int result;
do {
response = console.readLine(prompt);
result = parseResponse(response, error);
} while (!contains(options, result));
return result;
}
private static String getResponse(String prompt) {
Console console = System.console();
String response = "";
do {
response = console.readLine(prompt);
} while (response == "");
return response;
}
private static int parseResponse(String response, String error) {
try {
int parsedResponse = Integer.parseInt(response);
return parsedResponse;
} catch (NumberFormatException e) {
System.out.println(error);
}
return -1;
}
private static boolean contains(final int[] array, final int key) {
return IntStream.of(array).anyMatch(x -> x == key);
}
}

View File

@ -0,0 +1,120 @@
package org.bigbluebutton;
import org.bigbluebutton.api.model.entity.Recording;
import org.bigbluebutton.api.util.DataStore;
import org.bigbluebutton.api.service.XmlService;
import org.bigbluebutton.api.service.impl.XmlServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import java.io.File;
import java.io.StringReader;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
public class RecordingExportHandler {
private static final Logger logger = LoggerFactory.getLogger(RecordingExportHandler.class);
private static RecordingExportHandler instance;
private DataStore dataStore;
private XmlService xmlService;
private RecordingExportHandler() {
dataStore = DataStore.getInstance();
xmlService = new XmlServiceImpl();
}
public static RecordingExportHandler getInstance() {
if (instance == null) {
instance = new RecordingExportHandler();
}
return instance;
}
public void exportRecordings(String path) {
List<Recording> recordings = dataStore.findAll(Recording.class);
for (Recording recording : recordings) {
exportRecording(recording, path);
}
}
public void exportRecording(String recordId, String path) {
Recording recording = null;
if (recordId != null) {
recording = dataStore.findRecordingByRecordId(recordId);
}
if (recording != null) {
exportRecording(recording, path);
}
}
private void exportRecording(Recording recording, String path) {
logger.info("Attempting to export recording {} to {}", recording.getRecordId(), path);
try {
Path dirPath = Paths.get(path);
File dir = new File(dirPath.toAbsolutePath() + File.separator + recording.getRecordId());
logger.info("Checking if directory {} exists", dir.getAbsolutePath());
if (!dir.exists()) {
logger.info("Directory does not exist, creating");
dir.mkdir();
}
File file = new File(dir + File.separator + "metadata.xml");
logger.info("Attempting to create file {}", file.getAbsolutePath());
boolean fileCreated = file.createNewFile();
if (fileCreated) {
logger.info("Exporting {}", recording);
String xml = xmlService.recordingToXml(recording);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new InputSource(new StringReader(xml)));
document.normalize();
XPath xPath = XPathFactory.newInstance().newXPath();
NodeList nodeList = (NodeList) xPath.evaluate("//text()[normalize-space()='']", document,
XPathConstants.NODESET);
for (int i = 0; i < nodeList.getLength(); i++) {
Node node = nodeList.item(i);
node.getParentNode().removeChild(node);
}
TransformerFactory transformerFactory = TransformerFactory.newInstance();
Transformer transformer = transformerFactory.newTransformer();
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2");
transformer.setOutputProperty(OutputKeys.STANDALONE, "no");
DOMSource source = new DOMSource(document);
StreamResult result = new StreamResult(file);
transformer.transform(source, result);
}
} catch (Exception e) {
logger.error("Failed to export recording {}", recording.getRecordId());
e.printStackTrace();
}
}
}

View File

@ -0,0 +1,94 @@
package org.bigbluebutton;
import org.bigbluebutton.api.model.entity.*;
import org.bigbluebutton.api.util.DataStore;
import org.bigbluebutton.api.service.XmlService;
import org.bigbluebutton.api.service.impl.XmlServiceImpl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.CharacterData;
import org.w3c.dom.*;
import org.xml.sax.InputSource;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
public class RecordingImportHandler {
private static final Logger logger = LoggerFactory.getLogger(RecordingImportHandler.class);
private static RecordingImportHandler instance;
private DataStore dataStore;
private XmlService xmlService;
private RecordingImportHandler() {
dataStore = DataStore.getInstance();
xmlService = new XmlServiceImpl();
}
public static RecordingImportHandler getInstance() {
if (instance == null) {
instance = new RecordingImportHandler();
}
return instance;
}
public void importRecordings(String directory, boolean persist) {
logger.info("Attempting to import recordings from {}", directory);
String[] entries = new File(directory).list();
if (entries == null || entries.length == 0) {
logger.info("No recordings were found in the provided directory");
return;
}
for (String entry : entries) {
Recording recording = dataStore.findRecordingByRecordId(entry);
if (recording != null && persist) {
logger.info("Record found for {}. Skipping", entry);
continue;
}
String path = directory + "/" + entry + "/metadata.xml";
importRecording(path, entry, persist);
}
}
public Recording importRecording(String path, String recordId, boolean persist) {
logger.info("Attempting to import {}", path);
String content = null;
try {
byte[] encoded = Files.readAllBytes(Paths.get(path));
content = new String(encoded, StandardCharsets.UTF_8);
} catch (IOException e) {
logger.error("Failed to import {}", path);
e.printStackTrace();
}
Recording recording = null;
if (content != null) {
logger.info("File content: {}", content);
recording = xmlService.xmlToRecording(recordId, content);
}
if (recording != null) {
if (persist)
dataStore.save(recording);
}
return recording;
}
}

View File

@ -0,0 +1,47 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>
%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n
</Pattern>
</layout>
</appender>
<property name="HOME_LOG" value="logs/app.log"/>
<appender name="FILE-ROLLING" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${HOME_LOG}</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/archived/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<!-- each archived file, size max 10MB -->
<maxFileSize>10MB</maxFileSize>
<!-- total size of all archive files, if total size > 20GB, it will delete old archived file -->
<totalSizeCap>20GB</totalSizeCap>
<!-- 60 days to keep -->
<maxHistory>60</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d %p %c{1.} [%t] %m%n</pattern>
</encoder>
</appender>
<logger name="rollingFileLogger" level="debug" additivity="false">
<appender-ref ref="FILE-ROLLING"/>
</logger>
<logger name="consoleLogger" level="debug">
<appender-ref ref="CONSOLE" />
</logger>
<logger name="org.hibernate.SQL" level="ERROR"/>
<logger name="org.hibernate.type" level="INFO"/>
<root level="info">
<appender-ref ref="FILE-ROLLING"/>
<appender-ref ref="CONSOLE" />
</root>
</configuration>

Some files were not shown because too many files have changed in this diff Show More