mirror of
https://github.com/Thealexbarney/LibHac.git
synced 2024-11-14 10:49:41 +01:00
Get rid of old code
This commit is contained in:
parent
3e4dcd9466
commit
8861f25bc7
6 changed files with 41 additions and 358 deletions
|
@ -1,53 +0,0 @@
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using DiscUtils.Fat;
|
|
||||||
|
|
||||||
namespace LibHac.Nand
|
|
||||||
{
|
|
||||||
public class NandPartition : IFileSystemOld
|
|
||||||
{
|
|
||||||
public FatFileSystem Fs { get; }
|
|
||||||
|
|
||||||
public NandPartition(FatFileSystem fileSystem)
|
|
||||||
{
|
|
||||||
Fs = fileSystem;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool FileExists(string path)
|
|
||||||
{
|
|
||||||
return Fs.FileExists(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool DirectoryExists(string path)
|
|
||||||
{
|
|
||||||
return Fs.DirectoryExists(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Stream OpenFile(string path, FileMode mode)
|
|
||||||
{
|
|
||||||
return Fs.OpenFile(path, mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Stream OpenFile(string path, FileMode mode, FileAccess access)
|
|
||||||
{
|
|
||||||
return Fs.OpenFile(path, mode, access);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetFileSystemEntries(string path, string searchPattern)
|
|
||||||
{
|
|
||||||
return Fs.GetFileSystemEntries(path, searchPattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetFileSystemEntries(string path, string searchPattern, SearchOption searchOption)
|
|
||||||
{
|
|
||||||
string[] files = Fs.GetFiles(path, searchPattern, searchOption);
|
|
||||||
string[] dirs = Fs.GetDirectories(path, searchPattern, searchOption);
|
|
||||||
return files.Concat(dirs).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetFullPath(string path)
|
|
||||||
{
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,73 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace LibHac
|
|
||||||
{
|
|
||||||
public class FileSystem : IFileSystemOld
|
|
||||||
{
|
|
||||||
public string Root { get; }
|
|
||||||
|
|
||||||
public FileSystem(string rootDir)
|
|
||||||
{
|
|
||||||
Root = Path.GetFullPath(rootDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool FileExists(string path)
|
|
||||||
{
|
|
||||||
return File.Exists(Path.Combine(Root, path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool DirectoryExists(string path)
|
|
||||||
{
|
|
||||||
return Directory.Exists(Path.Combine(Root, path));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Stream OpenFile(string path, FileMode mode)
|
|
||||||
{
|
|
||||||
return new FileStream(Path.Combine(Root, path), mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Stream OpenFile(string path, FileMode mode, FileAccess access)
|
|
||||||
{
|
|
||||||
return new FileStream(Path.Combine(Root, path), mode, access);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetFileSystemEntries(string path, string searchPattern)
|
|
||||||
{
|
|
||||||
return Directory.GetFileSystemEntries(Path.Combine(Root, path), searchPattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string[] GetFileSystemEntries(string path, string searchPattern, SearchOption searchOption)
|
|
||||||
{
|
|
||||||
//return Directory.GetFileSystemEntries(Path.Combine(Root, path), searchPattern, searchOption);
|
|
||||||
var result = new List<string>();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
result.AddRange(GetFileSystemEntries(Path.Combine(Root, path), searchPattern));
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException) { /* Skip this directory */ }
|
|
||||||
|
|
||||||
if (searchOption == SearchOption.TopDirectoryOnly)
|
|
||||||
return result.ToArray();
|
|
||||||
|
|
||||||
string[] searchDirectories = Directory.GetDirectories(Path.Combine(Root, path));
|
|
||||||
foreach (string search in searchDirectories)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
result.AddRange(GetFileSystemEntries(search, searchPattern, searchOption));
|
|
||||||
}
|
|
||||||
catch (UnauthorizedAccessException) { /* Skip this result */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetFullPath(string path)
|
|
||||||
{
|
|
||||||
return Path.Combine(Root, path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
using System.IO;
|
|
||||||
|
|
||||||
namespace LibHac
|
|
||||||
{
|
|
||||||
public interface IFileSystemOld
|
|
||||||
{
|
|
||||||
bool FileExists(string path);
|
|
||||||
bool DirectoryExists(string path);
|
|
||||||
Stream OpenFile(string path, FileMode mode);
|
|
||||||
Stream OpenFile(string path, FileMode mode, FileAccess access);
|
|
||||||
string[] GetFileSystemEntries(string path, string searchPattern);
|
|
||||||
string[] GetFileSystemEntries(string path, string searchPattern, SearchOption searchOption);
|
|
||||||
string GetFullPath(string path);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,93 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using LibHac.IO;
|
|
||||||
|
|
||||||
namespace LibHac
|
|
||||||
{
|
|
||||||
public class Nax0 : IDisposable
|
|
||||||
{
|
|
||||||
private const int SectorSize = 0x4000;
|
|
||||||
|
|
||||||
public byte[] Hmac { get; private set; }
|
|
||||||
public byte[][] EncKeys { get; } = Util.CreateJaggedArray<byte[][]>(2, 0x10);
|
|
||||||
public byte[][] Keys { get; } = Util.CreateJaggedArray<byte[][]>(2, 0x10);
|
|
||||||
public byte[] Key { get; } = new byte[0x20];
|
|
||||||
public long Length { get; private set; }
|
|
||||||
public IStorage BaseStorage { get; }
|
|
||||||
private bool LeaveOpen { get; }
|
|
||||||
|
|
||||||
public Nax0(Keyset keyset, IStorage storage, string sdPath, bool leaveOpen)
|
|
||||||
{
|
|
||||||
LeaveOpen = leaveOpen;
|
|
||||||
ReadHeader(storage.AsStream());
|
|
||||||
DeriveKeys(keyset, sdPath, storage);
|
|
||||||
|
|
||||||
BaseStorage = new CachedStorage(new Aes128XtsStorage(storage.Slice(SectorSize), Key, SectorSize, leaveOpen), 4, leaveOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ReadHeader(Stream stream)
|
|
||||||
{
|
|
||||||
var reader = new BinaryReader(stream);
|
|
||||||
|
|
||||||
Hmac = reader.ReadBytes(0x20);
|
|
||||||
string magic = reader.ReadAscii(4);
|
|
||||||
reader.BaseStream.Position += 4;
|
|
||||||
if (magic != "NAX0") throw new InvalidDataException("Not an NAX0 file");
|
|
||||||
EncKeys[0] = reader.ReadBytes(0x10);
|
|
||||||
EncKeys[1] = reader.ReadBytes(0x10);
|
|
||||||
Length = reader.ReadInt64();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DeriveKeys(Keyset keyset, string sdPath, IStorage storage)
|
|
||||||
{
|
|
||||||
var validationHashKey = new byte[0x60];
|
|
||||||
storage.Read(validationHashKey, 0x20);
|
|
||||||
|
|
||||||
// Try both the NCA and save key sources and pick the one that works
|
|
||||||
for (int k = 0; k < 2; k++)
|
|
||||||
{
|
|
||||||
var naxSpecificKeys = Util.CreateJaggedArray<byte[][]>(2, 0x10);
|
|
||||||
var hashKey = new byte[0x10];
|
|
||||||
Array.Copy(keyset.SdCardKeys[k], hashKey, 0x10);
|
|
||||||
|
|
||||||
// Use the sd path to generate the kek for this NAX0
|
|
||||||
var hash = new HMACSHA256(hashKey);
|
|
||||||
byte[] sdPathBytes = Encoding.ASCII.GetBytes(sdPath);
|
|
||||||
byte[] checksum = hash.ComputeHash(sdPathBytes, 0, sdPathBytes.Length);
|
|
||||||
Array.Copy(checksum, 0, naxSpecificKeys[0], 0, 0x10);
|
|
||||||
Array.Copy(checksum, 0x10, naxSpecificKeys[1], 0, 0x10);
|
|
||||||
|
|
||||||
// Decrypt this NAX0's keys
|
|
||||||
Crypto.DecryptEcb(naxSpecificKeys[0], EncKeys[0], Keys[0], 0x10);
|
|
||||||
Crypto.DecryptEcb(naxSpecificKeys[1], EncKeys[1], Keys[1], 0x10);
|
|
||||||
Array.Copy(Keys[0], 0, Key, 0, 0x10);
|
|
||||||
Array.Copy(Keys[1], 0, Key, 0x10, 0x10);
|
|
||||||
|
|
||||||
// Copy the decrypted keys into the NAX0 header and use that for the HMAC key
|
|
||||||
// for validating that the keys are correct
|
|
||||||
Array.Copy(Keys[0], 0, validationHashKey, 8, 0x10);
|
|
||||||
Array.Copy(Keys[1], 0, validationHashKey, 0x18, 0x10);
|
|
||||||
|
|
||||||
var validationHash = new HMACSHA256(validationHashKey);
|
|
||||||
byte[] validationMac = validationHash.ComputeHash(keyset.SdCardKeys[k], 0x10, 0x10);
|
|
||||||
|
|
||||||
if (Util.ArraysEqual(Hmac, validationMac))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ArgumentException("NAX0 key derivation failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
if (!LeaveOpen)
|
|
||||||
{
|
|
||||||
BaseStorage?.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,7 +3,6 @@ using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using LibHac.IO;
|
using LibHac.IO;
|
||||||
using LibHac.IO.Save;
|
using LibHac.IO.Save;
|
||||||
|
|
||||||
|
@ -12,44 +11,23 @@ namespace LibHac
|
||||||
public class SwitchFs : IDisposable
|
public class SwitchFs : IDisposable
|
||||||
{
|
{
|
||||||
public Keyset Keyset { get; }
|
public Keyset Keyset { get; }
|
||||||
public IFileSystemOld Fs { get; }
|
public IAttributeFileSystem BaseFs { get; }
|
||||||
public string ContentsDir { get; }
|
public AesXtsFileSystem Fs { get; }
|
||||||
public string SaveDir { get; }
|
|
||||||
|
|
||||||
public Dictionary<string, Nca> Ncas { get; } = new Dictionary<string, Nca>(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, Nca> Ncas { get; } = new Dictionary<string, Nca>(StringComparer.OrdinalIgnoreCase);
|
||||||
public Dictionary<string, SaveDataFileSystem> Saves { get; } = new Dictionary<string, SaveDataFileSystem>(StringComparer.OrdinalIgnoreCase);
|
public Dictionary<string, SaveDataFileSystem> Saves { get; } = new Dictionary<string, SaveDataFileSystem>(StringComparer.OrdinalIgnoreCase);
|
||||||
public Dictionary<ulong, Title> Titles { get; } = new Dictionary<ulong, Title>();
|
public Dictionary<ulong, Title> Titles { get; } = new Dictionary<ulong, Title>();
|
||||||
public Dictionary<ulong, Application> Applications { get; } = new Dictionary<ulong, Application>();
|
public Dictionary<ulong, Application> Applications { get; } = new Dictionary<ulong, Application>();
|
||||||
|
|
||||||
public SwitchFs(Keyset keyset, IFileSystemOld fs)
|
public SwitchFs(Keyset keyset, IAttributeFileSystem fs)
|
||||||
{
|
{
|
||||||
Fs = fs;
|
BaseFs = fs;
|
||||||
Keyset = keyset;
|
Keyset = keyset;
|
||||||
|
|
||||||
if (fs.DirectoryExists("Nintendo"))
|
var concatFs = new ConcatenationFileSystem(BaseFs);
|
||||||
{
|
Fs = new AesXtsFileSystem(concatFs, keyset.SdCardKeys[1], 0x4000);
|
||||||
ContentsDir = fs.GetFullPath(Path.Combine("Nintendo", "Contents"));
|
|
||||||
SaveDir = fs.GetFullPath(Path.Combine("Nintendo", "save"));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (fs.DirectoryExists("Contents"))
|
|
||||||
{
|
|
||||||
ContentsDir = fs.GetFullPath("Contents");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.DirectoryExists("save"))
|
// OpenAllSaves();
|
||||||
{
|
|
||||||
SaveDir = fs.GetFullPath("save");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ContentsDir == null)
|
|
||||||
{
|
|
||||||
throw new DirectoryNotFoundException("Could not find \"Contents\" directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
OpenAllSaves();
|
|
||||||
OpenAllNcas();
|
OpenAllNcas();
|
||||||
ReadTitles();
|
ReadTitles();
|
||||||
ReadControls();
|
ReadControls();
|
||||||
|
@ -58,88 +36,70 @@ namespace LibHac
|
||||||
|
|
||||||
private void OpenAllNcas()
|
private void OpenAllNcas()
|
||||||
{
|
{
|
||||||
string[] files = Fs.GetFileSystemEntries(ContentsDir, "*.nca", SearchOption.AllDirectories);
|
IEnumerable<DirectoryEntry> files = Fs.OpenDirectory("/", OpenDirectoryMode.All).EnumerateEntries("*.nca", SearchOptions.RecurseSubdirectories);
|
||||||
|
|
||||||
foreach (string file in files)
|
foreach (DirectoryEntry fileEntry in files)
|
||||||
{
|
{
|
||||||
Nca nca = null;
|
Nca nca = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
bool isNax0;
|
var storage = new FileStorage(Fs.OpenFile(fileEntry.FullPath, OpenMode.Read));
|
||||||
IStorage storage = OpenSplitNcaStorage(Fs, file);
|
|
||||||
if (storage == null) continue;
|
|
||||||
|
|
||||||
using (var reader = new BinaryReader(storage.AsStream(), Encoding.Default, true))
|
|
||||||
{
|
|
||||||
reader.BaseStream.Position = 0x20;
|
|
||||||
isNax0 = reader.ReadUInt32() == 0x3058414E; // NAX0
|
|
||||||
reader.BaseStream.Position = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNax0)
|
|
||||||
{
|
|
||||||
string sdPath = "/" + Util.GetRelativePath(file, ContentsDir).Replace('\\', '/');
|
|
||||||
var nax0 = new Nax0(Keyset, storage, sdPath, false);
|
|
||||||
nca = new Nca(Keyset, nax0.BaseStorage, false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
nca = new Nca(Keyset, storage, false);
|
nca = new Nca(Keyset, storage, false);
|
||||||
}
|
|
||||||
|
|
||||||
nca.NcaId = Path.GetFileNameWithoutExtension(file);
|
nca.NcaId = Path.GetFileNameWithoutExtension(fileEntry.Name);
|
||||||
string extension = nca.Header.ContentType == ContentType.Meta ? ".cnmt.nca" : ".nca";
|
string extension = nca.Header.ContentType == ContentType.Meta ? ".cnmt.nca" : ".nca";
|
||||||
nca.Filename = nca.NcaId + extension;
|
nca.Filename = nca.NcaId + extension;
|
||||||
}
|
}
|
||||||
catch (MissingKeyException ex)
|
catch (MissingKeyException ex)
|
||||||
{
|
{
|
||||||
if (ex.Name == null)
|
if (ex.Name == null)
|
||||||
{ Console.WriteLine($"{ex.Message} File:\n{file}"); }
|
{ Console.WriteLine($"{ex.Message} File:\n{fileEntry}"); }
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
string name = ex.Type == KeyType.Title ? $"Title key for rights ID {ex.Name}" : ex.Name;
|
string name = ex.Type == KeyType.Title ? $"Title key for rights ID {ex.Name}" : ex.Name;
|
||||||
Console.WriteLine($"{ex.Message}\nKey: {name}\nFile: {file}");
|
Console.WriteLine($"{ex.Message}\nKey: {name}\nFile: {fileEntry}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"{ex.Message} File: {file}");
|
Console.WriteLine($"{ex.Message} File: {fileEntry}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nca?.NcaId != null) Ncas.Add(nca.NcaId, nca);
|
if (nca?.NcaId != null) Ncas.Add(nca.NcaId, nca);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenAllSaves()
|
//private void OpenAllSaves()
|
||||||
{
|
//{
|
||||||
if (SaveDir == null) return;
|
// if (SaveDir == null) return;
|
||||||
|
|
||||||
string[] files = Fs.GetFileSystemEntries(SaveDir, "*");
|
// string[] files = Fs.GetFileSystemEntries(SaveDir, "*");
|
||||||
|
|
||||||
foreach (string file in files)
|
// foreach (string file in files)
|
||||||
{
|
// {
|
||||||
SaveDataFileSystem save = null;
|
// SaveDataFileSystem save = null;
|
||||||
string saveName = Path.GetFileNameWithoutExtension(file);
|
// string saveName = Path.GetFileNameWithoutExtension(file);
|
||||||
|
|
||||||
try
|
// try
|
||||||
{
|
// {
|
||||||
IStorage storage = Fs.OpenFile(file, FileMode.Open).AsStorage();
|
// IStorage storage = Fs.OpenFile(file, FileMode.Open).AsStorage();
|
||||||
|
|
||||||
string sdPath = "/" + Util.GetRelativePath(file, SaveDir).Replace('\\', '/');
|
// string sdPath = "/" + Util.GetRelativePath(file, SaveDir).Replace('\\', '/');
|
||||||
var nax0 = new Nax0(Keyset, storage, sdPath, false);
|
// var nax0 = new Nax0(Keyset, storage, sdPath, false);
|
||||||
save = new SaveDataFileSystem(Keyset, nax0.BaseStorage, IntegrityCheckLevel.None, true);
|
// save = new SaveDataFileSystem(Keyset, nax0.BaseStorage, IntegrityCheckLevel.None, true);
|
||||||
}
|
// }
|
||||||
catch (Exception ex)
|
// catch (Exception ex)
|
||||||
{
|
// {
|
||||||
Console.WriteLine($"{ex.Message} File: {file}");
|
// Console.WriteLine($"{ex.Message} File: {file}");
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (save != null && saveName != null)
|
// if (save != null && saveName != null)
|
||||||
{
|
// {
|
||||||
Saves[saveName] = save;
|
// Saves[saveName] = save;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
private void ReadTitles()
|
private void ReadTitles()
|
||||||
{
|
{
|
||||||
|
@ -232,49 +192,6 @@ namespace LibHac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static IStorage OpenSplitNcaStorage(IFileSystemOld fs, string path)
|
|
||||||
{
|
|
||||||
var files = new List<string>();
|
|
||||||
var storages = new List<IStorage>();
|
|
||||||
|
|
||||||
if (fs.DirectoryExists(path))
|
|
||||||
{
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
string partName = Path.Combine(path, $"{files.Count:D2}");
|
|
||||||
if (!fs.FileExists(partName)) break;
|
|
||||||
|
|
||||||
files.Add(partName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (fs.FileExists(path))
|
|
||||||
{
|
|
||||||
if (Path.GetFileName(path) != "00")
|
|
||||||
{
|
|
||||||
return fs.OpenFile(path, FileMode.Open, FileAccess.Read).AsStorage();
|
|
||||||
}
|
|
||||||
files.Add(path);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException("Could not find the input file or directory");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files.Count == 1)
|
|
||||||
{
|
|
||||||
return fs.OpenFile(files[0], FileMode.Open, FileAccess.Read).AsStorage();
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (string file in files)
|
|
||||||
{
|
|
||||||
storages.Add(fs.OpenFile(file, FileMode.Open, FileAccess.Read).AsStorage());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storages.Count == 0) return null; //todo
|
|
||||||
|
|
||||||
return new ConcatenationStorage(storages, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void DisposeNcas()
|
private void DisposeNcas()
|
||||||
{
|
{
|
||||||
foreach (Nca nca in Ncas.Values)
|
foreach (Nca nca in Ncas.Values)
|
||||||
|
|
|
@ -13,7 +13,7 @@ namespace hactoolnet
|
||||||
{
|
{
|
||||||
public static void Process(Context ctx)
|
public static void Process(Context ctx)
|
||||||
{
|
{
|
||||||
var switchFs = new SwitchFs(ctx.Keyset, new FileSystem(ctx.Options.InFile));
|
var switchFs = new SwitchFs(ctx.Keyset, new LocalFileSystem($"{ctx.Options.InFile}/Nintendo/Contents"));
|
||||||
|
|
||||||
if (ctx.Options.ListNcas)
|
if (ctx.Options.ListNcas)
|
||||||
{
|
{
|
||||||
|
|
Loading…
Reference in a new issue