mirror of
https://github.com/MobileTooling/UnifiedFlashingPlatform.git
synced 2026-07-27 12:48:47 -07:00
Enable Preliminary FFU support
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
using Img2Ffu.Reader.Compression;
|
||||
using Img2Ffu.Reader.Enums;
|
||||
using Img2Ffu.Reader.Structs;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace Img2Ffu.Reader.Data
|
||||
{
|
||||
internal class ImageFlash
|
||||
{
|
||||
public ImageHeader ImageHeader;
|
||||
public string ManifestData = "";
|
||||
public byte[] Padding = [];
|
||||
public readonly List<Store> Stores = [];
|
||||
|
||||
private readonly long DataBlocksPosition;
|
||||
private readonly long InitialStreamPosition;
|
||||
|
||||
|
||||
public ImageFlash(Stream stream)
|
||||
{
|
||||
InitialStreamPosition = stream.Position;
|
||||
ImageHeader = stream.ReadStructure<ImageHeader>();
|
||||
|
||||
if (ImageHeader.Signature != "ImageFlash ")
|
||||
{
|
||||
throw new InvalidDataException("Invalid Image Header Signature!");
|
||||
}
|
||||
|
||||
byte[] manifestDataBuffer = new byte[ImageHeader.ManifestLength];
|
||||
_ = stream.Read(manifestDataBuffer, 0, (int)ImageHeader.ManifestLength);
|
||||
ASCIIEncoding asciiEncoding = new();
|
||||
ManifestData = asciiEncoding.GetString(manifestDataBuffer);
|
||||
|
||||
long Position = stream.Position;
|
||||
if (Position % (ImageHeader.ChunkSize * 1024) > 0)
|
||||
{
|
||||
long paddingSize = (ImageHeader.ChunkSize * 1024) - (Position % (ImageHeader.ChunkSize * 1024));
|
||||
Padding = new byte[paddingSize];
|
||||
_ = stream.Read(Padding, 0, (int)paddingSize);
|
||||
}
|
||||
|
||||
Store store = new(stream);
|
||||
Stores.Add(store);
|
||||
|
||||
if (store.StoreHeader.MajorVersion == 2 && store.StoreHeader.FullFlashMajorVersion == 2)
|
||||
{
|
||||
for (uint i = 2; i <= store.StoreHeaderV2.NumberOfStores; i++)
|
||||
{
|
||||
Stores.Add(new Store(stream));
|
||||
}
|
||||
}
|
||||
|
||||
DataBlocksPosition = stream.Position;
|
||||
}
|
||||
|
||||
|
||||
public ulong GetImageBlockCount()
|
||||
{
|
||||
ulong imageBlockCount = (ulong)(DataBlocksPosition - InitialStreamPosition) / (ImageHeader.ChunkSize * 1024);
|
||||
ulong storeBlockCount = GetDataBlockCount();
|
||||
return imageBlockCount + storeBlockCount;
|
||||
}
|
||||
|
||||
public byte[] GetImageBlock(Stream Stream, ulong dataBlockIndex)
|
||||
{
|
||||
ulong imageBlockCount = (ulong)(DataBlocksPosition - InitialStreamPosition) / (ImageHeader.ChunkSize * 1024);
|
||||
|
||||
// The data block is within the image headers
|
||||
if (dataBlockIndex < imageBlockCount)
|
||||
{
|
||||
byte[] dataBlock = new byte[(ImageHeader.ChunkSize * 1024)];
|
||||
ulong dataBlockPosition = (ulong)InitialStreamPosition + (dataBlockIndex * (ImageHeader.ChunkSize * 1024));
|
||||
|
||||
_ = Stream.Seek((long)dataBlockPosition, SeekOrigin.Begin);
|
||||
_ = Stream.Read(dataBlock, 0, dataBlock.Length);
|
||||
|
||||
return dataBlock;
|
||||
}
|
||||
// The data block is within the store data blocks, those may be compressed
|
||||
else
|
||||
{
|
||||
return GetDataBlock(Stream, dataBlockIndex - imageBlockCount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public ulong GetDataBlockCount()
|
||||
{
|
||||
ulong dataBlockCount = 0;
|
||||
foreach (Store store in Stores)
|
||||
{
|
||||
dataBlockCount += (ulong)store.WriteDescriptors.LongCount();
|
||||
}
|
||||
return dataBlockCount;
|
||||
}
|
||||
|
||||
public byte[] GetDataBlock(Stream Stream, ulong dataBlockIndex)
|
||||
{
|
||||
ulong dataBlockIndexOffset = 0;
|
||||
for (ulong storeIndex = 0; storeIndex < (ulong)Stores.Count; storeIndex++)
|
||||
{
|
||||
ulong storeDataBlockCount = GetStoreDataBlockCount(storeIndex);
|
||||
if (dataBlockIndexOffset + storeDataBlockCount > dataBlockIndex)
|
||||
{
|
||||
return GetStoreDataBlock(Stream, storeIndex, dataBlockIndex - dataBlockIndexOffset);
|
||||
}
|
||||
|
||||
dataBlockIndexOffset += storeDataBlockCount;
|
||||
}
|
||||
|
||||
throw new Exception("Invalid data block index value");
|
||||
}
|
||||
|
||||
|
||||
public ulong GetStoreDataBlockCount(ulong storeIndex)
|
||||
{
|
||||
return (ulong)Stores[(int)storeIndex].WriteDescriptors.LongCount();
|
||||
}
|
||||
|
||||
public byte[] GetStoreDataBlock(Stream Stream, ulong storeIndex, ulong dataBlockIndex)
|
||||
{
|
||||
ulong dataBlockPosition = (ulong)DataBlocksPosition;
|
||||
for (ulong s = 0; s < storeIndex; s++)
|
||||
{
|
||||
Store store = Stores[(int)s];
|
||||
if (store.CompressionAlgorithm != 0)
|
||||
{
|
||||
foreach (WriteDescriptor writeDescriptor in store.WriteDescriptors)
|
||||
{
|
||||
dataBlockPosition += writeDescriptor.DataSize;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dataBlockPosition += store.StoreHeader.WriteDescriptorCount * store.StoreHeader.BlockSize;
|
||||
}
|
||||
}
|
||||
|
||||
Store currentStore = Stores[(int)storeIndex];
|
||||
byte[] dataBlock = new byte[currentStore.StoreHeader.BlockSize];
|
||||
|
||||
CompressionAlgorithm compressionAlgorithm = (CompressionAlgorithm)currentStore.CompressionAlgorithm;
|
||||
|
||||
if (compressionAlgorithm != CompressionAlgorithm.None)
|
||||
{
|
||||
for (ulong i = 0; i < dataBlockIndex; i++)
|
||||
{
|
||||
WriteDescriptor writeDescriptor = currentStore.WriteDescriptors[(int)i];
|
||||
dataBlockPosition += writeDescriptor.DataSize;
|
||||
}
|
||||
|
||||
WriteDescriptor currentWriteDescriptor = currentStore.WriteDescriptors[(int)dataBlockIndex];
|
||||
uint compressedDataBlockSize = currentWriteDescriptor.DataSize;
|
||||
byte[] compressedDataBlock = new byte[compressedDataBlockSize];
|
||||
|
||||
_ = Stream.Seek((long)dataBlockPosition, SeekOrigin.Begin);
|
||||
_ = Stream.Read(compressedDataBlock, 0, compressedDataBlock.Length);
|
||||
|
||||
switch ((CompressionAlgorithm)currentStore.CompressionAlgorithm)
|
||||
{
|
||||
case CompressionAlgorithm.Default:
|
||||
{
|
||||
dataBlock = WindowsNativeCompression.Decompress(WindowsNativeCompressionAlgorithm.COMPRESSION_FORMAT_XPRESS, compressedDataBlock, (int)currentStore.StoreHeader.BlockSize);
|
||||
break;
|
||||
}
|
||||
case CompressionAlgorithm.LZNT1:
|
||||
{
|
||||
dataBlock = WindowsNativeCompression.Decompress(WindowsNativeCompressionAlgorithm.COMPRESSION_FORMAT_LZNT1, compressedDataBlock, (int)currentStore.StoreHeader.BlockSize);
|
||||
break;
|
||||
}
|
||||
case CompressionAlgorithm.XPRESS:
|
||||
{
|
||||
dataBlock = WindowsNativeCompression.Decompress(WindowsNativeCompressionAlgorithm.COMPRESSION_FORMAT_XPRESS, compressedDataBlock, (int)currentStore.StoreHeader.BlockSize);
|
||||
break;
|
||||
}
|
||||
case CompressionAlgorithm.XPRESS_HUFF:
|
||||
{
|
||||
dataBlock = WindowsNativeCompression.Decompress(WindowsNativeCompressionAlgorithm.COMPRESSION_FORMAT_XPRESS_HUFF, compressedDataBlock, (int)currentStore.StoreHeader.BlockSize);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
throw new NotImplementedException("The compression algorithm this data block uses is not currently implemented.");
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dataBlockPosition += dataBlockIndex * currentStore.StoreHeader.BlockSize;
|
||||
|
||||
_ = Stream.Seek((long)dataBlockPosition, SeekOrigin.Begin);
|
||||
_ = Stream.Read(dataBlock, 0, dataBlock.Length);
|
||||
}
|
||||
|
||||
return dataBlock;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
using Img2Ffu.Reader.Structs;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
|
||||
namespace Img2Ffu.Reader.Data
|
||||
{
|
||||
internal class SignedImage
|
||||
{
|
||||
public SecurityHeader SecurityHeader;
|
||||
public byte[] Catalog;
|
||||
public byte[] TableOfHashes;
|
||||
public byte[] Padding = [];
|
||||
public ImageFlash Image;
|
||||
|
||||
public ulong HeaderSize { get; }
|
||||
public int ChunkSize => (int)(SecurityHeader.ChunkSizeInKB * 1024);
|
||||
public ulong TotalChunkCount => Image.GetImageBlockCount();
|
||||
|
||||
public readonly List<byte[]> BlockHashes = [];
|
||||
public X509Certificate Certificate;
|
||||
|
||||
private readonly Stream Stream;
|
||||
|
||||
public SignedImage(Stream stream)
|
||||
{
|
||||
Stream = stream;
|
||||
|
||||
SecurityHeader = stream.ReadStructure<SecurityHeader>();
|
||||
|
||||
if (SecurityHeader.Signature != "SignedImage ")
|
||||
{
|
||||
throw new InvalidDataException("Invalid Security Header Signature!");
|
||||
}
|
||||
|
||||
Catalog = new byte[SecurityHeader.CatalogSize];
|
||||
_ = stream.Read(Catalog, 0, (int)SecurityHeader.CatalogSize);
|
||||
|
||||
TableOfHashes = new byte[SecurityHeader.HashTableSize];
|
||||
_ = stream.Read(TableOfHashes, 0, (int)SecurityHeader.HashTableSize);
|
||||
|
||||
long Position = stream.Position;
|
||||
uint sizeOfBlock = SecurityHeader.ChunkSizeInKB * 1024;
|
||||
if (Position % sizeOfBlock > 0)
|
||||
{
|
||||
long paddingSize = sizeOfBlock - (Position % sizeOfBlock);
|
||||
Padding = new byte[paddingSize];
|
||||
_ = stream.Read(Padding, 0, (int)paddingSize);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Certificate = new X509Certificate(Catalog);
|
||||
}
|
||||
catch { }
|
||||
|
||||
Image = new ImageFlash(stream);
|
||||
|
||||
HeaderSize = (ulong)stream.Position;
|
||||
|
||||
ParseBlockHashes();
|
||||
}
|
||||
|
||||
private void ParseBlockHashes()
|
||||
{
|
||||
BlockHashes.Clear();
|
||||
|
||||
ulong numberOfBlocksToVerify = Image.GetImageBlockCount();
|
||||
ulong sizeOfHash = (ulong)TableOfHashes.LongLength / numberOfBlocksToVerify;
|
||||
|
||||
for (int i = 0; i < TableOfHashes.Length; i += (int)sizeOfHash)
|
||||
{
|
||||
BlockHashes.Add(TableOfHashes[i..(i + (int)sizeOfHash)]);
|
||||
}
|
||||
}
|
||||
|
||||
private uint GetImageHeaderPosition()
|
||||
{
|
||||
uint sizeOfBlock = SecurityHeader.ChunkSizeInKB * 1024;
|
||||
uint ImageHeaderPosition = SecurityHeader.Size + SecurityHeader.CatalogSize + SecurityHeader.HashTableSize;
|
||||
if (ImageHeaderPosition % sizeOfBlock > 0)
|
||||
{
|
||||
uint paddingSize = sizeOfBlock - (ImageHeaderPosition % sizeOfBlock);
|
||||
ImageHeaderPosition += paddingSize;
|
||||
}
|
||||
return ImageHeaderPosition;
|
||||
}
|
||||
|
||||
public void VerifyFFU()
|
||||
{
|
||||
ulong numberOfBlocksToVerify = Image.GetImageBlockCount();
|
||||
|
||||
long oldPosition = Stream.Position;
|
||||
|
||||
using BinaryReader binaryReader = new(Stream, Encoding.ASCII, true);
|
||||
using BinaryReader hashTableBinaryReader = new(new MemoryStream(TableOfHashes));
|
||||
|
||||
for (ulong i = 0; i < numberOfBlocksToVerify; i++)
|
||||
{
|
||||
Console.Title = $"{i + 1}/{numberOfBlocksToVerify}";
|
||||
|
||||
if (SecurityHeader.AlgorithmId == 0x0000800c) // SHA256 Algorithm ID
|
||||
{
|
||||
byte[] block = Image.GetImageBlock(Stream, i);
|
||||
byte[] hash = SHA256.HashData(block);
|
||||
byte[] hashTableHash = BlockHashes.ElementAt((int)i);
|
||||
|
||||
if (!StructuralComparisons.StructuralEqualityComparer.Equals(hash, hashTableHash))
|
||||
{
|
||||
_ = Stream.Seek(oldPosition, SeekOrigin.Begin);
|
||||
throw new InvalidDataException($"The FFU image contains a mismatched hash value at chunk {i}. " +
|
||||
$"Expected: {BitConverter.ToString(hashTableHash).Replace("-", "")} " +
|
||||
$"Computed: {BitConverter.ToString(hash).Replace("-", "")}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidDataException($"Unknown Hash algorithm id: {SecurityHeader.AlgorithmId}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.Title = $"{numberOfBlocksToVerify}/{numberOfBlocksToVerify}";
|
||||
|
||||
_ = Stream.Seek(oldPosition, SeekOrigin.Begin);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Img2Ffu.Reader.Enums;
|
||||
using Img2Ffu.Reader.Structs;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Img2Ffu.Reader.Data
|
||||
{
|
||||
internal class Store
|
||||
{
|
||||
public StoreHeader StoreHeader;
|
||||
public uint CompressionAlgorithm;
|
||||
public StoreHeaderV2 StoreHeaderV2;
|
||||
public string DevicePath = ""; // Device path has no NUL at then end (V2 only)
|
||||
public readonly List<ValidationDescriptor> ValidationDescriptor = [];
|
||||
public readonly List<WriteDescriptor> WriteDescriptors = [];
|
||||
public readonly List<string> PlatformIDs = [];
|
||||
public byte[] Padding = [];
|
||||
public readonly FFUVersion FFUVersion;
|
||||
|
||||
public Store(Stream stream)
|
||||
{
|
||||
StoreHeader = stream.ReadStructure<StoreHeader>();
|
||||
|
||||
int lastFinding = 0;
|
||||
for (int i = 0; i < StoreHeader.PlatformId.Length; i++)
|
||||
{
|
||||
if (StoreHeader.PlatformId[i] == '\0')
|
||||
{
|
||||
if (i == lastFinding)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ASCIIEncoding asciiEnconding = new();
|
||||
string PlatformID = asciiEnconding.GetString(StoreHeader.PlatformId[lastFinding..i]);
|
||||
PlatformIDs.Add(PlatformID);
|
||||
lastFinding = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
FFUVersion = GetFFUVersion();
|
||||
|
||||
switch (FFUVersion)
|
||||
{
|
||||
case FFUVersion.V2:
|
||||
{
|
||||
StoreHeaderV2 = stream.ReadStructure<StoreHeaderV2>();
|
||||
byte[] stringBytes = new byte[StoreHeaderV2.DevicePathLength * 2];
|
||||
_ = stream.Read(stringBytes, 0, stringBytes.Length);
|
||||
UnicodeEncoding unicodeEncoding = new();
|
||||
DevicePath = unicodeEncoding.GetString(stringBytes);
|
||||
break;
|
||||
}
|
||||
case FFUVersion.V1_COMPRESSED:
|
||||
{
|
||||
using BinaryReader binaryReader = new(stream, Encoding.ASCII, true);
|
||||
CompressionAlgorithm = binaryReader.ReadUInt32();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (uint i = 0; i < StoreHeader.ValidateDescriptorCount; i++)
|
||||
{
|
||||
ValidationDescriptor.Add(new ValidationDescriptor(stream));
|
||||
}
|
||||
|
||||
for (uint i = 0; i < StoreHeader.WriteDescriptorCount; i++)
|
||||
{
|
||||
WriteDescriptors.Add(new WriteDescriptor(stream, FFUVersion));
|
||||
}
|
||||
|
||||
long Position = stream.Position;
|
||||
if (Position % StoreHeader.BlockSize > 0)
|
||||
{
|
||||
long paddingSize = StoreHeader.BlockSize - (Position % StoreHeader.BlockSize);
|
||||
Padding = new byte[paddingSize];
|
||||
_ = stream.Read(Padding, 0, (int)paddingSize);
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{{StoreHeader: {StoreHeader}, StoreHeaderV2: {StoreHeaderV2}, DevicePath: {DevicePath}}}";
|
||||
}
|
||||
|
||||
private FFUVersion GetFFUVersion() => (StoreHeader.MajorVersion, StoreHeader.FullFlashMajorVersion) switch
|
||||
{
|
||||
(1, 2) => FFUVersion.V1,
|
||||
(1, 3) => FFUVersion.V1_COMPRESSED,
|
||||
(2, 2) => FFUVersion.V2,
|
||||
_ => throw new InvalidDataException($"Unsupported FFU Store Format! MajorVersion: {StoreHeader.MajorVersion} FullFlashMajorVersion: {StoreHeader.FullFlashMajorVersion}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Img2Ffu.Reader.Structs;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace Img2Ffu.Reader.Data
|
||||
{
|
||||
internal class ValidationDescriptor
|
||||
{
|
||||
public ValidationEntry ValidationEntry;
|
||||
public byte[] ValidationBytes;
|
||||
|
||||
public ValidationDescriptor(Stream stream)
|
||||
{
|
||||
ValidationEntry = stream.ReadStructure<ValidationEntry>();
|
||||
|
||||
ValidationBytes = new byte[ValidationEntry.dwByteCount];
|
||||
_ = stream.Read(ValidationBytes, 0, (int)ValidationEntry.dwByteCount);
|
||||
}
|
||||
|
||||
public ValidationDescriptor(ValidationEntry validationEntry, byte[] validationBytes)
|
||||
{
|
||||
ValidationEntry = validationEntry;
|
||||
ValidationBytes = validationBytes;
|
||||
ValidationEntry.dwByteCount = (uint)validationBytes.LongLength;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{{ValidationEntry: {ValidationEntry}}}";
|
||||
}
|
||||
|
||||
public byte[] GetBytes()
|
||||
{
|
||||
List<byte> bytes = [];
|
||||
|
||||
ValidationEntry.dwByteCount = (uint)ValidationBytes.Length;
|
||||
|
||||
bytes.AddRange(ValidationEntry.GetBytes());
|
||||
bytes.AddRange(ValidationBytes);
|
||||
|
||||
return [.. bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Img2Ffu.Reader.Enums;
|
||||
using Img2Ffu.Reader.Structs;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace Img2Ffu.Reader.Data
|
||||
{
|
||||
internal class WriteDescriptor
|
||||
{
|
||||
public BlockDataEntry BlockDataEntry;
|
||||
public uint DataSize;
|
||||
|
||||
public readonly List<DiskLocation> DiskLocations = [];
|
||||
private readonly bool HasDataSizeField;
|
||||
|
||||
public WriteDescriptor(Stream stream, FFUVersion ffuVersion)
|
||||
{
|
||||
HasDataSizeField = ffuVersion == FFUVersion.V1_COMPRESSED;
|
||||
|
||||
BlockDataEntry = stream.ReadStructure<BlockDataEntry>();
|
||||
|
||||
if (HasDataSizeField)
|
||||
{
|
||||
using BinaryReader binaryReader = new(stream, Encoding.ASCII, true);
|
||||
DataSize = binaryReader.ReadUInt32();
|
||||
}
|
||||
|
||||
for (uint i = 0; i < BlockDataEntry.LocationCount; i++)
|
||||
{
|
||||
DiskLocations.Add(stream.ReadStructure<DiskLocation>());
|
||||
}
|
||||
}
|
||||
|
||||
public WriteDescriptor(BlockDataEntry blockDataEntry, List<DiskLocation> diskLocations, uint compressedDataBlockSize)
|
||||
{
|
||||
HasDataSizeField = true;
|
||||
|
||||
BlockDataEntry = blockDataEntry;
|
||||
DiskLocations = diskLocations;
|
||||
DataSize = compressedDataBlockSize;
|
||||
}
|
||||
|
||||
public WriteDescriptor(BlockDataEntry blockDataEntry, List<DiskLocation> diskLocations, FFUVersion ffuVersion)
|
||||
{
|
||||
HasDataSizeField = false;
|
||||
|
||||
BlockDataEntry = blockDataEntry;
|
||||
DiskLocations = diskLocations;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{{BlockDataEntry: {BlockDataEntry}}}";
|
||||
}
|
||||
|
||||
public byte[] GetBytes()
|
||||
{
|
||||
List<byte> bytes = [];
|
||||
|
||||
BlockDataEntry.LocationCount = (uint)DiskLocations.Count;
|
||||
|
||||
bytes.AddRange(BlockDataEntry.GetBytes());
|
||||
|
||||
if (HasDataSizeField)
|
||||
{
|
||||
bytes.AddRange(BitConverter.GetBytes(DataSize));
|
||||
}
|
||||
|
||||
foreach (DiskLocation diskLocation in DiskLocations)
|
||||
{
|
||||
bytes.AddRange(diskLocation.GetBytes());
|
||||
}
|
||||
|
||||
return [.. bytes];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user