From acb6b8cf09e1cd728420a87e52664f2555001abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20B=C3=BCchel?= Date: Thu, 1 Feb 2024 17:36:11 +0100 Subject: [PATCH] SEBSP-15, SEBSP-70: Implemented basic screen proctoring functionality including image format & quantization settings. --- .../DataMapping/ProctoringDataMapper.cs | 168 ++++++++++++++ .../ConfigurationData/DataProcessor.cs | 2 +- .../ConfigurationData/DataValues.cs | 9 + .../ConfigurationData/Keys.cs | 17 ++ .../JitsiMeet/JitsiMeetImplementation.cs | 83 ++++--- .../ProctoringController.cs | 4 +- .../ProctoringFactory.cs | 14 +- .../ProctoringImplementation.cs | 26 ++- .../SafeExamBrowser.Proctoring.csproj | 29 +++ .../ScreenProctoring/Imaging/Extensions.cs | 93 ++++++++ .../Imaging/ProcessingOrder.cs | 16 ++ .../ScreenProctoring/Imaging/ScreenShot.cs | 152 +++++++++++++ .../ScreenProctoringImplementation.cs | 207 ++++++++++++++++++ .../ScreenProctoring/Service/Api.cs | 26 +++ .../ScreenProctoring/Service/Parser.cs | 98 +++++++++ .../Service/Requests/ContentType.cs | 17 ++ .../Service/Requests/CreateSessionRequest.cs | 36 +++ .../Service/Requests/Extensions.cs | 26 +++ .../Service/Requests/Header.cs | 18 ++ .../Service/Requests/OAuth2TokenRequest.cs | 25 +++ .../Service/Requests/Request.cs | 176 +++++++++++++++ .../Service/Requests/ScreenShotRequest.cs | 52 +++++ .../Requests/TerminateSessionRequest.cs | 30 +++ .../ScreenProctoring/Service/ServiceProxy.cs | 107 +++++++++ .../Service/ServiceResponse.cs | 32 +++ SafeExamBrowser.Proctoring/packages.config | 3 + .../Operations/DisclaimerOperation.cs | 20 +- .../ProctoringWorkaroundOperation.cs | 2 +- .../Data/ServerResponse.cs | 4 +- .../Events/Proctoring/InstructionEventArgs.cs | 18 ++ .../Events/Proctoring/InstructionMethod.cs | 26 +++ .../Events/Proctoring/JitsiMeetInstruction.cs | 20 ++ ...toringConfigurationReceivedEventHandler.cs | 2 +- ...octoringInstructionReceivedEventHandler.cs | 4 +- .../Proctoring/ScreenProctoringInstruction.cs | 22 ++ .../Events/Proctoring/ZoomInstruction.cs | 23 ++ .../Events/ProctoringInstructionEventArgs.cs | 26 --- .../IServerProxy.cs | 1 + .../SafeExamBrowser.Server.Contracts.csproj | 10 +- SafeExamBrowser.Server/Data/Attributes.cs | 9 +- SafeExamBrowser.Server/Parser.cs | 55 ++++- SafeExamBrowser.Server/ServerProxy.cs | 1 + .../Proctoring/ImageFormat.cs | 36 +++ .../Proctoring/ImageQuantization.cs | 51 +++++ .../Proctoring/ProctoringSettings.cs | 6 + .../Proctoring/ScreenProctoringSettings.cs | 84 +++++++ .../SafeExamBrowser.Settings.csproj | 3 + 47 files changed, 1787 insertions(+), 102 deletions(-) create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/Extensions.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ProcessingOrder.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ScreenShot.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ContentType.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/CreateSessionRequest.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Extensions.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/OAuth2TokenRequest.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Request.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/TerminateSessionRequest.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs create mode 100644 SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceResponse.cs create mode 100644 SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionEventArgs.cs create mode 100644 SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionMethod.cs create mode 100644 SafeExamBrowser.Server.Contracts/Events/Proctoring/JitsiMeetInstruction.cs rename SafeExamBrowser.Server.Contracts/Events/{ => Proctoring}/ProctoringConfigurationReceivedEventHandler.cs (89%) rename SafeExamBrowser.Server.Contracts/Events/{ => Proctoring}/ProctoringInstructionReceivedEventHandler.cs (84%) create mode 100644 SafeExamBrowser.Server.Contracts/Events/Proctoring/ScreenProctoringInstruction.cs create mode 100644 SafeExamBrowser.Server.Contracts/Events/Proctoring/ZoomInstruction.cs delete mode 100644 SafeExamBrowser.Server.Contracts/Events/ProctoringInstructionEventArgs.cs create mode 100644 SafeExamBrowser.Settings/Proctoring/ImageFormat.cs create mode 100644 SafeExamBrowser.Settings/Proctoring/ImageQuantization.cs create mode 100644 SafeExamBrowser.Settings/Proctoring/ScreenProctoringSettings.cs diff --git a/SafeExamBrowser.Configuration/ConfigurationData/DataMapping/ProctoringDataMapper.cs b/SafeExamBrowser.Configuration/ConfigurationData/DataMapping/ProctoringDataMapper.cs index a7c45f8c..d0c3d94d 100644 --- a/SafeExamBrowser.Configuration/ConfigurationData/DataMapping/ProctoringDataMapper.cs +++ b/SafeExamBrowser.Configuration/ConfigurationData/DataMapping/ProctoringDataMapper.cs @@ -6,6 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +using System; using SafeExamBrowser.Settings; using SafeExamBrowser.Settings.Proctoring; @@ -74,6 +75,45 @@ namespace SafeExamBrowser.Configuration.ConfigurationData.DataMapping case Keys.Proctoring.JitsiMeet.VideoMuted: MapJitsiMeetVideoMuted(settings, value); break; + case Keys.Proctoring.ScreenProctoring.CaptureApplicationName: + MapCaptureApplicationName(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.CaptureBrowserUrl: + MapCaptureBrowserUrl(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.CaptureWindowTitle: + MapCaptureWindowTitle(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.ClientId: + MapClientId(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.ClientSecret: + MapClientSecret(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.GroupId: + MapGroupId(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.ImageDownscaling: + MapImageDownscaling(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.ImageFormat: + MapImageFormat(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.ImageQuantization: + MapImageQuantization(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.MaxInterval: + MapMaxInterval(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.MinInterval: + MapMinInterval(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.Enabled: + MapScreenProctoringEnabled(settings, value); + break; + case Keys.Proctoring.ScreenProctoring.ServiceUrl: + MapServiceUrl(settings, value); + break; case Keys.Proctoring.ShowRaiseHand: MapShowRaiseHand(settings, value); break; @@ -280,6 +320,134 @@ namespace SafeExamBrowser.Configuration.ConfigurationData.DataMapping } } + private void MapCaptureApplicationName(AppSettings settings, object value) + { + if (value is bool capture) + { + settings.Proctoring.ScreenProctoring.CaptureApplicationName = capture; + } + } + + private void MapCaptureBrowserUrl(AppSettings settings, object value) + { + if (value is bool capture) + { + settings.Proctoring.ScreenProctoring.CaptureBrowserUrl = capture; + } + } + + private void MapCaptureWindowTitle(AppSettings settings, object value) + { + if (value is bool capture) + { + settings.Proctoring.ScreenProctoring.CaptureWindowTitle = capture; + } + } + + private void MapClientId(AppSettings settings, object value) + { + if (value is string clientId) + { + settings.Proctoring.ScreenProctoring.ClientId = clientId; + } + } + + private void MapClientSecret(AppSettings settings, object value) + { + if (value is string secret) + { + settings.Proctoring.ScreenProctoring.ClientSecret = secret; + } + } + + private void MapGroupId(AppSettings settings, object value) + { + if (value is string groupId) + { + settings.Proctoring.ScreenProctoring.GroupId = groupId; + } + } + + private void MapImageDownscaling(AppSettings settings, object value) + { + if (value is double downscaling) + { + settings.Proctoring.ScreenProctoring.ImageDownscaling = downscaling; + } + } + + private void MapImageFormat(AppSettings settings, object value) + { + if (value is string s && Enum.TryParse(s, true, out var format)) + { + settings.Proctoring.ScreenProctoring.ImageFormat = format; + } + } + + private void MapImageQuantization(AppSettings settings, object value) + { + if (value is int quantization) + { + switch (quantization) + { + case 0: + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.BlackAndWhite1bpp; + break; + case 1: + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale2bpp; + break; + case 2: + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale4bpp; + break; + case 3: + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale8bpp; + break; + case 4: + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Color8bpp; + break; + case 5: + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Color16bpp; + break; + case 6: + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Color24bpp; + break; + } + + } + } + + private void MapMaxInterval(AppSettings settings, object value) + { + if (value is int interval) + { + settings.Proctoring.ScreenProctoring.MaxInterval = interval; + } + } + + private void MapMinInterval(AppSettings settings, object value) + { + if (value is int interval) + { + settings.Proctoring.ScreenProctoring.MinInterval = interval; + } + } + + private void MapScreenProctoringEnabled(AppSettings settings, object value) + { + if (value is bool enabled) + { + settings.Proctoring.ScreenProctoring.Enabled = enabled; + } + } + + private void MapServiceUrl(AppSettings settings, object value) + { + if (value is string url) + { + settings.Proctoring.ScreenProctoring.ServiceUrl = url; + } + } + private void MapShowRaiseHand(AppSettings settings, object value) { if (value is bool show) diff --git a/SafeExamBrowser.Configuration/ConfigurationData/DataProcessor.cs b/SafeExamBrowser.Configuration/ConfigurationData/DataProcessor.cs index abbc2a30..9ba7d7a5 100644 --- a/SafeExamBrowser.Configuration/ConfigurationData/DataProcessor.cs +++ b/SafeExamBrowser.Configuration/ConfigurationData/DataProcessor.cs @@ -76,7 +76,7 @@ namespace SafeExamBrowser.Configuration.ConfigurationData private void InitializeProctoringSettings(AppSettings settings) { - settings.Proctoring.Enabled = settings.Proctoring.JitsiMeet.Enabled; + settings.Proctoring.Enabled = settings.Proctoring.JitsiMeet.Enabled || settings.Proctoring.ScreenProctoring.Enabled; if (settings.Proctoring.JitsiMeet.Enabled && !settings.Proctoring.JitsiMeet.ReceiveVideo) { diff --git a/SafeExamBrowser.Configuration/ConfigurationData/DataValues.cs b/SafeExamBrowser.Configuration/ConfigurationData/DataValues.cs index d535f031..0e17e0ff 100644 --- a/SafeExamBrowser.Configuration/ConfigurationData/DataValues.cs +++ b/SafeExamBrowser.Configuration/ConfigurationData/DataValues.cs @@ -257,6 +257,15 @@ namespace SafeExamBrowser.Configuration.ConfigurationData settings.Proctoring.JitsiMeet.SendVideo = true; settings.Proctoring.JitsiMeet.ShowMeetingName = false; settings.Proctoring.JitsiMeet.VideoMuted = false; + settings.Proctoring.ScreenProctoring.CaptureApplicationName = true; + settings.Proctoring.ScreenProctoring.CaptureBrowserUrl = true; + settings.Proctoring.ScreenProctoring.CaptureWindowTitle = true; + settings.Proctoring.ScreenProctoring.Enabled = false; + settings.Proctoring.ScreenProctoring.ImageDownscaling = 1.0; + settings.Proctoring.ScreenProctoring.ImageFormat = ImageFormat.Png; + settings.Proctoring.ScreenProctoring.ImageQuantization = ImageQuantization.Grayscale4bpp; + settings.Proctoring.ScreenProctoring.MaxInterval = 5000; + settings.Proctoring.ScreenProctoring.MinInterval = 1000; settings.Proctoring.ShowRaiseHandNotification = true; settings.Proctoring.ShowTaskbarNotification = true; settings.Proctoring.WindowVisibility = WindowVisibility.Hidden; diff --git a/SafeExamBrowser.Configuration/ConfigurationData/Keys.cs b/SafeExamBrowser.Configuration/ConfigurationData/Keys.cs index d06dc813..8ba91236 100644 --- a/SafeExamBrowser.Configuration/ConfigurationData/Keys.cs +++ b/SafeExamBrowser.Configuration/ConfigurationData/Keys.cs @@ -256,6 +256,23 @@ namespace SafeExamBrowser.Configuration.ConfigurationData internal const string VideoMuted = "jitsiMeetVideoMuted"; } + internal static class ScreenProctoring + { + internal const string CaptureApplicationName = "screenProctoringMetadataActiveAppEnabled"; + internal const string CaptureBrowserUrl = "screenProctoringMetadataURLEnabled"; + internal const string CaptureWindowTitle = "screenProctoringMetadataWindowTitleEnabled"; + internal const string ClientId = "screenProctoringClientId"; + internal const string ClientSecret = "screenProctoringClientSecret"; + internal const string Enabled = "enableScreenProctoring"; + internal const string GroupId = "screenProctoringGroupId"; + internal const string ImageDownscaling = "screenProctoringImageDownscale"; + internal const string ImageFormat = "screenProctoringImageFormat"; + internal const string ImageQuantization = "screenProctoringImageQuantization"; + internal const string MaxInterval = "screenProctoringScreenshotMaxInterval"; + internal const string MinInterval = "screenProctoringScreenshotMinInterval"; + internal const string ServiceUrl = "screenProctoringServiceURL"; + } + internal static class Zoom { internal const string AllowChat = "zoomFeatureFlagChat"; diff --git a/SafeExamBrowser.Proctoring/JitsiMeet/JitsiMeetImplementation.cs b/SafeExamBrowser.Proctoring/JitsiMeet/JitsiMeetImplementation.cs index 28bcdbf8..83984a88 100644 --- a/SafeExamBrowser.Proctoring/JitsiMeet/JitsiMeetImplementation.cs +++ b/SafeExamBrowser.Proctoring/JitsiMeet/JitsiMeetImplementation.cs @@ -17,7 +17,7 @@ using SafeExamBrowser.Core.Contracts.Notifications.Events; using SafeExamBrowser.Core.Contracts.Resources.Icons; using SafeExamBrowser.I18n.Contracts; using SafeExamBrowser.Logging.Contracts; -using SafeExamBrowser.Server.Contracts.Events; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; using SafeExamBrowser.Settings.Proctoring; using SafeExamBrowser.SystemComponents.Contracts; using SafeExamBrowser.UserInterface.Contracts; @@ -41,9 +41,6 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet internal override string Name => nameof(JitsiMeet); - public override string Tooltip { get; protected set; } - public override IconResource IconResource { get; protected set; } - public override event NotificationChangedEventHandler NotificationChanged; internal JitsiMeetImplementation( @@ -60,21 +57,6 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet this.settings = settings; this.text = text; this.uiFactory = uiFactory; - - IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Inactive.xaml") }; - Tooltip = text.Get(TextKey.Notification_ProctoringInactiveTooltip); - } - - public override void Activate() - { - if (settings.WindowVisibility == WindowVisibility.Visible) - { - window?.BringToForeground(); - } - else if (settings.WindowVisibility == WindowVisibility.AllowToHide || settings.WindowVisibility == WindowVisibility.AllowToShow) - { - window?.Toggle(); - } } internal override void Initialize() @@ -87,11 +69,15 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet start &= !string.IsNullOrWhiteSpace(settings.JitsiMeet.RoomName); start &= !string.IsNullOrWhiteSpace(settings.JitsiMeet.ServerUrl); - logger.Info("Initialized proctoring."); - if (start) { - StartProctoring(); + logger.Info($"Initialized proctoring: All settings are valid, starting automatically..."); + Start(); + } + else + { + ShowNotificationInactive(); + logger.Info($"Initialized proctoring: Not all settings are valid or a server session is active, not starting automatically."); } } @@ -112,27 +98,37 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet settings.WindowVisibility = initialVisibility; } - StopProctoring(); - StartProctoring(); + Stop(); + Start(); logger.Info($"Successfully updated configuration: {nameof(allowChat)}={allowChat}, {nameof(receiveAudio)}={receiveAudio}, {nameof(receiveVideo)}={receiveVideo}."); } - internal override void ProctoringInstructionReceived(ProctoringInstructionEventArgs args) + internal override void ProctoringInstructionReceived(InstructionEventArgs args) { - logger.Info("Proctoring instruction received."); + if (args is JitsiMeetInstruction instruction) + { + logger.Info($"Proctoring instruction received: {instruction.Method}"); - settings.JitsiMeet.RoomName = args.JitsiMeetRoomName; - settings.JitsiMeet.ServerUrl = args.JitsiMeetServerUrl; - settings.JitsiMeet.Token = args.JitsiMeetToken; + if (instruction.Method == InstructionMethod.Join) + { + settings.JitsiMeet.RoomName = instruction.RoomName; + settings.JitsiMeet.ServerUrl = instruction.ServerUrl; + settings.JitsiMeet.Token = instruction.Token; - StopProctoring(); - StartProctoring(); + Stop(); + Start(); + } + else + { + Stop(); + } - logger.Info("Successfully processed instruction."); + logger.Info("Successfully processed instruction."); + } } - internal override void StartProctoring() + internal override void Start() { Application.Current.Dispatcher.Invoke(() => { @@ -173,7 +169,7 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet }); } - internal override void StopProctoring() + internal override void Stop() { if (control != default && window != default) { @@ -197,9 +193,28 @@ namespace SafeExamBrowser.Proctoring.JitsiMeet internal override void Terminate() { + Stop(); + TerminateNotification(); logger.Info("Terminated proctoring."); } + protected override void ActivateNotification() + { + if (settings.WindowVisibility == WindowVisibility.Visible) + { + window?.BringToForeground(); + } + else if (settings.WindowVisibility == WindowVisibility.AllowToHide || settings.WindowVisibility == WindowVisibility.AllowToShow) + { + window?.Toggle(); + } + } + + protected override void TerminateNotification() + { + // Nothing to do here for now. + } + private string LoadContent(ProctoringSettings settings) { var assembly = Assembly.GetAssembly(typeof(ProctoringController)); diff --git a/SafeExamBrowser.Proctoring/ProctoringController.cs b/SafeExamBrowser.Proctoring/ProctoringController.cs index 977109df..5de2cf2e 100644 --- a/SafeExamBrowser.Proctoring/ProctoringController.cs +++ b/SafeExamBrowser.Proctoring/ProctoringController.cs @@ -15,7 +15,7 @@ using SafeExamBrowser.Logging.Contracts; using SafeExamBrowser.Proctoring.Contracts; using SafeExamBrowser.Proctoring.Contracts.Events; using SafeExamBrowser.Server.Contracts; -using SafeExamBrowser.Server.Contracts.Events; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; using SafeExamBrowser.Settings.Proctoring; using SafeExamBrowser.SystemComponents.Contracts; using SafeExamBrowser.UserInterface.Contracts; @@ -144,7 +144,7 @@ namespace SafeExamBrowser.Proctoring } } - private void Server_ProctoringInstructionReceived(ProctoringInstructionEventArgs args) + private void Server_ProctoringInstructionReceived(InstructionEventArgs args) { foreach (var implementation in implementations) { diff --git a/SafeExamBrowser.Proctoring/ProctoringFactory.cs b/SafeExamBrowser.Proctoring/ProctoringFactory.cs index 844e4bdd..15a71f1f 100644 --- a/SafeExamBrowser.Proctoring/ProctoringFactory.cs +++ b/SafeExamBrowser.Proctoring/ProctoringFactory.cs @@ -11,6 +11,8 @@ using SafeExamBrowser.Configuration.Contracts; using SafeExamBrowser.I18n.Contracts; using SafeExamBrowser.Logging.Contracts; using SafeExamBrowser.Proctoring.JitsiMeet; +using SafeExamBrowser.Proctoring.ScreenProctoring; +using SafeExamBrowser.Proctoring.ScreenProctoring.Service; using SafeExamBrowser.Settings.Proctoring; using SafeExamBrowser.SystemComponents.Contracts; using SafeExamBrowser.UserInterface.Contracts; @@ -40,7 +42,17 @@ namespace SafeExamBrowser.Proctoring if (settings.JitsiMeet.Enabled) { - implementations.Add(new JitsiMeetImplementation(appConfig, fileSystem, logger.CloneFor(nameof(JitsiMeet)), settings, text, uiFactory)); + var logger = this.logger.CloneFor(nameof(JitsiMeet)); + + implementations.Add(new JitsiMeetImplementation(appConfig, fileSystem, logger, settings, text, uiFactory)); + } + + if (settings.ScreenProctoring.Enabled) + { + var logger = this.logger.CloneFor(nameof(ScreenProctoring)); + var service = new ServiceProxy(logger.CloneFor(nameof(ServiceProxy))); + + implementations.Add(new ScreenProctoringImplementation(logger, service, settings, text)); } return implementations; diff --git a/SafeExamBrowser.Proctoring/ProctoringImplementation.cs b/SafeExamBrowser.Proctoring/ProctoringImplementation.cs index 7d5f95f3..6be6625e 100644 --- a/SafeExamBrowser.Proctoring/ProctoringImplementation.cs +++ b/SafeExamBrowser.Proctoring/ProctoringImplementation.cs @@ -9,7 +9,7 @@ using SafeExamBrowser.Core.Contracts.Notifications; using SafeExamBrowser.Core.Contracts.Notifications.Events; using SafeExamBrowser.Core.Contracts.Resources.Icons; -using SafeExamBrowser.Server.Contracts.Events; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; namespace SafeExamBrowser.Proctoring { @@ -17,19 +17,29 @@ namespace SafeExamBrowser.Proctoring { internal abstract string Name { get; } - public abstract string Tooltip { get; protected set; } - public abstract IconResource IconResource { get; protected set; } + public string Tooltip { get; protected set; } + public IconResource IconResource { get; protected set; } public abstract event NotificationChangedEventHandler NotificationChanged; - public abstract void Activate(); - void INotification.Terminate() { } + void INotification.Activate() + { + ActivateNotification(); + } + + void INotification.Terminate() + { + TerminateNotification(); + } internal abstract void Initialize(); internal abstract void ProctoringConfigurationReceived(bool allowChat, bool receiveAudio, bool receiveVideo); - internal abstract void ProctoringInstructionReceived(ProctoringInstructionEventArgs args); - internal abstract void StartProctoring(); - internal abstract void StopProctoring(); + internal abstract void ProctoringInstructionReceived(InstructionEventArgs args); + internal abstract void Start(); + internal abstract void Stop(); internal abstract void Terminate(); + + protected abstract void ActivateNotification(); + protected abstract void TerminateNotification(); } } diff --git a/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj b/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj index 90bcc7d2..d9b62dce 100644 --- a/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj +++ b/SafeExamBrowser.Proctoring/SafeExamBrowser.Proctoring.csproj @@ -53,6 +53,15 @@ MinimumRecommendedRules.ruleset + + ..\packages\KGySoft.CoreLibraries.8.0.0\lib\net472\KGySoft.CoreLibraries.dll + + + ..\packages\KGySoft.Drawing.8.0.0\lib\net46\KGySoft.Drawing.dll + + + ..\packages\KGySoft.Drawing.Core.8.0.0\lib\net46\KGySoft.Drawing.Core.dll + ..\packages\Microsoft.Web.WebView2.1.0.2088.41\lib\net45\Microsoft.Web.WebView2.Core.dll @@ -69,6 +78,9 @@ + + + @@ -79,6 +91,22 @@ + + + + + + + + + + + + + + + + @@ -124,6 +152,7 @@ + diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/Extensions.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/Extensions.cs new file mode 100644 index 00000000..2ee4c90d --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/Extensions.cs @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + + +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.Windows.Forms; +using KGySoft.Drawing.Imaging; +using SafeExamBrowser.Settings.Proctoring; +using ImageFormat = SafeExamBrowser.Settings.Proctoring.ImageFormat; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Imaging +{ + internal static class Extensions + { + internal static void DrawCursorPosition(this Graphics graphics) + { + graphics.DrawArc(new Pen(Color.Red, 3), Cursor.Position.X - 25, Cursor.Position.Y - 25, 50, 50, 0, 360); + graphics.DrawArc(new Pen(Color.Yellow, 3), Cursor.Position.X - 22, Cursor.Position.Y - 22, 44, 44, 0, 360); + graphics.FillEllipse(Brushes.Red, Cursor.Position.X - 4, Cursor.Position.Y - 4, 8, 8); + graphics.FillEllipse(Brushes.Yellow, Cursor.Position.X - 2, Cursor.Position.Y - 2, 4, 4); + } + + internal static PixelFormat ToPixelFormat(this ImageQuantization quantization) + { + switch (quantization) + { + case ImageQuantization.BlackAndWhite1bpp: + return PixelFormat.Format1bppIndexed; + case ImageQuantization.Color8bpp: + return PixelFormat.Format8bppIndexed; + case ImageQuantization.Color16bpp: + return PixelFormat.Format16bppArgb1555; + case ImageQuantization.Color24bpp: + return PixelFormat.Format24bppRgb; + case ImageQuantization.Grayscale2bpp: + return PixelFormat.Format4bppIndexed; + case ImageQuantization.Grayscale4bpp: + return PixelFormat.Format4bppIndexed; + case ImageQuantization.Grayscale8bpp: + return PixelFormat.Format8bppIndexed; + default: + throw new NotImplementedException($"Image quantization '{quantization}' is not yet implemented!"); + } + } + + internal static IQuantizer ToQuantizer(this ImageQuantization quantization) + { + switch (quantization) + { + case ImageQuantization.BlackAndWhite1bpp: + return PredefinedColorsQuantizer.BlackAndWhite(); + case ImageQuantization.Color8bpp: + return PredefinedColorsQuantizer.SystemDefault8BppPalette(); + case ImageQuantization.Color16bpp: + return PredefinedColorsQuantizer.Rgb555(); + case ImageQuantization.Color24bpp: + return PredefinedColorsQuantizer.Rgb888(); + case ImageQuantization.Grayscale2bpp: + return PredefinedColorsQuantizer.Grayscale4(); + case ImageQuantization.Grayscale4bpp: + return PredefinedColorsQuantizer.Grayscale16(); + case ImageQuantization.Grayscale8bpp: + return PredefinedColorsQuantizer.Grayscale(); + default: + throw new NotImplementedException($"Image quantization '{quantization}' is not yet implemented!"); + } + } + + internal static System.Drawing.Imaging.ImageFormat ToSystemFormat(this ImageFormat format) + { + switch (format) + { + case ImageFormat.Bmp: + return System.Drawing.Imaging.ImageFormat.Bmp; + case ImageFormat.Gif: + return System.Drawing.Imaging.ImageFormat.Gif; + case ImageFormat.Jpg: + return System.Drawing.Imaging.ImageFormat.Jpeg; + case ImageFormat.Png: + return System.Drawing.Imaging.ImageFormat.Png; + default: + throw new NotImplementedException($"Image format '{format}' is not yet implemented!"); + } + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ProcessingOrder.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ProcessingOrder.cs new file mode 100644 index 00000000..5dcb47ce --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ProcessingOrder.cs @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Imaging +{ + internal enum ProcessingOrder + { + DownscalingQuantizing, + QuantizingDownscaling, + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ScreenShot.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ScreenShot.cs new file mode 100644 index 00000000..ff75b856 --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Imaging/ScreenShot.cs @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.Drawing; +using System.Drawing.Imaging; +using System.IO; +using System.Linq; +using System.Windows.Forms; +using KGySoft.Drawing; +using KGySoft.Drawing.Imaging; +using SafeExamBrowser.Logging.Contracts; +using SafeExamBrowser.Settings.Proctoring; +using ImageFormat = SafeExamBrowser.Settings.Proctoring.ImageFormat; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Imaging +{ + internal class ScreenShot : IDisposable + { + private readonly ILogger logger; + private readonly ScreenProctoringSettings settings; + + internal Bitmap Bitmap { get; private set; } + internal byte[] Data { get; private set; } + internal ImageFormat Format { get; private set; } + internal int Height { get; private set; } + internal int Width { get; private set; } + + public ScreenShot(ILogger logger, ScreenProctoringSettings settings) + { + this.logger = logger; + this.settings = settings; + } + + public void Dispose() + { + Bitmap?.Dispose(); + Bitmap = default; + Data = default; + } + + public string ToReducedString() + { + return $"{Width}x{Height}, {Data.Length / 1000:N0} kB, {Format.ToString().ToUpper()}"; + } + + public override string ToString() + { + return $"resolution: {Width}x{Height}, size: {Data.Length / 1000:N0} kB, format: {Format.ToString().ToUpper()}"; + } + + internal void Compress() + { + var order = ProcessingOrder.QuantizingDownscaling; + var original = ToReducedString(); + var parameters = $"{order}, {settings.ImageQuantization}, 1:{settings.ImageDownscaling}"; + + switch (order) + { + case ProcessingOrder.DownscalingQuantizing: + Downscale(); + Quantize(); + Serialize(); + break; + case ProcessingOrder.QuantizingDownscaling: + Quantize(); + Downscale(); + Serialize(); + break; + } + + logger.Debug($"Compressed from '{original}' to '{ToReducedString()}' ({parameters})."); + } + + internal void Take() + { + var x = Screen.AllScreens.Min(s => s.Bounds.X); + var y = Screen.AllScreens.Min(s => s.Bounds.Y); + var width = Screen.AllScreens.Max(s => s.Bounds.X + s.Bounds.Width) - x; + var height = Screen.AllScreens.Max(s => s.Bounds.Y + s.Bounds.Height) - y; + + Bitmap = new Bitmap(width, height, PixelFormat.Format24bppRgb); + Format = settings.ImageFormat; + Height = height; + Width = width; + + using (var graphics = Graphics.FromImage(Bitmap)) + { + graphics.CopyFromScreen(x, y, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy); + graphics.DrawCursorPosition(); + } + + Serialize(); + } + + private void Downscale() + { + if (settings.ImageDownscaling > 1) + { + Height = Convert.ToInt32(Height / settings.ImageDownscaling); + Width = Convert.ToInt32(Width / settings.ImageDownscaling); + + var downscaled = new Bitmap(Width, Height, Bitmap.PixelFormat); + + Bitmap.DrawInto(downscaled, new Rectangle(0, 0, Width, Height), ScalingMode.NearestNeighbor); + Bitmap.Dispose(); + Bitmap = downscaled; + } + } + + private void Quantize() + { + var ditherer = settings.ImageDownscaling > 1 ? OrderedDitherer.Bayer2x2 : default; + var pixelFormat = settings.ImageQuantization.ToPixelFormat(); + var quantizer = settings.ImageQuantization.ToQuantizer(); + + Bitmap = Bitmap.ConvertPixelFormat(pixelFormat, quantizer, ditherer); + } + + private void Serialize() + { + using (var memoryStream = new MemoryStream()) + { + if (Format == ImageFormat.Jpg) + { + SerializeJpg(memoryStream); + } + else + { + Bitmap.Save(memoryStream, Format.ToSystemFormat()); + } + + Data = memoryStream.ToArray(); + } + } + + private void SerializeJpg(MemoryStream memoryStream) + { + var codec = ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == System.Drawing.Imaging.ImageFormat.Jpeg.Guid); + var parameters = new EncoderParameters(1); + var quality = 100; + + parameters.Param[0] = new EncoderParameter(Encoder.Quality, quality); + Bitmap.Save(memoryStream, codec, parameters); + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs new file mode 100644 index 00000000..b58bf2be --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/ScreenProctoringImplementation.cs @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.Timers; +using SafeExamBrowser.Core.Contracts.Notifications.Events; +using SafeExamBrowser.Core.Contracts.Resources.Icons; +using SafeExamBrowser.I18n.Contracts; +using SafeExamBrowser.Logging.Contracts; +using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging; +using SafeExamBrowser.Proctoring.ScreenProctoring.Service; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; +using SafeExamBrowser.Settings.Proctoring; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring +{ + internal class ScreenProctoringImplementation : ProctoringImplementation + { + private readonly IModuleLogger logger; + private readonly ServiceProxy service; + private readonly ScreenProctoringSettings settings; + private readonly IText text; + private readonly Timer timer; + + internal override string Name => nameof(ScreenProctoring); + + public override event NotificationChangedEventHandler NotificationChanged; + + internal ScreenProctoringImplementation(IModuleLogger logger, ServiceProxy service, ProctoringSettings settings, IText text) + { + this.logger = logger; + this.service = service; + this.settings = settings.ScreenProctoring; + this.text = text; + this.timer = new Timer(); + } + + internal override void Initialize() + { + var start = true; + + start &= !string.IsNullOrWhiteSpace(settings.ClientId); + start &= !string.IsNullOrWhiteSpace(settings.ClientSecret); + start &= !string.IsNullOrWhiteSpace(settings.GroupId); + start &= !string.IsNullOrWhiteSpace(settings.ServiceUrl); + + timer.AutoReset = false; + timer.Interval = settings.MaxInterval; + + if (start) + { + logger.Info($"Initialized proctoring: All settings are valid, starting automatically..."); + + Connect(); + Start(); + } + else + { + ShowNotificationInactive(); + + logger.Info($"Initialized proctoring: Not all settings are valid or a server session is active, not starting automatically."); + } + } + + internal override void ProctoringConfigurationReceived(bool allowChat, bool receiveAudio, bool receiveVideo) + { + // Nothing to do here for now... + } + + internal override void ProctoringInstructionReceived(InstructionEventArgs args) + { + if (args is ScreenProctoringInstruction instruction) + { + logger.Info($"Proctoring instruction received: {instruction.Method}."); + + if (instruction.Method == InstructionMethod.Join) + { + settings.ClientId = instruction.ClientId; + settings.ClientSecret = instruction.ClientSecret; + settings.GroupId = instruction.GroupId; + settings.ServiceUrl = instruction.ServiceUrl; + + Connect(instruction.SessionId); + Start(); + } + else + { + Stop(); + } + + logger.Info("Successfully processed instruction."); + } + } + + internal override void Start() + { + timer.Elapsed += Timer_Elapsed; + timer.Start(); + + ShowNotificationActive(); + + logger.Info($"Started proctoring."); + } + + internal override void Stop() + { + timer.Elapsed -= Timer_Elapsed; + timer.Stop(); + + TerminateServiceSession(); + ShowNotificationInactive(); + + logger.Info("Stopped proctoring."); + } + + internal override void Terminate() + { + if (timer.Enabled) + { + Stop(); + } + + TerminateNotification(); + + logger.Info("Terminated proctoring."); + } + + protected override void ActivateNotification() + { + // Nothing to do here for now... + } + + protected override void TerminateNotification() + { + // Nothing to do here for now... + } + + private void Connect(string sessionId = default) + { + logger.Info("Connecting to service..."); + + var connect = service.Connect(settings.ServiceUrl); + + if (connect.Success) + { + if (sessionId == default) + { + logger.Info("Creating session..."); + service.CreateSession(settings.GroupId); + } + else + { + service.SessionId = sessionId; + } + } + } + + private void ShowNotificationActive() + { + // TODO: Replace with actual icon! + IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Active.xaml") }; + Tooltip = text.Get(TextKey.Notification_ProctoringActiveTooltip); + NotificationChanged?.Invoke(); + } + + private void ShowNotificationInactive() + { + // TODO: Replace with actual icon! + IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ProctoringNotification_Inactive.xaml") }; + Tooltip = text.Get(TextKey.Notification_ProctoringInactiveTooltip); + NotificationChanged?.Invoke(); + } + + private void TerminateServiceSession() + { + if (service.IsConnected) + { + logger.Info("Terminating session..."); + service.TerminateSession(); + } + } + + private void Timer_Elapsed(object sender, ElapsedEventArgs args) + { + try + { + using (var screenShot = new ScreenShot(logger.CloneFor(nameof(ScreenShot)), settings)) + { + screenShot.Take(); + screenShot.Compress(); + service.SendScreenShot(screenShot); + } + } + catch (Exception e) + { + logger.Error("Failed to process screen shot!", e); + } + + timer.Start(); + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs new file mode 100644 index 00000000..e3c9d725 --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Api.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service +{ + internal class Api + { + internal const string SESSION_ID = "%%_SESSION_ID_%%"; + + internal string AccessTokenEndpoint { get; set; } + internal string ScreenShotEndpoint { get; set; } + internal string SessionEndpoint { get; set; } + + internal Api() + { + AccessTokenEndpoint = "/oauth/token"; + ScreenShotEndpoint = $"/seb-api/v1/session/{SESSION_ID}/screenshot"; + SessionEndpoint = "/seb-api/v1/session"; + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs new file mode 100644 index 00000000..762fafda --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Parser.cs @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SafeExamBrowser.Logging.Contracts; +using SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service +{ + internal class Parser + { + private readonly ILogger logger; + + internal Parser(ILogger logger) + { + this.logger = logger; + } + + internal bool IsTokenExpired(HttpContent content) + { + var isExpired = false; + + try + { + var json = JsonConvert.DeserializeObject(Extract(content)) as JObject; + var error = json["error"].Value(); + + isExpired = error?.Equals("invalid_token", StringComparison.OrdinalIgnoreCase) == true; + } + catch (Exception e) + { + logger.Error("Failed to parse token expiration content!", e); + } + + return isExpired; + } + + internal bool TryParseOauth2Token(HttpContent content, out string oauth2Token) + { + oauth2Token = default; + + try + { + var json = JsonConvert.DeserializeObject(Extract(content)) as JObject; + + oauth2Token = json["access_token"].Value(); + } + catch (Exception e) + { + logger.Error("Failed to parse Oauth2 token!", e); + } + + return oauth2Token != default; + } + + internal bool TryParseSessionId(HttpResponseMessage response, out string sessionId) + { + sessionId = default; + + try + { + if (response.Headers.TryGetValues(Header.SESSION_ID, out var values)) + { + sessionId = values.First(); + } + } + catch (Exception e) + { + logger.Error("Failed to parse session identifier!", e); + } + + return sessionId != default; + } + + private string Extract(HttpContent content) + { + var task = Task.Run(async () => + { + return await content.ReadAsStreamAsync(); + }); + var stream = task.GetAwaiter().GetResult(); + var reader = new StreamReader(stream); + + return reader.ReadToEnd(); + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ContentType.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ContentType.cs new file mode 100644 index 00000000..87ee65f4 --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ContentType.cs @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal static class ContentType + { + internal const string JSON = "application/json;charset=UTF-8"; + internal const string OCTET_STREAM = "application/octet-stream"; + internal const string URL_ENCODED = "application/x-www-form-urlencoded"; + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/CreateSessionRequest.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/CreateSessionRequest.cs new file mode 100644 index 00000000..951d53ab --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/CreateSessionRequest.cs @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System.Net.Http; +using SafeExamBrowser.Logging.Contracts; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal class CreateSessionRequest : Request + { + internal CreateSessionRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser) + { + } + + internal bool TryExecute(string groupId, out string message, out string sessionId) + { + var group = (Header.GROUP_ID, groupId); + var success = TryExecute(HttpMethod.Post, api.SessionEndpoint, out var response, string.Empty, ContentType.URL_ENCODED, Authorization, group); + + message = response.ToLogString(); + sessionId = default; + + if (success) + { + parser.TryParseSessionId(response, out sessionId); + } + + return success; + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Extensions.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Extensions.cs new file mode 100644 index 00000000..102d39b3 --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Extensions.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.Net.Http; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal static class Extensions + { + internal static string ToLogString(this HttpResponseMessage response) + { + return $"{(int?) response?.StatusCode} {response?.StatusCode} {response?.ReasonPhrase}"; + } + + internal static long ToUnixTimestamp(this DateTime date) + { + return new DateTimeOffset(date).ToUnixTimeMilliseconds(); + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs new file mode 100644 index 00000000..14a6a689 --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Header.cs @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal static class Header + { + internal const string ACCEPT = "Accept"; + internal const string AUTHORIZATION = "Authorization"; + internal const string GROUP_ID = "SEB_GROUP_UUID"; + internal const string SESSION_ID = "SEB_SESSION_UUID"; + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/OAuth2TokenRequest.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/OAuth2TokenRequest.cs new file mode 100644 index 00000000..482fc379 --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/OAuth2TokenRequest.cs @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System.Net.Http; +using SafeExamBrowser.Logging.Contracts; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal class OAuth2TokenRequest : Request + { + internal OAuth2TokenRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser) + { + } + + internal bool TryExecute(out string message) + { + return TryRetrieveOAuth2Token(out message); + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Request.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Request.cs new file mode 100644 index 00000000..7677cdcb --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/Request.cs @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using SafeExamBrowser.Logging.Contracts; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal abstract class Request + { + private const int ATTEMPTS = 5; + + private static string connectionToken; + private static string oauth2Token; + + private readonly HttpClient httpClient; + + protected readonly Api api; + protected readonly ILogger logger; + protected readonly Parser parser; + + protected (string, string) Authorization => (Header.AUTHORIZATION, $"Bearer {oauth2Token}"); + + internal static string ConnectionToken + { + get { return connectionToken; } + set { connectionToken = value; } + } + + internal static string Oauth2Token + { + get { return oauth2Token; } + set { oauth2Token = value; } + } + + protected Request(Api api, HttpClient httpClient, ILogger logger, Parser parser) + { + this.api = api; + this.httpClient = httpClient; + this.logger = logger; + this.parser = parser; + } + + protected bool TryExecute( + HttpMethod method, + string url, + out HttpResponseMessage response, + object content = default, + string contentType = default, + params (string name, string value)[] headers) + { + response = default; + + for (var attempt = 0; attempt < ATTEMPTS && (response == default || !response.IsSuccessStatusCode); attempt++) + { + var request = BuildRequest(method, url, content, contentType, headers); + + try + { + response = httpClient.SendAsync(request).GetAwaiter().GetResult(); + + logger.Debug($"Completed request: {request.Method} '{request.RequestUri}' -> {response.ToLogString()}"); + + if (response.StatusCode == HttpStatusCode.Unauthorized && parser.IsTokenExpired(response.Content)) + { + logger.Info("OAuth2 token has expired, attempting to retrieve new one..."); + + if (TryRetrieveOAuth2Token(out var message)) + { + headers = UpdateOAuth2Token(headers); + } + } + } + catch (TaskCanceledException) + { + logger.Error($"Request {request.Method} '{request.RequestUri}' did not complete within {httpClient.Timeout}ms!"); + break; + } + catch (Exception e) + { + logger.Error($"Request {request.Method} '{request.RequestUri}' has failed!", e); + } + } + + return response != default && response.IsSuccessStatusCode; + } + + protected bool TryRetrieveOAuth2Token(out string message) + { + var secret = Convert.ToBase64String(Encoding.UTF8.GetBytes("test:test")); + var authorization = (Header.AUTHORIZATION, $"Basic {secret}"); + var content = "grant_type=client_credentials&scope=read write"; + var success = TryExecute(HttpMethod.Post, api.AccessTokenEndpoint, out var response, content, ContentType.URL_ENCODED, authorization); + + message = response.ToLogString(); + + if (success && parser.TryParseOauth2Token(response.Content, out oauth2Token)) + { + logger.Info("Successfully retrieved OAuth2 token."); + } + else + { + logger.Error("Failed to retrieve OAuth2 token!"); + } + + return success; + } + + private HttpRequestMessage BuildRequest( + HttpMethod method, + string url, + object content = default, + string contentType = default, + params (string name, string value)[] headers) + { + var request = new HttpRequestMessage(method, url); + + if (content != default) + { + if (content is string) + { + request.Content = new StringContent(content as string, Encoding.UTF8); + } + + if (content is byte[]) + { + request.Content = new ByteArrayContent(content as byte[]); + } + + if (contentType != default) + { + request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); + } + } + + request.Headers.Add(Header.ACCEPT, "application/json, */*"); + + foreach (var (name, value) in headers) + { + request.Headers.Add(name, value); + } + + return request; + } + + private (string name, string value)[] UpdateOAuth2Token((string name, string value)[] headers) + { + var result = new List<(string name, string value)>(); + + foreach (var header in headers) + { + if (header.name == Header.AUTHORIZATION) + { + result.Add((Header.AUTHORIZATION, $"Bearer {oauth2Token}")); + } + else + { + result.Add(header); + } + } + + return result.ToArray(); + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs new file mode 100644 index 00000000..cfe1672f --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/ScreenShotRequest.cs @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.Net.Http; +using SafeExamBrowser.Logging.Contracts; +using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging; +using SafeExamBrowser.Settings.Proctoring; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal class ScreenShotRequest : Request + { + internal ScreenShotRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser) + { + } + + internal bool TryExecute(ScreenShot screenShot, string sessionId, out string message) + { + var imageFormat = ("imageFormat", ToString(screenShot.Format)); + var timestamp = ("timestamp", DateTime.Now.ToUnixTimestamp().ToString()); + var url = api.ScreenShotEndpoint.Replace(Api.SESSION_ID, sessionId); + var success = TryExecute(HttpMethod.Post, url, out var response, screenShot.Data, ContentType.OCTET_STREAM, Authorization, imageFormat, timestamp); + + message = response.ToLogString(); + + return success; + } + + private string ToString(ImageFormat format) + { + switch (format) + { + case ImageFormat.Bmp: + return "bmp"; + case ImageFormat.Gif: + return "gif"; + case ImageFormat.Jpg: + return "jpg"; + case ImageFormat.Png: + return "png"; + default: + throw new NotImplementedException($"Image format {format} is not yet implemented!"); + } + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/TerminateSessionRequest.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/TerminateSessionRequest.cs new file mode 100644 index 00000000..3dbe8223 --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/Requests/TerminateSessionRequest.cs @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System.Net.Http; +using SafeExamBrowser.Logging.Contracts; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests +{ + internal class TerminateSessionRequest : Request + { + internal TerminateSessionRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser) + { + } + + internal bool TryExecute(string sessionId, out string message) + { + var url = $"{api.SessionEndpoint}/{sessionId}"; + var success = TryExecute(HttpMethod.Delete, url, out var response, contentType: ContentType.URL_ENCODED, headers: Authorization); + + message = response.ToLogString(); + + return success; + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs new file mode 100644 index 00000000..210b71bf --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceProxy.cs @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; +using System.Net.Http; +using SafeExamBrowser.Logging.Contracts; +using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging; +using SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests; + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service +{ + internal class ServiceProxy + { + private readonly Api api; + private readonly ILogger logger; + private readonly Parser parser; + + private HttpClient httpClient; + + internal bool IsConnected => SessionId != default; + internal string SessionId { get; set; } + + internal ServiceProxy(ILogger logger) + { + this.api = new Api(); + this.logger = logger; + this.parser = new Parser(logger); + } + + internal ServiceResponse Connect(string serviceUrl) + { + httpClient = new HttpClient { BaseAddress = new Uri(serviceUrl) }; + + var request = new OAuth2TokenRequest(api, httpClient, logger, parser); + var success = request.TryExecute(out var message); + + if (success) + { + logger.Info("Successfully connected to service."); + } + else + { + logger.Error("Failed to connect to service!"); + } + + return new ServiceResponse(success, message); + } + + internal ServiceResponse CreateSession(string groupId) + { + var request = new CreateSessionRequest(api, httpClient, logger, parser); + var success = request.TryExecute(groupId, out var message, out var sessionId); + + if (success) + { + SessionId = sessionId; + logger.Info("Successfully created session."); + } + else + { + logger.Error("Failed to create session!"); + } + + return new ServiceResponse(success, message); + } + + internal ServiceResponse SendScreenShot(ScreenShot screenShot) + { + var request = new ScreenShotRequest(api, httpClient, logger, parser); + var success = request.TryExecute(screenShot, SessionId, out var message); + + if (success) + { + logger.Info($"Successfully sent screen shot ({screenShot})."); + } + else + { + logger.Error("Failed to send screen shot!"); + } + + return new ServiceResponse(success, message); + } + + internal ServiceResponse TerminateSession() + { + var request = new TerminateSessionRequest(api, httpClient, logger, parser); + var success = request.TryExecute(SessionId, out var message); + + if (success) + { + SessionId = default; + logger.Info("Successfully terminated session."); + } + else + { + logger.Error("Failed to terminate session!"); + } + + return new ServiceResponse(success, message); + } + } +} diff --git a/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceResponse.cs b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceResponse.cs new file mode 100644 index 00000000..068a6f3d --- /dev/null +++ b/SafeExamBrowser.Proctoring/ScreenProctoring/Service/ServiceResponse.cs @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service +{ + internal class ServiceResponse + { + internal string Message { get; } + internal bool Success { get; } + + internal ServiceResponse(bool success, string message = default) + { + Message = message; + Success = success; + } + } + + internal class ServiceResponse : ServiceResponse + { + internal T Value { get; } + + internal ServiceResponse(bool success, T value, string message = default) : base(success, message) + { + Value = value; + } + } +} diff --git a/SafeExamBrowser.Proctoring/packages.config b/SafeExamBrowser.Proctoring/packages.config index d197b6eb..f78eeea6 100644 --- a/SafeExamBrowser.Proctoring/packages.config +++ b/SafeExamBrowser.Proctoring/packages.config @@ -1,5 +1,8 @@  + + + \ No newline at end of file diff --git a/SafeExamBrowser.Runtime/Operations/DisclaimerOperation.cs b/SafeExamBrowser.Runtime/Operations/DisclaimerOperation.cs index 6710713e..5e8a1b8f 100644 --- a/SafeExamBrowser.Runtime/Operations/DisclaimerOperation.cs +++ b/SafeExamBrowser.Runtime/Operations/DisclaimerOperation.cs @@ -31,9 +31,14 @@ namespace SafeExamBrowser.Runtime.Operations { var result = OperationResult.Success; - if (Context.Next.Settings.Proctoring.Enabled) + if (Context.Next.Settings.Proctoring.JitsiMeet.Enabled) { - result = ShowDisclaimer(); + result = ShowVideoProctoringDisclaimer(); + } + else if (Context.Next.Settings.Proctoring.ScreenProctoring.Enabled) + { + // TODO: Implement disclaimer! + // result = ShowScreenProctoringDisclaimer(); } else if (Context.Next.Settings.Proctoring.Zoom.Enabled) { @@ -51,9 +56,14 @@ namespace SafeExamBrowser.Runtime.Operations { var result = OperationResult.Success; - if (Context.Next.Settings.Proctoring.Enabled) + if (Context.Next.Settings.Proctoring.JitsiMeet.Enabled) { - result = ShowDisclaimer(); + result = ShowVideoProctoringDisclaimer(); + } + else if (Context.Next.Settings.Proctoring.ScreenProctoring.Enabled) + { + // TODO: Implement disclaimer! + // result = ShowScreenProctoringDisclaimer(); } else if (Context.Next.Settings.Proctoring.Zoom.Enabled) { @@ -72,7 +82,7 @@ namespace SafeExamBrowser.Runtime.Operations return OperationResult.Success; } - private OperationResult ShowDisclaimer() + private OperationResult ShowVideoProctoringDisclaimer() { var args = new MessageEventArgs { diff --git a/SafeExamBrowser.Runtime/Operations/ProctoringWorkaroundOperation.cs b/SafeExamBrowser.Runtime/Operations/ProctoringWorkaroundOperation.cs index ab59f5b0..cdcde035 100644 --- a/SafeExamBrowser.Runtime/Operations/ProctoringWorkaroundOperation.cs +++ b/SafeExamBrowser.Runtime/Operations/ProctoringWorkaroundOperation.cs @@ -27,7 +27,7 @@ namespace SafeExamBrowser.Runtime.Operations public override OperationResult Perform() { - if (Context.Next.Settings.Proctoring.Enabled && Context.Next.Settings.Security.KioskMode == KioskMode.CreateNewDesktop) + if (Context.Next.Settings.Proctoring.JitsiMeet.Enabled && Context.Next.Settings.Security.KioskMode == KioskMode.CreateNewDesktop) { Context.Next.Settings.Security.KioskMode = KioskMode.DisableExplorerShell; logger.Info("Switched kiosk mode to Disable Explorer Shell due to remote proctoring being enabled."); diff --git a/SafeExamBrowser.Server.Contracts/Data/ServerResponse.cs b/SafeExamBrowser.Server.Contracts/Data/ServerResponse.cs index b662b5fc..dd7a606e 100644 --- a/SafeExamBrowser.Server.Contracts/Data/ServerResponse.cs +++ b/SafeExamBrowser.Server.Contracts/Data/ServerResponse.cs @@ -23,7 +23,7 @@ namespace SafeExamBrowser.Server.Contracts.Data /// public bool Success { get; } - public ServerResponse(bool success, string message = default(string)) + public ServerResponse(bool success, string message = default) { Message = message; Success = success; @@ -41,7 +41,7 @@ namespace SafeExamBrowser.Server.Contracts.Data /// public T Value { get; } - public ServerResponse(bool success, T value, string message = default(string)) : base(success, message) + public ServerResponse(bool success, T value, string message = default) : base(success, message) { Value = value; } diff --git a/SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionEventArgs.cs b/SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionEventArgs.cs new file mode 100644 index 00000000..4907a8f9 --- /dev/null +++ b/SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionEventArgs.cs @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Server.Contracts.Events.Proctoring +{ + /// + /// Defines all parameters for a proctoring instruction received by the . + /// + public abstract class InstructionEventArgs + { + public InstructionMethod Method { get; set; } + } +} diff --git a/SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionMethod.cs b/SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionMethod.cs new file mode 100644 index 00000000..5554114c --- /dev/null +++ b/SafeExamBrowser.Server.Contracts/Events/Proctoring/InstructionMethod.cs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Server.Contracts.Events.Proctoring +{ + /// + /// Defines all possible methods for a proctoring instruction. + /// + public enum InstructionMethod + { + /// + /// Instructs to start proctoring resp. join a proctoring event or session. + /// + Join, + + /// + /// Instructs to stop proctoring resp. leave a proctoring event or session. + /// + Leave + } +} diff --git a/SafeExamBrowser.Server.Contracts/Events/Proctoring/JitsiMeetInstruction.cs b/SafeExamBrowser.Server.Contracts/Events/Proctoring/JitsiMeetInstruction.cs new file mode 100644 index 00000000..777303e4 --- /dev/null +++ b/SafeExamBrowser.Server.Contracts/Events/Proctoring/JitsiMeetInstruction.cs @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Server.Contracts.Events.Proctoring +{ + /// + /// Defines the parameters of a proctoring instruction for provider Jitsi Meet. + /// + public class JitsiMeetInstruction : InstructionEventArgs + { + public string RoomName { get; set; } + public string ServerUrl { get; set; } + public string Token { get; set; } + } +} diff --git a/SafeExamBrowser.Server.Contracts/Events/ProctoringConfigurationReceivedEventHandler.cs b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ProctoringConfigurationReceivedEventHandler.cs similarity index 89% rename from SafeExamBrowser.Server.Contracts/Events/ProctoringConfigurationReceivedEventHandler.cs rename to SafeExamBrowser.Server.Contracts/Events/Proctoring/ProctoringConfigurationReceivedEventHandler.cs index 367a2fac..c3e9350c 100644 --- a/SafeExamBrowser.Server.Contracts/Events/ProctoringConfigurationReceivedEventHandler.cs +++ b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ProctoringConfigurationReceivedEventHandler.cs @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -namespace SafeExamBrowser.Server.Contracts.Events +namespace SafeExamBrowser.Server.Contracts.Events.Proctoring { /// /// Event handler used to indicate that proctoring configuration data has been received. diff --git a/SafeExamBrowser.Server.Contracts/Events/ProctoringInstructionReceivedEventHandler.cs b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ProctoringInstructionReceivedEventHandler.cs similarity index 84% rename from SafeExamBrowser.Server.Contracts/Events/ProctoringInstructionReceivedEventHandler.cs rename to SafeExamBrowser.Server.Contracts/Events/Proctoring/ProctoringInstructionReceivedEventHandler.cs index 6da9db51..e8c7bcdf 100644 --- a/SafeExamBrowser.Server.Contracts/Events/ProctoringInstructionReceivedEventHandler.cs +++ b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ProctoringInstructionReceivedEventHandler.cs @@ -6,10 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -namespace SafeExamBrowser.Server.Contracts.Events +namespace SafeExamBrowser.Server.Contracts.Events.Proctoring { /// /// Event handler used to indicate that a proctoring instruction has been received. /// - public delegate void ProctoringInstructionReceivedEventHandler(ProctoringInstructionEventArgs args); + public delegate void ProctoringInstructionReceivedEventHandler(InstructionEventArgs args); } diff --git a/SafeExamBrowser.Server.Contracts/Events/Proctoring/ScreenProctoringInstruction.cs b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ScreenProctoringInstruction.cs new file mode 100644 index 00000000..32c8cc7b --- /dev/null +++ b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ScreenProctoringInstruction.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Server.Contracts.Events.Proctoring +{ + /// + /// Defines the parameters of a proctoring instruction for the screen proctoring implementation. + /// + public class ScreenProctoringInstruction : InstructionEventArgs + { + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string GroupId { get; set; } + public string ServiceUrl { get; set; } + public string SessionId { get; set; } + } +} diff --git a/SafeExamBrowser.Server.Contracts/Events/Proctoring/ZoomInstruction.cs b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ZoomInstruction.cs new file mode 100644 index 00000000..3653a0a2 --- /dev/null +++ b/SafeExamBrowser.Server.Contracts/Events/Proctoring/ZoomInstruction.cs @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Server.Contracts.Events.Proctoring +{ + /// + /// Defines the parameters of a proctoring instruction for provider Zoom. + /// + public class ZoomInstruction : InstructionEventArgs + { + public string MeetingNumber { get; set; } + public string Password { get; set; } + public string SdkKey { get; set; } + public string Signature { get; set; } + public string Subject { get; set; } + public string UserName { get; set; } + } +} diff --git a/SafeExamBrowser.Server.Contracts/Events/ProctoringInstructionEventArgs.cs b/SafeExamBrowser.Server.Contracts/Events/ProctoringInstructionEventArgs.cs deleted file mode 100644 index 985cfd54..00000000 --- a/SafeExamBrowser.Server.Contracts/Events/ProctoringInstructionEventArgs.cs +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - */ - -namespace SafeExamBrowser.Server.Contracts.Events -{ - /// - /// Defines all parameters for a proctoring instruction received by the . - /// - public class ProctoringInstructionEventArgs - { - public string JitsiMeetRoomName { get; set; } - public string JitsiMeetServerUrl { get; set; } - public string JitsiMeetToken { get; set; } - public string ZoomMeetingNumber { get; set; } - public string ZoomPassword { get; set; } - public string ZoomSdkKey { get; set; } - public string ZoomSignature { get; set; } - public string ZoomSubject { get; set; } - public string ZoomUserName { get; set; } - } -} diff --git a/SafeExamBrowser.Server.Contracts/IServerProxy.cs b/SafeExamBrowser.Server.Contracts/IServerProxy.cs index 4ea7c0ff..6012cb6f 100644 --- a/SafeExamBrowser.Server.Contracts/IServerProxy.cs +++ b/SafeExamBrowser.Server.Contracts/IServerProxy.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using SafeExamBrowser.Server.Contracts.Data; using SafeExamBrowser.Server.Contracts.Events; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; using SafeExamBrowser.Settings.Server; namespace SafeExamBrowser.Server.Contracts diff --git a/SafeExamBrowser.Server.Contracts/SafeExamBrowser.Server.Contracts.csproj b/SafeExamBrowser.Server.Contracts/SafeExamBrowser.Server.Contracts.csproj index 0725811b..efcae43a 100644 --- a/SafeExamBrowser.Server.Contracts/SafeExamBrowser.Server.Contracts.csproj +++ b/SafeExamBrowser.Server.Contracts/SafeExamBrowser.Server.Contracts.csproj @@ -58,9 +58,13 @@ - - - + + + + + + + diff --git a/SafeExamBrowser.Server/Data/Attributes.cs b/SafeExamBrowser.Server/Data/Attributes.cs index 8bb0edb0..b1905768 100644 --- a/SafeExamBrowser.Server/Data/Attributes.cs +++ b/SafeExamBrowser.Server/Data/Attributes.cs @@ -6,7 +6,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -using SafeExamBrowser.Server.Contracts.Events; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; namespace SafeExamBrowser.Server.Data { @@ -14,15 +14,10 @@ namespace SafeExamBrowser.Server.Data { internal bool AllowChat { get; set; } internal int Id { get; set; } - internal ProctoringInstructionEventArgs Instruction { get; set; } + internal InstructionEventArgs Instruction { get; set; } internal string Message { get; set; } internal bool ReceiveAudio { get; set; } internal bool ReceiveVideo { get; set; } internal AttributeType Type { get; set; } - - internal Attributes() - { - Instruction = new ProctoringInstructionEventArgs(); - } } } diff --git a/SafeExamBrowser.Server/Parser.cs b/SafeExamBrowser.Server/Parser.cs index b8219a5e..98c67574 100644 --- a/SafeExamBrowser.Server/Parser.cs +++ b/SafeExamBrowser.Server/Parser.cs @@ -16,6 +16,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using SafeExamBrowser.Logging.Contracts; using SafeExamBrowser.Server.Contracts.Data; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; using SafeExamBrowser.Server.Data; using SafeExamBrowser.Server.Requests; @@ -314,19 +315,55 @@ namespace SafeExamBrowser.Server switch (provider) { case "JITSI_MEET": - attributes.Instruction.JitsiMeetRoomName = attributesJson["jitsiMeetRoom"].Value(); - attributes.Instruction.JitsiMeetServerUrl = attributesJson["jitsiMeetServerURL"].Value(); - attributes.Instruction.JitsiMeetToken = attributesJson["jitsiMeetToken"].Value(); + attributes.Instruction = ParseJitsiMeetInstruction(attributesJson); + break; + case "SCREEN_PROCTORING": + attributes.Instruction = ParseScreenProctoringInstruction(attributesJson); break; case "ZOOM": - attributes.Instruction.ZoomMeetingNumber = attributesJson["zoomRoom"].Value(); - attributes.Instruction.ZoomPassword = attributesJson["zoomMeetingKey"].Value(); - attributes.Instruction.ZoomSdkKey = attributesJson["zoomAPIKey"].Value(); - attributes.Instruction.ZoomSignature = attributesJson["zoomToken"].Value(); - attributes.Instruction.ZoomSubject = attributesJson["zoomSubject"].Value(); - attributes.Instruction.ZoomUserName = attributesJson["zoomUserName"].Value(); + attributes.Instruction = ParseZoomInstruction(attributesJson); break; } + + if (attributes.Instruction != default) + { + attributes.Instruction.Method = attributesJson["method"].Value() == "JOIN" ? InstructionMethod.Join : InstructionMethod.Leave; + } + } + + private JitsiMeetInstruction ParseJitsiMeetInstruction(JObject attributesJson) + { + return new JitsiMeetInstruction + { + RoomName = attributesJson["jitsiMeetRoom"].Value(), + ServerUrl = attributesJson["jitsiMeetServerURL"].Value(), + Token = attributesJson["jitsiMeetToken"].Value() + }; + } + + private ScreenProctoringInstruction ParseScreenProctoringInstruction(JObject attributesJson) + { + return new ScreenProctoringInstruction + { + ClientId = attributesJson["screenProctoringClientId"].Value(), + ClientSecret = attributesJson["screenProctoringClientSecret"].Value(), + GroupId = attributesJson["screenProctoringGroupId"].Value(), + ServiceUrl = attributesJson["screenProctoringServiceURL"].Value(), + SessionId = attributesJson["screenProctoringClientSessionId"].Value() + }; + } + + private ZoomInstruction ParseZoomInstruction(JObject attributesJson) + { + return new ZoomInstruction + { + MeetingNumber = attributesJson["zoomRoom"].Value(), + Password = attributesJson["zoomMeetingKey"].Value(), + SdkKey = attributesJson["zoomAPIKey"].Value(), + Signature = attributesJson["zoomToken"].Value(), + Subject = attributesJson["zoomSubject"].Value(), + UserName = attributesJson["zoomUserName"].Value() + }; } private void ParseReconfigurationInstruction(Attributes attributes, JObject attributesJson) diff --git a/SafeExamBrowser.Server/ServerProxy.cs b/SafeExamBrowser.Server/ServerProxy.cs index f2473d82..0d233691 100644 --- a/SafeExamBrowser.Server/ServerProxy.cs +++ b/SafeExamBrowser.Server/ServerProxy.cs @@ -20,6 +20,7 @@ using SafeExamBrowser.Logging.Contracts; using SafeExamBrowser.Server.Contracts; using SafeExamBrowser.Server.Contracts.Data; using SafeExamBrowser.Server.Contracts.Events; +using SafeExamBrowser.Server.Contracts.Events.Proctoring; using SafeExamBrowser.Server.Data; using SafeExamBrowser.Server.Requests; using SafeExamBrowser.Settings.Server; diff --git a/SafeExamBrowser.Settings/Proctoring/ImageFormat.cs b/SafeExamBrowser.Settings/Proctoring/ImageFormat.cs new file mode 100644 index 00000000..0c0d936c --- /dev/null +++ b/SafeExamBrowser.Settings/Proctoring/ImageFormat.cs @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Settings.Proctoring +{ + /// + /// Defines all possible image formats for the screen proctoring. + /// + public enum ImageFormat + { + /// + /// An image with the Windows Bitmap format. + /// + Bmp, + + /// + /// An image with the Graphics Interchange Format format. + /// + Gif, + + /// + /// An image with the Joint Photographic Experts Group format. + /// + Jpg, + + /// + /// An image with the Portable Network Graphics format. + /// + Png + } +} diff --git a/SafeExamBrowser.Settings/Proctoring/ImageQuantization.cs b/SafeExamBrowser.Settings/Proctoring/ImageQuantization.cs new file mode 100644 index 00000000..207fd2cf --- /dev/null +++ b/SafeExamBrowser.Settings/Proctoring/ImageQuantization.cs @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +namespace SafeExamBrowser.Settings.Proctoring +{ + /// + /// Defines all possible image quantization algorithms for the screen proctoring. + /// + public enum ImageQuantization + { + /// + /// Reduces an image to a black and white image with 1 bit per pixel. + /// + BlackAndWhite1bpp, + + /// + /// Reduces an image to a colored image with 8 bits per pixel (256 colors). + /// + Color8bpp, + + /// + /// Reduces an image to a colored image with 16 bits per pixel (65'536 colors). + /// + Color16bpp, + + /// + /// Reduces an image to a colored image with 24 bits per pixel (16'777'216 colors). + /// + Color24bpp, + + /// + /// Reduces an image to a grayscale image with 2 bits per pixel (4 shades). + /// + Grayscale2bpp, + + /// + /// Reduces an image to a grayscale image with 4 bits per pixel (16 shades). + /// + Grayscale4bpp, + + /// + /// Reduces an image to a grayscale image with 8 bits per pixel (256 shades). + /// + Grayscale8bpp + } +} diff --git a/SafeExamBrowser.Settings/Proctoring/ProctoringSettings.cs b/SafeExamBrowser.Settings/Proctoring/ProctoringSettings.cs index 149d2f0f..7d2a42af 100644 --- a/SafeExamBrowser.Settings/Proctoring/ProctoringSettings.cs +++ b/SafeExamBrowser.Settings/Proctoring/ProctoringSettings.cs @@ -31,6 +31,11 @@ namespace SafeExamBrowser.Settings.Proctoring /// public JitsiMeetSettings JitsiMeet { get; set; } + /// + /// All settings for the screen proctoring. + /// + public ScreenProctoringSettings ScreenProctoring { get; set; } + /// /// Determines whether the raise hand notification will be shown in the shell. /// @@ -54,6 +59,7 @@ namespace SafeExamBrowser.Settings.Proctoring public ProctoringSettings() { JitsiMeet = new JitsiMeetSettings(); + ScreenProctoring = new ScreenProctoringSettings(); Zoom = new ZoomSettings(); } } diff --git a/SafeExamBrowser.Settings/Proctoring/ScreenProctoringSettings.cs b/SafeExamBrowser.Settings/Proctoring/ScreenProctoringSettings.cs new file mode 100644 index 00000000..799b9a1b --- /dev/null +++ b/SafeExamBrowser.Settings/Proctoring/ScreenProctoringSettings.cs @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2023 ETH Zürich, Educational Development and Technology (LET) + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +using System; + +namespace SafeExamBrowser.Settings.Proctoring +{ + /// + /// All settings for the screen proctoring. + /// + [Serializable] + public class ScreenProctoringSettings + { + /// + /// Determines whether the name of the active application shall be captured and transmitted as part of the image meta data. + /// + public bool CaptureApplicationName { get; set; } + + /// + /// Determines whether the URL of the currently opened web page shall be captured and transmitted as part of the image meta data. + /// + public bool CaptureBrowserUrl { get; set; } + + /// + /// Determines whether the title of the currently active window shall be captured and transmitted as part of the image meta data. + /// + public bool CaptureWindowTitle { get; set; } + + /// + /// The client identifier used for authentication with the screen proctoring service. + /// + public string ClientId { get; set; } + + /// + /// The client secret used for authentication with the screen proctoring service. + /// + public string ClientSecret { get; set; } + + /// + /// Determines whether the screen proctoring is enabled. + /// + public bool Enabled { get; set; } + + /// + /// The identifier of the group to which the user belongs. + /// + public string GroupId { get; set; } + + /// + /// Defines the factor to be used for downscaling of the screen shots. + /// + public double ImageDownscaling { get; set; } + + /// + /// Defines the image format to be used for the screen shots. + /// + public ImageFormat ImageFormat { get; set; } + + /// + /// Defines the algorithm to be used for quantization of the screen shots. + /// + public ImageQuantization ImageQuantization { get; set; } + + /// + /// The maximum time interval in milliseconds between screen shot transmissions. + /// + public int MaxInterval { get; set; } + + /// + /// The minimum time interval in milliseconds between screen shot transmissions. + /// + public int MinInterval { get; set; } + + /// + /// The URL of the screen proctoring service. + /// + public string ServiceUrl { get; set; } + } +} diff --git a/SafeExamBrowser.Settings/SafeExamBrowser.Settings.csproj b/SafeExamBrowser.Settings/SafeExamBrowser.Settings.csproj index 347bc3cc..40006ad4 100644 --- a/SafeExamBrowser.Settings/SafeExamBrowser.Settings.csproj +++ b/SafeExamBrowser.Settings/SafeExamBrowser.Settings.csproj @@ -73,8 +73,11 @@ + + +