mirror of
https://github.com/SabreTools/SabreTools.Serialization.git
synced 2026-09-22 06:45:11 +00:00
Convert Installshield Executable code to use proper reader/wrapper instead of living in PortableExecutable (#59)
* Figure out how to access OverlayAddress in wrapper or reader (ideally the latter) for a non-PE reader/wrapper * Code works * Remove TODOs * First round of fixes. * use constants * remove comment
This commit is contained in:
committed by
GitHub
parent
7f7d0f84ef
commit
5dfe6aefb0
@@ -5,14 +5,14 @@ using Xunit;
|
||||
|
||||
namespace SabreTools.Serialization.Test.Readers
|
||||
{
|
||||
public class InstallShieldExecutableFileTests
|
||||
public class InstallShieldExecutableTests
|
||||
{
|
||||
[Fact]
|
||||
public void NullArray_Null()
|
||||
{
|
||||
byte[]? data = null;
|
||||
int offset = 0;
|
||||
var deserializer = new InstallShieldExecutableFile();
|
||||
var deserializer = new InstallShieldExecutable();
|
||||
|
||||
var actual = deserializer.Deserialize(data, offset);
|
||||
Assert.Null(actual);
|
||||
@@ -23,7 +23,7 @@ namespace SabreTools.Serialization.Test.Readers
|
||||
{
|
||||
byte[]? data = [];
|
||||
int offset = 0;
|
||||
var deserializer = new InstallShieldExecutableFile();
|
||||
var deserializer = new InstallShieldExecutable();
|
||||
|
||||
var actual = deserializer.Deserialize(data, offset);
|
||||
Assert.Null(actual);
|
||||
@@ -34,7 +34,7 @@ namespace SabreTools.Serialization.Test.Readers
|
||||
{
|
||||
byte[]? data = [.. Enumerable.Repeat<byte>(0xFF, 1024)];
|
||||
int offset = 0;
|
||||
var deserializer = new InstallShieldExecutableFile();
|
||||
var deserializer = new InstallShieldExecutable();
|
||||
|
||||
var actual = deserializer.Deserialize(data, offset);
|
||||
Assert.Null(actual);
|
||||
@@ -44,7 +44,7 @@ namespace SabreTools.Serialization.Test.Readers
|
||||
public void NullStream_Null()
|
||||
{
|
||||
Stream? data = null;
|
||||
var deserializer = new InstallShieldExecutableFile();
|
||||
var deserializer = new InstallShieldExecutable();
|
||||
|
||||
var actual = deserializer.Deserialize(data);
|
||||
Assert.Null(actual);
|
||||
@@ -54,7 +54,7 @@ namespace SabreTools.Serialization.Test.Readers
|
||||
public void EmptyStream_Null()
|
||||
{
|
||||
Stream? data = new MemoryStream([]);
|
||||
var deserializer = new InstallShieldExecutableFile();
|
||||
var deserializer = new InstallShieldExecutable();
|
||||
|
||||
var actual = deserializer.Deserialize(data);
|
||||
Assert.Null(actual);
|
||||
@@ -64,7 +64,7 @@ namespace SabreTools.Serialization.Test.Readers
|
||||
public void InvalidStream_Null()
|
||||
{
|
||||
Stream? data = new MemoryStream([.. Enumerable.Repeat<byte>(0xFF, 1024)]);
|
||||
var deserializer = new InstallShieldExecutableFile();
|
||||
var deserializer = new InstallShieldExecutable();
|
||||
|
||||
var actual = deserializer.Deserialize(data);
|
||||
Assert.Null(actual);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace SabreTools.Data.Models.InstallShieldExecutable
|
||||
{
|
||||
public static class Constants
|
||||
{
|
||||
/// <summary>
|
||||
/// The signature for one of two kinds of currently unsupported ISEXE formats.
|
||||
/// </summary>
|
||||
public const string ISSignatureString = "InstallShield";
|
||||
|
||||
/// <summary>
|
||||
/// The signature for one of two kinds of currently unsupported ISEXE formats.
|
||||
/// </summary>
|
||||
public const string ISSetupSignatureString = "ISSetupStream";
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace SabreTools.Data.Models.InstallShieldExecutable
|
||||
{
|
||||
/// <summary>
|
||||
/// Set of attributes for each fileEntry in an InstallShield Executable
|
||||
/// </summary>
|
||||
public class FileEntry
|
||||
{
|
||||
/// <summary>
|
||||
@@ -24,5 +27,11 @@ namespace SabreTools.Data.Models.InstallShieldExecutable
|
||||
/// Length of the file. Stored in the installshield executable as a string.
|
||||
/// </summary>
|
||||
public ulong Length { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Offset of the file.
|
||||
/// </summary>
|
||||
/// <remarks>This is not stored in the installshield executable, but it needs to be stored here for extraction.</remarks>
|
||||
public long Offset { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ namespace SabreTools.Data.Models.InstallShieldExecutable
|
||||
/// documents the plain variant. Clearer naming and separation between the types is yet
|
||||
/// to come.
|
||||
/// </remarks>
|
||||
/// TODO: Look into making the array a dictionary
|
||||
/// There is no unified header or footer that indicates a file
|
||||
/// table, so having either each file entry cache the data
|
||||
/// or be associated with an offset may make it more useful.
|
||||
|
||||
98
SabreTools.Serialization/Readers/InstallShieldExecutable.cs
Normal file
98
SabreTools.Serialization/Readers/InstallShieldExecutable.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.InstallShieldExecutable;
|
||||
using SabreTools.IO.Extensions;
|
||||
using static SabreTools.Data.Models.InstallShieldExecutable.Constants;
|
||||
|
||||
namespace SabreTools.Serialization.Readers
|
||||
{
|
||||
public class InstallShieldExecutable : BaseBinaryReader<SFX>
|
||||
{
|
||||
public override SFX? Deserialize(Stream? data)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data == null || !data.CanRead)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var sfx = new SFX();
|
||||
|
||||
// Cache the initial offset
|
||||
long initialOffset = data.Position;
|
||||
|
||||
var sfxList = new List<FileEntry>();
|
||||
|
||||
while (data.Position < data.Length)
|
||||
{
|
||||
// Try to parse the entry
|
||||
var fileEntry = ParseFileEntry(data, initialOffset);
|
||||
if (fileEntry == null)
|
||||
break;
|
||||
|
||||
// Get the length, and make sure it won't EOF
|
||||
long length = (long)fileEntry.Length;
|
||||
if (length > data.Length - data.Position)
|
||||
break;
|
||||
|
||||
data.SeekIfPossible(length, SeekOrigin.Current);
|
||||
sfxList.Add(fileEntry);
|
||||
}
|
||||
|
||||
if (sfxList.Count == 0)
|
||||
return null;
|
||||
|
||||
sfx.Entries = [.. sfxList];
|
||||
return sfx;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore the actual error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a Stream into a FileEntry
|
||||
/// </summary>
|
||||
/// <param name="data">Stream to parse</param>
|
||||
/// <returns>Filled FileEntry on success, null on error</returns>
|
||||
public static FileEntry? ParseFileEntry(Stream data, long initialOffset)
|
||||
{
|
||||
string? name = data.ReadNullTerminatedAnsiString();
|
||||
if (name == null)
|
||||
return null;
|
||||
|
||||
// Both of these strings indicate that this is a different kind of encrypted and/or compressed format of
|
||||
// ISEXE that is not yet supported, but will be in the future.
|
||||
// They return early because no extraction can be performed, like how MsCab currently returns if a folder
|
||||
// is LZX or Quantum.
|
||||
if (name == ISSignatureString)
|
||||
return null;
|
||||
|
||||
if (name == ISSetupSignatureString)
|
||||
return null;
|
||||
|
||||
string? path = data.ReadNullTerminatedAnsiString();
|
||||
if (path == null)
|
||||
return null;
|
||||
|
||||
string? version = data.ReadNullTerminatedAnsiString();
|
||||
if (version == null)
|
||||
return null;
|
||||
|
||||
var lengthString = data.ReadNullTerminatedAnsiString();
|
||||
if (lengthString == null || !ulong.TryParse(lengthString, out var lengthValue))
|
||||
return null;
|
||||
|
||||
var obj = new FileEntry();
|
||||
obj.Name = name;
|
||||
obj.Path = path;
|
||||
obj.Version = version;
|
||||
obj.Length = lengthValue;
|
||||
obj.Offset = data.Position - initialOffset;
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.InstallShieldExecutable;
|
||||
using SabreTools.IO.Extensions;
|
||||
|
||||
namespace SabreTools.Serialization.Readers
|
||||
{
|
||||
// TODO: This should parse an entire SFX, not just a single entry
|
||||
public class InstallShieldExecutableFile : BaseBinaryReader<FileEntry>
|
||||
{
|
||||
public override FileEntry? Deserialize(Stream? data)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data == null || !data.CanRead)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Cache the initial offset
|
||||
long initialOffset = data.Position;
|
||||
|
||||
// Try to parse the entry
|
||||
var fileEntry = ParseFileEntry(data);
|
||||
if (fileEntry == null)
|
||||
return null;
|
||||
|
||||
return fileEntry;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore the actual error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a Stream into a FileEntry
|
||||
/// </summary>
|
||||
/// <param name="data">Stream to parse</param>
|
||||
/// <returns>Filled FileEntry on success, null on error</returns>
|
||||
public static FileEntry? ParseFileEntry(Stream data)
|
||||
{
|
||||
string? name = data.ReadNullTerminatedAnsiString();
|
||||
if (name == null)
|
||||
return null;
|
||||
|
||||
string? path = data.ReadNullTerminatedAnsiString();
|
||||
if (path == null)
|
||||
return null;
|
||||
|
||||
string? version = data.ReadNullTerminatedAnsiString();
|
||||
if (version == null)
|
||||
return null;
|
||||
|
||||
var lengthString = data.ReadNullTerminatedAnsiString();
|
||||
if (lengthString == null || !ulong.TryParse(lengthString, out var lengthValue))
|
||||
return null;
|
||||
|
||||
var obj = new FileEntry();
|
||||
|
||||
obj.Name = name;
|
||||
obj.Path = path;
|
||||
obj.Version = version;
|
||||
obj.Length = lengthValue;
|
||||
|
||||
return obj;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using SabreTools.IO.Extensions;
|
||||
|
||||
namespace SabreTools.Serialization.Wrappers
|
||||
{
|
||||
public partial class InstallShieldExecutable : IExtractable
|
||||
{
|
||||
#region Extraction
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool Extract(string outputDirectory, bool includeDebug)
|
||||
{
|
||||
const int chunkSize = 2048 * 1024;
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < Entries.Length; i++)
|
||||
{
|
||||
var entry = Entries[i];
|
||||
_dataSource.SeekIfPossible(entry.Offset, SeekOrigin.Begin);
|
||||
|
||||
// Get the length, and make sure it won't EOF
|
||||
long length = (long)entry.Length;
|
||||
if (length > _dataSource.Length - _dataSource.Position)
|
||||
break;
|
||||
|
||||
// Ensure directory separators are consistent
|
||||
var filename = entry.Path.TrimEnd('\0');
|
||||
if (Path.DirectorySeparatorChar == '\\')
|
||||
filename = filename.Replace('/', '\\');
|
||||
else if (Path.DirectorySeparatorChar == '/')
|
||||
filename = filename.Replace('\\', '/');
|
||||
|
||||
// Ensure the full output directory exists
|
||||
filename = Path.Combine(outputDirectory, filename);
|
||||
var directoryName = Path.GetDirectoryName(filename);
|
||||
if (directoryName != null && !Directory.Exists(directoryName))
|
||||
Directory.CreateDirectory(directoryName);
|
||||
|
||||
// Write the output file
|
||||
using var fs = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
|
||||
while (length > 0)
|
||||
{
|
||||
int bytesToRead = (int)Math.Min(length, chunkSize);
|
||||
|
||||
byte[] buffer = _dataSource.ReadBytes(bytesToRead);
|
||||
fs.Write(buffer, 0, bytesToRead);
|
||||
fs.Flush();
|
||||
|
||||
length -= bytesToRead;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (includeDebug) Console.Error.WriteLine(ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
97
SabreTools.Serialization/Wrappers/InstallShieldExecutable.cs
Normal file
97
SabreTools.Serialization/Wrappers/InstallShieldExecutable.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System.IO;
|
||||
using SabreTools.Data.Models.InstallShieldExecutable;
|
||||
|
||||
namespace SabreTools.Serialization.Wrappers
|
||||
{
|
||||
public partial class InstallShieldExecutable : WrapperBase<SFX>
|
||||
{
|
||||
#region Descriptive Properties
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string DescriptionString => "InstallShield Executable";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extension Properties
|
||||
|
||||
/// <inheritdoc cref="SFX.FileEntry"/>
|
||||
public FileEntry[] Entries => Model.Entries;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
|
||||
/// <inheritdoc/>
|
||||
public InstallShieldExecutable(SFX model, byte[] data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public InstallShieldExecutable(SFX model, byte[] data, int offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public InstallShieldExecutable(SFX model, byte[] data, int offset, int length) : base(model, data, offset, length) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public InstallShieldExecutable(SFX model, Stream data) : base(model, data) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public InstallShieldExecutable(SFX model, Stream data, long offset) : base(model, data, offset) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public InstallShieldExecutable(SFX model, Stream data, long offset, long length) : base(model, data, offset, length) { }
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static Constructors
|
||||
|
||||
/// <summary>
|
||||
/// Create an InstallShield Executable from a byte array and offset
|
||||
/// </summary>
|
||||
/// <param name="data">Byte array representing the executable</param>
|
||||
/// <param name="offset">Offset within the array to parse</param>
|
||||
/// <returns>An executable wrapper on success, null on failure</returns>
|
||||
public static InstallShieldExecutable? Create(byte[]? data, int offset)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data == null || data.Length == 0)
|
||||
return null;
|
||||
|
||||
// If the offset is out of bounds
|
||||
if (offset < 0 || offset >= data.Length)
|
||||
return null;
|
||||
|
||||
// Create a memory stream and use that
|
||||
var dataStream = new MemoryStream(data, offset, data.Length - offset);
|
||||
return Create(dataStream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create an InstallShield Executable from a Stream
|
||||
/// </summary>
|
||||
/// <param name="data">Stream representing the executable</param>
|
||||
/// <returns>An executable wrapper on success, null on failure</returns>
|
||||
public static InstallShieldExecutable? Create(Stream? data)
|
||||
{
|
||||
// If the data is invalid
|
||||
if (data == null || !data.CanRead)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Cache the current offset
|
||||
long currentOffset = data.Position;
|
||||
|
||||
var model = new Readers.InstallShieldExecutable().Deserialize(data);
|
||||
if (model == null)
|
||||
return null;
|
||||
|
||||
return new InstallShieldExecutable(model, data, currentOffset);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -161,78 +161,6 @@ namespace SabreTools.Serialization.Wrappers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract data from an InstallShield Executable
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">Output directory to write to</param>
|
||||
/// <param name="includeDebug">True to include debug data, false otherwise</param>
|
||||
/// <returns>True if extraction succeeded, false otherwise</returns>
|
||||
public bool ExtractInstallShieldExecutable(string outputDirectory, bool includeDebug)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Return if overlay doesn't exist.
|
||||
long overlayAddress = OverlayAddress;
|
||||
if (overlayAddress < 0)
|
||||
return false;
|
||||
|
||||
const int chunkSize = 2048 * 1024;
|
||||
var reader = new Readers.InstallShieldExecutableFile();
|
||||
|
||||
lock (_dataSourceLock)
|
||||
{
|
||||
// Ensure the stream is starting at the overlay address
|
||||
_dataSource.SeekIfPossible(overlayAddress, SeekOrigin.Begin);
|
||||
|
||||
while (_dataSource.Position < _dataSource.Length)
|
||||
{
|
||||
// Try to deserialize the source data
|
||||
var entry = reader.Deserialize(_dataSource);
|
||||
if (entry?.Path == null)
|
||||
return false;
|
||||
|
||||
// Get the length, and make sure it won't EOF
|
||||
long length = (long)entry.Length;
|
||||
if (length > _dataSource.Length - _dataSource.Position)
|
||||
break;
|
||||
|
||||
// Ensure directory separators are consistent
|
||||
var filename = entry.Path.TrimEnd('\0');
|
||||
if (Path.DirectorySeparatorChar == '\\')
|
||||
filename = filename.Replace('/', '\\');
|
||||
else if (Path.DirectorySeparatorChar == '/')
|
||||
filename = filename.Replace('\\', '/');
|
||||
|
||||
// Ensure the full output directory exists
|
||||
filename = Path.Combine(outputDirectory, filename);
|
||||
var directoryName = Path.GetDirectoryName(filename);
|
||||
if (directoryName != null && !Directory.Exists(directoryName))
|
||||
Directory.CreateDirectory(directoryName);
|
||||
|
||||
// Write the output file
|
||||
using var fs = File.Open(filename, FileMode.Create, FileAccess.Write, FileShare.ReadWrite);
|
||||
while (length > 0)
|
||||
{
|
||||
int bytesToRead = (int)Math.Min(length, chunkSize);
|
||||
|
||||
byte[] buffer = _dataSource.ReadBytes(bytesToRead);
|
||||
fs.Write(buffer, 0, bytesToRead);
|
||||
fs.Flush();
|
||||
|
||||
length -= bytesToRead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (includeDebug) Console.Error.WriteLine(ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract data from the overlay
|
||||
/// </summary>
|
||||
@@ -611,6 +539,22 @@ namespace SabreTools.Serialization.Wrappers
|
||||
// Attempt to extract package
|
||||
return MatroschkaPackage.Extract(outputDirectory, includeDebug);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract data from an Installshield Executable
|
||||
/// </summary>
|
||||
/// <param name="outputDirectory">Output directory to write to</param>
|
||||
/// <param name="includeDebug">True to include debug data, false otherwise</param>
|
||||
/// <returns>True if extraction succeeded, false otherwise</returns>
|
||||
public bool ExtractInstallShieldExecutable(string outputDirectory, bool includeDebug)
|
||||
{
|
||||
// Check if executable contains an InstallShield Executable or not
|
||||
if (ISEXE == null)
|
||||
return false;
|
||||
|
||||
// Attempt to extract package
|
||||
return ISEXE.Extract(outputDirectory, includeDebug);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract a Spoon Installer SFX overlay
|
||||
|
||||
@@ -257,6 +257,56 @@ namespace SabreTools.Serialization.Wrappers
|
||||
}
|
||||
}
|
||||
} = null;
|
||||
|
||||
/// <summary>
|
||||
/// InstallShield Executable wrapper, if it exists
|
||||
/// </summary>
|
||||
public InstallShieldExecutable? ISEXE
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_installshieldExecutableLock)
|
||||
{
|
||||
// Use the cached data if possible
|
||||
if (field != null)
|
||||
return field;
|
||||
|
||||
// Check to see if creation has already been attempted
|
||||
if (_installshieldExecutableFailed)
|
||||
return null;
|
||||
|
||||
// Get the available source length, if possible
|
||||
var dataLength = Length;
|
||||
if (dataLength == -1)
|
||||
{
|
||||
_installshieldExecutableFailed = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if there's a valid OverlayAddress
|
||||
if (OverlayAddress < 0 || OverlayAddress > dataLength)
|
||||
{
|
||||
_installshieldExecutableFailed = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the package
|
||||
lock (_dataSourceLock)
|
||||
{
|
||||
_dataSource.SeekIfPossible(OverlayAddress, SeekOrigin.Begin);
|
||||
field = InstallShieldExecutable.Create(_dataSource);
|
||||
}
|
||||
|
||||
if (field?.Entries.Length == 0)
|
||||
{
|
||||
_installshieldExecutableFailed = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
} = null;
|
||||
|
||||
/// <inheritdoc cref="Executable.OptionalHeader"/>
|
||||
public Data.Models.PortableExecutable.OptionalHeader OptionalHeader => Model.OptionalHeader;
|
||||
@@ -1028,6 +1078,16 @@ namespace SabreTools.Serialization.Wrappers
|
||||
/// </summary>
|
||||
private readonly object _headerPaddingStringsLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Lock object for <see cref="InstallShieldExecutable"/>
|
||||
/// </summary>
|
||||
private readonly object _installshieldExecutableLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Cached attempt at creation for <see cref="InstallShieldExecutable"/>
|
||||
/// </summary>
|
||||
private bool _installshieldExecutableFailed = false;
|
||||
|
||||
/// <summary>
|
||||
/// Lock object for <see cref="MatroschkaPackage"/>
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user