Restore SEBPatch

This commit is contained in:
2025-06-01 11:44:20 +02:00
commit 8c656e3137
1297 changed files with 142172 additions and 0 deletions

View File

@@ -0,0 +1,211 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 KGySoft.CoreLibraries;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Core.Contracts.Notifications;
using SafeExamBrowser.I18n.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Monitoring.Contracts.Applications;
using SafeExamBrowser.Proctoring.Contracts;
using SafeExamBrowser.Proctoring.Contracts.Events;
using SafeExamBrowser.Server.Contracts;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.SystemComponents.Contracts;
using SafeExamBrowser.UserInterface.Contracts;
using SafeExamBrowser.WindowsApi.Contracts;
namespace SafeExamBrowser.Proctoring
{
public class ProctoringController : IProctoringController
{
private readonly ProctoringFactory factory;
private readonly IModuleLogger logger;
private readonly IServerProxy server;
private IEnumerable<ProctoringImplementation> implementations;
public bool IsHandRaised { get; private set; }
public IEnumerable<INotification> Notifications => new List<INotification>(implementations);
public event ProctoringEventHandler HandLowered;
public event ProctoringEventHandler HandRaised;
public event RemainingWorkUpdatedEventHandler RemainingWorkUpdated
{
add { implementations.ForEach(i => i.RemainingWorkUpdated += value); }
remove { implementations.ForEach(i => i.RemainingWorkUpdated -= value); }
}
public ProctoringController(
AppConfig appConfig,
IApplicationMonitor applicationMonitor,
IBrowserApplication browser,
IFileSystem fileSystem,
IModuleLogger logger,
INativeMethods nativeMethods,
IServerProxy server,
IText text,
IUserInterfaceFactory uiFactory)
{
this.logger = logger;
this.server = server;
factory = new ProctoringFactory(appConfig, applicationMonitor, browser, fileSystem, logger, nativeMethods, text, uiFactory);
implementations = new List<ProctoringImplementation>();
}
public void ExecuteRemainingWork()
{
foreach (var implementation in implementations)
{
try
{
implementation.ExecuteRemainingWork();
}
catch (Exception e)
{
logger.Error($"Failed to execute remaining work for '{implementation.Name}'!", e);
}
}
}
public bool HasRemainingWork()
{
var hasWork = false;
foreach (var implementation in implementations)
{
try
{
if (implementation.HasRemainingWork())
{
hasWork = true;
}
}
catch (Exception e)
{
logger.Error($"Failed to check whether has remaining work for '{implementation.Name}'!", e);
}
}
return hasWork;
}
public void Initialize(ProctoringSettings settings)
{
implementations = factory.CreateAllActive(settings);
server.HandConfirmed += Server_HandConfirmed;
server.ProctoringConfigurationReceived += Server_ProctoringConfigurationReceived;
server.ProctoringInstructionReceived += Server_ProctoringInstructionReceived;
foreach (var implementation in implementations)
{
try
{
implementation.Initialize();
}
catch (Exception e)
{
logger.Error($"Failed to initialize proctoring implementation '{implementation.Name}'!", e);
}
}
}
public void LowerHand()
{
var response = server.LowerHand();
if (response.Success)
{
IsHandRaised = false;
HandLowered?.Invoke();
logger.Info("Hand lowered.");
}
else
{
logger.Error($"Failed to send lower hand notification to server! Message: {response.Message}.");
}
}
public void RaiseHand(string message = null)
{
var response = server.RaiseHand(message);
if (response.Success)
{
IsHandRaised = true;
HandRaised?.Invoke();
logger.Info("Hand raised.");
}
else
{
logger.Error($"Failed to send raise hand notification to server! Message: {response.Message}.");
}
}
public void Terminate()
{
foreach (var implementation in implementations)
{
try
{
implementation.Terminate();
}
catch (Exception e)
{
logger.Error($"Failed to terminate proctoring implementation '{implementation.Name}'!", e);
}
}
}
private void Server_HandConfirmed()
{
logger.Info("Hand confirmation received.");
IsHandRaised = false;
HandLowered?.Invoke();
}
private void Server_ProctoringConfigurationReceived(bool allowChat, bool receiveAudio, bool receiveVideo)
{
foreach (var implementation in implementations)
{
try
{
implementation.ProctoringConfigurationReceived(allowChat, receiveAudio, receiveVideo);
}
catch (Exception e)
{
logger.Error($"Failed to update proctoring configuration for '{implementation.Name}'!", e);
}
}
}
private void Server_ProctoringInstructionReceived(InstructionEventArgs args)
{
foreach (var implementation in implementations)
{
try
{
implementation.ProctoringInstructionReceived(args);
}
catch (Exception e)
{
logger.Error($"Failed to process proctoring instruction for '{implementation.Name}'!", e);
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Collections.Generic;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.I18n.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Monitoring.Contracts.Applications;
using SafeExamBrowser.Proctoring.ScreenProctoring;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.SystemComponents.Contracts;
using SafeExamBrowser.UserInterface.Contracts;
using SafeExamBrowser.WindowsApi.Contracts;
namespace SafeExamBrowser.Proctoring
{
internal class ProctoringFactory
{
private readonly AppConfig appConfig;
private readonly IApplicationMonitor applicationMonitor;
private readonly IBrowserApplication browser;
private readonly IFileSystem fileSystem;
private readonly IModuleLogger logger;
private readonly INativeMethods nativeMethods;
private readonly IText text;
private readonly IUserInterfaceFactory uiFactory;
public ProctoringFactory(
AppConfig appConfig,
IApplicationMonitor applicationMonitor,
IBrowserApplication browser,
IFileSystem fileSystem,
IModuleLogger logger,
INativeMethods nativeMethods,
IText text,
IUserInterfaceFactory uiFactory)
{
this.appConfig = appConfig;
this.applicationMonitor = applicationMonitor;
this.browser = browser;
this.fileSystem = fileSystem;
this.logger = logger;
this.nativeMethods = nativeMethods;
this.text = text;
this.uiFactory = uiFactory;
}
internal IEnumerable<ProctoringImplementation> CreateAllActive(ProctoringSettings settings)
{
var implementations = new List<ProctoringImplementation>();
if (settings.ScreenProctoring.Enabled)
{
var logger = this.logger.CloneFor(nameof(ScreenProctoring));
var service = new ServiceProxy(logger.CloneFor(nameof(ServiceProxy)));
implementations.Add(new ScreenProctoringImplementation(appConfig, applicationMonitor, browser, logger, nativeMethods, service, settings, text));
}
return implementations;
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2022 ETH Zürich, IT Services
*
* 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 SafeExamBrowser.Core.Contracts.Notifications;
using SafeExamBrowser.Core.Contracts.Notifications.Events;
using SafeExamBrowser.Core.Contracts.Resources.Icons;
using SafeExamBrowser.Proctoring.Contracts.Events;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
namespace SafeExamBrowser.Proctoring
{
internal abstract class ProctoringImplementation : INotification
{
internal abstract string Name { get; }
public bool CanActivate { get; protected set; }
public string Tooltip { get; protected set; }
public IconResource IconResource { get; protected set; }
internal event RemainingWorkUpdatedEventHandler RemainingWorkUpdated;
public event NotificationChangedEventHandler NotificationChanged;
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(InstructionEventArgs args);
internal abstract void Start();
internal abstract void Stop();
internal abstract void Terminate();
internal virtual void ExecuteRemainingWork() { }
internal virtual bool HasRemainingWork() => false;
protected virtual void ActivateNotification() { }
protected virtual void TerminateNotification() { }
protected void InvokeNotificationChanged()
{
NotificationChanged?.Invoke();
}
protected void InvokeRemainingWorkUpdated(RemainingWorkUpdatedEventArgs args)
{
RemainingWorkUpdated?.Invoke(args);
}
}
}

View File

@@ -0,0 +1,32 @@
using System.Reflection;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("SafeExamBrowser.Proctoring")]
[assembly: AssemblyDescription("Safe Exam Browser")]
[assembly: AssemblyCompany("ETH Zürich")]
[assembly: AssemblyProduct("SafeExamBrowser.Proctoring")]
[assembly: AssemblyCopyright("Copyright © 2024 ETH Zürich, IT Services")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("3f1f262e-a07c-4513-83c6-d7ef2f203ebf")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0.0")]

View File

@@ -0,0 +1,173 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{3F1F262E-A07C-4513-83C6-D7EF2F203EBF}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>SafeExamBrowser.Proctoring</RootNamespace>
<AssemblyName>SafeExamBrowser.Proctoring</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
<TargetFrameworkProfile />
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\x64\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<PlatformTarget>x64</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x64'">
<OutputPath>bin\x64\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<PlatformTarget>x64</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
<DebugSymbols>true</DebugSymbols>
<OutputPath>bin\x86\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<DebugType>full</DebugType>
<PlatformTarget>x86</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
<OutputPath>bin\x86\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<Optimize>true</Optimize>
<DebugType>pdbonly</DebugType>
<PlatformTarget>x86</PlatformTarget>
<ErrorReport>prompt</ErrorReport>
<CodeAnalysisRuleSet>MinimumRecommendedRules.ruleset</CodeAnalysisRuleSet>
</PropertyGroup>
<ItemGroup>
<Reference Include="KGySoft.CoreLibraries, Version=8.1.0.0, Culture=neutral, PublicKeyToken=b45eba277439ddfe, processorArchitecture=MSIL">
<HintPath>..\packages\KGySoft.CoreLibraries.8.1.0\lib\net472\KGySoft.CoreLibraries.dll</HintPath>
</Reference>
<Reference Include="KGySoft.Drawing, Version=8.1.0.0, Culture=neutral, PublicKeyToken=b45eba277439ddfe, processorArchitecture=MSIL">
<HintPath>..\packages\KGySoft.Drawing.8.1.0\lib\net46\KGySoft.Drawing.dll</HintPath>
</Reference>
<Reference Include="KGySoft.Drawing.Core, Version=8.1.0.0, Culture=neutral, PublicKeyToken=b45eba277439ddfe, processorArchitecture=MSIL">
<HintPath>..\packages\KGySoft.Drawing.Core.8.1.0\lib\net46\KGySoft.Drawing.Core.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Drawing" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Windows.Forms" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="WindowsBase" />
</ItemGroup>
<ItemGroup>
<Compile Include="ProctoringController.cs" />
<Compile Include="ProctoringFactory.cs" />
<Compile Include="ProctoringImplementation.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="ScreenProctoring\Buffer.cs" />
<Compile Include="ScreenProctoring\Cache.cs" />
<Compile Include="ScreenProctoring\Data\IntervalTrigger.cs" />
<Compile Include="ScreenProctoring\Data\KeyboardTrigger.cs" />
<Compile Include="ScreenProctoring\Data\MetaData.cs" />
<Compile Include="ScreenProctoring\Data\MouseTrigger.cs" />
<Compile Include="ScreenProctoring\Events\DataCollectedEventHandler.cs" />
<Compile Include="ScreenProctoring\Imaging\Extensions.cs" />
<Compile Include="ScreenProctoring\Data\MetaDataAggregator.cs" />
<Compile Include="ScreenProctoring\Imaging\ProcessingOrder.cs" />
<Compile Include="ScreenProctoring\Imaging\ScreenShot.cs" />
<Compile Include="ScreenProctoring\Imaging\ScreenShotProcessor.cs" />
<Compile Include="ScreenProctoring\ScreenProctoringImplementation.cs" />
<Compile Include="ScreenProctoring\Service\Api.cs" />
<Compile Include="ScreenProctoring\DataCollector.cs" />
<Compile Include="ScreenProctoring\Service\Parser.cs" />
<Compile Include="ScreenProctoring\Service\Requests\ContentType.cs" />
<Compile Include="ScreenProctoring\Service\Requests\CreateSessionRequest.cs" />
<Compile Include="ScreenProctoring\Service\Requests\Header.cs" />
<Compile Include="ScreenProctoring\Service\Requests\Extensions.cs" />
<Compile Include="ScreenProctoring\Service\Requests\HealthRequest.cs" />
<Compile Include="ScreenProctoring\Service\Requests\TerminateSessionRequest.cs" />
<Compile Include="ScreenProctoring\Service\Requests\OAuth2TokenRequest.cs" />
<Compile Include="ScreenProctoring\Service\Requests\Request.cs" />
<Compile Include="ScreenProctoring\Service\Requests\ScreenShotRequest.cs" />
<Compile Include="ScreenProctoring\Service\ServiceProxy.cs" />
<Compile Include="ScreenProctoring\Service\ServiceResponse.cs" />
<Compile Include="ScreenProctoring\TransmissionSpooler.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SafeExamBrowser.Applications.Contracts\SafeExamBrowser.Applications.Contracts.csproj">
<Project>{ac77745d-3b41-43e2-8e84-d40e5a4ee77f}</Project>
<Name>SafeExamBrowser.Applications.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Browser.Contracts\SafeExamBrowser.Browser.Contracts.csproj">
<Project>{5FB5273D-277C-41DD-8593-A25CE1AFF2E9}</Project>
<Name>SafeExamBrowser.Browser.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Configuration.Contracts\SafeExamBrowser.Configuration.Contracts.csproj">
<Project>{7d74555e-63e1-4c46-bd0a-8580552368c8}</Project>
<Name>SafeExamBrowser.Configuration.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Core.Contracts\SafeExamBrowser.Core.Contracts.csproj">
<Project>{fe0e1224-b447-4b14-81e7-ed7d84822aa0}</Project>
<Name>SafeExamBrowser.Core.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.I18n.Contracts\SafeExamBrowser.I18n.Contracts.csproj">
<Project>{1858ddf3-bc2a-4bff-b663-4ce2ffeb8b7d}</Project>
<Name>SafeExamBrowser.I18n.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Logging.Contracts\SafeExamBrowser.Logging.Contracts.csproj">
<Project>{64ea30fb-11d4-436a-9c2b-88566285363e}</Project>
<Name>SafeExamBrowser.Logging.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Monitoring.Contracts\SafeExamBrowser.Monitoring.Contracts.csproj">
<Project>{6D563A30-366D-4C35-815B-2C9E6872278B}</Project>
<Name>SafeExamBrowser.Monitoring.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Proctoring.Contracts\SafeExamBrowser.Proctoring.Contracts.csproj">
<Project>{8e52bd1c-0540-4f16-b181-6665d43f7a7b}</Project>
<Name>SafeExamBrowser.Proctoring.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Server.Contracts\SafeExamBrowser.Server.Contracts.csproj">
<Project>{db701e6f-bddc-4cec-b662-335a9dc11809}</Project>
<Name>SafeExamBrowser.Server.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.Settings\SafeExamBrowser.Settings.csproj">
<Project>{30b2d907-5861-4f39-abad-c4abf1b3470e}</Project>
<Name>SafeExamBrowser.Settings</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.SystemComponents.Contracts\SafeExamBrowser.SystemComponents.Contracts.csproj">
<Project>{903129c6-e236-493b-9ad6-c6a57f647a3a}</Project>
<Name>SafeExamBrowser.SystemComponents.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.UserInterface.Contracts\SafeExamBrowser.UserInterface.Contracts.csproj">
<Project>{c7889e97-6ff6-4a58-b7cb-521ed276b316}</Project>
<Name>SafeExamBrowser.UserInterface.Contracts</Name>
</ProjectReference>
<ProjectReference Include="..\SafeExamBrowser.WindowsApi.Contracts\SafeExamBrowser.WindowsApi.Contracts.csproj">
<Project>{7016F080-9AA5-41B2-A225-385AD877C171}</Project>
<Name>SafeExamBrowser.WindowsApi.Contracts</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@@ -0,0 +1,92 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Linq;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
internal class Buffer
{
private readonly object @lock = new object();
private readonly List<(MetaData metaData, DateTime schedule, ScreenShot screenShot)> list;
private readonly ILogger logger;
internal int Count
{
get
{
lock (@lock)
{
return list.Count;
}
}
}
internal Buffer(ILogger logger)
{
this.list = new List<(MetaData, DateTime, ScreenShot)>();
this.logger = logger;
}
internal bool Any()
{
lock (@lock)
{
return list.Any();
}
}
internal void Dequeue()
{
lock (@lock)
{
if (list.Any())
{
var (_, schedule, screenShot) = list.First();
list.RemoveAt(0);
logger.Debug($"Removed data for '{screenShot.CaptureTime:HH:mm:ss} -> {schedule:HH:mm:ss}', {Count} item(s) remaining.");
}
}
}
internal void Enqueue(MetaData metaData, DateTime schedule, ScreenShot screenShot)
{
lock (@lock)
{
list.Add((metaData, schedule, screenShot));
list.Sort((a, b) => DateTime.Compare(a.schedule, b.schedule));
logger.Debug($"Buffered data for '{screenShot.CaptureTime:HH:mm:ss} -> {schedule:HH:mm:ss}', now holding {Count} item(s).");
}
}
internal bool TryPeek(out MetaData metaData, out DateTime schedule, out ScreenShot screenShot)
{
lock (@lock)
{
metaData = default;
schedule = default;
screenShot = default;
if (list.Any())
{
(metaData, schedule, screenShot) = list.First();
}
return metaData != default && screenShot != default;
}
}
}
}

View File

@@ -0,0 +1,241 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Concurrent;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
using SafeExamBrowser.Settings.Proctoring;
namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
internal class Cache
{
private const string DATA_FILE_EXTENSION = "xml";
private readonly AppConfig appConfig;
private readonly ILogger logger;
private readonly ConcurrentQueue<(string fileName, int checksum, string hash)> queue;
internal int Count => queue.Count;
internal string Directory { get; private set; }
public Cache(AppConfig appConfig, ILogger logger)
{
this.appConfig = appConfig;
this.logger = logger;
this.queue = new ConcurrentQueue<(string, int, string)>();
}
internal bool Any()
{
return queue.Any();
}
internal bool TryEnqueue(MetaData metaData, ScreenShot screenShot)
{
var fileName = $"{screenShot.CaptureTime:yyyy-MM-dd HH\\hmm\\mss\\sfff\\m\\s}";
var success = false;
try
{
InitializeDirectory();
SaveData(fileName, metaData, screenShot);
SaveImage(fileName, screenShot);
Enqueue(fileName, metaData, screenShot);
success = true;
logger.Debug($"Cached data for '{fileName}', now holding {Count} item(s).");
}
catch (Exception e)
{
logger.Error($"Failed to cache data for '{fileName}'!", e);
}
return success;
}
internal bool TryDequeue(out MetaData metaData, out ScreenShot screenShot)
{
var success = false;
metaData = default;
screenShot = default;
if (queue.Any() && queue.TryPeek(out var item))
{
try
{
LoadData(item.fileName, out metaData, out screenShot);
LoadImage(item.fileName, screenShot);
Dequeue(item.fileName, item.checksum, item.hash, metaData, screenShot);
success = true;
logger.Debug($"Removed data for '{item.fileName}', {Count} item(s) remaining.");
}
catch (Exception e)
{
logger.Error($"Failed to remove data for '{item.fileName}'!", e);
}
}
return success;
}
private void Dequeue(string fileName, int checksum, string hash, MetaData metaData, ScreenShot screenShot)
{
var dataPath = Path.Combine(Directory, $"{fileName}.{DATA_FILE_EXTENSION}");
var extension = screenShot.Format.ToString().ToLower();
var imagePath = Path.Combine(Directory, $"{fileName}.{extension}");
if (checksum != GenerateChecksum(screenShot))
{
logger.Warn($"The checksum for '{fileName}' does not match, the image data may be manipulated!");
}
if (hash != GenerateHash(metaData, screenShot))
{
logger.Warn($"The hash for '{fileName}' does not match, the metadata may be manipulated!");
}
File.Delete(dataPath);
File.Delete(imagePath);
while (!queue.TryDequeue(out _)) ;
}
private void Enqueue(string fileName, MetaData metaData, ScreenShot screenShot)
{
var checksum = GenerateChecksum(screenShot);
var hash = GenerateHash(metaData, screenShot);
queue.Enqueue((fileName, checksum, hash));
}
private int GenerateChecksum(ScreenShot screenShot)
{
var checksum = default(int);
foreach (var data in screenShot.Data)
{
unchecked
{
checksum += data;
}
}
return checksum;
}
private string GenerateHash(MetaData metaData, ScreenShot screenShot)
{
var hash = default(string);
using (var algorithm = new SHA256Managed())
{
var input = metaData.ToJson() + screenShot.CaptureTime + screenShot.Format + screenShot.Height + screenShot.Width;
var bytes = Encoding.UTF8.GetBytes(input);
var result = algorithm.ComputeHash(bytes);
hash = string.Join(string.Empty, result.Select(b => $"{b:x2}"));
}
return hash;
}
private void InitializeDirectory()
{
if (Directory == default)
{
Directory = Path.Combine(appConfig.TemporaryDirectory, nameof(ScreenProctoring));
}
if (!System.IO.Directory.Exists(Directory))
{
System.IO.Directory.CreateDirectory(Directory);
logger.Debug($"Created caching directory '{Directory}'.");
}
}
private void LoadData(string fileName, out MetaData metaData, out ScreenShot screenShot)
{
var dataPath = Path.Combine(Directory, $"{fileName}.{DATA_FILE_EXTENSION}");
var document = XDocument.Load(dataPath);
var xml = document.Descendants(nameof(MetaData)).First();
metaData = new MetaData();
screenShot = new ScreenShot();
metaData.ApplicationInfo = xml.Descendants(nameof(MetaData.ApplicationInfo)).First().Value;
metaData.BrowserInfo = xml.Descendants(nameof(MetaData.BrowserInfo)).First().Value;
metaData.Elapsed = TimeSpan.Parse(xml.Descendants(nameof(MetaData.Elapsed)).First().Value);
metaData.TriggerInfo = xml.Descendants(nameof(MetaData.TriggerInfo)).First().Value;
metaData.Urls = xml.Descendants(nameof(MetaData.Urls)).First().Value;
metaData.WindowTitle = xml.Descendants(nameof(MetaData.WindowTitle)).First().Value;
xml = document.Descendants(nameof(ScreenShot)).First();
screenShot.CaptureTime = DateTime.Parse(xml.Descendants(nameof(ScreenShot.CaptureTime)).First().Value);
screenShot.Format = (ImageFormat) Enum.Parse(typeof(ImageFormat), xml.Descendants(nameof(ScreenShot.Format)).First().Value);
screenShot.Height = int.Parse(xml.Descendants(nameof(ScreenShot.Height)).First().Value);
screenShot.Width = int.Parse(xml.Descendants(nameof(ScreenShot.Width)).First().Value);
}
private void LoadImage(string fileName, ScreenShot screenShot)
{
var extension = screenShot.Format.ToString().ToLower();
var imagePath = Path.Combine(Directory, $"{fileName}.{extension}");
screenShot.Data = File.ReadAllBytes(imagePath);
}
private void SaveData(string fileName, MetaData metaData, ScreenShot screenShot)
{
var data = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
var dataPath = Path.Combine(Directory, $"{fileName}.{DATA_FILE_EXTENSION}");
data.Add(
new XElement("Data",
new XElement(nameof(MetaData),
new XElement(nameof(MetaData.ApplicationInfo), metaData.ApplicationInfo),
new XElement(nameof(MetaData.BrowserInfo), metaData.BrowserInfo),
new XElement(nameof(MetaData.Elapsed), metaData.Elapsed.ToString()),
new XElement(nameof(MetaData.TriggerInfo), metaData.TriggerInfo),
new XElement(nameof(MetaData.Urls), metaData.Urls),
new XElement(nameof(MetaData.WindowTitle), metaData.WindowTitle)
),
new XElement(nameof(ScreenShot),
new XElement(nameof(ScreenShot.CaptureTime), screenShot.CaptureTime.ToString()),
new XElement(nameof(ScreenShot.Format), screenShot.Format),
new XElement(nameof(ScreenShot.Height), screenShot.Height),
new XElement(nameof(ScreenShot.Width), screenShot.Width)
)
)
);
data.Save(dataPath);
}
private void SaveImage(string fileName, ScreenShot screenShot)
{
var extension = screenShot.Format.ToString().ToLower();
var imagePath = Path.Combine(Directory, $"{fileName}.{extension}");
File.WriteAllBytes(imagePath, screenShot.Data);
}
}
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Data
{
internal class IntervalTrigger
{
public int ConfigurationValue { get; internal set; }
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Windows.Input;
using SafeExamBrowser.WindowsApi.Contracts.Events;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Data
{
internal class KeyboardTrigger
{
public Key Key { get; internal set; }
public KeyModifier Modifier { get; internal set; }
public KeyState State { get; internal set; }
}
}

View File

@@ -0,0 +1,171 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Linq;
using System.Text;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Monitoring.Contracts.Applications;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.WindowsApi.Contracts.Events;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Data
{
internal class MetaDataAggregator
{
private readonly IApplicationMonitor applicationMonitor;
private readonly IBrowserApplication browser;
private readonly ILogger logger;
private readonly MetaDataSettings settings;
private string applicationInfo;
private string browserInfo;
private TimeSpan elapsed;
private string triggerInfo;
private string urls;
private int urlCount;
private string windowTitle;
internal MetaData Data => new MetaData
{
ApplicationInfo = applicationInfo,
BrowserInfo = browserInfo,
Elapsed = elapsed,
TriggerInfo = triggerInfo,
Urls = urls,
WindowTitle = windowTitle
};
internal MetaDataAggregator(
IApplicationMonitor applicationMonitor,
IBrowserApplication browser,
TimeSpan elapsed,
ILogger logger,
MetaDataSettings settings)
{
this.applicationMonitor = applicationMonitor;
this.browser = browser;
this.elapsed = elapsed;
this.logger = logger;
this.settings = settings;
}
internal void Capture(IntervalTrigger interval = default, KeyboardTrigger keyboard = default, MouseTrigger mouse = default)
{
Initialize();
CaptureApplicationData();
CaptureBrowserData();
if (interval != default)
{
CaptureIntervalTrigger(interval);
}
else if (keyboard != default)
{
CaptureKeyboardTrigger(keyboard);
}
else if (mouse != default)
{
CaptureMouseTrigger(mouse);
}
logger.Debug($"Captured metadata: {applicationInfo} / {browserInfo} / {urlCount} URL(s) / {triggerInfo} / {windowTitle}.");
}
private void CaptureApplicationData()
{
if (applicationMonitor.TryGetActiveApplication(out var application))
{
if (settings.CaptureApplicationData)
{
applicationInfo = BuildApplicationInfo(application);
}
if (settings.CaptureWindowTitle)
{
windowTitle = string.IsNullOrEmpty(application.Window.Title) ? "-" : application.Window.Title;
}
}
}
private void CaptureBrowserData()
{
if (settings.CaptureBrowserData)
{
var windows = browser.GetWindows();
browserInfo = string.Join(", ", windows.Select(w => $"{(w.IsMainWindow ? "Main" : "Additional")} Window: {w.Title}"));
urls = string.Join(", ", windows.Select(w => w.Url));
urlCount = windows.Count();
}
}
private void CaptureIntervalTrigger(IntervalTrigger interval)
{
triggerInfo = $"Maximum interval of {interval.ConfigurationValue}ms has been reached.";
}
private void CaptureKeyboardTrigger(KeyboardTrigger keyboard)
{
var flags = Enum.GetValues(typeof(KeyModifier))
.OfType<KeyModifier>()
.Where(m => m != KeyModifier.None && keyboard.Modifier.HasFlag(m) && !keyboard.Key.ToString().Contains(m.ToString()));
var modifiers = flags.Any() ? string.Join(" + ", flags) + " + " : string.Empty;
if (flags.Any())
{
triggerInfo = $"'{modifiers}{keyboard.Key}' has been {keyboard.State.ToString().ToLower()}.";
}
else
{
triggerInfo = $"A key has been {keyboard.State.ToString().ToLower()}.";
}
}
private void CaptureMouseTrigger(MouseTrigger mouse)
{
if (mouse.Info.IsTouch)
{
triggerInfo = $"Tap as {mouse.Button} mouse button has been {mouse.State.ToString().ToLower()} at ({mouse.Info.X}/{mouse.Info.Y}).";
}
else
{
triggerInfo = $"{mouse.Button} mouse button has been {mouse.State.ToString().ToLower()} at ({mouse.Info.X}/{mouse.Info.Y}).";
}
}
private string BuildApplicationInfo(ActiveApplication application)
{
var info = new StringBuilder();
info.Append(application.Process.Name);
if (application.Process.OriginalName != default)
{
info.Append($" ({application.Process.OriginalName}{(application.Process.Signature == default ? ")" : "")}");
}
if (application.Process.Signature != default)
{
info.Append($"{(application.Process.OriginalName == default ? "(" : ", ")}{application.Process.Signature})");
}
return info.ToString();
}
private void Initialize()
{
applicationInfo = "-";
browserInfo = "-";
triggerInfo = "-";
urls = "-";
windowTitle = "-";
}
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Data
{
internal class MetaData
{
internal string ApplicationInfo { get; set; }
internal string BrowserInfo { get; set; }
internal TimeSpan Elapsed { get; set; }
internal string TriggerInfo { get; set; }
internal string Urls { get; set; }
internal string WindowTitle { get; set; }
internal string ToJson()
{
var json = new JObject
{
[Header.Metadata.ApplicationInfo] = ApplicationInfo,
[Header.Metadata.BrowserInfo] = BrowserInfo,
[Header.Metadata.BrowserUrls] = Urls,
[Header.Metadata.TriggerInfo] = TriggerInfo,
[Header.Metadata.WindowTitle] = WindowTitle
};
return json.ToString(Formatting.None);
}
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 SafeExamBrowser.WindowsApi.Contracts.Events;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Data
{
internal class MouseTrigger
{
public MouseButton Button { get; internal set; }
public MouseInformation Info { get; internal set; }
public MouseButtonState State { get; internal set; }
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Threading;
using System.Threading.Tasks;
using System.Timers;
using System.Windows.Input;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Monitoring.Contracts.Applications;
using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
using SafeExamBrowser.Proctoring.ScreenProctoring.Events;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.WindowsApi.Contracts;
using SafeExamBrowser.WindowsApi.Contracts.Events;
using MouseButton = SafeExamBrowser.WindowsApi.Contracts.Events.MouseButton;
using MouseButtonState = SafeExamBrowser.WindowsApi.Contracts.Events.MouseButtonState;
using Timer = System.Timers.Timer;
namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
internal class DataCollector
{
private readonly object @lock = new object();
private readonly IApplicationMonitor applicationMonitor;
private readonly IBrowserApplication browser;
private readonly IModuleLogger logger;
private readonly INativeMethods nativeMethods;
private readonly ScreenProctoringSettings settings;
private readonly Timer timer;
private DateTime last;
private Guid? keyboardHookId;
private Guid? mouseHookId;
internal event DataCollectedEventHandler DataCollected;
internal DataCollector(
IApplicationMonitor applicationMonitor,
IBrowserApplication browser,
IModuleLogger logger,
INativeMethods nativeMethods,
ScreenProctoringSettings settings)
{
this.applicationMonitor = applicationMonitor;
this.browser = browser;
this.logger = logger;
this.nativeMethods = nativeMethods;
this.settings = settings;
this.timer = new Timer();
}
internal void Start()
{
last = DateTime.Now;
keyboardHookId = nativeMethods.RegisterKeyboardHook(KeyboardHookCallback);
mouseHookId = nativeMethods.RegisterMouseHook(MouseHookCallback);
timer.AutoReset = false;
timer.Elapsed += MaxIntervalElapsed;
timer.Interval = settings.MaxInterval;
timer.Start();
logger.Debug("Started.");
}
internal void Stop()
{
last = DateTime.Now;
if (keyboardHookId.HasValue)
{
nativeMethods.DeregisterKeyboardHook(keyboardHookId.Value);
}
if (mouseHookId.HasValue)
{
nativeMethods.DeregisterMouseHook(mouseHookId.Value);
}
keyboardHookId = default;
mouseHookId = default;
timer.Elapsed -= MaxIntervalElapsed;
timer.Stop();
logger.Debug("Stopped.");
}
private bool KeyboardHookCallback(int keyCode, KeyModifier modifier, KeyState state)
{
var trigger = new KeyboardTrigger
{
Key = KeyInterop.KeyFromVirtualKey(keyCode),
Modifier = modifier,
State = state
};
TryCollect(keyboard: trigger);
return false;
}
private void MaxIntervalElapsed(object sender, ElapsedEventArgs args)
{
var trigger = new IntervalTrigger
{
ConfigurationValue = settings.MaxInterval,
};
TryCollect(interval: trigger);
}
private bool MouseHookCallback(MouseButton button, MouseButtonState state, MouseInformation info)
{
var trigger = new MouseTrigger
{
Button = button,
Info = info,
State = state
};
TryCollect(mouse: trigger);
return false;
}
private void TryCollect(IntervalTrigger interval = default, KeyboardTrigger keyboard = default, MouseTrigger mouse = default)
{
if (MinIntervalElapsed() && Monitor.TryEnter(@lock))
{
var elapsed = DateTime.Now.Subtract(last);
last = DateTime.Now;
timer.Stop();
Task.Run(() =>
{
try
{
var metaData = new MetaDataAggregator(applicationMonitor, browser, elapsed, logger.CloneFor(nameof(MetaDataAggregator)), settings.MetaData);
var screenShot = new ScreenShotProcessor(logger.CloneFor(nameof(ScreenShotProcessor)), settings);
metaData.Capture(interval, keyboard, mouse);
screenShot.Take();
screenShot.Compress();
DataCollected?.Invoke(metaData.Data, screenShot.Data);
screenShot.Dispose();
}
catch (Exception e)
{
logger.Error("Failed to execute data collection!", e);
}
});
timer.Start();
Monitor.Exit(@lock);
}
}
private bool MinIntervalElapsed()
{
return DateTime.Now.Subtract(last) >= new TimeSpan(0, 0, 0, 0, settings.MinInterval);
}
}
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 SafeExamBrowser.Proctoring.ScreenProctoring.Data;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Events
{
internal delegate void DataCollectedEventHandler(MetaData metaData, ScreenShot screenShot);
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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!");
}
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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,
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 SafeExamBrowser.Settings.Proctoring;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Imaging
{
internal class ScreenShot : IDisposable
{
internal DateTime CaptureTime { get; set; }
internal byte[] Data { get; set; }
internal ImageFormat Format { get; set; }
internal int Height { get; set; }
internal int Width { get; set; }
public void Dispose()
{
Data = default;
}
public override string ToString()
{
return $"captured: {CaptureTime}, format: {Format.ToString().ToUpper()}, resolution: {Width}x{Height}, size: {Data.Length / 1000:N0}kB";
}
}
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 ScreenShotProcessor : IDisposable
{
private readonly ILogger logger;
private readonly ScreenProctoringSettings settings;
private Bitmap bitmap;
private DateTime captureTime;
private byte[] data;
private ImageFormat format;
private int height;
private int width;
internal ScreenShot Data => new ScreenShot
{
CaptureTime = captureTime,
Data = data,
Format = format,
Height = height,
Width = width
};
public ScreenShotProcessor(ILogger logger, ScreenProctoringSettings settings)
{
this.logger = logger;
this.settings = settings;
}
public void Dispose()
{
bitmap?.Dispose();
bitmap = default;
data = default;
}
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);
captureTime = DateTime.Now;
format = settings.ImageFormat;
this.height = height;
this.width = width;
using (var graphics = Graphics.FromImage(bitmap))
{
graphics.CopyFromScreen(x, y, 0, 0, new Size(width, height), CopyPixelOperation.SourceCopy);
graphics.DrawCursorPosition();
}
Serialize();
logger.Debug($"Captured '{ToReducedString()}' at {captureTime}.");
}
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);
}
private string ToReducedString()
{
return $"{width}x{height}, {data.Length / 1000:N0}kB, {format.ToString().ToUpper()}";
}
}
}

View File

@@ -0,0 +1,212 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Core.Contracts.Resources.Icons;
using SafeExamBrowser.I18n.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Monitoring.Contracts.Applications;
using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service;
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
using SafeExamBrowser.Settings.Proctoring;
using SafeExamBrowser.WindowsApi.Contracts;
namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
internal class ScreenProctoringImplementation : ProctoringImplementation
{
private readonly DataCollector collector;
private readonly IModuleLogger logger;
private readonly ServiceProxy service;
private readonly ScreenProctoringSettings settings;
private readonly TransmissionSpooler spooler;
private readonly IText text;
internal override string Name => nameof(ScreenProctoring);
internal ScreenProctoringImplementation(
AppConfig appConfig,
IApplicationMonitor applicationMonitor,
IBrowserApplication browser,
IModuleLogger logger,
INativeMethods nativeMethods,
ServiceProxy service,
ProctoringSettings settings,
IText text)
{
this.collector = new DataCollector(applicationMonitor, browser, logger.CloneFor(nameof(DataCollector)), nativeMethods, settings.ScreenProctoring);
this.logger = logger;
this.service = service;
this.settings = settings.ScreenProctoring;
this.spooler = new TransmissionSpooler(appConfig, logger.CloneFor(nameof(TransmissionSpooler)), service);
this.text = text;
}
internal override void ExecuteRemainingWork()
{
logger.Info("Starting execution of remaining work...");
spooler.ExecuteRemainingWork(InvokeRemainingWorkUpdated);
logger.Info("Terminated execution of remaining work.");
}
internal override bool HasRemainingWork()
{
var hasWork = spooler.HasRemainingWork();
if (hasWork)
{
logger.Info("There is remaining work to be done.");
}
else
{
logger.Info("There is no remaining work to be done.");
}
return hasWork;
}
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);
if (start)
{
logger.Info($"Initialized proctoring: All settings are valid, starting automatically...");
Connect();
Start();
}
else
{
UpdateNotification(false);
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()
{
collector.DataCollected += Collector_DataCollected;
collector.Start();
spooler.Start();
UpdateNotification(true);
logger.Info($"Started proctoring.");
}
internal override void Stop()
{
collector.Stop();
collector.DataCollected -= Collector_DataCollected;
spooler.Stop();
TerminateSession();
UpdateNotification(false);
logger.Info("Stopped proctoring.");
}
internal override void Terminate()
{
Stop();
logger.Info("Terminated proctoring.");
}
private void Collector_DataCollected(MetaData metaData, ScreenShot screenShot)
{
spooler.Add(metaData, screenShot);
}
private void Connect(string sessionId = default)
{
logger.Info("Connecting to service...");
var connect = service.Connect(settings.ClientId, settings.ClientSecret, settings.ServiceUrl);
if (connect.Success)
{
if (sessionId == default)
{
logger.Info("Creating session...");
service.CreateSession(settings.GroupId);
}
else
{
service.SessionId = sessionId;
}
}
}
private void TerminateSession()
{
if (service.IsConnected)
{
logger.Info("Terminating session...");
service.TerminateSession();
}
}
private void UpdateNotification(bool active)
{
CanActivate = false;
if (active)
{
IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ScreenProctoring_Active.xaml") };
Tooltip = text.Get(TextKey.Notification_ProctoringActiveTooltip);
}
else
{
IconResource = new XamlIconResource { Uri = new Uri("pack://application:,,,/SafeExamBrowser.UserInterface.Desktop;component/Images/ScreenProctoring_Inactive.xaml") };
Tooltip = text.Get(TextKey.Notification_ProctoringInactiveTooltip);
}
InvokeNotificationChanged();
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 HealthEndpoint { get; set; }
internal string ScreenShotEndpoint { get; set; }
internal string SessionEndpoint { get; set; }
internal Api()
{
AccessTokenEndpoint = "/oauth/token";
HealthEndpoint = "/health";
ScreenShotEndpoint = $"/seb-api/v1/session/{SESSION_ID}/screenshot";
SessionEndpoint = "/seb-api/v1/session";
}
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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<string>();
isExpired = error?.Equals("invalid_token", StringComparison.OrdinalIgnoreCase) == true;
}
catch (Exception e)
{
logger.Error("Failed to parse token expiration content!", e);
}
return isExpired;
}
internal bool TryParseHealth(HttpResponseMessage response, out int health)
{
var success = false;
health = default;
try
{
if (response.Headers.TryGetValues(Header.HEALTH, out var values))
{
success = int.TryParse(values.First(), out health);
}
}
catch (Exception e)
{
logger.Error("Failed to parse health!", e);
}
return success;
}
internal bool TryParseOauth2Token(HttpContent content, out string oauth2Token)
{
oauth2Token = default;
try
{
var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
oauth2Token = json["access_token"].Value<string>();
}
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();
}
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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";
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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;
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 System.Text;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
{
internal static class Extensions
{
internal static string ToLogString(this HttpResponseMessage response)
{
return response == default ? "No Response" : $"{(int) response.StatusCode} {response.StatusCode} {response.ReasonPhrase}";
}
internal static string ToSummary(this Exception exception)
{
var trimChars = new[] { '.', '!' };
var summary = new StringBuilder(exception.Message?.TrimEnd(trimChars));
for (var inner = exception.InnerException; inner != default; inner = inner.InnerException)
{
summary.Append($" -> {inner.Message?.TrimEnd(trimChars)}");
}
return summary.ToString();
}
internal static long ToUnixTimestamp(this DateTime date)
{
return new DateTimeOffset(date).ToUnixTimeMilliseconds();
}
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 HEALTH = "sps_server_health";
internal const string IMAGE_FORMAT = "imageFormat";
internal const string METADATA = "metaData";
internal const string SESSION_ID = "SEB_SESSION_UUID";
internal const string TIMESTAMP = "timestamp";
internal static class Metadata
{
internal const string ApplicationInfo = "screenProctoringMetadataApplication";
internal const string BrowserInfo = "screenProctoringMetadataBrowser";
internal const string BrowserUrls = "screenProctoringMetadataURL";
internal const string TriggerInfo = "screenProctoringMetadataUserAction";
internal const string WindowTitle = "screenProctoringMetadataWindowTitle";
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 HealthRequest : Request
{
internal HealthRequest(Api api, HttpClient httpClient, ILogger logger, Parser parser) : base(api, httpClient, logger, parser)
{
}
internal bool TryExecute(out int health, out string message)
{
var url = api.HealthEndpoint;
var success = TryExecute(HttpMethod.Get, url, out var response);
health = default;
message = response.ToLogString();
if (success)
{
parser.TryParseHealth(response, out health);
}
return success;
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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(string clientId, string clientSecret, out string message)
{
ClientId = clientId;
ClientSecret = clientSecret;
return TryRetrieveOAuth2Token(out message);
}
}
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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 oauth2Token;
private readonly HttpClient httpClient;
private bool hadException;
protected readonly Api api;
protected readonly ILogger logger;
protected readonly Parser parser;
protected static string ClientId { get; set; }
protected static string ClientSecret { get; set; }
protected (string, string) Authorization => (Header.AUTHORIZATION, $"Bearer {oauth2Token}");
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.Warn($"Request {request.Method} '{request.RequestUri}' did not complete within {httpClient.Timeout}!");
break;
}
catch (Exception e)
{
if (IsFirstException())
{
logger.Warn($"Request {request.Method} '{request.RequestUri}' has failed: {e.ToSummary()}!");
}
}
}
return response != default && response.IsSuccessStatusCode;
}
protected bool TryRetrieveOAuth2Token(out string message)
{
var secret = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{ClientId}:{ClientSecret}"));
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 bool IsFirstException()
{
var isFirst = !hadException;
hadException = true;
return isFirst;
}
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();
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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;
using System.Net.Http;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
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(MetaData metaData, ScreenShot screenShot, string sessionId, out int health, out string message)
{
var imageFormat = (Header.IMAGE_FORMAT, ToString(screenShot.Format));
var metdataJson = (Header.METADATA, WebUtility.UrlEncode(metaData.ToJson()));
var timestamp = (Header.TIMESTAMP, screenShot.CaptureTime.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, metdataJson, timestamp);
health = default;
message = response.ToLogString();
if (success)
{
parser.TryParseHealth(response, out health);
}
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!");
}
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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;
}
}
}

View File

@@ -0,0 +1,129 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Data;
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 clientId, string clientSecret, string serviceUrl)
{
httpClient = new HttpClient
{
BaseAddress = new Uri(serviceUrl),
Timeout = TimeSpan.FromSeconds(10)
};
var request = new OAuth2TokenRequest(api, httpClient, logger, parser);
var success = request.TryExecute(clientId, clientSecret, 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<int> GetHealth()
{
var request = new HealthRequest(api, httpClient, logger, parser);
var success = request.TryExecute(out var health, out var message);
if (success)
{
logger.Info($"Successfully queried health (value: {health}).");
}
else
{
logger.Warn("Failed to query health!");
}
return new ServiceResponse<int>(success, health, message);
}
internal ServiceResponse<int> Send(MetaData metaData, ScreenShot screenShot)
{
var request = new ScreenShotRequest(api, httpClient, logger, parser);
var success = request.TryExecute(metaData, screenShot, SessionId, out var health, out var message);
if (success)
{
logger.Info($"Successfully sent screen shot ({screenShot}).");
}
else
{
logger.Error("Failed to send screen shot!");
}
return new ServiceResponse<int>(success, health, 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);
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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<T> : ServiceResponse
{
internal T Value { get; }
internal ServiceResponse(bool success, T value, string message = default) : base(success, message)
{
Value = value;
}
}
}

View File

@@ -0,0 +1,421 @@
/*
* Copyright (c) 2024 ETH Zürich, IT Services
*
* 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.Concurrent;
using System.Threading;
using System.Timers;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.Contracts.Events;
using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service;
using Timer = System.Timers.Timer;
namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
internal class TransmissionSpooler
{
private const int BAD = 10;
private const int GOOD = 0;
private readonly Buffer buffer;
private readonly Cache cache;
private readonly ILogger logger;
private readonly ConcurrentQueue<(MetaData metaData, ScreenShot screenShot)> queue;
private readonly Random random;
private readonly ServiceProxy service;
private readonly Timer timer;
private int health;
private bool networkIssue;
private bool recovering;
private DateTime resume;
private Thread thread;
private CancellationTokenSource token;
internal TransmissionSpooler(AppConfig appConfig, IModuleLogger logger, ServiceProxy service)
{
this.buffer = new Buffer(logger.CloneFor(nameof(Buffer)));
this.cache = new Cache(appConfig, logger.CloneFor(nameof(Cache)));
this.logger = logger;
this.queue = new ConcurrentQueue<(MetaData, ScreenShot)>();
this.random = new Random();
this.service = service;
this.timer = new Timer();
}
internal void Add(MetaData metaData, ScreenShot screenShot)
{
queue.Enqueue((metaData, screenShot));
}
internal void ExecuteRemainingWork(Action<RemainingWorkUpdatedEventArgs> updateStatus)
{
var previous = buffer.Count + cache.Count;
var progress = 0;
var total = previous;
while (HasRemainingWork() && service.IsConnected && (!networkIssue || recovering || DateTime.Now < resume))
{
var remaining = buffer.Count + cache.Count;
if (total < remaining)
{
total = remaining;
}
else if (previous < remaining)
{
total += remaining - previous;
}
previous = remaining;
progress = total - remaining;
updateStatus(new RemainingWorkUpdatedEventArgs
{
IsWaiting = recovering || networkIssue,
Next = buffer.TryPeek(out _, out var schedule, out _) ? schedule : default(DateTime?),
Progress = progress,
Resume = resume,
Total = total
});
Thread.Sleep(100);
}
if (networkIssue)
{
updateStatus(new RemainingWorkUpdatedEventArgs { HasFailed = true, CachePath = cache.Directory });
}
else
{
updateStatus(new RemainingWorkUpdatedEventArgs { IsFinished = true });
}
}
internal bool HasRemainingWork()
{
return buffer.Any() || cache.Any();
}
internal void Start()
{
const int FIFTEEN_SECONDS = 15000;
logger.Debug("Starting...");
health = GOOD;
recovering = false;
resume = default;
token = new CancellationTokenSource();
thread = new Thread(Execute);
thread.IsBackground = true;
thread.Start();
timer.AutoReset = false;
timer.Elapsed += Timer_Elapsed;
timer.Interval = FIFTEEN_SECONDS;
}
internal void Stop()
{
const int TEN_SECONDS = 10000;
if (thread != default)
{
logger.Debug("Stopping...");
timer.Stop();
timer.Elapsed -= Timer_Elapsed;
try
{
token.Cancel();
}
catch (Exception e)
{
logger.Error("Failed to initiate execution cancellation!", e);
}
try
{
if (!thread.Join(TEN_SECONDS))
{
thread.Abort();
logger.Warn($"Aborted execution since stopping gracefully within {TEN_SECONDS / 1000} seconds failed!");
}
}
catch (Exception e)
{
logger.Error("Failed to stop!", e);
}
recovering = false;
resume = default;
thread = default;
token = default;
}
}
private void Execute()
{
logger.Debug("Ready.");
while (!token.IsCancellationRequested)
{
if (health == BAD)
{
ExecuteCaching();
}
else if (recovering)
{
ExecuteRecovery();
}
else if (health == GOOD)
{
ExecuteNormally();
}
else
{
ExecuteDeferred();
}
Thread.Sleep(50);
}
logger.Debug("Stopped.");
}
private void ExecuteCaching()
{
CacheFromBuffer();
CacheFromQueue();
}
private void ExecuteDeferred()
{
BufferFromCache();
BufferFromQueue();
TransmitFromBuffer();
}
private void ExecuteNormally()
{
TransmitFromBuffer();
TransmitFromCache();
TransmitFromQueue();
}
private void ExecuteRecovery()
{
recovering = DateTime.Now < resume;
if (recovering)
{
CacheFromQueue();
}
else
{
timer.Stop();
logger.Info($"Recovery terminated, deactivating local caching and resuming transmission.");
}
}
private void BufferFromCache()
{
if (cache.TryDequeue(out var metaData, out var screenShot))
{
buffer.Enqueue(metaData, CalculateSchedule(metaData), screenShot);
}
}
private void BufferFromQueue()
{
if (TryDequeue(out var metaData, out var screenShot))
{
buffer.Enqueue(metaData, CalculateSchedule(metaData), screenShot);
}
}
private void CacheFromBuffer()
{
if (buffer.TryPeek(out var metaData, out _, out var screenShot) && cache.TryEnqueue(metaData, screenShot))
{
buffer.Dequeue();
screenShot.Dispose();
}
}
private void CacheFromQueue()
{
if (TryDequeue(out var metaData, out var screenShot))
{
if (cache.TryEnqueue(metaData, screenShot))
{
screenShot.Dispose();
}
else
{
buffer.Enqueue(metaData, CalculateSchedule(metaData), screenShot);
}
}
}
private DateTime CalculateSchedule(MetaData metaData)
{
var timeout = (health + 1) * metaData.Elapsed.TotalMilliseconds;
var schedule = DateTime.Now.AddMilliseconds(timeout);
return schedule;
}
private void TransmitFromBuffer()
{
var hasItem = buffer.TryPeek(out var metaData, out var schedule, out var screenShot);
var ready = schedule <= DateTime.Now;
if (hasItem && ready)
{
buffer.Dequeue();
if (TryTransmit(metaData, screenShot))
{
screenShot.Dispose();
}
else
{
buffer.Enqueue(metaData, CalculateSchedule(metaData), screenShot);
}
}
}
private void TransmitFromCache()
{
if (cache.TryDequeue(out var metaData, out var screenShot))
{
if (TryTransmit(metaData, screenShot))
{
screenShot.Dispose();
}
else
{
buffer.Enqueue(metaData, CalculateSchedule(metaData), screenShot);
}
}
}
private void TransmitFromQueue()
{
if (TryDequeue(out var metaData, out var screenShot))
{
if (TryTransmit(metaData, screenShot))
{
screenShot.Dispose();
}
else
{
buffer.Enqueue(metaData, CalculateSchedule(metaData), screenShot);
}
}
}
private bool TryDequeue(out MetaData metaData, out ScreenShot screenShot)
{
metaData = default;
screenShot = default;
if (queue.TryDequeue(out var item))
{
metaData = item.metaData;
screenShot = item.screenShot;
}
return metaData != default && screenShot != default;
}
private bool TryTransmit(MetaData metaData, ScreenShot screenShot)
{
var success = false;
if (service.IsConnected)
{
var response = service.Send(metaData, screenShot);
var value = response.Success ? response.Value : BAD;
health = UpdateHealth(value);
networkIssue = !response.Success;
success = response.Success;
}
else
{
logger.Warn("Cannot send screen shot as service is disconnected!");
}
return success;
}
private void Timer_Elapsed(object sender, ElapsedEventArgs e)
{
if (service.IsConnected)
{
var response = service.GetHealth();
var value = response.Success ? response.Value : BAD;
health = UpdateHealth(value);
networkIssue = !response.Success;
}
else
{
logger.Warn("Cannot query health as service is disconnected!");
}
if (!timer.Enabled)
{
timer.Start();
}
}
private int UpdateHealth(int value)
{
const int THREE_MINUTES = 180;
var previous = health;
var current = value > BAD ? BAD : (value < GOOD ? GOOD : value);
if (previous != current)
{
logger.Info($"Service health {(previous < current ? "deteriorated" : "improved")} from {previous} to {current}.");
if (current == BAD)
{
recovering = false;
resume = DateTime.Now.AddSeconds(random.Next(0, THREE_MINUTES));
if (!timer.Enabled)
{
timer.Start();
}
logger.Warn($"Activating local caching and suspending transmission due to bad service health (resume: {resume:HH:mm:ss}).");
}
else if (previous == BAD)
{
recovering = true;
resume = DateTime.Now < resume ? resume : DateTime.Now.AddSeconds(random.Next(0, THREE_MINUTES));
logger.Info($"Starting recovery while maintaining local caching (resume: {resume:HH:mm:ss}).");
}
}
return current;
}
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="KGySoft.CoreLibraries" version="8.1.0" targetFramework="net48" />
<package id="KGySoft.Drawing" version="8.1.0" targetFramework="net48" />
<package id="KGySoft.Drawing.Core" version="8.1.0" targetFramework="net48" />
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
</packages>