mirror of
https://github.com/rwinkhart/libmutton.git
synced 2026-08-30 13:56:40 -04:00
Return errors, rather than printing them
This commit is contained in:
+59
-50
@@ -99,11 +99,11 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool, error) {
|
||||
}
|
||||
|
||||
// GetSSHOutput runs a command over SSH and returns the output as a string.
|
||||
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) string {
|
||||
func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) (string, error) {
|
||||
// create a session
|
||||
sshSession, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to establish SSH session: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return "", errors.New("unable to establish SSH session: " + err.Error())
|
||||
}
|
||||
|
||||
// provide stdin data for session
|
||||
@@ -113,40 +113,46 @@ func GetSSHOutput(sshClient *ssh.Client, cmd, stdin string) string {
|
||||
var output []byte
|
||||
output, err = sshSession.CombinedOutput(cmd)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to run SSH command: "+err.Error(), global.ErrorSyncProcess, true)
|
||||
return "", errors.New("unable to run SSH command: " + err.Error())
|
||||
}
|
||||
|
||||
// convert the output to a string and remove leading/trailing whitespace
|
||||
outputString := string(output)
|
||||
outputString = strings.TrimSpace(outputString)
|
||||
|
||||
return outputString
|
||||
return outputString, nil
|
||||
}
|
||||
|
||||
// getRemoteDataFromClient returns a map of remote entries to their modification times, a list of remote folders, a list of queued deletions, and the current server&client times as UNIX timestamps.
|
||||
func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string]int64, []string, []string, int64, int64) {
|
||||
func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string]int64, []string, []string, int64, int64, error) {
|
||||
// get remote output over SSH
|
||||
deviceIDList := global.GenDeviceIDList(true)
|
||||
deviceIDList, err := global.GenDeviceIDList()
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, err
|
||||
}
|
||||
if len(deviceIDList) == 0 {
|
||||
if manualSync {
|
||||
back.PrintError("Sync failed - No device ID found", back.ErrorTargetNotFound, true)
|
||||
return nil, nil, nil, 0, 0, errors.New("no device ID found")
|
||||
} else {
|
||||
back.Exit(0) // exit silently if the sync job was called automatically, as the user may just be in offline mode
|
||||
}
|
||||
}
|
||||
clientTime := time.Now().Unix() // get client time now to avoid accuracy issues caused by unpredictable sync time
|
||||
output := GetSSHOutput(sshClient, "libmuttonserver fetch", (deviceIDList)[0].Name())
|
||||
output, err := GetSSHOutput(sshClient, "libmuttonserver fetch", (deviceIDList)[0].Name())
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to run remote command: " + err.Error())
|
||||
}
|
||||
|
||||
// split output into slice based on occurrences of FSSpace
|
||||
outputSlice := strings.Split(output, global.FSSpace)
|
||||
|
||||
// parse output/re-form lists
|
||||
if len(outputSlice) != 5 { // ensure information from server is complete
|
||||
back.PrintError("Sync failed - Unable to fetch remote data; server returned an unexpected response", global.ErrorSyncProcess, true)
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to run remote command; server returned an unexpected response")
|
||||
}
|
||||
serverTime, err := strconv.ParseInt(outputSlice[0], 10, 64)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to parse server time: "+err.Error(), back.ErrorRead, true)
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to parse server time: " + err.Error())
|
||||
}
|
||||
entries := strings.Split(outputSlice[1], global.FSMisc)[1:]
|
||||
modsStrings := strings.Split(outputSlice[2], global.FSMisc)[1:]
|
||||
@@ -159,7 +165,7 @@ func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string
|
||||
for _, modString := range modsStrings {
|
||||
mod, err = strconv.ParseInt(modString, 10, 64)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to parse mod time: "+err.Error(), back.ErrorRead, true)
|
||||
return nil, nil, nil, 0, 0, errors.New("unable to parse mod time: " + err.Error())
|
||||
}
|
||||
mods = append(mods, mod)
|
||||
}
|
||||
@@ -170,13 +176,16 @@ func getRemoteDataFromClient(sshClient *ssh.Client, manualSync bool) (map[string
|
||||
entryModMap[entry] = mods[i]
|
||||
}
|
||||
|
||||
return entryModMap, folders, deletions, serverTime, clientTime
|
||||
return entryModMap, folders, deletions, serverTime, clientTime, nil
|
||||
}
|
||||
|
||||
// getLocalData returns a map of local entries to their modification times.
|
||||
func getLocalData() map[string]int64 {
|
||||
func getLocalData() (map[string]int64, error) {
|
||||
// get a list of all entries
|
||||
entries, _ := synccommon.WalkEntryDir()
|
||||
entries, _, err := synccommon.WalkEntryDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// get a list of all entry modification times
|
||||
modList := synccommon.GetModTimes(entries)
|
||||
@@ -188,7 +197,7 @@ func getLocalData() map[string]int64 {
|
||||
}
|
||||
|
||||
// return the lists
|
||||
return entryModMap
|
||||
return entryModMap, nil
|
||||
}
|
||||
|
||||
// targetLocationFormatSFTP formats the target location to match the remote server's entry directory and path separator.
|
||||
@@ -201,17 +210,14 @@ func targetLocationFormatSFTP(targetName, serverEntryRoot string, serverIsWindow
|
||||
}
|
||||
|
||||
// sftpSync takes two slices of entries (one for downloads and one for uploads) and syncs them between the client and server using SFTP.
|
||||
func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, downloadList, uploadList []string) {
|
||||
func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, downloadList, uploadList []string) error {
|
||||
// create an SFTP client from sshClient
|
||||
sftpClient, err := sftp.NewClient(sshClient)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to establish SFTP session: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return errors.New("unable to establish SFTP session: " + err.Error())
|
||||
}
|
||||
defer func(sftpClient *sftp.Client) {
|
||||
err = sftpClient.Close()
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to close SFTP client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
}
|
||||
_ = sftpClient.Close()
|
||||
}(sftpClient)
|
||||
|
||||
// iterate over the download list
|
||||
@@ -228,7 +234,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var fileInfo os.FileInfo
|
||||
fileInfo, err = sftpClient.Stat(remoteEntryFullPath)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to get remote file info (mod time): "+err.Error(), back.ErrorRead, true)
|
||||
return errors.New("unable to get remote file info (mod time): " + err.Error())
|
||||
}
|
||||
modTime := fileInfo.ModTime()
|
||||
|
||||
@@ -236,7 +242,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var remoteFile *sftp.File
|
||||
remoteFile, err = sftpClient.Open(remoteEntryFullPath)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to open remote file: "+err.Error(), back.ErrorRead, true)
|
||||
return errors.New("unable to open remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// store path to local entry
|
||||
@@ -246,13 +252,13 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var localFile *os.File
|
||||
localFile, err = os.OpenFile(localEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to create local file: "+err.Error(), back.ErrorWrite, true)
|
||||
return errors.New("unable to create local file: " + err.Error())
|
||||
}
|
||||
|
||||
// download the file
|
||||
_, err = remoteFile.WriteTo(localFile)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to download remote file: "+err.Error(), global.ErrorSyncProcess, true)
|
||||
return errors.New("unable to download remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// close the files
|
||||
@@ -281,7 +287,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var fileInfo os.FileInfo
|
||||
fileInfo, err = os.Stat(localEntryFullPath)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to get local file info (mod time): "+err.Error(), back.ErrorRead, true)
|
||||
return errors.New("unable to get local file info (mod time): " + err.Error())
|
||||
}
|
||||
modTime := fileInfo.ModTime()
|
||||
|
||||
@@ -289,7 +295,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var localFile *os.File
|
||||
localFile, err = os.Open(localEntryFullPath)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to open local file: "+err.Error(), back.ErrorRead, true)
|
||||
return errors.New("unable to open local file: " + err.Error())
|
||||
}
|
||||
|
||||
// store path to remote entry
|
||||
@@ -299,13 +305,13 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
var remoteFile *sftp.File
|
||||
remoteFile, err = sftpClient.OpenFile(remoteEntryFullPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to create remote file ("+remoteEntryFullPath+"): "+err.Error(), back.ErrorWrite, true)
|
||||
return errors.New("unable to create remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// upload the file
|
||||
_, err = localFile.WriteTo(remoteFile)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to upload local file: "+err.Error(), global.ErrorSyncProcess, true)
|
||||
return errors.New("unable to upload local file: " + err.Error())
|
||||
}
|
||||
|
||||
// close the files
|
||||
@@ -315,7 +321,7 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
// set permissions on remote file
|
||||
err = sftpClient.Chmod(remoteEntryFullPath, 0600)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to set permissions on remote file: "+err.Error(), global.ErrorSyncProcess, true)
|
||||
return errors.New("unable to set permissions on remote file: " + err.Error())
|
||||
}
|
||||
|
||||
// set the modification time of the remote file to match the value saved from the local file (from before the upload)
|
||||
@@ -325,6 +331,8 @@ func sftpSync(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows bool, dow
|
||||
if filesTransferred {
|
||||
fmt.Println() // add a gap between upload and sync complete messages
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncLists determines which entries need to be downloaded and uploaded for synchronizations and calls sftpSync with this information.
|
||||
@@ -377,24 +385,24 @@ func syncLists(sshClient *ssh.Client, sshEntryRoot string, sshIsWindows, timeSyn
|
||||
}
|
||||
|
||||
// deletionSync removes entries from the client that have been deleted on the server (multi-client deletion).
|
||||
func deletionSync(deletions []string) {
|
||||
func deletionSync(deletions []string) error {
|
||||
var filesDeleted bool
|
||||
for _, deletion := range deletions {
|
||||
filesDeleted = true // set a flag to indicate that files have been deleted (used to determine whether to print a gap between deletion and other messages)
|
||||
fmt.Println(synccommon.AnsiDelete+deletion+back.AnsiReset, "has been sheared, removing locally (if it exists)")
|
||||
err := os.RemoveAll(global.TargetLocationFormat(deletion))
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Failed to shear "+deletion+" locally: "+err.Error(), back.ErrorWrite, true)
|
||||
return errors.New("unable to shear " + deletion + " locally: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if filesDeleted {
|
||||
fmt.Println() // add a gap between deletion and other messages
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// folderSync creates folders on the client (from the given list of folder names).
|
||||
func folderSync(folders []string) {
|
||||
func folderSync(folders []string) error {
|
||||
for _, folder := range folders {
|
||||
// store the full local path of the folder
|
||||
folderFullPath := global.TargetLocationFormat(folder)
|
||||
@@ -405,35 +413,33 @@ func folderSync(folders []string) {
|
||||
if !isFile && !isAccessible {
|
||||
err := os.MkdirAll(folderFullPath, 0700)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Failed to create folder ("+folder+"): "+err.Error(), back.ErrorWrite, true)
|
||||
return errors.New("unable to create folder (" + folder + "): " + err.Error())
|
||||
}
|
||||
} else if isFile {
|
||||
back.PrintError("Sync failed - Failed to create folder ("+folder+") - A file with the same name already exists", global.ErrorTargetExists, true)
|
||||
return errors.New("unable to create folder (" + folder + "): a file with the same name already exists")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunJob runs the SSH sync job.
|
||||
// Setting manualSync to true will throw errors if sync is not configured (online mode is assumed).
|
||||
// Setting returnLists to true will return the deletions, downloads, and uploads lists for use by the client.
|
||||
func RunJob(manualSync, returnLists bool) [3][]string {
|
||||
func RunJob(manualSync, returnLists bool) ([3][]string, error) {
|
||||
// get SSH client to re-use throughout the sync process
|
||||
sshClient, sshEntryRoot, sshIsWindows, err := GetSSHClient(manualSync)
|
||||
if err != nil {
|
||||
back.PrintError("sync failed - unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
}
|
||||
if sshClient == nil { // indicate SSH dialing failure for interactive clients
|
||||
return [3][]string{nil, nil, nil}
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
defer func(sshClient *ssh.Client) {
|
||||
err := sshClient.Close()
|
||||
if err != nil {
|
||||
back.PrintError("sync failed - unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
}
|
||||
_ = sshClient.Close()
|
||||
}(sshClient)
|
||||
|
||||
// fetch remote lists
|
||||
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime := getRemoteDataFromClient(sshClient, manualSync)
|
||||
remoteEntryModMap, remoteFolders, deletions, serverTime, clientTime, err := getRemoteDataFromClient(sshClient, manualSync)
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to fetch remote data: " + err.Error())
|
||||
}
|
||||
|
||||
// sync deletions
|
||||
deletionSync(deletions)
|
||||
@@ -442,7 +448,10 @@ func RunJob(manualSync, returnLists bool) [3][]string {
|
||||
folderSync(remoteFolders)
|
||||
|
||||
// fetch local lists
|
||||
localEntryModMap := getLocalData()
|
||||
localEntryModMap, err := getLocalData()
|
||||
if err != nil {
|
||||
return [3][]string{nil, nil, nil}, errors.New("unable to fetch local entry data: " + err.Error())
|
||||
}
|
||||
|
||||
// prior to syncing lists, ensure the client and server clocks are synced within 45 seconds
|
||||
var timeSynced = true
|
||||
@@ -457,9 +466,9 @@ func RunJob(manualSync, returnLists bool) [3][]string {
|
||||
if returnLists {
|
||||
lists = syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, true, localEntryModMap, remoteEntryModMap)
|
||||
lists[0] = deletions
|
||||
return lists
|
||||
return lists, nil
|
||||
}
|
||||
syncLists(sshClient, sshEntryRoot, sshIsWindows, timeSynced, false, localEntryModMap, remoteEntryModMap)
|
||||
back.Exit(0) // exit program if running non-interactively
|
||||
return lists // dummy return for when not returning lists
|
||||
back.Exit(0) // exit program if running non-interactively
|
||||
return lists, nil // dummy return for when not returning lists
|
||||
}
|
||||
|
||||
+25
-12
@@ -1,6 +1,7 @@
|
||||
package syncclient
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/rwinkhart/go-boilerplate/back"
|
||||
@@ -10,14 +11,17 @@ import (
|
||||
|
||||
// ShearRemoteFromClient removes the target file or directory from the local system and calls the server to remove it remotely and add it to the deletions list.
|
||||
// It can safely be called in offline mode, as well, so this is the intended interface for shearing (ShearLocal should only be used directly by the server binary).
|
||||
func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
|
||||
deviceID, isDir := synccommon.ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
|
||||
func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) error {
|
||||
deviceID, isDir, err := synccommon.ShearLocal(targetLocationIncomplete, "") // remove the target from the local system and get the device ID of the client
|
||||
if err != nil {
|
||||
return errors.New("unable to shear target locally: " + err.Error())
|
||||
}
|
||||
|
||||
if !forceOffline && deviceID != "" { // ensure a device ID exists (online mode)
|
||||
// create an SSH client; manualSync is false in case a device ID exists but SSH is not configured
|
||||
sshClient, _, _, err := GetSSHClient(false)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
// ensure targetLocationIncomplete ends with a slash if it is a directory (for clarity in shear message)
|
||||
@@ -31,24 +35,28 @@ func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
back.Exit(0) // sync is not required after shearing since the target has already been removed from the local system
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameRemoteFromClient renames oldLocationIncomplete to newLocationIncomplete on the local system and calls the server to perform the rename remotely and add the old target to the deletions list.
|
||||
// It can safely be called in offline mode, as well, so this is the intended interface for renaming (RenameLocal should only be used directly by the server binary).
|
||||
func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, forceOffline bool) {
|
||||
func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, forceOffline bool) error {
|
||||
synccommon.RenameLocal(oldLocationIncomplete, newLocationIncomplete, false) // move the target on the local system
|
||||
|
||||
deviceIDList := global.GenDeviceIDList(true)
|
||||
deviceIDList, err := global.GenDeviceIDList()
|
||||
if err != nil {
|
||||
return errors.New("unable to generate device ID list: " + err.Error())
|
||||
}
|
||||
if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode)
|
||||
// create an SSH client; manualSync is false in case a device ID exists but SSH is not configured
|
||||
sshClient, _, _, err := GetSSHClient(false)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
// call the server to move the target on the remote system and add the old target to the deletions list
|
||||
@@ -60,24 +68,28 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string,
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
back.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddFolderRemoteFromClient creates a new entry-containing directory on the local system and calls the server to create the folder remotely.
|
||||
// It can safely be called in offline mode, as well, so this is the intended interface for adding folders (AddFolderLocal should only be used directly by the server binary).
|
||||
func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline bool) {
|
||||
func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline bool) error {
|
||||
synccommon.AddFolderLocal(targetLocationIncomplete) // add the folder on the local system
|
||||
|
||||
deviceIDList := global.GenDeviceIDList(true)
|
||||
deviceIDList, err := global.GenDeviceIDList()
|
||||
if err != nil {
|
||||
return errors.New("unable to generate device ID list: " + err.Error())
|
||||
}
|
||||
if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode)
|
||||
// create an SSH client; manualSync is false in case a device ID exists but SSH is not configured
|
||||
sshClient, _, _, err := GetSSHClient(false)
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return errors.New("unable to connect to SSH client: " + err.Error())
|
||||
}
|
||||
|
||||
// call the server to create the folder remotely
|
||||
@@ -86,9 +98,10 @@ func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline boo
|
||||
// close the SSH client
|
||||
err = sshClient.Close()
|
||||
if err != nil {
|
||||
back.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true)
|
||||
return errors.New("unable to close SSH client: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
back.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user