Restore SEBPatch
This commit is contained in:
19
SafeExamBrowser.Server/Data/ApiVersion1.cs
Normal file
19
SafeExamBrowser.Server/Data/ApiVersion1.cs
Normal 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/.
|
||||
*/
|
||||
|
||||
namespace SafeExamBrowser.Server.Data
|
||||
{
|
||||
internal class ApiVersion1
|
||||
{
|
||||
public string AccessTokenEndpoint { get; set; }
|
||||
public string HandshakeEndpoint { get; set; }
|
||||
public string ConfigurationEndpoint { get; set; }
|
||||
public string PingEndpoint { get; set; }
|
||||
public string LogEndpoint { get; set; }
|
||||
}
|
||||
}
|
17
SafeExamBrowser.Server/Data/AttributeType.cs
Normal file
17
SafeExamBrowser.Server/Data/AttributeType.cs
Normal 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.Server.Data
|
||||
{
|
||||
internal enum AttributeType
|
||||
{
|
||||
None,
|
||||
Hand,
|
||||
LockScreen
|
||||
}
|
||||
}
|
23
SafeExamBrowser.Server/Data/Attributes.cs
Normal file
23
SafeExamBrowser.Server/Data/Attributes.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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.Server.Contracts.Events.Proctoring;
|
||||
|
||||
namespace SafeExamBrowser.Server.Data
|
||||
{
|
||||
internal class Attributes
|
||||
{
|
||||
internal bool AllowChat { get; set; }
|
||||
internal int Id { get; set; }
|
||||
internal InstructionEventArgs Instruction { get; set; }
|
||||
internal string Message { get; set; }
|
||||
internal bool ReceiveAudio { get; set; }
|
||||
internal bool ReceiveVideo { get; set; }
|
||||
internal AttributeType Type { get; set; }
|
||||
}
|
||||
}
|
19
SafeExamBrowser.Server/Data/Instructions.cs
Normal file
19
SafeExamBrowser.Server/Data/Instructions.cs
Normal 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/.
|
||||
*/
|
||||
|
||||
namespace SafeExamBrowser.Server.Data
|
||||
{
|
||||
internal sealed class Instructions
|
||||
{
|
||||
internal const string LOCK_SCREEN = "SEB_FORCE_LOCK_SCREEN";
|
||||
internal const string NOTIFICATION_CONFIRM = "NOTIFICATION_CONFIRM";
|
||||
internal const string PROCTORING = "SEB_PROCTORING";
|
||||
internal const string PROCTORING_RECONFIGURATION = "SEB_RECONFIGURE_SETTINGS";
|
||||
internal const string QUIT = "SEB_QUIT";
|
||||
}
|
||||
}
|
58
SafeExamBrowser.Server/Extensions.cs
Normal file
58
SafeExamBrowser.Server/Extensions.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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;
|
||||
using SafeExamBrowser.Settings.Logging;
|
||||
|
||||
namespace SafeExamBrowser.Server
|
||||
{
|
||||
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 ToLogType(this LogLevel severity)
|
||||
{
|
||||
switch (severity)
|
||||
{
|
||||
case LogLevel.Debug:
|
||||
return "DEBUG_LOG";
|
||||
case LogLevel.Error:
|
||||
return "ERROR_LOG";
|
||||
case LogLevel.Info:
|
||||
return "INFO_LOG";
|
||||
case LogLevel.Warning:
|
||||
return "WARN_LOG";
|
||||
}
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
59
SafeExamBrowser.Server/FileSystem.cs
Normal file
59
SafeExamBrowser.Server/FileSystem.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using SafeExamBrowser.Configuration.Contracts;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
|
||||
namespace SafeExamBrowser.Server
|
||||
{
|
||||
internal class FileSystem
|
||||
{
|
||||
private readonly AppConfig appConfig;
|
||||
private readonly ILogger logger;
|
||||
|
||||
internal FileSystem(AppConfig appConfig, ILogger logger)
|
||||
{
|
||||
this.appConfig = appConfig;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
internal bool TrySaveFile(HttpContent content, out Uri uri)
|
||||
{
|
||||
uri = new Uri(Path.Combine(appConfig.TemporaryDirectory, $"ServerExam{appConfig.ConfigurationFileExtension}"));
|
||||
|
||||
try
|
||||
{
|
||||
var task = Task.Run(async () =>
|
||||
{
|
||||
return await content.ReadAsStreamAsync();
|
||||
});
|
||||
|
||||
using (var data = task.GetAwaiter().GetResult())
|
||||
using (var file = new FileStream(uri.LocalPath, FileMode.Create))
|
||||
{
|
||||
data.Seek(0, SeekOrigin.Begin);
|
||||
data.CopyTo(file);
|
||||
data.Flush();
|
||||
file.Flush();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error($"Failed to save file '{uri.LocalPath}'!", e);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
414
SafeExamBrowser.Server/Parser.cs
Normal file
414
SafeExamBrowser.Server/Parser.cs
Normal file
@@ -0,0 +1,414 @@
|
||||
/*
|
||||
* 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.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.Server.Contracts.Data;
|
||||
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Server.Requests;
|
||||
|
||||
namespace SafeExamBrowser.Server
|
||||
{
|
||||
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 TryParseApi(HttpContent content, out ApiVersion1 api)
|
||||
{
|
||||
var success = false;
|
||||
|
||||
api = new ApiVersion1();
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
|
||||
var apisJson = json["api-versions"];
|
||||
|
||||
foreach (var apiJson in apisJson.AsJEnumerable())
|
||||
{
|
||||
if (apiJson["name"].Value<string>().Equals("v1"))
|
||||
{
|
||||
foreach (var endpoint in apiJson["endpoints"].AsJEnumerable())
|
||||
{
|
||||
var name = endpoint["name"].Value<string>();
|
||||
var location = endpoint["location"].Value<string>();
|
||||
|
||||
switch (name)
|
||||
{
|
||||
case "access-token-endpoint":
|
||||
api.AccessTokenEndpoint = location;
|
||||
break;
|
||||
case "seb-configuration-endpoint":
|
||||
api.ConfigurationEndpoint = location;
|
||||
break;
|
||||
case "seb-handshake-endpoint":
|
||||
api.HandshakeEndpoint = location;
|
||||
break;
|
||||
case "seb-log-endpoint":
|
||||
api.LogEndpoint = location;
|
||||
break;
|
||||
case "seb-ping-endpoint":
|
||||
api.PingEndpoint = location;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
success = true;
|
||||
}
|
||||
|
||||
if (!success)
|
||||
{
|
||||
logger.Error("The selected SEB server instance does not support the required API version!");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to parse server API!", e);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
internal bool TryParseAppSignatureKeySalt(HttpResponseMessage response, out string salt)
|
||||
{
|
||||
salt = default;
|
||||
|
||||
try
|
||||
{
|
||||
var hasHeader = response.Headers.TryGetValues(Header.APP_SIGNATURE_KEY_SALT, out var values);
|
||||
|
||||
if (hasHeader)
|
||||
{
|
||||
salt = values.First();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to parse app signature key salt!", e);
|
||||
}
|
||||
|
||||
return salt != default;
|
||||
}
|
||||
|
||||
internal bool TryParseBrowserExamKey(HttpResponseMessage response, out string browserExamKey)
|
||||
{
|
||||
browserExamKey = default;
|
||||
|
||||
try
|
||||
{
|
||||
var hasHeader = response.Headers.TryGetValues(Header.BROWSER_EXAM_KEY, out var values);
|
||||
|
||||
if (hasHeader)
|
||||
{
|
||||
browserExamKey = values.First();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to parse browser exam key!", e);
|
||||
}
|
||||
|
||||
return browserExamKey != default;
|
||||
}
|
||||
|
||||
internal bool TryParseConnectionToken(HttpResponseMessage response, out string connectionToken)
|
||||
{
|
||||
connectionToken = default;
|
||||
|
||||
try
|
||||
{
|
||||
var hasHeader = response.Headers.TryGetValues(Header.CONNECTION_TOKEN, out var values);
|
||||
|
||||
if (hasHeader)
|
||||
{
|
||||
connectionToken = values.First();
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to retrieve connection token!");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to parse connection token!", e);
|
||||
}
|
||||
|
||||
return connectionToken != default;
|
||||
}
|
||||
|
||||
internal bool TryParseExams(HttpContent content, out IEnumerable<Exam> exams)
|
||||
{
|
||||
var list = new List<Exam>();
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.DeserializeObject(Extract(content)) as JArray;
|
||||
|
||||
foreach (var exam in json.AsJEnumerable())
|
||||
{
|
||||
list.Add(new Exam
|
||||
{
|
||||
Id = exam["examId"].Value<string>(),
|
||||
LmsName = exam["lmsType"].Value<string>(),
|
||||
Name = exam["name"].Value<string>(),
|
||||
Url = exam["url"].Value<string>()
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to parse exams!", e);
|
||||
}
|
||||
|
||||
exams = list;
|
||||
|
||||
return exams.Any();
|
||||
}
|
||||
|
||||
internal bool TryParseInstruction(HttpContent content, out Attributes attributes, out string instruction, out string instructionConfirmation)
|
||||
{
|
||||
attributes = new Attributes();
|
||||
instruction = default;
|
||||
instructionConfirmation = default;
|
||||
|
||||
try
|
||||
{
|
||||
var json = JsonConvert.DeserializeObject(Extract(content)) as JObject;
|
||||
|
||||
if (json != default(JObject))
|
||||
{
|
||||
instruction = json["instruction"].Value<string>();
|
||||
|
||||
if (json.ContainsKey("attributes"))
|
||||
{
|
||||
var attributesJson = json["attributes"] as JObject;
|
||||
|
||||
if (attributesJson.ContainsKey("instruction-confirm"))
|
||||
{
|
||||
instructionConfirmation = attributesJson["instruction-confirm"].Value<string>();
|
||||
}
|
||||
|
||||
attributes = ParseAttributes(attributesJson, instruction);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to parse instruction!", e);
|
||||
}
|
||||
|
||||
return instruction != default;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private Attributes ParseAttributes(JObject attributesJson, string instruction)
|
||||
{
|
||||
var attributes = new Attributes();
|
||||
|
||||
switch (instruction)
|
||||
{
|
||||
case Instructions.LOCK_SCREEN:
|
||||
ParseLockScreenInstruction(attributes, attributesJson);
|
||||
break;
|
||||
case Instructions.NOTIFICATION_CONFIRM:
|
||||
ParseNotificationConfirmation(attributes, attributesJson);
|
||||
break;
|
||||
case Instructions.PROCTORING:
|
||||
ParseProctoringInstruction(attributes, attributesJson);
|
||||
break;
|
||||
case Instructions.PROCTORING_RECONFIGURATION:
|
||||
ParseReconfigurationInstruction(attributes, attributesJson);
|
||||
break;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private void ParseLockScreenInstruction(Attributes attributes, JObject attributesJson)
|
||||
{
|
||||
if (attributesJson.ContainsKey("message"))
|
||||
{
|
||||
attributes.Message = attributesJson["message"].Value<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseNotificationConfirmation(Attributes attributes, JObject attributesJson)
|
||||
{
|
||||
if (attributesJson.ContainsKey("id"))
|
||||
{
|
||||
attributes.Id = attributesJson["id"].Value<int>();
|
||||
}
|
||||
|
||||
if (attributesJson.ContainsKey("type"))
|
||||
{
|
||||
switch (attributesJson["type"].Value<string>())
|
||||
{
|
||||
case "lockscreen":
|
||||
attributes.Type = AttributeType.LockScreen;
|
||||
break;
|
||||
case "raisehand":
|
||||
attributes.Type = AttributeType.Hand;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseProctoringInstruction(Attributes attributes, JObject attributesJson)
|
||||
{
|
||||
var provider = attributesJson["service-type"].Value<string>();
|
||||
|
||||
switch (provider)
|
||||
{
|
||||
case "JITSI_MEET":
|
||||
attributes.Instruction = ParseJitsiMeetInstruction(attributesJson);
|
||||
break;
|
||||
case "SCREEN_PROCTORING":
|
||||
attributes.Instruction = ParseScreenProctoringInstruction(attributesJson);
|
||||
break;
|
||||
case "ZOOM":
|
||||
attributes.Instruction = ParseZoomInstruction(attributesJson);
|
||||
break;
|
||||
}
|
||||
|
||||
if (attributes.Instruction != default)
|
||||
{
|
||||
attributes.Instruction.Method = attributesJson["method"].Value<string>() == "JOIN" ? InstructionMethod.Join : InstructionMethod.Leave;
|
||||
}
|
||||
}
|
||||
|
||||
private JitsiMeetInstruction ParseJitsiMeetInstruction(JObject attributesJson)
|
||||
{
|
||||
return new JitsiMeetInstruction
|
||||
{
|
||||
RoomName = attributesJson["jitsiMeetRoom"].Value<string>(),
|
||||
ServerUrl = attributesJson["jitsiMeetServerURL"].Value<string>(),
|
||||
Token = attributesJson["jitsiMeetToken"].Value<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private ScreenProctoringInstruction ParseScreenProctoringInstruction(JObject attributesJson)
|
||||
{
|
||||
return new ScreenProctoringInstruction
|
||||
{
|
||||
ClientId = attributesJson["screenProctoringClientId"].Value<string>(),
|
||||
ClientSecret = attributesJson["screenProctoringClientSecret"].Value<string>(),
|
||||
GroupId = attributesJson["screenProctoringGroupId"].Value<string>(),
|
||||
ServiceUrl = attributesJson["screenProctoringServiceURL"].Value<string>(),
|
||||
SessionId = attributesJson["screenProctoringClientSessionId"].Value<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private ZoomInstruction ParseZoomInstruction(JObject attributesJson)
|
||||
{
|
||||
return new ZoomInstruction
|
||||
{
|
||||
MeetingNumber = attributesJson["zoomRoom"].Value<string>(),
|
||||
Password = attributesJson["zoomMeetingKey"].Value<string>(),
|
||||
SdkKey = attributesJson["zoomAPIKey"].Value<string>(),
|
||||
Signature = attributesJson["zoomToken"].Value<string>(),
|
||||
Subject = attributesJson["zoomSubject"].Value<string>(),
|
||||
UserName = attributesJson["zoomUserName"].Value<string>()
|
||||
};
|
||||
}
|
||||
|
||||
private void ParseReconfigurationInstruction(Attributes attributes, JObject attributesJson)
|
||||
{
|
||||
if (attributesJson.ContainsKey("jitsiMeetFeatureFlagChat"))
|
||||
{
|
||||
attributes.AllowChat = attributesJson["jitsiMeetFeatureFlagChat"].Value<bool>();
|
||||
}
|
||||
|
||||
if (attributesJson.ContainsKey("zoomFeatureFlagChat"))
|
||||
{
|
||||
attributes.AllowChat = attributesJson["zoomFeatureFlagChat"].Value<bool>();
|
||||
}
|
||||
|
||||
if (attributesJson.ContainsKey("jitsiMeetReceiveAudio"))
|
||||
{
|
||||
attributes.ReceiveAudio = attributesJson["jitsiMeetReceiveAudio"].Value<bool>();
|
||||
}
|
||||
|
||||
if (attributesJson.ContainsKey("zoomReceiveAudio"))
|
||||
{
|
||||
attributes.ReceiveAudio = attributesJson["zoomReceiveAudio"].Value<bool>();
|
||||
}
|
||||
|
||||
if (attributesJson.ContainsKey("jitsiMeetReceiveVideo"))
|
||||
{
|
||||
attributes.ReceiveVideo = attributesJson["jitsiMeetReceiveVideo"].Value<bool>();
|
||||
}
|
||||
|
||||
if (attributesJson.ContainsKey("zoomReceiveVideo"))
|
||||
{
|
||||
attributes.ReceiveVideo = attributesJson["zoomReceiveVideo"].Value<bool>();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
33
SafeExamBrowser.Server/Properties/AssemblyInfo.cs
Normal file
33
SafeExamBrowser.Server/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
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.Server")]
|
||||
[assembly: AssemblyDescription("Safe Exam Browser")]
|
||||
[assembly: AssemblyCompany("ETH Zürich")]
|
||||
[assembly: AssemblyProduct("SafeExamBrowser.Server")]
|
||||
[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("46edbde0-58b4-4725-9783-0c55c3d49c0c")]
|
||||
|
||||
// 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.*")]
|
||||
[assembly: AssemblyVersion("1.0.0.0")]
|
||||
[assembly: AssemblyFileVersion("1.0.0.0")]
|
||||
[assembly: AssemblyInformationalVersion("1.0.0.0")]
|
42
SafeExamBrowser.Server/Requests/ApiRequest.cs
Normal file
42
SafeExamBrowser.Server/Requests/ApiRequest.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class ApiRequest : BaseRequest
|
||||
{
|
||||
internal ApiRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(out ApiVersion1 api, out string message)
|
||||
{
|
||||
var success = TryExecute(HttpMethod.Get, settings.ApiUrl, out var response);
|
||||
|
||||
api = new ApiVersion1();
|
||||
message = response.ToLogString();
|
||||
|
||||
if (success)
|
||||
{
|
||||
parser.TryParseApi(response.Content, out api);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
37
SafeExamBrowser.Server/Requests/AppSignatureKeyRequest.cs
Normal file
37
SafeExamBrowser.Server/Requests/AppSignatureKeyRequest.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class AppSignatureKeyRequest : BaseRequest
|
||||
{
|
||||
internal AppSignatureKeyRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(string appSignatureKey, out string message)
|
||||
{
|
||||
var content = $"seb_signature_key={appSignatureKey}";
|
||||
var success = TryExecute(new HttpMethod("PATCH"), api.HandshakeEndpoint, out var response, content, ContentType.URL_ENCODED, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
63
SafeExamBrowser.Server/Requests/AvailableExamsRequest.cs
Normal file
63
SafeExamBrowser.Server/Requests/AvailableExamsRequest.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 System.Net.Http;
|
||||
using SafeExamBrowser.Configuration.Contracts;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Contracts.Data;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
using SafeExamBrowser.SystemComponents.Contracts;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class AvailableExamsRequest : BaseRequest
|
||||
{
|
||||
private readonly AppConfig appConfig;
|
||||
private readonly ISystemInfo systemInfo;
|
||||
private readonly IUserInfo userInfo;
|
||||
|
||||
internal AvailableExamsRequest(
|
||||
ApiVersion1 api,
|
||||
AppConfig appConfig,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings,
|
||||
ISystemInfo systemInfo,
|
||||
IUserInfo userInfo) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
this.appConfig = appConfig;
|
||||
this.systemInfo = systemInfo;
|
||||
this.userInfo = userInfo;
|
||||
}
|
||||
|
||||
internal bool TryExecute(string examId, out IEnumerable<Exam> exams, out string message)
|
||||
{
|
||||
var clientInfo = $"client_id={userInfo.GetUserName()}&seb_machine_name={systemInfo.Name}";
|
||||
var versionInfo = $"seb_os_name={systemInfo.OperatingSystemInfo}&seb_version={appConfig.ProgramInformationalVersion}";
|
||||
var content = $"institutionId={settings.Institution}&{clientInfo}&{versionInfo}{(examId == default ? "" : $"&examId={examId}")}";
|
||||
|
||||
var success = TryExecute(HttpMethod.Post, api.HandshakeEndpoint, out var response, content, ContentType.URL_ENCODED, Authorization);
|
||||
|
||||
exams = default;
|
||||
message = response.ToLogString();
|
||||
|
||||
if (success)
|
||||
{
|
||||
var hasExams = parser.TryParseExams(response.Content, out exams);
|
||||
var hasToken = TryRetrieveConnectionToken(response);
|
||||
|
||||
success = hasExams && hasToken;
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
213
SafeExamBrowser.Server/Requests/BaseRequest.cs
Normal file
213
SafeExamBrowser.Server/Requests/BaseRequest.cs
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal abstract class BaseRequest
|
||||
{
|
||||
private static string connectionToken;
|
||||
private static string oauth2Token;
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
|
||||
private bool hadException;
|
||||
|
||||
protected readonly ApiVersion1 api;
|
||||
protected readonly ILogger logger;
|
||||
protected readonly Parser parser;
|
||||
protected readonly ServerSettings settings;
|
||||
|
||||
protected (string, string) Authorization => (Header.AUTHORIZATION, $"Bearer {oauth2Token}");
|
||||
protected (string, string) Token => (Header.CONNECTION_TOKEN, connectionToken);
|
||||
|
||||
internal static string ConnectionToken
|
||||
{
|
||||
get { return connectionToken; }
|
||||
set { connectionToken = value; }
|
||||
}
|
||||
|
||||
internal static string Oauth2Token
|
||||
{
|
||||
get { return oauth2Token; }
|
||||
set { oauth2Token = value; }
|
||||
}
|
||||
|
||||
protected BaseRequest(ApiVersion1 api, HttpClient httpClient, ILogger logger, Parser parser, ServerSettings settings)
|
||||
{
|
||||
this.api = api;
|
||||
this.httpClient = httpClient;
|
||||
this.logger = logger;
|
||||
this.parser = parser;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
protected bool TryExecute(
|
||||
HttpMethod method,
|
||||
string url,
|
||||
out HttpResponseMessage response,
|
||||
string content = default,
|
||||
string contentType = default,
|
||||
params (string name, string value)[] headers)
|
||||
{
|
||||
response = default;
|
||||
|
||||
for (var attempt = 0; attempt < settings.RequestAttempts && (response == default || !response.IsSuccessStatusCode); attempt++)
|
||||
{
|
||||
var request = BuildRequest(method, url, content, contentType, headers);
|
||||
|
||||
try
|
||||
{
|
||||
response = httpClient.SendAsync(request).GetAwaiter().GetResult();
|
||||
|
||||
if (PerformLoggingFor(request))
|
||||
{
|
||||
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)
|
||||
{
|
||||
if (PerformLoggingFor(request))
|
||||
{
|
||||
logger.Warn($"Request {request.Method} '{request.RequestUri}' did not complete within {settings.RequestTimeout}ms!");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
if (PerformLoggingFor(request) && IsFirstException())
|
||||
{
|
||||
logger.Warn($"Request {request.Method} '{request.RequestUri}' has failed: {e.ToSummary()}!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response != default && response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
protected bool TryRetrieveConnectionToken(HttpResponseMessage response)
|
||||
{
|
||||
var success = parser.TryParseConnectionToken(response, out connectionToken);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully retrieved connection token.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to retrieve connection token!");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
protected bool TryRetrieveOAuth2Token(out string message)
|
||||
{
|
||||
var secret = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.ClientName}:{settings.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,
|
||||
string content = default,
|
||||
string contentType = default,
|
||||
params (string name, string value)[] headers)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, url);
|
||||
|
||||
if (content != default)
|
||||
{
|
||||
request.Content = new StringContent(content, Encoding.UTF8);
|
||||
|
||||
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 bool PerformLoggingFor(HttpRequestMessage request)
|
||||
{
|
||||
return request.RequestUri.AbsolutePath != api.LogEndpoint && request.RequestUri.AbsolutePath != api.PingEndpoint;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
45
SafeExamBrowser.Server/Requests/ConfirmLockScreenRequest.cs
Normal file
45
SafeExamBrowser.Server/Requests/ConfirmLockScreenRequest.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 Newtonsoft.Json.Linq;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class ConfirmLockScreenRequest : BaseRequest
|
||||
{
|
||||
internal ConfirmLockScreenRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(int lockScreenId, out string message)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["numericValue"] = lockScreenId,
|
||||
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
|
||||
["type"] = "NOTIFICATION_CONFIRMED"
|
||||
};
|
||||
var content = json.ToString();
|
||||
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, ContentType.JSON, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
16
SafeExamBrowser.Server/Requests/ContentType.cs
Normal file
16
SafeExamBrowser.Server/Requests/ContentType.cs
Normal 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.Server.Requests
|
||||
{
|
||||
internal static class ContentType
|
||||
{
|
||||
internal const string JSON = "application/json;charset=UTF-8";
|
||||
internal const string URL_ENCODED = "application/x-www-form-urlencoded";
|
||||
}
|
||||
}
|
37
SafeExamBrowser.Server/Requests/DisconnectionRequest.cs
Normal file
37
SafeExamBrowser.Server/Requests/DisconnectionRequest.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class DisconnectionRequest : BaseRequest
|
||||
{
|
||||
internal DisconnectionRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(out string message)
|
||||
{
|
||||
var content = "delete=true";
|
||||
var success = TryExecute(HttpMethod.Delete, api.HandshakeEndpoint, out var response, content, ContentType.URL_ENCODED, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
39
SafeExamBrowser.Server/Requests/ExamConfigurationRequest.cs
Normal file
39
SafeExamBrowser.Server/Requests/ExamConfigurationRequest.cs
Normal 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.Net.Http;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Contracts.Data;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class ExamConfigurationRequest : BaseRequest
|
||||
{
|
||||
internal ExamConfigurationRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(Exam exam, out HttpContent content, out string message)
|
||||
{
|
||||
var url = $"{api.ConfigurationEndpoint}?examId={exam.Id}";
|
||||
var success = TryExecute(HttpMethod.Get, url, out var response, default, default, Authorization, Token);
|
||||
|
||||
content = response?.Content;
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
19
SafeExamBrowser.Server/Requests/Header.cs
Normal file
19
SafeExamBrowser.Server/Requests/Header.cs
Normal 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/.
|
||||
*/
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal static class Header
|
||||
{
|
||||
internal const string ACCEPT = "Accept";
|
||||
internal const string APP_SIGNATURE_KEY_SALT = "SEBExamSalt";
|
||||
internal const string AUTHORIZATION = "Authorization";
|
||||
internal const string BROWSER_EXAM_KEY = "SEBServerBEK";
|
||||
internal const string CONNECTION_TOKEN = "SEBConnectionToken";
|
||||
}
|
||||
}
|
46
SafeExamBrowser.Server/Requests/LockScreenRequest.cs
Normal file
46
SafeExamBrowser.Server/Requests/LockScreenRequest.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 Newtonsoft.Json.Linq;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class LockScreenRequest : BaseRequest
|
||||
{
|
||||
internal LockScreenRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(int lockScreenId, string text, out string message)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["numericValue"] = lockScreenId,
|
||||
["text"] = $"<lockscreen> {text}",
|
||||
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
|
||||
["type"] = "NOTIFICATION"
|
||||
};
|
||||
var content = json.ToString();
|
||||
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, ContentType.JSON, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
42
SafeExamBrowser.Server/Requests/LogRequest.cs
Normal file
42
SafeExamBrowser.Server/Requests/LogRequest.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 Newtonsoft.Json.Linq;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class LogRequest : BaseRequest
|
||||
{
|
||||
internal LogRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(ILogMessage message)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["text"] = message.Message,
|
||||
["timestamp"] = message.DateTime.ToUnixTimestamp(),
|
||||
["type"] = message.Severity.ToLogType()
|
||||
};
|
||||
|
||||
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out _, json.ToString(), ContentType.JSON, Authorization, Token);
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
45
SafeExamBrowser.Server/Requests/LowerHandRequest.cs
Normal file
45
SafeExamBrowser.Server/Requests/LowerHandRequest.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 Newtonsoft.Json.Linq;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class LowerHandRequest : BaseRequest
|
||||
{
|
||||
internal LowerHandRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(int handId, out string message)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["numericValue"] = handId,
|
||||
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
|
||||
["type"] = "NOTIFICATION_CONFIRMED"
|
||||
};
|
||||
var content = json.ToString();
|
||||
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, ContentType.JSON, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
52
SafeExamBrowser.Server/Requests/NetworkAdapterRequest.cs
Normal file
52
SafeExamBrowser.Server/Requests/NetworkAdapterRequest.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 Newtonsoft.Json.Linq;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Logging;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
using SafeExamBrowser.SystemComponents.Contracts.Network;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class NetworkAdapterRequest : BaseRequest
|
||||
{
|
||||
internal NetworkAdapterRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(IWirelessNetwork network, out string message)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["text"] = $"<wlan> {(network != default ? $"{network.Name}: {network.Status}, {network.SignalStrength}%" : "not connected")}",
|
||||
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
|
||||
["type"] = LogLevel.Info.ToLogType()
|
||||
};
|
||||
|
||||
if (network != default)
|
||||
{
|
||||
json["numericValue"] = network.SignalStrength;
|
||||
}
|
||||
|
||||
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, json.ToString(), ContentType.JSON, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
32
SafeExamBrowser.Server/Requests/OAuth2TokenRequest.cs
Normal file
32
SafeExamBrowser.Server/Requests/OAuth2TokenRequest.cs
Normal 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.Net.Http;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class OAuth2TokenRequest : BaseRequest
|
||||
{
|
||||
internal OAuth2TokenRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(out string message)
|
||||
{
|
||||
return TryRetrieveOAuth2Token(out message);
|
||||
}
|
||||
}
|
||||
}
|
45
SafeExamBrowser.Server/Requests/PingRequest.cs
Normal file
45
SafeExamBrowser.Server/Requests/PingRequest.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class PingRequest : BaseRequest
|
||||
{
|
||||
internal PingRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(int pingNumber, out HttpContent content, out string message, string confirmation = default)
|
||||
{
|
||||
var requestContent = $"timestamp={DateTime.Now.ToUnixTimestamp()}&ping-number={pingNumber}";
|
||||
|
||||
if (confirmation != default)
|
||||
{
|
||||
requestContent = $"{requestContent}&instruction-confirm={confirmation}";
|
||||
}
|
||||
|
||||
var success = TryExecute(HttpMethod.Post, api.PingEndpoint, out var response, requestContent, ContentType.URL_ENCODED, Authorization, Token);
|
||||
|
||||
content = response?.Content;
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
64
SafeExamBrowser.Server/Requests/PowerSupplyRequest.cs
Normal file
64
SafeExamBrowser.Server/Requests/PowerSupplyRequest.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* 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 Newtonsoft.Json.Linq;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Logging;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
using SafeExamBrowser.SystemComponents.Contracts.PowerSupply;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class PowerSupplyRequest : BaseRequest
|
||||
{
|
||||
internal PowerSupplyRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(IPowerSupplyStatus status, bool previouslyConnected, int previousValue, out string message)
|
||||
{
|
||||
var connected = status.IsOnline;
|
||||
var text = default(string);
|
||||
var value = Convert.ToInt32(status.BatteryCharge * 100);
|
||||
|
||||
if (value != previousValue)
|
||||
{
|
||||
var chargeInfo = $"{status.BatteryChargeStatus} at {value}%";
|
||||
var gridInfo = $"{(connected ? "connected to" : "disconnected from")} the power grid";
|
||||
|
||||
text = $"<battery> {chargeInfo}, {status.BatteryTimeRemaining} remaining, {gridInfo}";
|
||||
}
|
||||
else if (connected != previouslyConnected)
|
||||
{
|
||||
text = $"<battery> Device has been {(connected ? "connected to" : "disconnected from")} power grid";
|
||||
}
|
||||
|
||||
var json = new JObject
|
||||
{
|
||||
["numericValue"] = value,
|
||||
["text"] = text,
|
||||
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
|
||||
["type"] = LogLevel.Info.ToLogType()
|
||||
};
|
||||
|
||||
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, json.ToString(), ContentType.JSON, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
46
SafeExamBrowser.Server/Requests/RaiseHandRequest.cs
Normal file
46
SafeExamBrowser.Server/Requests/RaiseHandRequest.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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 Newtonsoft.Json.Linq;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class RaiseHandRequest : BaseRequest
|
||||
{
|
||||
internal RaiseHandRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(int handId, string text, out string message)
|
||||
{
|
||||
var json = new JObject
|
||||
{
|
||||
["numericValue"] = handId,
|
||||
["text"] = $"<raisehand> {text}",
|
||||
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
|
||||
["type"] = "NOTIFICATION"
|
||||
};
|
||||
var content = json.ToString();
|
||||
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, ContentType.JSON, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
41
SafeExamBrowser.Server/Requests/SelectExamRequest.cs
Normal file
41
SafeExamBrowser.Server/Requests/SelectExamRequest.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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;
|
||||
using SafeExamBrowser.Server.Contracts.Data;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class SelectExamRequest : BaseRequest
|
||||
{
|
||||
internal SelectExamRequest(ApiVersion1 api, HttpClient httpClient, ILogger logger, Parser parser, ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(Exam exam, out string message, out string appSignatureKeySalt, out string browserExamKey)
|
||||
{
|
||||
var content = $"examId={exam.Id}";
|
||||
var success = TryExecute(HttpMethod.Put, api.HandshakeEndpoint, out var response, content, ContentType.URL_ENCODED, Authorization, Token);
|
||||
|
||||
appSignatureKeySalt = default;
|
||||
browserExamKey = default;
|
||||
message = response.ToLogString();
|
||||
|
||||
if (success)
|
||||
{
|
||||
parser.TryParseAppSignatureKeySalt(response, out appSignatureKeySalt);
|
||||
parser.TryParseBrowserExamKey(response, out browserExamKey);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
38
SafeExamBrowser.Server/Requests/UserIdentifierRequest.cs
Normal file
38
SafeExamBrowser.Server/Requests/UserIdentifierRequest.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
|
||||
namespace SafeExamBrowser.Server.Requests
|
||||
{
|
||||
internal class UserIdentifierRequest : BaseRequest
|
||||
{
|
||||
internal UserIdentifierRequest(
|
||||
ApiVersion1 api,
|
||||
HttpClient httpClient,
|
||||
ILogger logger,
|
||||
Parser parser,
|
||||
ServerSettings settings) : base(api, httpClient, logger, parser, settings)
|
||||
{
|
||||
}
|
||||
|
||||
internal bool TryExecute(string examId, string identifier, out string message)
|
||||
{
|
||||
var content = $"examId={examId}&seb_user_session_id={identifier}";
|
||||
var method = new HttpMethod("PATCH");
|
||||
var success = TryExecute(method, api.HandshakeEndpoint, out var response, content, ContentType.URL_ENCODED, Authorization, Token);
|
||||
|
||||
message = response.ToLogString();
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
117
SafeExamBrowser.Server/SafeExamBrowser.Server.csproj
Normal file
117
SafeExamBrowser.Server/SafeExamBrowser.Server.csproj
Normal file
@@ -0,0 +1,117 @@
|
||||
<?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>{46EDBDE0-58B4-4725-9783-0C55C3D49C0C}</ProjectGuid>
|
||||
<OutputType>Library</OutputType>
|
||||
<AppDesignerFolder>Properties</AppDesignerFolder>
|
||||
<RootNamespace>SafeExamBrowser.Server</RootNamespace>
|
||||
<AssemblyName>SafeExamBrowser.Server</AssemblyName>
|
||||
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
|
||||
<FileAlignment>512</FileAlignment>
|
||||
<Deterministic>true</Deterministic>
|
||||
<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="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="System" />
|
||||
<Reference Include="Microsoft.CSharp" />
|
||||
<Reference Include="System.Net.Http" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Data\ApiVersion1.cs" />
|
||||
<Compile Include="Data\Attributes.cs" />
|
||||
<Compile Include="Data\AttributeType.cs" />
|
||||
<Compile Include="Data\Instructions.cs" />
|
||||
<Compile Include="Extensions.cs" />
|
||||
<Compile Include="FileSystem.cs" />
|
||||
<Compile Include="Parser.cs" />
|
||||
<Compile Include="Properties\AssemblyInfo.cs" />
|
||||
<Compile Include="Requests\ApiRequest.cs" />
|
||||
<Compile Include="Requests\AppSignatureKeyRequest.cs" />
|
||||
<Compile Include="Requests\AvailableExamsRequest.cs" />
|
||||
<Compile Include="Requests\BaseRequest.cs" />
|
||||
<Compile Include="Requests\ConfirmLockScreenRequest.cs" />
|
||||
<Compile Include="Requests\ContentType.cs" />
|
||||
<Compile Include="Requests\DisconnectionRequest.cs" />
|
||||
<Compile Include="Requests\ExamConfigurationRequest.cs" />
|
||||
<Compile Include="Requests\Header.cs" />
|
||||
<Compile Include="Requests\LockScreenRequest.cs" />
|
||||
<Compile Include="Requests\LogRequest.cs" />
|
||||
<Compile Include="Requests\LowerHandRequest.cs" />
|
||||
<Compile Include="Requests\NetworkAdapterRequest.cs" />
|
||||
<Compile Include="Requests\OAuth2TokenRequest.cs" />
|
||||
<Compile Include="Requests\PingRequest.cs" />
|
||||
<Compile Include="Requests\PowerSupplyRequest.cs" />
|
||||
<Compile Include="Requests\RaiseHandRequest.cs" />
|
||||
<Compile Include="Requests\SelectExamRequest.cs" />
|
||||
<Compile Include="Requests\UserIdentifierRequest.cs" />
|
||||
<Compile Include="ServerProxy.cs" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<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.Logging.Contracts\SafeExamBrowser.Logging.Contracts.csproj">
|
||||
<Project>{64ea30fb-11d4-436a-9c2b-88566285363e}</Project>
|
||||
<Name>SafeExamBrowser.Logging.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>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||
</Project>
|
536
SafeExamBrowser.Server/ServerProxy.cs
Normal file
536
SafeExamBrowser.Server/ServerProxy.cs
Normal file
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
* 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.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using Newtonsoft.Json;
|
||||
using SafeExamBrowser.Configuration.Contracts;
|
||||
using SafeExamBrowser.Configuration.Contracts.Cryptography;
|
||||
using SafeExamBrowser.Logging.Contracts;
|
||||
using SafeExamBrowser.Server.Contracts;
|
||||
using SafeExamBrowser.Server.Contracts.Data;
|
||||
using SafeExamBrowser.Server.Contracts.Events;
|
||||
using SafeExamBrowser.Server.Contracts.Events.Proctoring;
|
||||
using SafeExamBrowser.Server.Data;
|
||||
using SafeExamBrowser.Server.Requests;
|
||||
using SafeExamBrowser.Settings.Server;
|
||||
using SafeExamBrowser.SystemComponents.Contracts;
|
||||
using SafeExamBrowser.SystemComponents.Contracts.Network;
|
||||
using SafeExamBrowser.SystemComponents.Contracts.PowerSupply;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace SafeExamBrowser.Server
|
||||
{
|
||||
public class ServerProxy : ILogObserver, IServerProxy
|
||||
{
|
||||
private readonly AppConfig appConfig;
|
||||
private readonly FileSystem fileSystem;
|
||||
private readonly ConcurrentQueue<string> instructionConfirmations;
|
||||
private readonly IKeyGenerator keyGenerator;
|
||||
private readonly ILogger logger;
|
||||
private readonly ConcurrentQueue<ILogContent> logContent;
|
||||
private readonly Timer logTimer;
|
||||
private readonly Parser parser;
|
||||
private readonly Timer pingTimer;
|
||||
private readonly IPowerSupply powerSupply;
|
||||
private readonly ISystemInfo systemInfo;
|
||||
private readonly IUserInfo userInfo;
|
||||
private readonly INetworkAdapter networkAdapter;
|
||||
|
||||
private ApiVersion1 api;
|
||||
private int currentHandId;
|
||||
private int currentLockScreenId;
|
||||
private string examId;
|
||||
private HttpClient httpClient;
|
||||
private int notificationId;
|
||||
private int pingNumber;
|
||||
private bool powerSupplyConnected;
|
||||
private int powerSupplyValue;
|
||||
private ServerSettings settings;
|
||||
private int wirelessNetworkValue;
|
||||
|
||||
public event ServerEventHandler HandConfirmed;
|
||||
public event ServerEventHandler LockScreenConfirmed;
|
||||
public event ProctoringConfigurationReceivedEventHandler ProctoringConfigurationReceived;
|
||||
public event ProctoringInstructionReceivedEventHandler ProctoringInstructionReceived;
|
||||
public event TerminationRequestedEventHandler TerminationRequested;
|
||||
public event LockScreenRequestedEventHandler LockScreenRequested;
|
||||
|
||||
public ServerProxy(
|
||||
AppConfig appConfig,
|
||||
IKeyGenerator keyGenerator,
|
||||
ILogger logger,
|
||||
ISystemInfo systemInfo,
|
||||
IUserInfo userInfo,
|
||||
IPowerSupply powerSupply = default,
|
||||
INetworkAdapter networkAdapter = default)
|
||||
{
|
||||
this.api = new ApiVersion1();
|
||||
this.appConfig = appConfig;
|
||||
this.keyGenerator = keyGenerator;
|
||||
this.fileSystem = new FileSystem(appConfig, logger);
|
||||
this.instructionConfirmations = new ConcurrentQueue<string>();
|
||||
this.logger = logger;
|
||||
this.logContent = new ConcurrentQueue<ILogContent>();
|
||||
this.logTimer = new Timer();
|
||||
this.networkAdapter = networkAdapter;
|
||||
this.parser = new Parser(logger);
|
||||
this.pingTimer = new Timer();
|
||||
this.powerSupply = powerSupply;
|
||||
this.systemInfo = systemInfo;
|
||||
this.userInfo = userInfo;
|
||||
}
|
||||
|
||||
public ServerResponse ConfirmLockScreen()
|
||||
{
|
||||
var request = new ConfirmLockScreenRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(currentLockScreenId, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info($"Successfully sent notification confirmation for lock screen #{currentLockScreenId}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error($"Failed to send notification confirmation for lock screen #{currentLockScreenId}!");
|
||||
}
|
||||
|
||||
return new ServerResponse(success, message);
|
||||
}
|
||||
|
||||
public ServerResponse Connect()
|
||||
{
|
||||
var request = new ApiRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(out api, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully loaded server API.");
|
||||
success = new OAuth2TokenRequest(api, httpClient, logger, parser, settings).TryExecute(out message);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to load server API!");
|
||||
}
|
||||
|
||||
return new ServerResponse(success, message);
|
||||
}
|
||||
|
||||
public ServerResponse Disconnect()
|
||||
{
|
||||
var request = new DisconnectionRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully terminated connection.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to terminate connection!");
|
||||
}
|
||||
|
||||
return new ServerResponse(success, message);
|
||||
}
|
||||
|
||||
public ServerResponse<IEnumerable<Exam>> GetAvailableExams(string examId = default)
|
||||
{
|
||||
var request = new AvailableExamsRequest(api, appConfig, httpClient, logger, parser, settings, systemInfo, userInfo);
|
||||
var success = request.TryExecute(examId, out var exams, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully retrieved available exams.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to retrieve available exams!");
|
||||
}
|
||||
|
||||
return new ServerResponse<IEnumerable<Exam>>(success, exams, message);
|
||||
}
|
||||
|
||||
public ServerResponse<Uri> GetConfigurationFor(Exam exam)
|
||||
{
|
||||
var request = new ExamConfigurationRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(exam, out var content, out var message);
|
||||
var uri = default(Uri);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully retrieved exam configuration.");
|
||||
|
||||
success = fileSystem.TrySaveFile(content, out uri);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info($"Successfully saved exam configuration as '{uri}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to save exam configuration!");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to retrieve exam configuration!");
|
||||
}
|
||||
|
||||
return new ServerResponse<Uri>(success, uri, message);
|
||||
}
|
||||
|
||||
public ConnectionInfo GetConnectionInfo()
|
||||
{
|
||||
return new ConnectionInfo
|
||||
{
|
||||
Api = JsonConvert.SerializeObject(api),
|
||||
ConnectionToken = BaseRequest.ConnectionToken,
|
||||
Oauth2Token = BaseRequest.Oauth2Token
|
||||
};
|
||||
}
|
||||
|
||||
public void Initialize(ServerSettings settings)
|
||||
{
|
||||
this.settings = settings;
|
||||
|
||||
httpClient = new HttpClient();
|
||||
httpClient.BaseAddress = new Uri(settings.ServerUrl);
|
||||
|
||||
if (settings.RequestTimeout > 0)
|
||||
{
|
||||
httpClient.Timeout = TimeSpan.FromMilliseconds(settings.RequestTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize(string api, string connectionToken, string examId, string oauth2Token, ServerSettings settings)
|
||||
{
|
||||
this.api = JsonConvert.DeserializeObject<ApiVersion1>(api);
|
||||
this.examId = examId;
|
||||
|
||||
BaseRequest.ConnectionToken = connectionToken;
|
||||
BaseRequest.Oauth2Token = oauth2Token;
|
||||
|
||||
Initialize(settings);
|
||||
}
|
||||
|
||||
public ServerResponse LockScreen(string text = null)
|
||||
{
|
||||
var request = new LockScreenRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(currentLockScreenId = ++notificationId, text, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info($"Successfully sent notification for lock screen #{currentLockScreenId}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error($"Failed to send notification for lock screen #{currentLockScreenId}!");
|
||||
}
|
||||
|
||||
return new ServerResponse(success, message);
|
||||
}
|
||||
|
||||
public ServerResponse LowerHand()
|
||||
{
|
||||
var request = new LowerHandRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(currentHandId, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully sent lower hand notification.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to send lower hand notification!");
|
||||
}
|
||||
|
||||
return new ServerResponse(success, message);
|
||||
}
|
||||
|
||||
public void Notify(ILogContent content)
|
||||
{
|
||||
logContent.Enqueue(content);
|
||||
}
|
||||
|
||||
public ServerResponse RaiseHand(string text = null)
|
||||
{
|
||||
var request = new RaiseHandRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(currentHandId = ++notificationId, text, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully sent raise hand notification.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to send raise hand notification!");
|
||||
}
|
||||
|
||||
return new ServerResponse(success, message);
|
||||
}
|
||||
|
||||
public ServerResponse<string> SendSelectedExam(Exam exam)
|
||||
{
|
||||
var request = new SelectExamRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(exam, out var message, out var appSignatureKeySalt, out var browserExamKey);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully sent selected exam.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to send selected exam!");
|
||||
}
|
||||
|
||||
if (success && appSignatureKeySalt != default)
|
||||
{
|
||||
logger.Info("App signature key salt detected, performing key exchange...");
|
||||
success = TrySendAppSignatureKey(appSignatureKeySalt, out message);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info("No app signature key salt detected, skipping key exchange.");
|
||||
}
|
||||
|
||||
if (browserExamKey != default)
|
||||
{
|
||||
logger.Info("Custom browser exam key detected.");
|
||||
}
|
||||
|
||||
return new ServerResponse<string>(success, browserExamKey, message);
|
||||
}
|
||||
|
||||
public ServerResponse SendUserIdentifier(string identifier)
|
||||
{
|
||||
var request = new UserIdentifierRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(examId, identifier, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully sent user identifier.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to send user identifier!");
|
||||
}
|
||||
|
||||
return new ServerResponse(success, message);
|
||||
}
|
||||
|
||||
public void StartConnectivity()
|
||||
{
|
||||
foreach (var item in logger.GetLog())
|
||||
{
|
||||
logContent.Enqueue(item);
|
||||
}
|
||||
|
||||
logger.Subscribe(this);
|
||||
logTimer.AutoReset = false;
|
||||
logTimer.Elapsed += LogTimer_Elapsed;
|
||||
logTimer.Interval = 500;
|
||||
logTimer.Start();
|
||||
logger.Info("Started sending log items.");
|
||||
|
||||
pingTimer.AutoReset = false;
|
||||
pingTimer.Elapsed += PingTimer_Elapsed;
|
||||
pingTimer.Interval = settings.PingInterval;
|
||||
pingTimer.Start();
|
||||
logger.Info("Started sending pings.");
|
||||
|
||||
if (powerSupply != default && networkAdapter != default)
|
||||
{
|
||||
powerSupply.StatusChanged += PowerSupply_StatusChanged;
|
||||
networkAdapter.Changed += NetworkAdapter_Changed;
|
||||
logger.Info("Started monitoring system components.");
|
||||
}
|
||||
}
|
||||
|
||||
public void StopConnectivity()
|
||||
{
|
||||
if (powerSupply != default && networkAdapter != default)
|
||||
{
|
||||
powerSupply.StatusChanged -= PowerSupply_StatusChanged;
|
||||
networkAdapter.Changed -= NetworkAdapter_Changed;
|
||||
logger.Info("Stopped monitoring system components.");
|
||||
}
|
||||
|
||||
logger.Unsubscribe(this);
|
||||
logTimer.Stop();
|
||||
logTimer.Elapsed -= LogTimer_Elapsed;
|
||||
logger.Info("Stopped sending log items.");
|
||||
|
||||
pingTimer.Stop();
|
||||
pingTimer.Elapsed -= PingTimer_Elapsed;
|
||||
logger.Info("Stopped sending pings.");
|
||||
}
|
||||
|
||||
private void LogTimer_Elapsed(object sender, ElapsedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!logContent.IsEmpty)
|
||||
{
|
||||
if (logContent.TryDequeue(out var c) && c is ILogMessage message)
|
||||
{
|
||||
new LogRequest(api, httpClient, logger, parser, settings).TryExecute(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to send log!", e);
|
||||
}
|
||||
|
||||
logTimer.Start();
|
||||
}
|
||||
|
||||
private void PingTimer_Elapsed(object sender, ElapsedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
instructionConfirmations.TryDequeue(out var confirmation);
|
||||
|
||||
var request = new PingRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(++pingNumber, out var content, out var message, confirmation);
|
||||
|
||||
if (success)
|
||||
{
|
||||
if (parser.TryParseInstruction(content, out var attributes, out var instruction, out var instructionConfirmation))
|
||||
{
|
||||
HandleInstruction(attributes, instruction);
|
||||
|
||||
if (instructionConfirmation != default)
|
||||
{
|
||||
instructionConfirmations.Enqueue(instructionConfirmation);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warn($"Failed to send ping: {message}!");
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to send ping!", e);
|
||||
}
|
||||
|
||||
pingTimer.Start();
|
||||
}
|
||||
|
||||
private void PowerSupply_StatusChanged(IPowerSupplyStatus status)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = Convert.ToInt32(status.BatteryCharge * 100);
|
||||
var statusChanged = powerSupplyConnected != status.IsOnline;
|
||||
var valueChanged = powerSupplyValue != value;
|
||||
|
||||
if (statusChanged || valueChanged)
|
||||
{
|
||||
var request = new PowerSupplyRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(status, powerSupplyConnected, powerSupplyValue, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
powerSupplyConnected = status.IsOnline;
|
||||
powerSupplyValue = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warn($"Failed to send power supply status: {message}!");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to send power supply status!", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void NetworkAdapter_Changed()
|
||||
{
|
||||
const int NOT_CONNECTED = -1;
|
||||
|
||||
try
|
||||
{
|
||||
var network = networkAdapter.GetWirelessNetworks().FirstOrDefault(n => n.Status == ConnectionStatus.Connected);
|
||||
var statusChanged = network == default && wirelessNetworkValue != NOT_CONNECTED;
|
||||
var valueChanged = network != default && Math.Abs(network.SignalStrength - wirelessNetworkValue) >= 5;
|
||||
|
||||
if (statusChanged || valueChanged)
|
||||
{
|
||||
var request = new NetworkAdapterRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(network, out var message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
wirelessNetworkValue = network?.SignalStrength ?? NOT_CONNECTED;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Warn($"Failed to send wireless status: {message}!");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.Error("Failed to send wireless status!", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleInstruction(Attributes attributes, string instruction)
|
||||
{
|
||||
switch (instruction)
|
||||
{
|
||||
case Instructions.LOCK_SCREEN:
|
||||
Task.Run(() => LockScreenRequested?.Invoke(attributes.Message));
|
||||
break;
|
||||
case Instructions.NOTIFICATION_CONFIRM when attributes.Type == AttributeType.LockScreen:
|
||||
Task.Run(() => LockScreenConfirmed?.Invoke());
|
||||
break;
|
||||
case Instructions.NOTIFICATION_CONFIRM when attributes.Type == AttributeType.Hand:
|
||||
Task.Run(() => HandConfirmed?.Invoke());
|
||||
break;
|
||||
case Instructions.PROCTORING:
|
||||
Task.Run(() => ProctoringInstructionReceived?.Invoke(attributes.Instruction));
|
||||
break;
|
||||
case Instructions.PROCTORING_RECONFIGURATION:
|
||||
Task.Run(() => ProctoringConfigurationReceived?.Invoke(attributes.AllowChat, attributes.ReceiveAudio, attributes.ReceiveVideo));
|
||||
break;
|
||||
case Instructions.QUIT:
|
||||
Task.Run(() => TerminationRequested?.Invoke());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TrySendAppSignatureKey(string salt, out string message)
|
||||
{
|
||||
var appSignatureKey = keyGenerator.CalculateAppSignatureKey(BaseRequest.ConnectionToken, salt);
|
||||
var request = new AppSignatureKeyRequest(api, httpClient, logger, parser, settings);
|
||||
var success = request.TryExecute(appSignatureKey, out message);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.Info("Successfully sent app signature key.");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Error("Failed to send app signature key!");
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
}
|
||||
}
|
4
SafeExamBrowser.Server/packages.config
Normal file
4
SafeExamBrowser.Server/packages.config
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<packages>
|
||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
||||
</packages>
|
Reference in New Issue
Block a user