mirror of
https://github.com/ddnet/ddnet.git
synced 2024-11-13 03:28:19 +00:00
Merge #6299
6299: Show error message when downloaded map cannot be saved r=def- a=Robyt3 Check if deleting the old map file or renaming the temporary downloaded map fails. If so, show an error message which indicates that the user should delete the map file manually. Sometimes downloaded map files seem to end up with wrong permissions, ownership or with read-only flag set, which makes the client unable to delete them. ![screenshot_2023-01-22_17-19-12](https://user-images.githubusercontent.com/23437060/213927019-ff49cb72-f60a-4c1a-b48b-d34e40d1420e.png) Closes #5825. ## Checklist - [X] Tested the change ingame - [ ] Provided screenshots if it is a visual change - [ ] Tested in combination with possibly related configuration options - [ ] Written a unit test (especially base/) or added coverage to integration test - [ ] Considered possible null pointers and out of bounds array indexing - [ ] Changed no physics that affect existing maps - [ ] Tested the change with [ASan+UBSan or valgrind's memcheck](https://github.com/ddnet/ddnet/#using-addresssanitizer--undefinedbehavioursanitizer-or-valgrinds-memcheck) (optional) Co-authored-by: Robert Müller <robytemueller@gmail.com>
This commit is contained in:
commit
b8e7160555
|
@ -2333,6 +2333,21 @@ int fs_removedir(const char *path)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int fs_is_file(const char *path)
|
||||||
|
{
|
||||||
|
#if defined(CONF_FAMILY_WINDOWS)
|
||||||
|
WCHAR wPath[IO_MAX_PATH_LENGTH];
|
||||||
|
dbg_assert(MultiByteToWideChar(CP_UTF8, 0, path, -1, wPath, std::size(wPath)) > 0, "MultiByteToWideChar failure");
|
||||||
|
DWORD attributes = GetFileAttributesW(wPath);
|
||||||
|
return attributes != INVALID_FILE_ATTRIBUTES && !(attributes & FILE_ATTRIBUTE_DIRECTORY) ? 1 : 0;
|
||||||
|
#else
|
||||||
|
struct stat sb;
|
||||||
|
if(stat(path, &sb) == -1)
|
||||||
|
return 0;
|
||||||
|
return S_ISREG(sb.st_mode) ? 1 : 0;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
int fs_is_dir(const char *path)
|
int fs_is_dir(const char *path)
|
||||||
{
|
{
|
||||||
#if defined(CONF_FAMILY_WINDOWS)
|
#if defined(CONF_FAMILY_WINDOWS)
|
||||||
|
|
|
@ -1786,18 +1786,24 @@ int str_time_float(float secs, int format, char *buffer, int buffer_size);
|
||||||
*/
|
*/
|
||||||
void str_escape(char **dst, const char *src, const char *end);
|
void str_escape(char **dst, const char *src, const char *end);
|
||||||
|
|
||||||
/* Group: Filesystem */
|
/**
|
||||||
|
* @defgroup Filesystem
|
||||||
|
*
|
||||||
|
* Utilities for accessing the file system.
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_listdir
|
* Lists the files and folders in a directory.
|
||||||
Lists the files in a directory
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
dir - Directory to list
|
* @param dir Directory to list.
|
||||||
cb - Callback function to call for each entry
|
* @param cb Callback function to call for each entry.
|
||||||
type - Type of the directory
|
* @param type Type of the directory.
|
||||||
user - Pointer to give to the callback
|
* @param user Pointer to give to the callback.
|
||||||
*/
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
typedef int (*FS_LISTDIR_CALLBACK)(const char *name, int is_dir, int dir_type, void *user);
|
typedef int (*FS_LISTDIR_CALLBACK)(const char *name, int is_dir, int dir_type, void *user);
|
||||||
void fs_listdir(const char *dir, FS_LISTDIR_CALLBACK cb, int type, void *user);
|
void fs_listdir(const char *dir, FS_LISTDIR_CALLBACK cb, int type, void *user);
|
||||||
|
|
||||||
|
@ -1808,174 +1814,207 @@ typedef struct
|
||||||
time_t m_TimeModified; // seconds since UNIX Epoch
|
time_t m_TimeModified; // seconds since UNIX Epoch
|
||||||
} CFsFileInfo;
|
} CFsFileInfo;
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_listdir_fileinfo
|
* Lists the files and folders in a directory and gets additional file information.
|
||||||
Lists the files in a directory and gets additional file information
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
dir - Directory to list
|
* @param dir Directory to list.
|
||||||
cb - Callback function to call for each entry
|
* @param cb Callback function to call for each entry.
|
||||||
type - Type of the directory
|
* @param type Type of the directory.
|
||||||
user - Pointer to give to the callback
|
* @param user Pointer to give to the callback.
|
||||||
*/
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
typedef int (*FS_LISTDIR_CALLBACK_FILEINFO)(const CFsFileInfo *info, int is_dir, int dir_type, void *user);
|
typedef int (*FS_LISTDIR_CALLBACK_FILEINFO)(const CFsFileInfo *info, int is_dir, int dir_type, void *user);
|
||||||
void fs_listdir_fileinfo(const char *dir, FS_LISTDIR_CALLBACK_FILEINFO cb, int type, void *user);
|
void fs_listdir_fileinfo(const char *dir, FS_LISTDIR_CALLBACK_FILEINFO cb, int type, void *user);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_makedir
|
* Creates a directory.
|
||||||
Creates a directory
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
path - Directory to create
|
* @param path Directory to create.
|
||||||
|
*
|
||||||
Returns:
|
* @return 0 on success. Negative value on failure.
|
||||||
Returns 0 on success. Negative value on failure.
|
*
|
||||||
|
* @remark Does not create several directories if needed. "a/b/c" will
|
||||||
Remarks:
|
* result in a failure if b or a does not exist.
|
||||||
Does not create several directories if needed. "a/b/c" will result
|
*
|
||||||
in a failure if b or a does not exist.
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
*/
|
*/
|
||||||
int fs_makedir(const char *path);
|
int fs_makedir(const char *path);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_removedir
|
* Removes a directory.
|
||||||
Removes a directory
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
path - Directory to remove
|
* @param path Directory to remove.
|
||||||
|
*
|
||||||
Returns:
|
* @return 0 on success. Negative value on failure.
|
||||||
Returns 0 on success. Negative value on failure.
|
*
|
||||||
|
* @remark Cannot remove a non-empty directory.
|
||||||
Remarks:
|
*
|
||||||
Cannot remove a non-empty directory.
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
*/
|
*/
|
||||||
int fs_removedir(const char *path);
|
int fs_removedir(const char *path);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_makedir_rec_for
|
* Recursively create directories for a file.
|
||||||
Recursively create directories for a file
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
path - File for which to create directories
|
* @param path - File for which to create directories.
|
||||||
|
*
|
||||||
Returns:
|
* @return 0 on success. Negative value on failure.
|
||||||
Returns 0 on success. Negative value on failure.
|
*
|
||||||
*/
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
int fs_makedir_rec_for(const char *path);
|
int fs_makedir_rec_for(const char *path);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_storage_path
|
* Fetches per user configuration directory.
|
||||||
Fetches per user configuration directory.
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Returns:
|
*
|
||||||
Returns 0 on success. Negative value on failure.
|
* @param appname Name of the application.
|
||||||
|
* @param path Buffer that will receive the storage path.
|
||||||
Remarks:
|
* @param max Size of the buffer.
|
||||||
- Returns ~/.appname on UNIX based systems
|
*
|
||||||
- Returns ~/Library/Applications Support/appname on macOS
|
* @return 0 on success. Negative value on failure.
|
||||||
- Returns %APPDATA%/Appname on Windows based systems
|
*
|
||||||
*/
|
* @remark Returns ~/.appname on UNIX based systems.
|
||||||
|
* @remark Returns ~/Library/Applications Support/appname on macOS.
|
||||||
|
* @remark Returns %APPDATA%/Appname on Windows based systems.
|
||||||
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
int fs_storage_path(const char *appname, char *path, int max);
|
int fs_storage_path(const char *appname, char *path, int max);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_is_dir
|
* Checks if a file exists.
|
||||||
Checks if directory exists
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
|
*
|
||||||
|
* @param path the path to check.
|
||||||
|
*
|
||||||
|
* @return 1 if a file with the given path exists,
|
||||||
|
* 0 on failure or if the file does not exist.
|
||||||
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
|
int fs_is_file(const char *path);
|
||||||
|
|
||||||
Returns:
|
/**
|
||||||
Returns 1 on success, 0 on failure.
|
* Checks if a folder exists.
|
||||||
*/
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
|
*
|
||||||
|
* @param path the path to check.
|
||||||
|
*
|
||||||
|
* @return 1 if a folder with the given path exists,
|
||||||
|
* 0 on failure or if the folder does not exist.
|
||||||
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
int fs_is_dir(const char *path);
|
int fs_is_dir(const char *path);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_is_relative_path
|
* Checks whether a given path is relative or absolute.
|
||||||
Checks whether a given path is relative or absolute.
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Returns:
|
*
|
||||||
Returns 1 if relative, 0 if absolute.
|
* @param path Path to check.
|
||||||
*/
|
*
|
||||||
|
* @return 1 if relative, 0 if absolute.
|
||||||
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
int fs_is_relative_path(const char *path);
|
int fs_is_relative_path(const char *path);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_chdir
|
* Changes the current working directory.
|
||||||
Changes current working directory
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Returns:
|
*
|
||||||
Returns 0 on success, 1 on failure.
|
* @param path New working directory path.
|
||||||
*/
|
*
|
||||||
|
* @return 0 on success, 1 on failure.
|
||||||
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
int fs_chdir(const char *path);
|
int fs_chdir(const char *path);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_getcwd
|
* Gets the current working directory.
|
||||||
Gets the current working directory.
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Returns:
|
*
|
||||||
Returns a pointer to the buffer on success, 0 on failure.
|
* @param buffer Buffer that will receive the current working directory.
|
||||||
*/
|
* @param buffer_size Size of the buffer.
|
||||||
|
*
|
||||||
|
* @return Pointer to the buffer on success, nullptr on failure.
|
||||||
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
|
*/
|
||||||
char *fs_getcwd(char *buffer, int buffer_size);
|
char *fs_getcwd(char *buffer, int buffer_size);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_parent_dir
|
* Get the parent directory of a directory.
|
||||||
Get the parent directory of a directory
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
path - The directory string
|
* @param path Path of the directory. The parent will be store in this buffer as well.
|
||||||
|
*
|
||||||
Returns:
|
* @return 0 on success, 1 on failure.
|
||||||
Returns 0 on success, 1 on failure.
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
Remarks:
|
*/
|
||||||
- The string is treated as zero-terminated string.
|
|
||||||
*/
|
|
||||||
int fs_parent_dir(char *path);
|
int fs_parent_dir(char *path);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_remove
|
* Deletes a file.
|
||||||
Deletes the file with the specified name.
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
filename - The file to delete
|
* @param filename Path of the file to delete.
|
||||||
|
*
|
||||||
Returns:
|
* @return 0 on success, 1 on failure.
|
||||||
Returns 0 on success, 1 on failure.
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
Remarks:
|
* @remark Returns an error if the path specifies a directory name.
|
||||||
- The strings are treated as zero-terminated strings.
|
*/
|
||||||
- Returns an error if the path specifies a directory name.
|
|
||||||
*/
|
|
||||||
int fs_remove(const char *filename);
|
int fs_remove(const char *filename);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_rename
|
* Renames the file or directory. If the paths differ the file will be moved.
|
||||||
Renames the file or directory. If the paths differ the file will be moved.
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
oldname - The current name
|
* @param oldname The current path of a file or directory.
|
||||||
newname - The new name
|
* @param newname The new path for the file or directory.
|
||||||
|
*
|
||||||
Returns:
|
* @return 0 on success, 1 on failure.
|
||||||
Returns 0 on success, 1 on failure.
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
Remarks:
|
*/
|
||||||
- The strings are treated as zero-terminated strings.
|
|
||||||
*/
|
|
||||||
int fs_rename(const char *oldname, const char *newname);
|
int fs_rename(const char *oldname, const char *newname);
|
||||||
|
|
||||||
/*
|
/**
|
||||||
Function: fs_file_time
|
* Gets the creation and the last modification date of a file or directory.
|
||||||
Gets the creation and the last modification date of a file.
|
*
|
||||||
|
* @ingroup Filesystem
|
||||||
Parameters:
|
*
|
||||||
name - The filename.
|
* @param name Path of a file or directory.
|
||||||
created - Pointer to time_t
|
* @param created Pointer where the creation time will be stored.
|
||||||
modified - Pointer to time_t
|
* @param modified Pointer where the modification time will be stored.
|
||||||
|
*
|
||||||
Returns:
|
* @return 0 on success, non-zero on failure.
|
||||||
0 on success, non-zero on failure
|
*
|
||||||
|
* @remark The strings are treated as zero-terminated strings.
|
||||||
Remarks:
|
* @remark Returned time is in seconds since UNIX Epoch.
|
||||||
- Returned time is in seconds since UNIX Epoch
|
*/
|
||||||
*/
|
|
||||||
int fs_file_time(const char *name, time_t *created, time_t *modified);
|
int fs_file_time(const char *name, time_t *created, time_t *modified);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -2284,8 +2284,18 @@ void CClient::FinishMapDownload()
|
||||||
m_MapdownloadTotalsize = -1;
|
m_MapdownloadTotalsize = -1;
|
||||||
SHA256_DIGEST *pSha256 = m_MapdownloadSha256Present ? &m_MapdownloadSha256 : 0;
|
SHA256_DIGEST *pSha256 = m_MapdownloadSha256Present ? &m_MapdownloadSha256 : 0;
|
||||||
|
|
||||||
Storage()->RemoveFile(m_aMapdownloadFilename, IStorage::TYPE_SAVE);
|
bool FileSuccess = true;
|
||||||
Storage()->RenameFile(m_aMapdownloadFilenameTemp, m_aMapdownloadFilename, IStorage::TYPE_SAVE);
|
if(Storage()->FileExists(m_aMapdownloadFilename, IStorage::TYPE_SAVE))
|
||||||
|
FileSuccess &= Storage()->RemoveFile(m_aMapdownloadFilename, IStorage::TYPE_SAVE);
|
||||||
|
FileSuccess &= Storage()->RenameFile(m_aMapdownloadFilenameTemp, m_aMapdownloadFilename, IStorage::TYPE_SAVE);
|
||||||
|
if(!FileSuccess)
|
||||||
|
{
|
||||||
|
ResetMapDownload();
|
||||||
|
char aError[128 + IO_MAX_PATH_LENGTH];
|
||||||
|
str_format(aError, sizeof(aError), Localize("Could not save downloaded map. Try manually deleting this file: %s"), m_aMapdownloadFilename);
|
||||||
|
DisconnectWithReason(aError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// load map
|
// load map
|
||||||
const char *pError = LoadMap(m_aMapdownloadName, m_aMapdownloadFilename, pSha256, m_MapdownloadCrc);
|
const char *pError = LoadMap(m_aMapdownloadName, m_aMapdownloadFilename, pSha256, m_MapdownloadCrc);
|
||||||
|
|
|
@ -433,6 +433,44 @@ public:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
template<typename F>
|
||||||
|
bool GenericExists(const char *pFilename, int Type, F &&CheckFunction)
|
||||||
|
{
|
||||||
|
TranslateType(Type, pFilename);
|
||||||
|
|
||||||
|
char aBuffer[IO_MAX_PATH_LENGTH];
|
||||||
|
if(Type == TYPE_ALL)
|
||||||
|
{
|
||||||
|
// check all available directories
|
||||||
|
for(int i = TYPE_SAVE; i < m_NumPaths; ++i)
|
||||||
|
{
|
||||||
|
if(CheckFunction(GetPath(i, pFilename, aBuffer, sizeof(aBuffer))))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else if(Type == TYPE_ABSOLUTE || (Type >= TYPE_SAVE && Type < m_NumPaths))
|
||||||
|
{
|
||||||
|
// check wanted directory
|
||||||
|
return CheckFunction(GetPath(Type, pFilename, aBuffer, sizeof(aBuffer)));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dbg_assert(false, "Type invalid");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FileExists(const char *pFilename, int Type) override
|
||||||
|
{
|
||||||
|
return GenericExists(pFilename, Type, fs_is_file);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool FolderExists(const char *pFilename, int Type) override
|
||||||
|
{
|
||||||
|
return GenericExists(pFilename, Type, fs_is_dir);
|
||||||
|
}
|
||||||
|
|
||||||
bool ReadFile(const char *pFilename, int Type, void **ppResult, unsigned *pResultLen) override
|
bool ReadFile(const char *pFilename, int Type, void **ppResult, unsigned *pResultLen) override
|
||||||
{
|
{
|
||||||
IOHANDLE File = OpenFile(pFilename, IOFLAG_READ, Type);
|
IOHANDLE File = OpenFile(pFilename, IOFLAG_READ, Type);
|
||||||
|
|
|
@ -25,15 +25,15 @@ public:
|
||||||
/**
|
/**
|
||||||
* Translates to TYPE_SAVE if a path is relative
|
* Translates to TYPE_SAVE if a path is relative
|
||||||
* and to TYPE_ABSOLUTE if a path is absolute.
|
* and to TYPE_ABSOLUTE if a path is absolute.
|
||||||
* Only usable with OpenFile, ReadFile, ReadFileStr
|
* Only usable with OpenFile, ReadFile, ReadFileStr,
|
||||||
* and GetCompletePath.
|
* GetCompletePath, FileExists and FolderExists.
|
||||||
*/
|
*/
|
||||||
TYPE_SAVE_OR_ABSOLUTE = -3,
|
TYPE_SAVE_OR_ABSOLUTE = -3,
|
||||||
/**
|
/**
|
||||||
* Translates to TYPE_ALL if a path is relative
|
* Translates to TYPE_ALL if a path is relative
|
||||||
* and to TYPE_ABSOLUTE if a path is absolute.
|
* and to TYPE_ABSOLUTE if a path is absolute.
|
||||||
* Only usable with OpenFile, ReadFile, ReadFileStr
|
* Only usable with OpenFile, ReadFile, ReadFileStr,
|
||||||
* and GetCompletePath.
|
* GetCompletePath, FileExists and FolderExists.
|
||||||
*/
|
*/
|
||||||
TYPE_ALL_OR_ABSOLUTE = -4,
|
TYPE_ALL_OR_ABSOLUTE = -4,
|
||||||
|
|
||||||
|
@ -45,6 +45,8 @@ public:
|
||||||
virtual void ListDirectory(int Type, const char *pPath, FS_LISTDIR_CALLBACK pfnCallback, void *pUser) = 0;
|
virtual void ListDirectory(int Type, const char *pPath, FS_LISTDIR_CALLBACK pfnCallback, void *pUser) = 0;
|
||||||
virtual void ListDirectoryInfo(int Type, const char *pPath, FS_LISTDIR_CALLBACK_FILEINFO pfnCallback, void *pUser) = 0;
|
virtual void ListDirectoryInfo(int Type, const char *pPath, FS_LISTDIR_CALLBACK_FILEINFO pfnCallback, void *pUser) = 0;
|
||||||
virtual IOHANDLE OpenFile(const char *pFilename, int Flags, int Type, char *pBuffer = nullptr, int BufferSize = 0) = 0;
|
virtual IOHANDLE OpenFile(const char *pFilename, int Flags, int Type, char *pBuffer = nullptr, int BufferSize = 0) = 0;
|
||||||
|
virtual bool FileExists(const char *pFilename, int Type) = 0;
|
||||||
|
virtual bool FolderExists(const char *pFilename, int Type) = 0;
|
||||||
virtual bool ReadFile(const char *pFilename, int Type, void **ppResult, unsigned *pResultLen) = 0;
|
virtual bool ReadFile(const char *pFilename, int Type, void **ppResult, unsigned *pResultLen) = 0;
|
||||||
virtual char *ReadFileStr(const char *pFilename, int Type) = 0;
|
virtual char *ReadFileStr(const char *pFilename, int Type) = 0;
|
||||||
virtual bool FindFile(const char *pFilename, const char *pPath, int Type, char *pBuffer, int BufferSize) = 0;
|
virtual bool FindFile(const char *pFilename, const char *pPath, int Type, char *pBuffer, int BufferSize) = 0;
|
||||||
|
|
Loading…
Reference in a new issue