mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94fa2fe3a6 | ||
|
|
f6afecbc0a |
No files matched your search
@@ -634,6 +634,7 @@ export const enVaultMessages: Messages = {
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.noFiles': 'No files to upload',
|
||||
'sftp.upload.virtualFolderError': 'Folders dragged from an archive window cannot be uploaded. Extract the archive to a normal folder first, then upload the folder.',
|
||||
'sftp.upload.partialSuccess': 'Uploaded {success} file(s); {failed} failed',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
'sftp.upload.completedToPath': 'Uploaded to {path}',
|
||||
|
||||
@@ -634,6 +634,7 @@ export const esVaultMessages: Messages = {
|
||||
'sftp.upload.currentFile': 'Actual: {fileName}',
|
||||
'sftp.upload.cancelled': 'Subida cancelada',
|
||||
'sftp.upload.noFiles': 'No hay archivos para subir',
|
||||
'sftp.upload.virtualFolderError': 'No se pueden subir carpetas arrastradas desde una ventana de archivo comprimido. Extrae el archivo a una carpeta normal y vuelve a subirlo.',
|
||||
'sftp.upload.partialSuccess': 'Se subieron {success} archivo(s); {failed} fallaron',
|
||||
'sftp.upload.cancel': 'Cancelar',
|
||||
'sftp.upload.completedToPath': 'Subido a {path}',
|
||||
|
||||
@@ -676,6 +676,7 @@ export const ruVaultMessages: Messages = {
|
||||
'sftp.upload.currentFile': 'Текущий: {fileName}',
|
||||
'sftp.upload.cancelled': 'Загрузка отменена',
|
||||
'sftp.upload.noFiles': 'Нет файлов для загрузки',
|
||||
'sftp.upload.virtualFolderError': 'Нельзя загрузить папку, перетащенную из окна архива. Сначала распакуйте архив в обычную папку и загрузите её.',
|
||||
'sftp.upload.partialSuccess': 'Загружено: {success}; ошибок: {failed}',
|
||||
'sftp.upload.cancel': 'Отмена',
|
||||
'sftp.upload.completedToPath': 'Загружено в {path}',
|
||||
|
||||
@@ -187,6 +187,7 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.noFiles': '没有可上传的文件',
|
||||
'sftp.upload.virtualFolderError': '无法上传从压缩包中直接拖出的文件夹,请先解压到普通文件夹后再上传。',
|
||||
'sftp.upload.partialSuccess': '已上传 {success} 个文件,{failed} 个失败',
|
||||
'sftp.upload.cancel': '取消',
|
||||
'sftp.upload.completedToPath': '已上传至 {path}',
|
||||
|
||||
@@ -187,6 +187,7 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'sftp.upload.currentFile': '目前: {fileName}',
|
||||
'sftp.upload.cancelled': '上傳已取消',
|
||||
'sftp.upload.noFiles': '沒有可上傳的檔案',
|
||||
'sftp.upload.virtualFolderError': '無法上傳從壓縮檔中直接拖出的資料夾,請先解壓縮到一般資料夾後再上傳。',
|
||||
'sftp.upload.partialSuccess': '已上傳 {success} 個檔案,{failed} 個失敗',
|
||||
'sftp.upload.cancel': '取消',
|
||||
'sftp.upload.completedToPath': '已上傳至 {path}',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
uploadFromFileList,
|
||||
} from "../../lib/uploadService.ts";
|
||||
import { DEFAULT_SFTP_FILE_TRANSFER_CONCURRENCY } from "../../domain/sftpTransferConcurrency.ts";
|
||||
import { SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR } from "../../domain/sftpVirtualDrop.ts";
|
||||
|
||||
function createDataTransfer(files: File[]): DataTransfer {
|
||||
return {
|
||||
@@ -1196,3 +1197,86 @@ test("does not restart a direct upload that was already cancelled", async () =>
|
||||
{ fileName: "", success: false, cancelled: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("archive-window folder drags (path-less empty pseudo-files) fail fast without a conflict dialog", async () => {
|
||||
const dialogConflicts: unknown[] = [];
|
||||
const uploadedPaths: string[] = [];
|
||||
const stagedFiles: string[] = [];
|
||||
// Chromium presents an archive folder as an extension-less, empty,
|
||||
// path-less pseudo-file with no entry.
|
||||
const pseudoFolder = new File([], "folder");
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[{ file: pseudoFolder, relativePath: "folder", isDirectory: false }],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async () => ({ type: "directory", size: 0, lastModified: 1000 }),
|
||||
stageUploadFile: async (stagedFile) => {
|
||||
stagedFiles.push(stagedFile.name);
|
||||
return "/netcatty-temp/folder";
|
||||
},
|
||||
startStreamTransfer: async (payload) => {
|
||||
uploadedPaths.push(payload.sourcePath);
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
resolveConflict: async (conflict) => {
|
||||
dialogConflicts.push(conflict);
|
||||
return "duplicate";
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Pseudo folders must never reach the conflict dialog or the transfer path.
|
||||
assert.deepEqual(dialogConflicts, []);
|
||||
assert.deepEqual(stagedFiles, []);
|
||||
assert.deepEqual(uploadedPaths, []);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].fileName, "folder");
|
||||
assert.equal(results[0].success, false);
|
||||
assert.equal(results[0].error, SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR);
|
||||
});
|
||||
|
||||
test("mixed drops upload real files and only refuse the pseudo archive folder", async () => {
|
||||
const uploadedPaths: string[] = [];
|
||||
const pseudoFolder = new File([], "folder");
|
||||
const realFile = new File(["payload"], "notes.txt");
|
||||
|
||||
const results = await uploadEntriesDirect(
|
||||
[
|
||||
{ file: pseudoFolder, relativePath: "folder", isDirectory: false },
|
||||
{ file: realFile, relativePath: "notes.txt", isDirectory: false },
|
||||
],
|
||||
{
|
||||
targetPath: "/target",
|
||||
sftpId: "sftp-1",
|
||||
isLocal: false,
|
||||
bridge: {
|
||||
mkdirSftp: async () => {},
|
||||
statSftp: async () => null,
|
||||
stageUploadFile: async () => "/netcatty-temp/notes.txt",
|
||||
deleteTempFile: async () => {},
|
||||
startStreamTransfer: async (payload) => {
|
||||
uploadedPaths.push(payload.sourcePath);
|
||||
return { transferId: payload.transferId };
|
||||
},
|
||||
},
|
||||
joinPath: (base, name) => `${base}/${name}`,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(uploadedPaths, ["/netcatty-temp/notes.txt"]);
|
||||
assert.deepEqual(
|
||||
results.map((result) => ({ fileName: result.fileName, success: result.success })),
|
||||
[
|
||||
{ fileName: "folder", success: false },
|
||||
{ fileName: "notes.txt", success: true },
|
||||
],
|
||||
);
|
||||
assert.equal(results[0].error, SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR);
|
||||
});
|
||||
@@ -24,6 +24,8 @@ import { filterHiddenFiles, isNavigableDirectory } from "../utils";
|
||||
import { resolveSameConnectionPasteAction } from "../../../application/state/sftp/samePanePaste";
|
||||
import type { SftpFileEntry } from "../../../types";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import { resolveSftpUploadErrorText } from "../reportSftpUploadResults";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { toast } from "../../ui/toast";
|
||||
import { isAppLockOverlayActive } from '../../../infrastructure/appLockOverlayDom';
|
||||
import {
|
||||
@@ -183,6 +185,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
dialogActionScopeId,
|
||||
isActive,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const { t } = useI18n();
|
||||
const typeaheadRef = useRef<{ paneId: string; state: SftpTypeaheadState } | null>(null);
|
||||
|
||||
const getFocusedPane = useCallback(() => {
|
||||
@@ -222,10 +225,13 @@ export const useSftpKeyboardShortcuts = ({
|
||||
toast.success(`Uploaded ${successCount} item${successCount === 1 ? "" : "s"}.`, "SFTP");
|
||||
}
|
||||
failedFiles.forEach((failed) => {
|
||||
const errorMsg = failed.error ? ` - ${failed.error}` : "";
|
||||
// Virtual archive-folder drags store a sentinel; map it to the
|
||||
// localized, actionable message instead of showing the raw marker.
|
||||
const mappedError = resolveSftpUploadErrorText(failed.error, t);
|
||||
const errorMsg = mappedError ? ` - ${mappedError}` : "";
|
||||
toast.error(`Upload failed: ${failed.fileName}${errorMsg}`, "SFTP");
|
||||
});
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const triggerPathBackedClipboardUpload = useCallback((
|
||||
files: ClipboardLocalFile[],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { reportSftpUploadResults } from "./reportSftpUploadResults.ts";
|
||||
import { reportSftpUploadResults, resolveSftpUploadErrorText } from "./reportSftpUploadResults.ts";
|
||||
import { SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR } from "../../domain/sftpVirtualDrop";
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (key === "sftp.upload.partialSuccess") {
|
||||
@@ -117,3 +118,30 @@ test("partial terminal drop success names the exact remote destination", () => {
|
||||
message: "partial 1/1 (/srv/releases)",
|
||||
});
|
||||
});
|
||||
|
||||
test("replaces the virtual archive-folder sentinel with a localized message", () => {
|
||||
const calls: Array<{ type: string; message: string }> = [];
|
||||
reportSftpUploadResults({
|
||||
results: [
|
||||
{ fileName: "folder", success: false, error: SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR },
|
||||
],
|
||||
t,
|
||||
toast: {
|
||||
success: (message) => calls.push({ type: "success", message }),
|
||||
error: (message) => calls.push({ type: "error", message }),
|
||||
info: (message) => calls.push({ type: "info", message }),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls[0]?.type, "error");
|
||||
assert.equal(
|
||||
calls[0]?.message,
|
||||
"sftp.error.uploadFailed: folder - sftp.upload.virtualFolderError",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveSftpUploadErrorText maps the sentinel for non-reporting consumers", () => {
|
||||
assert.equal(resolveSftpUploadErrorText(SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR, t), "sftp.upload.virtualFolderError");
|
||||
assert.equal(resolveSftpUploadErrorText("stream error", t), "stream error");
|
||||
assert.equal(resolveSftpUploadErrorText(undefined, t), undefined);
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
import { isSftpVirtualFolderUploadError } from "../../domain/sftpVirtualDrop";
|
||||
import type { UploadResult } from "../../lib/uploadService.types";
|
||||
|
||||
/**
|
||||
* Maps a raw upload error for display. The virtual archive-folder sentinel is
|
||||
* an internal marker, never user-facing text: every upload-result consumer
|
||||
* must route errors through this helper before presenting them.
|
||||
*/
|
||||
export const resolveSftpUploadErrorText = (
|
||||
error: string | undefined,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
): string | undefined =>
|
||||
isSftpVirtualFolderUploadError(error) ? t("sftp.upload.virtualFolderError") : error;
|
||||
|
||||
export type ReportSftpUploadResultsOptions = {
|
||||
results: readonly UploadResult[];
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
@@ -38,7 +50,8 @@ export function reportSftpUploadResults({
|
||||
|
||||
if (failed.length > 0) {
|
||||
for (const item of failed) {
|
||||
const errorMsg = item.error ? ` - ${item.error}` : "";
|
||||
const rawError = resolveSftpUploadErrorText(item.error, t);
|
||||
const errorMsg = rawError ? ` - ${rawError}` : "";
|
||||
toast.error(
|
||||
`${t("sftp.error.uploadFailed")}: ${item.fileName || t("sftp.upload")}${errorMsg}`,
|
||||
"SFTP",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR,
|
||||
isSftpVirtualFolderUploadError,
|
||||
isVirtualFolderPseudoFileDrop,
|
||||
} from "./sftpVirtualDrop.ts";
|
||||
|
||||
const fileEntry = (overrides: Partial<Parameters<typeof isVirtualFolderPseudoFileDrop>[0]> = {}) => ({
|
||||
relativePath: "folder",
|
||||
isDirectory: false,
|
||||
localPath: undefined,
|
||||
size: 0,
|
||||
file: { size: 0 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("detects path-less extension-less empty pseudo-files as archive folder drags", () => {
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry()), true);
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ size: undefined, file: null })), true);
|
||||
});
|
||||
|
||||
test("keeps real directory entries on the folder upload flow", () => {
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ isDirectory: true })), false);
|
||||
});
|
||||
|
||||
test("keeps path-backed drops on the normal flow", () => {
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ localPath: "\\Temp\\folder" })), false);
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ localPath: "/tmp/folder" })), false);
|
||||
});
|
||||
|
||||
test("keeps nested enumerated children and dotted names on the normal flow", () => {
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "folder/notes" })), false);
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "backup 2.0" })), false);
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "" })), false);
|
||||
});
|
||||
|
||||
test("keeps path-less virtual files that still carry content", () => {
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "Makefile", size: 120, file: { size: 120 } })), false);
|
||||
assert.equal(isVirtualFolderPseudoFileDrop(fileEntry({ relativePath: "logo", size: 4096, file: { size: 4096 } })), false);
|
||||
});
|
||||
|
||||
test("sentinel helpers only match the exact marker", () => {
|
||||
assert.equal(isSftpVirtualFolderUploadError(SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR), true);
|
||||
assert.equal(isSftpVirtualFolderUploadError("stream error"), false);
|
||||
assert.equal(isSftpVirtualFolderUploadError(undefined), false);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Windows archive-window drags (WinRAR / 7-Zip / Explorer zip) hand the app
|
||||
* "virtual" items: Chromium forwards the shell FILEDESCRIPTOR/FILECONTENTS
|
||||
* data without a real disk path, so Electron's path resolution returns
|
||||
* nothing. A folder dragged out of an archive arrives in the renderer as a
|
||||
* single path-less, extension-less, empty pseudo-file — its contents cannot
|
||||
* be enumerated, so it can neither be merged nor safely replaced, and
|
||||
* streaming it only fabricates an empty file before failing with a confusing
|
||||
* path error (#3057). Those items are rejected before the conflict dialog
|
||||
* with an actionable message pointing the user to extract the archive first.
|
||||
*/
|
||||
|
||||
export interface VirtualFolderDropCandidate {
|
||||
relativePath: string;
|
||||
isDirectory: boolean;
|
||||
localPath?: string;
|
||||
size?: number;
|
||||
/** Renderer pseudo-file handle; null for directory entries. */
|
||||
file?: { size?: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel stored in `UploadResult.error`. The toast reporting layer replaces
|
||||
* this with a localized message before it is presented to the user, so the
|
||||
* sentinel must never be shown as raw text.
|
||||
*/
|
||||
export const SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR = "netcatty:virtual-folder-upload";
|
||||
|
||||
export const isSftpVirtualFolderUploadError = (error: string | undefined): boolean =>
|
||||
error === SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR;
|
||||
|
||||
export const isVirtualFolderPseudoFileDrop = (
|
||||
entry: VirtualFolderDropCandidate,
|
||||
): boolean => {
|
||||
// Real directory entries keep the normal folder flow (merge/replace dialog).
|
||||
if (entry.isDirectory) return false;
|
||||
// Normal Explorer/Finder drags always expose a real local path in Electron.
|
||||
if (entry.localPath) return false;
|
||||
// Only standalone roots matter. Enumerated children keep slash-nested
|
||||
// relative paths and upload as real (streamed) files.
|
||||
const relativePath = entry.relativePath || "";
|
||||
if (!relativePath || relativePath.includes("/")) return false;
|
||||
// Folder names can contain dots; only extension-less names are unambiguous
|
||||
// enough to reject automatically.
|
||||
if (relativePath.includes(".")) return false;
|
||||
// Virtual folder descriptors carry no streamable content.
|
||||
const size = entry.file?.size ?? entry.size;
|
||||
return size === undefined || size === 0;
|
||||
};
|
||||
+40
-7
@@ -17,6 +17,10 @@ import {
|
||||
} from "../domain/sftpConflict";
|
||||
import { isMissingStatError } from "../domain/sftpStatError";
|
||||
import { resolveSftpTransferConcurrency } from "../domain/sftpTransferConcurrency";
|
||||
import {
|
||||
SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR,
|
||||
isVirtualFolderPseudoFileDrop,
|
||||
} from "../domain/sftpVirtualDrop";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -257,8 +261,37 @@ async function uploadEntriesWithOptionalCompression(
|
||||
resolveConflict,
|
||||
} = config;
|
||||
|
||||
// Windows archive-window drags hand over "virtual" items without a real
|
||||
// disk path. A folder dragged out of an archive arrives as one path-less,
|
||||
// extension-less, empty pseudo-file whose content cannot be enumerated, so
|
||||
// uploading would fabricate an empty file and its same-name conflict cannot
|
||||
// honestly offer merge/replace (#3057). Fail those items fast with an
|
||||
// actionable error; everything else keeps the regular flow.
|
||||
const virtualFolderResults: UploadResult[] = [];
|
||||
const uploadableEntries: DropEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (isVirtualFolderPseudoFileDrop(entry)) {
|
||||
virtualFolderResults.push({
|
||||
fileName: entry.relativePath,
|
||||
success: false,
|
||||
error: SFTP_VIRTUAL_FOLDER_UPLOAD_ERROR,
|
||||
});
|
||||
} else {
|
||||
uploadableEntries.push(entry);
|
||||
}
|
||||
}
|
||||
if (uploadableEntries.length === 0) {
|
||||
return virtualFolderResults;
|
||||
}
|
||||
entries = uploadableEntries;
|
||||
|
||||
const withVirtualFolderResults = (results: UploadResult[]): UploadResult[] => [
|
||||
...virtualFolderResults,
|
||||
...results,
|
||||
];
|
||||
|
||||
if (!useCompressedUpload || isLocal || !sftpId) {
|
||||
return uploadEntries(
|
||||
return withVirtualFolderResults(await uploadEntries(
|
||||
entries,
|
||||
targetPath,
|
||||
sftpId,
|
||||
@@ -270,12 +303,12 @@ async function uploadEntriesWithOptionalCompression(
|
||||
resolveConflict,
|
||||
config.targetHostId,
|
||||
config.fileTransferConcurrency,
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
const rootGroups = Array.from(detectRootFolders(entries).entries());
|
||||
if (!rootGroups.some(([key]) => !key.startsWith("__file__"))) {
|
||||
return uploadEntries(
|
||||
return withVirtualFolderResults(await uploadEntries(
|
||||
entries,
|
||||
targetPath,
|
||||
sftpId,
|
||||
@@ -287,7 +320,7 @@ async function uploadEntriesWithOptionalCompression(
|
||||
resolveConflict,
|
||||
config.targetHostId,
|
||||
config.fileTransferConcurrency,
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
const statTarget = async (path: string) => {
|
||||
@@ -350,7 +383,7 @@ async function uploadEntriesWithOptionalCompression(
|
||||
|
||||
if (action === "stop") {
|
||||
await controller?.cancel();
|
||||
return [{ fileName: info.rootName, success: false, cancelled: true }];
|
||||
return withVirtualFolderResults([{ fileName: info.rootName, success: false, cancelled: true }]);
|
||||
}
|
||||
if (action === "skip") {
|
||||
resolvedResults.push({ fileName: info.rootName, success: false, cancelled: true });
|
||||
@@ -397,7 +430,7 @@ async function uploadEntriesWithOptionalCompression(
|
||||
result.success || result.error !== compressionFallbackError
|
||||
));
|
||||
if (regularEntries.length === 0) {
|
||||
return [...resolvedResults, ...terminalCompressedResults];
|
||||
return withVirtualFolderResults([...resolvedResults, ...terminalCompressedResults]);
|
||||
}
|
||||
|
||||
const replayResolvedConflict: UploadConfig["resolveConflict"] = resolveConflict
|
||||
@@ -419,7 +452,7 @@ async function uploadEntriesWithOptionalCompression(
|
||||
config.targetHostId,
|
||||
config.fileTransferConcurrency,
|
||||
);
|
||||
return [...resolvedResults, ...terminalCompressedResults, ...regularResults];
|
||||
return withVirtualFolderResults([...resolvedResults, ...terminalCompressedResults, ...regularResults]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user