mirror of
https://github.com/real-zony/ZonyLrcToolsX.git
synced 2025-07-01 20:23:22 +00:00
feat: Use MusicDecrypto as a decryption driver.
This commit is contained in:
parent
fdc5f27692
commit
a46abba2f9
14
README.md
14
README.md
@ -29,29 +29,31 @@ macOS 和 Linux 用户请打开终端,切换到软件目录,一样执行命
|
||||
子命令为 `download`,可用于下载歌词数据和专辑图像,支持多个下载器进行下载。
|
||||
|
||||
```shell
|
||||
./ZonyLrcTools.Cli.exe download -d|dir <WAIT_SCAN_DIRECTORY> [-l|--lyric] [-a|--album] [-n|--number]
|
||||
.\ZonyLrcTools.Cli.exe download -d|dir <WAIT_SCAN_DIRECTORY> [-l|--lyric] [-a|--album] [-n|--number]
|
||||
|
||||
./ZonyLrcTools.Cli.exe download -h|--help
|
||||
.\ZonyLrcTools.Cli.exe download -h|--help
|
||||
```
|
||||
|
||||
**例子**
|
||||
|
||||
```shell
|
||||
# 下载歌词
|
||||
./ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -l -n 2
|
||||
.\ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -l -n 2
|
||||
|
||||
# 下载专辑封面
|
||||
./ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -a -n 2
|
||||
.\ZonyLrcTools.Cli.exe download -d "C:\歌曲目录" -a -n 2
|
||||
```
|
||||
|
||||
#### 加密格式转换
|
||||
|
||||
子命令为 `util`,可用于转换部分加密歌曲,**仅供个人研究学习使用,思路与源码都来自于网络**。
|
||||
|
||||
目前软件支持 NCM、QCM(开发中...🚧) 格式的音乐文件转换,命令如下。
|
||||
具体支持的格式请参考项目 [MusicDecrypto](https://github.com/davidxuang/MusicDecrypto/blob/master/MusicDecrypto.Library/DecryptoFactory.cs#L23),本工具仅做一个集成,替换掉原本自己的一些实现。现在不需要指定对应的类型参数,程序会自动根据文件后缀选择适合的解密算法。
|
||||
|
||||
命令只需要一个参数 `-s`,指定需要转换的文件夹或者是文件路径。
|
||||
|
||||
```shell
|
||||
./ZonyLrcTools.Cli.exe util -t=Ncm D:\CloudMusic
|
||||
.\ZonyLrcTools.Cli.exe util -s D:\CloudMusic
|
||||
```
|
||||
|
||||
### 配置文件
|
||||
|
@ -1,24 +1,16 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using McMaster.Extensions.CommandLineUtils;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using ZonyLrcTools.Common.Infrastructure.Exceptions;
|
||||
using MusicDecrypto.Library;
|
||||
using ZonyLrcTools.Common.Infrastructure.IO;
|
||||
using ZonyLrcTools.Common.Infrastructure.Threading;
|
||||
using ZonyLrcTools.Common.MusicDecryption;
|
||||
|
||||
namespace ZonyLrcTools.Cli.Commands.SubCommand
|
||||
{
|
||||
public enum SupportFileType
|
||||
{
|
||||
Ncm = 1,
|
||||
Qcm = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 工具类相关命令。
|
||||
/// </summary>
|
||||
@ -28,13 +20,9 @@ namespace ZonyLrcTools.Cli.Commands.SubCommand
|
||||
private readonly ILogger<UtilityCommand> _logger;
|
||||
private readonly IMusicDecryptor _musicDecryptor;
|
||||
|
||||
[Required(ErrorMessage = "音乐格式为必须参数,请指定 -t 参数。")]
|
||||
[Option("-t|--type", CommandOptionType.SingleValue, Description = "需要转换的文件格式,参数[Ncm、Qcm]。", ShowInHelpText = true)]
|
||||
public SupportFileType Type { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "文件路径为必须按参数,请传入有效路径。")]
|
||||
[Argument(0, "FilePath", "指定需要转换的音乐文件路径,支持目录和文件路径。")]
|
||||
public string FilePath { get; set; }
|
||||
[Required(ErrorMessage = "请指定需要解密的歌曲文件或文件夹路径。")]
|
||||
[Option("-s|--source", CommandOptionType.SingleValue, Description = "需要解密的歌曲文件或文件夹路径。", ShowInHelpText = true)]
|
||||
public string Source { get; set; }
|
||||
|
||||
private readonly IFileScanner _fileScanner;
|
||||
|
||||
@ -49,57 +37,41 @@ namespace ZonyLrcTools.Cli.Commands.SubCommand
|
||||
|
||||
protected override async Task<int> OnExecuteAsync(CommandLineApplication app)
|
||||
{
|
||||
if (Directory.Exists(FilePath))
|
||||
if (Directory.Exists(Source))
|
||||
{
|
||||
_logger.LogInformation("开始扫描文件夹,请稍等...");
|
||||
|
||||
var files = (await _fileScanner.ScanAsync(FilePath, new[] { "*.ncm" }))
|
||||
var files = (await _fileScanner.ScanAsync(Source, DecryptoFactory.KnownExtensions))
|
||||
.SelectMany(f => f.FilePaths)
|
||||
.ToList();
|
||||
|
||||
_logger.LogInformation($"扫描完成,共 {files.Count} 个文件,准备转换。");
|
||||
|
||||
var wrapTask = new WarpTask(4);
|
||||
var tasks = files.Select(path => wrapTask.RunAsync(() => Convert(path)));
|
||||
var tasks = files.Select(path => wrapTask.RunAsync(async () =>
|
||||
{
|
||||
_logger.LogInformation($"开始转换文件:{path}");
|
||||
var result = await _musicDecryptor.ConvertMusicAsync(path);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
_logger.LogInformation($"转换完成,文件保存在:{result.OutputFilePath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"转换失败,原因:{result.ErrorMessage}");
|
||||
}
|
||||
}));
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
else if (File.Exists(FilePath))
|
||||
else if (File.Exists(Source))
|
||||
{
|
||||
await Convert(FilePath);
|
||||
await _musicDecryptor.ConvertMusicAsync(Source);
|
||||
}
|
||||
|
||||
_logger.LogInformation("所有文件已经转换完成...");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async Task Convert(string filePath)
|
||||
{
|
||||
if (Type != SupportFileType.Ncm)
|
||||
{
|
||||
throw new ErrorCodeException(ErrorCodes.OnlySupportNcmFormatFile);
|
||||
}
|
||||
|
||||
var memoryStream = new MemoryStream();
|
||||
await using var file = File.Open(filePath, FileMode.Open);
|
||||
{
|
||||
var buffer = new Memory<byte>(new byte[2048]);
|
||||
while (await file.ReadAsync(buffer) > 0)
|
||||
{
|
||||
// TODO: Large Object Issue!!!!!
|
||||
await memoryStream.WriteAsync(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Large Object Issue!!!!!
|
||||
var result = await _musicDecryptor.ConvertMusic(memoryStream.ToArray());
|
||||
var newFileName = Path.Combine(Path.GetDirectoryName(filePath),
|
||||
$"{Path.GetFileNameWithoutExtension(filePath)}.{((JObject)result.ExtensionObjects["JSON"]).SelectToken("$.format").Value<string>()}");
|
||||
|
||||
await using var musicFileStream = File.Create(newFileName);
|
||||
await musicFileStream.WriteAsync(result.Data);
|
||||
await musicFileStream.FlushAsync();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,8 @@
|
||||
namespace ZonyLrcTools.Common.Infrastructure.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// 日志记录器,包装了 CLI 和网页日志的两种输出方式。
|
||||
/// </summary>
|
||||
public interface IWarpLogger
|
||||
{
|
||||
Task DebugAsync(string message, Exception? exception = null);
|
||||
|
@ -113,6 +113,7 @@ public class LyricsDownloader : ILyricsDownloader, ISingletonDependency
|
||||
}
|
||||
}
|
||||
|
||||
// Convert UTF-8 to selected encoding.
|
||||
private byte[] Utf8ToSelectedEncoding(LyricsItemCollection lyrics)
|
||||
{
|
||||
var supportEncodings = Encoding.GetEncodings();
|
||||
|
@ -1,14 +1,29 @@
|
||||
namespace ZonyLrcTools.Common.MusicDecryption
|
||||
{
|
||||
public class DecryptionResult
|
||||
public sealed class DecryptionResult
|
||||
{
|
||||
public byte[] Data { get; protected set; }
|
||||
public bool IsSuccess { get; set; } = false;
|
||||
|
||||
public Dictionary<string, object> ExtensionObjects { get; set; }
|
||||
public string? OutputFilePath { get; set; } = default;
|
||||
|
||||
public DecryptionResult(byte[] data)
|
||||
public string? ErrorMessage { get; set; } = default;
|
||||
|
||||
public static DecryptionResult Failed(string errorMessage)
|
||||
{
|
||||
Data = data;
|
||||
return new DecryptionResult
|
||||
{
|
||||
IsSuccess = false,
|
||||
ErrorMessage = errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
public static DecryptionResult Success(string outputFilePath)
|
||||
{
|
||||
return new DecryptionResult
|
||||
{
|
||||
IsSuccess = true,
|
||||
OutputFilePath = outputFilePath
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
using MusicDecrypto.Library;
|
||||
using ZonyLrcTools.Common.Infrastructure.DependencyInject;
|
||||
using ZonyLrcTools.Common.Infrastructure.Logging;
|
||||
|
||||
namespace ZonyLrcTools.Common.MusicDecryption
|
||||
{
|
||||
/// <inheritdoc cref="ZonyLrcTools.Common.MusicDecryption.IMusicDecryptor" />
|
||||
public class DefaultMusicDecryptor : IMusicDecryptor, ITransientDependency
|
||||
{
|
||||
private readonly IWarpLogger _warpLogger;
|
||||
|
||||
public DefaultMusicDecryptor(IWarpLogger warpLogger)
|
||||
{
|
||||
_warpLogger = warpLogger;
|
||||
}
|
||||
|
||||
public async Task<DecryptionResult> ConvertMusicAsync(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var buffer = new MarshalMemoryStream();
|
||||
await using var file = new FileStream(filePath, FileMode.Open, FileAccess.Read);
|
||||
buffer.SetLengthWithPadding(file.Length);
|
||||
await file.CopyToAsync(buffer);
|
||||
|
||||
using var decrypto = DecryptoFactory.Create(buffer, Path.GetFileName(filePath), message => { });
|
||||
var outFileName = decrypto.Decrypt().NewName;
|
||||
var outFilePath = Path.Combine(Path.GetDirectoryName(filePath)!, outFileName);
|
||||
|
||||
if (!File.Exists(outFilePath))
|
||||
{
|
||||
await using var outFile = new FileStream(outFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
await buffer.CopyToAsync(outFile);
|
||||
}
|
||||
|
||||
return DecryptionResult.Success(outFilePath);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return DecryptionResult.Failed(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,15 +1,20 @@
|
||||
namespace ZonyLrcTools.Common.MusicDecryption
|
||||
using MusicDecrypto.Library;
|
||||
|
||||
namespace ZonyLrcTools.Common.MusicDecryption
|
||||
{
|
||||
/// <summary>
|
||||
/// 音乐解密器,用于将加密的歌曲数据,转换为可识别的歌曲格式。
|
||||
/// <remarks>
|
||||
/// 这个类型仅仅是对 <see cref="DecryptoFactory"/> 相关功能的封装,方便在本工具进行单元测试。
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
public interface IMusicDecryptor
|
||||
{
|
||||
/// <summary>
|
||||
/// 将加密数据转换为可识别的歌曲格式。
|
||||
/// 将加密数据歌曲文件转换为可识别的歌曲格式。
|
||||
/// </summary>
|
||||
/// <param name="sourceBytes">源加密的歌曲数据。</param>
|
||||
/// <returns>解密完成的歌曲数据。</returns>
|
||||
Task<DecryptionResult> ConvertMusic(byte[] sourceBytes);
|
||||
/// <param name="filePath">加密歌曲文件的路径。</param>
|
||||
/// <returns>转换结果。</returns>
|
||||
Task<DecryptionResult> ConvertMusicAsync(string filePath);
|
||||
}
|
||||
}
|
@ -1,180 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using ZonyLrcTools.Common.Infrastructure.DependencyInject;
|
||||
|
||||
namespace ZonyLrcTools.Common.MusicDecryption
|
||||
{
|
||||
/// <summary>
|
||||
/// NCM 音乐转换器,用于将 NCM 格式的音乐转换为可播放的格式。
|
||||
/// </summary>
|
||||
public class NcmMusicDecryptor : IMusicDecryptor, ITransientDependency
|
||||
{
|
||||
protected readonly byte[] AesCoreKey = { 0x68, 0x7A, 0x48, 0x52, 0x41, 0x6D, 0x73, 0x6F, 0x35, 0x6B, 0x49, 0x6E, 0x62, 0x61, 0x78, 0x57 };
|
||||
protected readonly byte[] AesModifyKey = { 0x23, 0x31, 0x34, 0x6C, 0x6A, 0x6B, 0x5F, 0x21, 0x5C, 0x5D, 0x26, 0x30, 0x55, 0x3C, 0x27, 0x28 };
|
||||
|
||||
public async Task<DecryptionResult> ConvertMusic(byte[] sourceBytes)
|
||||
{
|
||||
var stream = new MemoryStream(sourceBytes);
|
||||
var streamReader = new BinaryReader(stream);
|
||||
|
||||
var lengthBytes = new byte[4];
|
||||
lengthBytes = streamReader.ReadBytes(4);
|
||||
if (BitConverter.ToInt32(lengthBytes) != 0x4e455443)
|
||||
{
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
lengthBytes = streamReader.ReadBytes(4);
|
||||
if (BitConverter.ToInt32(lengthBytes) != 0x4d414446)
|
||||
{
|
||||
throw new Exception();
|
||||
}
|
||||
|
||||
stream.Seek(2, SeekOrigin.Current);
|
||||
stream.Read(lengthBytes);
|
||||
|
||||
var keyBytes = new byte[BitConverter.ToInt32(lengthBytes)];
|
||||
stream.Read(keyBytes);
|
||||
|
||||
// 对已经加密的数据进行异或操作。
|
||||
for (int i = 0; i < keyBytes.Length; i++)
|
||||
{
|
||||
keyBytes[i] ^= 0x64;
|
||||
}
|
||||
|
||||
var coreKeyBytes = GetBytesByOffset(DecryptAes128Ecb(AesCoreKey, keyBytes), 17);
|
||||
|
||||
var modifyDataBytes = new byte[streamReader.ReadInt32()];
|
||||
stream.Read(modifyDataBytes);
|
||||
for (int i = 0; i < modifyDataBytes.Length; i++)
|
||||
{
|
||||
modifyDataBytes[i] ^= 0x63;
|
||||
}
|
||||
|
||||
var decryptBase64Bytes = Convert.FromBase64String(Encoding.UTF8.GetString(GetBytesByOffset(modifyDataBytes, 22)));
|
||||
var decryptModifyData = DecryptAes128Ecb(AesModifyKey, decryptBase64Bytes);
|
||||
|
||||
var musicInfoJson = JObject.Parse(Encoding.UTF8.GetString(GetBytesByOffset(decryptModifyData, 6)));
|
||||
|
||||
// CRC 校验
|
||||
stream.Seek(4, SeekOrigin.Current);
|
||||
stream.Seek(5, SeekOrigin.Current);
|
||||
|
||||
GetAlbumImageBytes(stream, streamReader);
|
||||
|
||||
var sBox = BuildKeyBox(coreKeyBytes);
|
||||
return new DecryptionResult(GetMusicBytes(sBox, stream).ToArray())
|
||||
{
|
||||
ExtensionObjects = new Dictionary<string, object>
|
||||
{
|
||||
{ "JSON", musicInfoJson }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] GetBytesByOffset(byte[] srcBytes, int offset = 0)
|
||||
{
|
||||
var resultBytes = new byte[srcBytes.Length - offset];
|
||||
Array.Copy(srcBytes, offset, resultBytes, 0, srcBytes.Length - offset);
|
||||
return resultBytes;
|
||||
}
|
||||
|
||||
private byte[] DecryptAes128Ecb(byte[] keyBytes, byte[] data)
|
||||
{
|
||||
var aes = Aes.Create();
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Mode = CipherMode.ECB;
|
||||
using var decryptor = aes.CreateDecryptor(keyBytes, null);
|
||||
var result = decryptor.TransformFinalBlock(data, 0, data.Length);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RC4 加密,生成 KeyBox。
|
||||
/// </summary>
|
||||
/// <param name="key"></param>
|
||||
/// <returns></returns>
|
||||
private byte[] BuildKeyBox(byte[] key)
|
||||
{
|
||||
byte[] box = new byte[256];
|
||||
for (int i = 0; i < 256; ++i)
|
||||
{
|
||||
box[i] = (byte)i;
|
||||
}
|
||||
|
||||
byte keyLength = (byte)key.Length;
|
||||
byte c;
|
||||
byte lastByte = 0;
|
||||
byte keyOffset = 0;
|
||||
byte swap;
|
||||
|
||||
for (int i = 0; i < 256; ++i)
|
||||
{
|
||||
swap = box[i];
|
||||
c = (byte)((swap + lastByte + key[keyOffset++]) & 0xff);
|
||||
|
||||
if (keyOffset >= keyLength)
|
||||
{
|
||||
keyOffset = 0;
|
||||
}
|
||||
|
||||
box[i] = box[c];
|
||||
box[c] = swap;
|
||||
lastByte = c;
|
||||
}
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获得歌曲的专辑图像信息。
|
||||
/// </summary>
|
||||
/// <param name="stream">原始文件流。</param>
|
||||
/// <param name="streamReader">二进制读取器。</param>
|
||||
private byte[] GetAlbumImageBytes(Stream stream, BinaryReader streamReader)
|
||||
{
|
||||
var imgLength = streamReader.ReadInt32();
|
||||
|
||||
if (imgLength <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var imgBuffer = streamReader.ReadBytes(imgLength);
|
||||
|
||||
return imgBuffer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获得歌曲的完整数据。
|
||||
/// </summary>
|
||||
/// <param name="sBox"></param>
|
||||
/// <param name="stream">原始文件流。</param>
|
||||
private MemoryStream GetMusicBytes(byte[] sBox, Stream stream)
|
||||
{
|
||||
var n = 0x8000;
|
||||
var memoryStream = new MemoryStream();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var tb = new byte[n];
|
||||
var result = stream.Read(tb);
|
||||
if (result <= 0) break;
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var j = (byte)((i + 1) & 0xff);
|
||||
tb[i] ^= sBox[sBox[j] + sBox[(sBox[j] + j) & 0xff] & 0xff];
|
||||
}
|
||||
|
||||
memoryStream.Write(tb);
|
||||
}
|
||||
|
||||
memoryStream.Flush();
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
}
|
||||
}
|
@ -20,4 +20,8 @@
|
||||
<Compile Remove="Lyrics\Providers\Kugeci\KugeciDownloader.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\vendor\MusicDecrypto\MusicDecrypto.Library\MusicDecrypto.Library.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@ -98,5 +98,12 @@ namespace ZonyLrcTools.Tests.Infrastructure.Lyrics
|
||||
var lyric = await _lyricsProvider.DownloadAsync("Bones", "Image Dragons");
|
||||
lyric.ToString().ShouldNotContain("Gimme, gimme, gimme some time to think");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAsync_Issue123_Test()
|
||||
{
|
||||
var lyric = await _lyricsProvider.DownloadAsync("橄榄树", "苏曼");
|
||||
lyric.ToString().ShouldNotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZonyLrcTools.Common.MusicDecryption;
|
||||
|
||||
@ -10,26 +10,13 @@ namespace ZonyLrcTools.Tests.Infrastructure.MusicDecryption
|
||||
public class NcmMusicDecryptorTests : TestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConvertMusic_Test()
|
||||
public async Task ConvertMusicAsync_Test()
|
||||
{
|
||||
var decryptor = ServiceProvider.GetRequiredService<IMusicDecryptor>();
|
||||
var ncmFilePath = Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "Loren Gray - Queen.ncm");
|
||||
|
||||
await using var fs = File.Open(Path.Combine(Directory.GetCurrentDirectory(), "MusicFiles", "Loren Gray - Queen.ncm"), FileMode.Open);
|
||||
using var reader = new BinaryReader(fs);
|
||||
var response = await decryptor.ConvertMusic(reader.ReadBytes((int) fs.Length));
|
||||
|
||||
var musicFilePath = Path.Combine(Directory.GetCurrentDirectory(),
|
||||
"MusicFiles",
|
||||
$"Loren Gray - Queen.{((JObject) response.ExtensionObjects["JSON"]).SelectToken("$.format").Value<string>()}");
|
||||
|
||||
if (File.Exists(musicFilePath))
|
||||
{
|
||||
File.Delete(musicFilePath);
|
||||
}
|
||||
|
||||
await using var musicFileStream = File.Create(musicFilePath);
|
||||
await musicFileStream.WriteAsync(response.Data);
|
||||
await musicFileStream.FlushAsync();
|
||||
var result = await decryptor.ConvertMusicAsync(ncmFilePath);
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user