SEBSP-23: Finished basic network redundancy. This build contains a service health simulation.

This commit is contained in:
Damian Büchel 2024-02-21 18:37:23 +01:00
parent a213ec0f7d
commit f902ee9598
18 changed files with 744 additions and 389 deletions

View file

@ -69,7 +69,7 @@ namespace SafeExamBrowser.Proctoring
var logger = this.logger.CloneFor(nameof(ScreenProctoring));
var service = new ServiceProxy(logger.CloneFor(nameof(ServiceProxy)));
implementations.Add(new ScreenProctoringImplementation(applicationMonitor, browser, logger, nativeMethods, service, settings, text));
implementations.Add(new ScreenProctoringImplementation(appConfig, applicationMonitor, browser, logger, nativeMethods, service, settings, text));
}
return implementations;

View file

@ -82,6 +82,8 @@
<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>
@ -91,14 +93,17 @@
<Compile Include="ProctoringFactory.cs" />
<Compile Include="ProctoringImplementation.cs" />
<Compile Include="Properties\AssemblyInfo.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\Metadata.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" />

View file

@ -0,0 +1,242 @@
/*
* 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.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 Queue<(string fileName, int checksum, string hash)> queue;
private string Directory { get; set; }
public Cache(AppConfig appConfig, ILogger logger)
{
this.appConfig = appConfig;
this.logger = logger;
this.queue = new Queue<(string, int, string)>();
}
internal bool Any()
{
return false;
}
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}'.");
}
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())
{
var (fileName, checksum, hash) = queue.Peek();
try
{
LoadData(fileName, out metaData, out screenShot);
LoadImage(fileName, screenShot);
Dequeue(fileName, checksum, hash, metaData, screenShot);
success = true;
logger.Debug($"Uncached data for '{fileName}'.");
}
catch (Exception e)
{
logger.Error($"Failed to uncache data for '{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);
queue.Dequeue();
}
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

@ -11,6 +11,5 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring.Data
internal class IntervalTrigger
{
public int ConfigurationValue { get; internal set; }
public int TimeElapsed { get; internal set; }
}
}

View file

@ -0,0 +1,138 @@
/*
* 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.Linq;
using System.Text;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Monitoring.Contracts.Applications;
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 string applicationInfo;
private string browserInfo;
private TimeSpan elapsed;
private string triggerInfo;
private string urls;
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)
{
this.applicationMonitor = applicationMonitor;
this.browser = browser;
this.elapsed = elapsed;
this.logger = logger;
}
internal void Capture(IntervalTrigger interval = default, KeyboardTrigger keyboard = default, MouseTrigger mouse = default)
{
CaptureApplicationData();
CaptureBrowserData();
if (interval != default)
{
CaptureIntervalTrigger(interval);
}
else if (keyboard != default)
{
CaptureKeyboardTrigger(keyboard);
}
else if (mouse != default)
{
CaptureMouseTrigger(mouse);
}
// TODO: Can only log URLs when allowed by policy in browser configuration!
logger.Debug($"Captured metadata: {applicationInfo} / {browserInfo} / {triggerInfo} / {urls} / {windowTitle}.");
}
private void CaptureApplicationData()
{
if (applicationMonitor.TryGetActiveApplication(out var application))
{
applicationInfo = BuildApplicationInfo(application);
windowTitle = string.IsNullOrEmpty(application.Window.Title) ? "-" : application.Window.Title;
}
else
{
applicationInfo = "-";
windowTitle = "-";
}
}
private void CaptureBrowserData()
{
var windows = browser.GetWindows();
browserInfo = string.Join(", ", windows.Select(w => $"{(w.IsMainWindow ? "Main" : "Additional")} Window: {w.Title} ({w.Url})"));
urls = string.Join(", ", windows.Select(w => w.Url));
}
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));
var modifiers = flags.Any() ? string.Join(" + ", flags) + " + " : string.Empty;
triggerInfo = $"'{modifiers}{keyboard.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();
}
}
}

View file

@ -7,60 +7,20 @@
*/
using System;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Monitoring.Contracts.Applications;
using SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests;
using SafeExamBrowser.WindowsApi.Contracts.Events;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Data
{
internal class Metadata
internal class MetaData
{
private readonly IApplicationMonitor applicationMonitor;
private readonly IBrowserApplication browser;
private readonly ILogger logger;
internal string ApplicationInfo { get; private set; }
internal string BrowserInfo { get; private set; }
internal TimeSpan Elapsed { get; private set; }
internal string TriggerInfo { get; private set; }
internal string Urls { get; private set; }
internal string WindowTitle { get; private set; }
internal Metadata(IApplicationMonitor applicationMonitor, IBrowserApplication browser, TimeSpan elapsed, ILogger logger)
{
this.applicationMonitor = applicationMonitor;
this.browser = browser;
this.Elapsed = elapsed;
this.logger = logger;
}
internal void Capture(IntervalTrigger interval = default, KeyboardTrigger keyboard = default, MouseTrigger mouse = default)
{
CaptureApplicationData();
CaptureBrowserData();
if (interval != default)
{
CaptureIntervalTrigger(interval);
}
else if (keyboard != default)
{
CaptureKeyboardTrigger(keyboard);
}
else if (mouse != default)
{
CaptureMouseTrigger(mouse);
}
// TODO: Can only log URLs when allowed by policy in browser configuration!
logger.Debug($"Captured metadata: {ApplicationInfo} / {BrowserInfo} / {TriggerInfo} / {Urls} / {WindowTitle}.");
}
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()
{
@ -75,71 +35,5 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring.Data
return json.ToString(Formatting.None);
}
private void CaptureApplicationData()
{
if (applicationMonitor.TryGetActiveApplication(out var application))
{
ApplicationInfo = BuildApplicationInfo(application);
WindowTitle = string.IsNullOrEmpty(application.Window.Title) ? "-" : application.Window.Title;
}
else
{
ApplicationInfo = "-";
WindowTitle = "-";
}
}
private void CaptureBrowserData()
{
var windows = browser.GetWindows();
BrowserInfo = string.Join(", ", windows.Select(w => $"{(w.IsMainWindow ? "Main" : "Additional")} Window: {w.Title} ({w.Url})"));
Urls = string.Join(", ", windows.Select(w => w.Url));
}
private void CaptureIntervalTrigger(IntervalTrigger interval)
{
TriggerInfo = $"Maximum interval of {interval.ConfigurationValue}ms has been reached ({interval.TimeElapsed}ms).";
}
private void CaptureKeyboardTrigger(KeyboardTrigger keyboard)
{
var flags = Enum.GetValues(typeof(KeyModifier)).OfType<KeyModifier>().Where(m => m != KeyModifier.None && keyboard.Modifier.HasFlag(m));
var modifiers = flags.Any() ? string.Join(" + ", flags) + " + " : string.Empty;
TriggerInfo = $"'{modifiers}{keyboard.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();
}
}
}

View file

@ -115,7 +115,6 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
var trigger = new IntervalTrigger
{
ConfigurationValue = settings.MaxInterval,
TimeElapsed = Convert.ToInt32(DateTime.Now.Subtract(last).TotalMilliseconds)
};
TryCollect(interval: trigger);
@ -148,14 +147,16 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
try
{
var metadata = new Metadata(applicationMonitor, browser, elapsed, logger.CloneFor(nameof(Metadata)));
var screenShot = new ScreenShot(logger.CloneFor(nameof(ScreenShot)), settings);
var metaData = new MetaDataAggregator(applicationMonitor, browser, elapsed, logger.CloneFor(nameof(MetaDataAggregator)));
var screenShot = new ScreenShotProcessor(logger.CloneFor(nameof(ScreenShotProcessor)), settings);
metadata.Capture(interval, keyboard, mouse);
metaData.Capture(interval, keyboard, mouse);
screenShot.Take();
screenShot.Compress();
DataCollected?.Invoke(metadata, screenShot);
DataCollected?.Invoke(metaData.Data, screenShot.Data);
screenShot.Dispose();
}
catch (Exception e)
{

View file

@ -11,5 +11,5 @@ using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
namespace SafeExamBrowser.Proctoring.ScreenProctoring.Events
{
internal delegate void DataCollectedEventHandler(Metadata metadata, ScreenShot screenShot);
internal delegate void DataCollectedEventHandler(MetaData metaData, ScreenShot screenShot);
}

View file

@ -7,146 +7,26 @@
*/
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;
}
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()
{
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);
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) 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 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

@ -8,6 +8,7 @@
using System;
using SafeExamBrowser.Browser.Contracts;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Core.Contracts.Notifications.Events;
using SafeExamBrowser.Core.Contracts.Resources.Icons;
using SafeExamBrowser.I18n.Contracts;
@ -36,6 +37,7 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
public override event NotificationChangedEventHandler NotificationChanged;
internal ScreenProctoringImplementation(
AppConfig appConfig,
IApplicationMonitor applicationMonitor,
IBrowserApplication browser,
IModuleLogger logger,
@ -48,7 +50,7 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
this.logger = logger;
this.service = service;
this.settings = settings.ScreenProctoring;
this.spooler = new TransmissionSpooler(logger.CloneFor(nameof(TransmissionSpooler)), service);
this.spooler = new TransmissionSpooler(appConfig, logger.CloneFor(nameof(TransmissionSpooler)), service);
this.text = text;
}
@ -138,9 +140,9 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
logger.Info("Terminated proctoring.");
}
private void Collector_DataCollected(Metadata metadata, ScreenShot screenShot)
private void Collector_DataCollected(MetaData metaData, ScreenShot screenShot)
{
spooler.Add(metadata, screenShot);
spooler.Add(metaData, screenShot);
}
private void Connect(string sessionId = default)

View file

@ -52,9 +52,16 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service
health = default;
if (response.Headers.TryGetValues(Header.HEALTH, out var values))
try
{
success = int.TryParse(values.First(), out health);
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;

View file

@ -21,10 +21,10 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service.Requests
{
}
internal bool TryExecute(Metadata metadata, ScreenShot screenShot, string sessionId, out string message)
internal bool TryExecute(MetaData metaData, ScreenShot screenShot, string sessionId, out string message)
{
var imageFormat = (Header.IMAGE_FORMAT, ToString(screenShot.Format));
var metdataJson = (Header.METADATA, metadata.ToJson());
var metdataJson = (Header.METADATA, metaData.ToJson());
var timestamp = (Header.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, metdataJson, timestamp);

View file

@ -87,10 +87,10 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring.Service
return new ServiceResponse<int>(success, health, message);
}
internal ServiceResponse Send(Metadata metadata, ScreenShot screenShot)
internal ServiceResponse Send(MetaData metaData, ScreenShot screenShot)
{
var request = new ScreenShotRequest(api, httpClient, logger, parser);
var success = request.TryExecute(metadata, screenShot, SessionId, out var message);
var success = request.TryExecute(metaData, screenShot, SessionId, out var message);
if (success)
{

View file

@ -12,6 +12,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Timers;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Proctoring.ScreenProctoring.Data;
using SafeExamBrowser.Proctoring.ScreenProctoring.Imaging;
@ -25,32 +26,34 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
const int BAD = 10;
const int GOOD = 0;
private readonly Cache cache;
private readonly ILogger logger;
private readonly ConcurrentQueue<(Metadata metadata, ScreenShot screenShot)> queue;
private readonly ConcurrentQueue<(MetaData metaData, ScreenShot screenShot)> queue;
private readonly Random random;
private readonly ServiceProxy service;
private readonly Timer timer;
private Queue<(Metadata metadata, DateTime schedule, ScreenShot screenShot)> buffer;
private Queue<(MetaData metaData, DateTime schedule, ScreenShot screenShot)> buffer;
private int health;
private bool recovering;
private DateTime resume;
private Thread thread;
private CancellationTokenSource token;
internal TransmissionSpooler(ILogger logger, ServiceProxy service)
internal TransmissionSpooler(AppConfig appConfig, IModuleLogger logger, ServiceProxy service)
{
this.buffer = new Queue<(Metadata, DateTime, ScreenShot)>();
this.buffer = new Queue<(MetaData, DateTime, ScreenShot)>();
this.cache = new Cache(appConfig, logger.CloneFor(nameof(Cache)));
this.logger = logger;
this.queue = new ConcurrentQueue<(Metadata, ScreenShot)>();
this.queue = new ConcurrentQueue<(MetaData, ScreenShot)>();
this.random = new Random();
this.service = service;
this.timer = new Timer();
}
internal void Add(Metadata metadata, ScreenShot screenShot)
internal void Add(MetaData metaData, ScreenShot screenShot)
{
queue.Enqueue((metadata, screenShot));
queue.Enqueue((metaData, screenShot));
}
internal void Start()
@ -68,9 +71,10 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
thread.IsBackground = true;
thread.Start();
// TODO: Only use timer when BAD, until then read health from transmission response!
timer.AutoReset = false;
timer.Elapsed += Timer_Elapsed;
timer.Interval = 2000;
timer.Interval = 10000;
// TODO: Revert!
// timer.Interval = FIFTEEN_SECONDS;
timer.Start();
@ -93,17 +97,15 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
}
catch (Exception e)
{
logger.Error("Failed to initiate cancellation!", e);
logger.Error("Failed to initiate execution cancellation!", e);
}
try
{
var success = thread.Join(TEN_SECONDS);
if (!success)
if (!thread.Join(TEN_SECONDS))
{
thread.Abort();
logger.Warn($"Aborted since stopping gracefully within {TEN_SECONDS / 1000:N0} seconds failed!");
logger.Warn($"Aborted execution since stopping gracefully within {TEN_SECONDS / 1000} seconds failed!");
}
}
catch (Exception e)
@ -125,7 +127,7 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
if (health == BAD)
{
ExecuteCacheOnly();
ExecuteCaching();
}
else if (recovering)
{
@ -146,7 +148,7 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
logger.Debug("Stopped.");
}
private void ExecuteCacheOnly()
private void ExecuteCaching()
{
const int THREE_MINUTES = 180;
@ -158,18 +160,15 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
logger.Warn($"Activating local caching and suspending transmission due to bad service health (value: {health}, resume: {resume:HH:mm:ss}).");
}
CacheBuffer();
CacheFromBuffer();
CacheFromQueue();
}
private void ExecuteDeferred()
{
Schedule(health);
if (TryPeekFromBuffer(out _, out var schedule, out _) && schedule <= DateTime.Now)
{
TryTransmitFromBuffer();
}
BufferFromCache();
BufferFromQueue();
TryTransmitFromBuffer();
}
private void ExecuteNormally()
@ -181,19 +180,22 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
private void ExecuteRecovery()
{
CacheFromQueue();
recovering = DateTime.Now < resume;
if (!recovering)
if (recovering)
{
CacheFromQueue();
}
else
{
logger.Info($"Deactivating local caching and resuming transmission due to improved service health (value: {health}).");
}
}
private void Buffer(Metadata metadata, DateTime schedule, ScreenShot screenShot)
private void Buffer(MetaData metaData, DateTime schedule, ScreenShot screenShot)
{
buffer.Enqueue((metadata, schedule, screenShot));
buffer = new Queue<(Metadata, DateTime, ScreenShot)>(buffer.OrderBy((b) => b.schedule));
buffer.Enqueue((metaData, schedule, screenShot));
buffer = new Queue<(MetaData, DateTime, ScreenShot)>(buffer.OrderBy((b) => b.schedule));
// TODO: Remove!
PrintBuffer();
@ -202,111 +204,117 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
private void PrintBuffer()
{
logger.Log("-------------------------------------------------------------------------------------------------------");
logger.Info($"Buffer: {buffer.Count}");
logger.Info("");
logger.Log($"\t\t\t\tBuffer: {buffer.Count} items");
foreach (var (m, t, s) in buffer)
{
logger.Log($"\t\t{t} ({m.Elapsed} {s.Data.Length})");
logger.Log($"\t\t\t\t{s.CaptureTime:HH:mm:ss} -> {t:HH:mm:ss} ({m.Elapsed} {s.Data.Length / 1000:N0}kB)");
}
logger.Log("-------------------------------------------------------------------------------------------------------");
}
private void CacheBuffer()
private void BufferFromCache()
{
foreach (var (metadata, _, screenShot) in buffer)
if (cache.TryDequeue(out var metaData, out var screenShot))
{
using (screenShot)
{
Cache(metadata, screenShot);
}
}
// TODO: Revert!
// buffer.Clear();
}
private void CacheFromQueue()
{
if (TryDequeue(out var metadata, out var screenShot))
{
using (screenShot)
{
Cache(metadata, screenShot);
}
Buffer(metaData, CalculateSchedule(metaData), screenShot);
}
}
private void Cache(Metadata metadata, ScreenShot screenShot)
private void BufferFromQueue()
{
// TODO: Implement caching!
//var directory = Dispatcher.Invoke(() => OutputPath.Text);
//var extension = screenShot.Format.ToString().ToLower();
//var path = Path.Combine(directory, $"{DateTime.Now:HH\\hmm\\mss\\sfff\\m\\s}.{extension}");
//if (!Directory.Exists(directory))
//{
// Directory.CreateDirectory(directory);
// logger.Debug($"Created local output directory '{directory}'.");
//}
//File.WriteAllBytes(path, screenShot.Data);
//logger.Debug($"Screen shot saved as '{path}'.");
}
private void Schedule(int health)
{
if (TryDequeue(out var metadata, out var screenShot))
if (TryDequeue(out var metaData, out var screenShot))
{
var schedule = DateTime.Now.AddMilliseconds((health + 1) * metadata.Elapsed.TotalMilliseconds);
Buffer(metadata, schedule, screenShot);
Buffer(metaData, CalculateSchedule(metaData), screenShot);
}
}
private bool TryDequeue(out Metadata metadata, out ScreenShot screenShot)
private void CacheFromBuffer()
{
metadata = default;
screenShot = default;
if (queue.TryDequeue(out var item))
if (TryPeekFromBuffer(out var metaData, out _, out var screenShot))
{
metadata = item.metadata;
screenShot = item.screenShot;
}
return metadata != default && screenShot != default;
}
private bool TryPeekFromBuffer(out Metadata metadata, out DateTime schedule, out ScreenShot screenShot)
{
metadata = default;
schedule = default;
screenShot = default;
if (buffer.Any())
{
(metadata, schedule, screenShot) = buffer.Peek();
}
return metadata != default && screenShot != default;
}
private bool TryTransmitFromBuffer()
{
var success = false;
if (TryPeekFromBuffer(out var metadata, out _, out var screenShot))
{
// TODO: Exception after sending of screenshot, most likely due to concurrent disposal!!
success = TryTransmit(metadata, screenShot);
var success = cache.TryEnqueue(metaData, screenShot);
if (success)
{
buffer.Dequeue();
screenShot.Dispose();
// TODO: Revert!
// TODO: Remove!
PrintBuffer();
}
}
}
private void CacheFromQueue()
{
if (TryDequeue(out var metaData, out var screenShot))
{
var success = cache.TryEnqueue(metaData, screenShot);
if (success)
{
screenShot.Dispose();
}
else
{
Buffer(metaData, DateTime.Now, screenShot);
}
}
}
private DateTime CalculateSchedule(MetaData metaData)
{
var timeout = (health + 1) * metaData.Elapsed.TotalMilliseconds;
var schedule = DateTime.Now.AddMilliseconds(timeout);
return schedule;
}
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 TryPeekFromBuffer(out MetaData metaData, out DateTime schedule, out ScreenShot screenShot)
{
metaData = default;
schedule = default;
screenShot = default;
if (buffer.Any())
{
(metaData, schedule, screenShot) = buffer.Peek();
}
return metaData != default && screenShot != default;
}
private bool TryTransmitFromBuffer()
{
var success = false;
if (TryPeekFromBuffer(out var metaData, out var schedule, out var screenShot) && schedule <= DateTime.Now)
{
success = TryTransmit(metaData, screenShot);
if (success)
{
buffer.Dequeue();
screenShot.Dispose();
// TODO: Remove!
PrintBuffer();
}
}
@ -316,17 +324,21 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
private bool TryTransmitFromCache()
{
var success = false;
var success = true;
// TODO: Implement transmission from cache!
//if (Cache.Any())
//{
//
//}
//else
//{
// success = true;
//}
if (cache.TryDequeue(out var metaData, out var screenShot))
{
success = TryTransmit(metaData, screenShot);
if (success)
{
screenShot.Dispose();
}
else
{
Buffer(metaData, DateTime.Now, screenShot);
}
}
return success;
}
@ -335,9 +347,9 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
{
var success = false;
if (TryDequeue(out var metadata, out var screenShot))
if (TryDequeue(out var metaData, out var screenShot))
{
success = TryTransmit(metadata, screenShot);
success = TryTransmit(metaData, screenShot);
if (success)
{
@ -345,20 +357,20 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
}
else
{
Buffer(metadata, DateTime.Now, screenShot);
Buffer(metaData, DateTime.Now, screenShot);
}
}
return success;
}
private bool TryTransmit(Metadata metadata, ScreenShot screenShot)
private bool TryTransmit(MetaData metaData, ScreenShot screenShot)
{
var success = false;
if (service.IsConnected)
{
success = service.Send(metadata, screenShot).Success;
success = service.Send(metaData, screenShot).Success;
}
else
{
@ -368,7 +380,8 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
return success;
}
private readonly Random temp = new Random();
private int factor = 2;
private int bads = 0;
private void Timer_Elapsed(object sender, ElapsedEventArgs e)
{
@ -396,12 +409,26 @@ namespace SafeExamBrowser.Proctoring.ScreenProctoring
var previous = health;
health += temp.Next(-3, 5);
health = health < GOOD ? GOOD : (health > BAD ? BAD : health);
if (bads < 2)
{
bads += health == BAD ? 1 : 0;
factor = health == BAD ? -2 : (health == GOOD ? 2 : factor);
health += factor;
health = health < GOOD ? GOOD : (health > BAD ? BAD : health);
}
else
{
health = 0;
}
if (previous != health)
{
logger.Info($"Service health {(previous < health ? "deteriorated" : "improved")} from {previous} to {health}.");
logger.Warn($"Service health {(previous < health ? "deteriorated" : "improved")} from {previous} to {health}.");
if (bads >= 2 && health == 0)
{
logger.Warn("Stopped health simulation.");
}
}
timer.Start();

View file

@ -14,6 +14,11 @@ namespace SafeExamBrowser.Server
{
internal static class Extensions
{
internal static string ToLogString(this HttpResponseMessage response)
{
return $"{(int?) response?.StatusCode} {response?.StatusCode} {response?.ReasonPhrase}";
}
internal static string ToLogType(this LogLevel severity)
{
switch (severity)
@ -31,11 +36,6 @@ namespace SafeExamBrowser.Server
return "UNKNOWN";
}
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();

View file

@ -30,7 +30,7 @@ namespace SafeExamBrowser.Server.Requests
var url = $"{api.ConfigurationEndpoint}?examId={exam.Id}";
var success = TryExecute(HttpMethod.Get, url, out var response, default, default, Authorization, Token);
content = response.Content;
content = response?.Content;
message = response.ToLogString();
return success;

View file

@ -36,7 +36,7 @@ namespace SafeExamBrowser.Server.Requests
var success = TryExecute(HttpMethod.Post, api.PingEndpoint, out var response, requestContent, ContentType.URL_ENCODED, Authorization, Token);
content = response.Content;
content = response?.Content;
message = response.ToLogString();
return success;