(refactor): code review
This commit is contained in:
parent
6d996ae3c8
commit
c52e3db122
@ -29,7 +29,7 @@ I have also prepared ***.cmd** file to simplify service system integration:
|
|||||||
Install.cmd
|
Install.cmd
|
||||||
|
|
||||||
```bat
|
```bat
|
||||||
sc.exe create "Unified Scheduler Service" binpath="%~dp0UScheduler.exe
|
sc.exe create "Unified Scheduler Service" binpath="%~dp0UScheduler.exe"
|
||||||
pause
|
pause
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -30,23 +30,18 @@ namespace UScheduler.BackgroundServices {
|
|||||||
|
|
||||||
//stop background service if there are no PowerShell scripts to run
|
//stop background service if there are no PowerShell scripts to run
|
||||||
if (psScripts.Count == 0) {
|
if (psScripts.Count == 0) {
|
||||||
_logger.LogInformation("No PowerShell scripts to run, stopping PSScriptBackgroundService");
|
_logger.LogWarning("No PowerShell scripts to run, stopping PSScriptBackgroundService");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var psScript in psScripts) {
|
foreach (var psScript in psScripts) {
|
||||||
if (psScript.GetPathOrDefault == string.Empty)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var scriptPath = psScript.GetPathOrDefault;
|
var scriptPath = psScript.GetPathOrDefault;
|
||||||
|
|
||||||
if (_psScriptService.GetRunningScriptTasks().Contains(scriptPath)) {
|
if (scriptPath == string.Empty)
|
||||||
_logger.LogInformation($"PowerShell script {scriptPath} is already running");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation($"Running PowerShell script {scriptPath}");
|
_logger.LogInformation($"Running PowerShell script {scriptPath}");
|
||||||
_psScriptService.RunScript(scriptPath, psScript.GetIsSignedOrDefault);
|
_psScriptService.RunScript(scriptPath, psScript.GetIsSignedOrDefault, stoppingToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||||
@ -56,8 +51,6 @@ namespace UScheduler.BackgroundServices {
|
|||||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||||
_logger.LogInformation("Stopping PSScriptBackgroundService due to cancellation request");
|
_logger.LogInformation("Stopping PSScriptBackgroundService due to cancellation request");
|
||||||
|
|
||||||
_psScriptService.TerminateAllScripts();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError(ex, "{Message}", ex.Message);
|
_logger.LogError(ex, "{Message}", ex.Message);
|
||||||
@ -73,5 +66,17 @@ namespace UScheduler.BackgroundServices {
|
|||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||||
|
// Perform cleanup tasks here
|
||||||
|
_logger.LogInformation("Stopping PSScriptBackgroundService");
|
||||||
|
|
||||||
|
_psScriptService.TerminateAllScripts();
|
||||||
|
|
||||||
|
_logger.LogInformation("PSScriptBackgroundService stopped");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json;
|
||||||
using UScheduler.Services;
|
using UScheduler.Services;
|
||||||
|
|
||||||
namespace UScheduler.BackgroundServices;
|
namespace UScheduler.BackgroundServices;
|
||||||
@ -30,25 +31,22 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
|||||||
|
|
||||||
//stop background service if there are no processes to run
|
//stop background service if there are no processes to run
|
||||||
if (processes.Count == 0) {
|
if (processes.Count == 0) {
|
||||||
_logger.LogInformation("No processes to run, stopping ProcessBackgroundService");
|
_logger.LogWarning("No processes to run, stopping ProcessBackgroundService");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var process in processes) {
|
foreach (var process in processes) {
|
||||||
if (process.GetPathOrDefault == string.Empty)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var processPath = process.GetPathOrDefault;
|
var processPath = process.GetPathOrDefault;
|
||||||
var processArgs = process.GetArgsOrDefault;
|
var processArgs = process.GetArgsOrDefault;
|
||||||
|
|
||||||
if (_processService.GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == processPath)) {
|
if (processPath == string.Empty)
|
||||||
_logger.LogInformation($"Process {processPath} is already running");
|
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation($"Running process {processPath} with arguments {string.Join(", ", processArgs)}");
|
_logger.LogInformation($"Running process {processPath} with arguments {string.Join(", ", processArgs)}");
|
||||||
_processService.RunProcess(processPath, processArgs, stoppingToken);
|
_processService.RunProcess(processPath, processArgs, stoppingToken);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||||
}
|
}
|
||||||
@ -57,8 +55,6 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
|||||||
// When the stopping token is canceled, for example, a call made from services.msc,
|
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||||
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||||
_logger.LogInformation("Stopping ProcessBackgroundService due to cancellation request");
|
_logger.LogInformation("Stopping ProcessBackgroundService due to cancellation request");
|
||||||
|
|
||||||
_processService.TerminateAllProcesses();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError(ex, "{Message}", ex.Message);
|
_logger.LogError(ex, "{Message}", ex.Message);
|
||||||
@ -74,4 +70,15 @@ public sealed class ProcessBackgroundService : BackgroundService {
|
|||||||
Environment.Exit(1);
|
Environment.Exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override Task StopAsync(CancellationToken stoppingToken) {
|
||||||
|
// Perform cleanup tasks here
|
||||||
|
_logger.LogInformation("Stopping ProcessBackgroundService");
|
||||||
|
|
||||||
|
_processService.TerminateAllProcesses();
|
||||||
|
|
||||||
|
_logger.LogInformation("All processes terminated");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,20 +27,15 @@ namespace UScheduler {
|
|||||||
public class Configuration {
|
public class Configuration {
|
||||||
|
|
||||||
public string? ServiceName { get; set; }
|
public string? ServiceName { get; set; }
|
||||||
public string? Description { get; set; }
|
|
||||||
public string? DisplayName { get; set; }
|
|
||||||
|
|
||||||
public List<PowershellScript>? Powershell { get; set; }
|
public List<PowershellScript>? Powershell { get; set; }
|
||||||
|
|
||||||
public List<ProcessConfiguration>? Processes { get; set; }
|
public List<ProcessConfiguration>? Processes { get; set; }
|
||||||
|
|
||||||
public string ServiceNameOrDefault => ServiceName ?? string.Empty;
|
public string ServiceNameOrDefault => ServiceName ?? string.Empty;
|
||||||
public string DescriptionOrDefault => Description ?? string.Empty;
|
|
||||||
|
|
||||||
public string DisplayNameOrDefault => DisplayName ?? string.Empty;
|
public List<PowershellScript> PowershellOrDefault => Powershell ?? [];
|
||||||
|
|
||||||
public List<PowershellScript> PowershellOrDefault => Powershell ?? new List<PowershellScript>();
|
public List<ProcessConfiguration> ProcessesOrDefault => Processes ?? [];
|
||||||
|
|
||||||
public List<ProcessConfiguration> ProcessesOrDefault => Processes ?? new List<ProcessConfiguration>();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
"%~dp0PSScriptsService.exe" install
|
sc.exe create "Unified Scheduler Service" binpath="%~dp0UScheduler.exe"
|
||||||
|
sc description "Unified Scheduler Service" "Windows service, which allows you to invoke PowerShell Scripts and Processes"
|
||||||
sc.exe create ".NET Joke Service" binpath="C:\Path\To\App.WindowsService.exe"
|
|
||||||
sc.exe create "Svc Name" binpath="C:\Path\To\App.exe --contentRoot C:\Other\Path"
|
|
||||||
pause
|
pause
|
||||||
@ -1,13 +1,15 @@
|
|||||||
using Microsoft.Extensions.Logging.Configuration;
|
using Microsoft.Extensions.Logging.Configuration;
|
||||||
using Microsoft.Extensions.Logging.EventLog;
|
using Microsoft.Extensions.Logging.EventLog;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
using UScheduler;
|
using UScheduler;
|
||||||
using UScheduler.BackgroundServices;
|
using UScheduler.BackgroundServices;
|
||||||
using UScheduler.Services;
|
using UScheduler.Services;
|
||||||
|
|
||||||
// read configuration from appsettings.json
|
// read configuration from appsettings.json
|
||||||
var configurationRoot = new ConfigurationBuilder()
|
var configurationRoot = new ConfigurationBuilder()
|
||||||
.SetBasePath(Directory.GetCurrentDirectory())
|
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
||||||
.AddJsonFile("appsettings.json", optional: true)
|
.AddJsonFile("appsettings.json", optional: true)
|
||||||
|
.AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
// bind Configuration section inside configuration to a new instance of Settings
|
// bind Configuration section inside configuration to a new instance of Settings
|
||||||
@ -19,13 +21,11 @@ builder.Services.AddWindowsService(options => {
|
|||||||
options.ServiceName = configuration.ServiceNameOrDefault;
|
options.ServiceName = configuration.ServiceNameOrDefault;
|
||||||
});
|
});
|
||||||
|
|
||||||
LoggerProviderOptions.RegisterProviderOptions<
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||||
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
LoggerProviderOptions.RegisterProviderOptions<
|
||||||
|
EventLogSettings, EventLogLoggerProvider>(builder.Services);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// register configuration as IOptions<Configuration>
|
|
||||||
builder.Services.Configure<Configuration>(configurationRoot.GetSection("Configurations"));
|
builder.Services.Configure<Configuration>(configurationRoot.GetSection("Configurations"));
|
||||||
|
|
||||||
builder.Services.AddSingleton<ProcessService>();
|
builder.Services.AddSingleton<ProcessService>();
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||||
|
-->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<Configuration>Release</Configuration>
|
||||||
|
<Platform>Any CPU</Platform>
|
||||||
|
<PublishDir>bin\Release\net8.0\win-x64\publish\win-x64\</PublishDir>
|
||||||
|
<PublishProtocol>FileSystem</PublishProtocol>
|
||||||
|
<_TargetId>Folder</_TargetId>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<SelfContained>true</SelfContained>
|
||||||
|
<PublishReadyToRun>true</PublishReadyToRun>
|
||||||
|
<PublishTrimmed>false</PublishTrimmed>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@ -17,9 +17,14 @@ namespace UScheduler.Services {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task RunScript(string scriptPath, bool signed) {
|
public Task RunScript(string scriptPath, bool signed, CancellationToken stoppingToken) {
|
||||||
_logger.LogInformation($"Preparing to run script {scriptPath}");
|
_logger.LogInformation($"Preparing to run script {scriptPath}");
|
||||||
|
|
||||||
|
if (GetRunningScriptTasks().Contains(scriptPath)) {
|
||||||
|
_logger.LogInformation($"PowerShell script {scriptPath} is already running");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
if (!File.Exists(scriptPath)) {
|
if (!File.Exists(scriptPath)) {
|
||||||
_logger.LogError($"Script file {scriptPath} does not exist");
|
_logger.LogError($"Script file {scriptPath} does not exist");
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
@ -60,11 +65,17 @@ namespace UScheduler.Services {
|
|||||||
ps.Invoke();
|
ps.Invoke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
catch (OperationCanceledException) {
|
||||||
|
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||||
|
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||||
|
_logger.LogInformation($"Stopping script {scriptPath} due to cancellation request");
|
||||||
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError($"Error running script {scriptPath}: {ex.Message}");
|
_logger.LogError($"Error running script {scriptPath}: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
_runningScripts.TryRemove(scriptPath, out _);
|
TerminateScript(scriptPath);
|
||||||
_logger.LogInformation($"Script {scriptPath} completed and removed from running scripts");
|
_logger.LogInformation($"Script {scriptPath} completed and removed from running scripts");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,11 +16,20 @@ namespace UScheduler.Services {
|
|||||||
Process? process = null;
|
Process? process = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (GetRunningProcesses().Any(x => x.Value.StartInfo.FileName == processPath)) {
|
||||||
|
_logger.LogInformation($"Process {processPath} is already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
process = new Process();
|
process = new Process();
|
||||||
process.StartInfo.FileName = processPath;
|
|
||||||
process.StartInfo.UseShellExecute = false;
|
process.StartInfo = new ProcessStartInfo {
|
||||||
process.StartInfo.RedirectStandardOutput = true;
|
FileName = processPath,
|
||||||
process.StartInfo.RedirectStandardError = true;
|
WorkingDirectory = Path.GetDirectoryName(processPath),
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true
|
||||||
|
};
|
||||||
|
|
||||||
foreach (var arg in args)
|
foreach (var arg in args)
|
||||||
process.StartInfo.ArgumentList.Add(arg);
|
process.StartInfo.ArgumentList.Add(arg);
|
||||||
@ -41,14 +50,17 @@ namespace UScheduler.Services {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) {
|
catch (OperationCanceledException) {
|
||||||
_logger.LogInformation($"Process {processPath} was cancelled");
|
// When the stopping token is canceled, for example, a call made from services.msc,
|
||||||
|
// we shouldn't exit with a non-zero exit code. In other words, this is expected...
|
||||||
|
_logger.LogWarning($"Process {processPath} was canceled");
|
||||||
}
|
}
|
||||||
catch (Exception ex) {
|
catch (Exception ex) {
|
||||||
_logger.LogError($"Error running process {processPath}: {ex.Message}");
|
_logger.LogError($"Error running process {processPath}: {ex.Message}");
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
if (process != null && _runningProcesses.ContainsKey(process.Id)) {
|
if (process != null && _runningProcesses.ContainsKey(process.Id)) {
|
||||||
_runningProcesses.TryRemove(process.Id, out _);
|
TerminateProcessById(process.Id);
|
||||||
|
|
||||||
_logger.LogInformation($"Process {processPath} with ID {process.Id} removed from running processes");
|
_logger.LogInformation($"Process {processPath} with ID {process.Id} removed from running processes");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -60,16 +72,29 @@ namespace UScheduler.Services {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void TerminateProcessById(int processId) {
|
public void TerminateProcessById(int processId) {
|
||||||
if (_runningProcesses.TryRemove(processId, out var process)) {
|
// Check if the process is in the running processes list
|
||||||
_logger.LogInformation($"Terminating process with ID {processId}");
|
if (!_runningProcesses.TryGetValue(processId, out var processToTerminate)) {
|
||||||
process.Kill();
|
_logger.LogWarning($"Failed to terminate process {processId}. Process not found.");
|
||||||
_logger.LogInformation($"Process with ID {processId} terminated");
|
return;
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
_logger.LogWarning($"Failed to terminate process with ID {processId}. Process not found.");
|
// Kill the process
|
||||||
|
try {
|
||||||
|
processToTerminate.Kill(true);
|
||||||
|
_logger.LogInformation($"Process {processId} terminated");
|
||||||
|
}
|
||||||
|
catch (Exception ex) {
|
||||||
|
_logger.LogError($"Error terminating process {processId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the process has exited
|
||||||
|
if (!processToTerminate.HasExited) {
|
||||||
|
_logger.LogWarning($"Failed to terminate process {processId}. Process still running.");
|
||||||
|
TerminateProcessById(processId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public void TerminateAllProcesses() {
|
public void TerminateAllProcesses() {
|
||||||
foreach (var process in _runningProcesses) {
|
foreach (var process in _runningProcesses) {
|
||||||
TerminateProcessById(process.Key);
|
TerminateProcessById(process.Key);
|
||||||
|
|||||||
@ -24,6 +24,15 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Abstractions\" />
|
<None Update="Install.cmd">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="Uninstall.cmd">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Properties\PublishProfiles\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,5 +1,2 @@
|
|||||||
"%~dp0PSScriptsService.exe" uninstall
|
sc.exe delete "Unified Scheduler Service"
|
||||||
|
|
||||||
sc.exe delete ".NET Joke Service"
|
|
||||||
|
|
||||||
pause
|
pause
|
||||||
@ -1,8 +1,31 @@
|
|||||||
{
|
{
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Warning"
|
||||||
"Microsoft.Hosting.Lifetime": "Information"
|
},
|
||||||
|
"EventLog": {
|
||||||
|
"SourceName": "UScheduler",
|
||||||
|
"LogName": "Application",
|
||||||
|
"LogLevel": {
|
||||||
|
"Microsoft": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Configurations": {
|
||||||
|
"ServiceName": "UScheduler",
|
||||||
|
|
||||||
|
"Powershell": [
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
"Processes": [
|
||||||
|
{
|
||||||
|
"Path": "C:\\Users\\maksym\\Desktop\\Programs\\syncthing-windows-amd64-v1.27.1\\syncthing.exe",
|
||||||
|
"Args": ["--no-restart", "--home=C:\\Users\\maksym\\Desktop\\Data\\Syncthing"],
|
||||||
|
"RestartOnFailure": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -14,24 +14,14 @@
|
|||||||
},
|
},
|
||||||
"Configurations": {
|
"Configurations": {
|
||||||
"ServiceName": "UScheduler",
|
"ServiceName": "UScheduler",
|
||||||
"Description": "Windows service, which allows you to invoke PowerShell Scripts and Processes",
|
|
||||||
"DisplayName": "Unified Scheduler Service",
|
|
||||||
|
|
||||||
"Powershell": [
|
"Powershell": [
|
||||||
{
|
|
||||||
"Path": "",
|
|
||||||
"StartScript": "",
|
|
||||||
"Signed": true
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
|
|
||||||
"Processes": [
|
"Processes": [
|
||||||
{
|
|
||||||
"Path": "C:\\Users\\maksym\\Desktop\\syncthing-windows-amd64-v1.27.1\\syncthing.exe",
|
|
||||||
"Args": [],
|
|
||||||
"RestartOnFailure": true
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user