mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 13:18:09 +00:00
fix(workboard): refuse unsafe SSHFS SQLite storage
Preserve rollback journaling for NFS and SMB-backed stores, refuse SSHFS after symlink-aware mount classification, and close Workboard database handles when filesystem policy rejects initialization.
This commit is contained in:
42
extensions/workboard/src/sqlite-store-policy.test.ts
Normal file
42
extensions/workboard/src/sqlite-store-policy.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { close, configureSqliteConnectionPragmas } = vi.hoisted(() => ({
|
||||
close: vi.fn(),
|
||||
configureSqliteConnectionPragmas: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:sqlite", () => ({
|
||||
DatabaseSync: vi.fn(function DatabaseSync() {
|
||||
return { close };
|
||||
}),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/plugin-state-runtime", () => ({
|
||||
configureSqliteConnectionPragmas,
|
||||
}));
|
||||
|
||||
import { createWorkboardSqliteStores } from "./sqlite-store.js";
|
||||
|
||||
describe("Workboard SQLite policy", () => {
|
||||
beforeEach(() => {
|
||||
close.mockClear();
|
||||
configureSqliteConnectionPragmas.mockReset();
|
||||
});
|
||||
|
||||
it("closes a newly opened database when filesystem policy refuses it", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-workboard-policy-"));
|
||||
const dbPath = path.join(dir, "workboard.sqlite");
|
||||
configureSqliteConnectionPragmas.mockImplementation(() => {
|
||||
throw new Error("SSHFS is unsupported");
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => createWorkboardSqliteStores({ dbPath })).toThrow(/SSHFS/);
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -390,17 +390,27 @@ function createDatabase(dbPath: string): {
|
||||
fs.closeSync(fs.openSync(dbPath, "a", WORKBOARD_SQLITE_FILE_MODE));
|
||||
}
|
||||
const db = new DatabaseSync(dbPath);
|
||||
const maintenance = configureSqliteConnectionPragmas(db, {
|
||||
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
|
||||
checkpointIntervalMs: 0,
|
||||
databaseLabel: "workboard database",
|
||||
databasePath: dbPath,
|
||||
foreignKeys: true,
|
||||
synchronous: "NORMAL",
|
||||
});
|
||||
ensureWorkboardSchema(db);
|
||||
hardenWorkboardDatabaseFiles(dbPath);
|
||||
return { db, maintenance };
|
||||
let maintenance: ReturnType<typeof configureSqliteConnectionPragmas> | undefined;
|
||||
try {
|
||||
maintenance = configureSqliteConnectionPragmas(db, {
|
||||
busyTimeoutMs: WORKBOARD_SQLITE_BUSY_TIMEOUT_MS,
|
||||
checkpointIntervalMs: 0,
|
||||
databaseLabel: "workboard database",
|
||||
databasePath: dbPath,
|
||||
foreignKeys: true,
|
||||
synchronous: "NORMAL",
|
||||
});
|
||||
ensureWorkboardSchema(db);
|
||||
hardenWorkboardDatabaseFiles(dbPath);
|
||||
return { db, maintenance };
|
||||
} catch (error) {
|
||||
try {
|
||||
maintenance?.close();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function childRows(db: DatabaseSync, table: string, cardId: string): Row[] {
|
||||
|
||||
@@ -204,6 +204,82 @@ describe("sqlite WAL maintenance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses fuse.sshfs mountinfo entries", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-"));
|
||||
try {
|
||||
const db = createMockDb();
|
||||
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
`42 12 0:41 / ${tempDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
configureSqliteWalMaintenance(db, {
|
||||
checkpointIntervalMs: 0,
|
||||
databaseLabel: "test-db",
|
||||
databasePath: path.join(tempDir, "openclaw.sqlite"),
|
||||
}),
|
||||
).toThrow(/test-db .*SSHFS.*refusing to open/);
|
||||
|
||||
expect(db["prepare"]).not.toHaveBeenCalled();
|
||||
expect(db["exec"]).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses symlinked paths into fuse.sshfs mounts", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-link-"));
|
||||
const mountDir = path.join(tempDir, "mount");
|
||||
const linkedDir = path.join(tempDir, "linked");
|
||||
try {
|
||||
fs.mkdirSync(mountDir);
|
||||
fs.symlinkSync(mountDir, linkedDir);
|
||||
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
`42 12 0:41 / ${mountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
configureSqliteWalMaintenance(createMockDb(), {
|
||||
checkpointIntervalMs: 0,
|
||||
databasePath: path.join(linkedDir, "openclaw.sqlite"),
|
||||
}),
|
||||
).toThrow(/SSHFS.*refusing to open/);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("matches raw mount paths when the existing path canonicalizes elsewhere", () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-prefix-"));
|
||||
const canonicalMountDir = path.join(tempDir, "canonical-mount");
|
||||
const rawMountDir = path.join(tempDir, "raw-mount");
|
||||
try {
|
||||
fs.mkdirSync(canonicalMountDir);
|
||||
fs.symlinkSync(canonicalMountDir, rawMountDir);
|
||||
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue(
|
||||
`42 12 0:41 / ${rawMountDir} rw,relatime - fuse.sshfs user@host:/share rw\n`,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
configureSqliteWalMaintenance(createMockDb(), {
|
||||
checkpointIntervalMs: 0,
|
||||
databasePath: path.join(rawMountDir, "openclaw.sqlite"),
|
||||
}),
|
||||
).toThrow(/SSHFS.*refusing to open/);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses mount command filesystem names on platforms without proc mountinfo", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
|
||||
try {
|
||||
@@ -250,6 +326,60 @@ describe("sqlite WAL maintenance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["macfuse", "sshfs#user@host:/share"],
|
||||
["macfuse", "host:/share"],
|
||||
["macfuse", "user@host:"],
|
||||
["osxfuse", "user@host:/share"],
|
||||
["osxfuse", "sshfs@osxfuse0"],
|
||||
])("refuses SSHFS reported as %s by mount", (fsType, source) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-sshfs-macfuse-"));
|
||||
try {
|
||||
const db = createMockDb();
|
||||
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
|
||||
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
|
||||
throw new Error("no proc mountinfo");
|
||||
});
|
||||
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
|
||||
Buffer.from(`${source} on ${tempDir} (${fsType}, nodev, nosuid)\n`),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
configureSqliteWalMaintenance(db, {
|
||||
checkpointIntervalMs: 0,
|
||||
databasePath: path.join(tempDir, "openclaw.sqlite"),
|
||||
}),
|
||||
).toThrow(/refusing to open/);
|
||||
|
||||
expect(db["exec"]).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps WAL enabled for non-remote macFUSE mounts", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-macfuse-"));
|
||||
try {
|
||||
const db = createMockDb();
|
||||
vi.spyOn(fs, "statfsSync").mockReturnValue(statfsFixture(0));
|
||||
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
|
||||
throw new Error("no proc mountinfo");
|
||||
});
|
||||
vi.spyOn(childProcess, "execFileSync").mockReturnValue(
|
||||
Buffer.from(`remote-volume on ${tempDir} (macfuse, nodev, nosuid)\n`),
|
||||
);
|
||||
|
||||
configureSqliteWalMaintenance(db, {
|
||||
checkpointIntervalMs: 0,
|
||||
databasePath: path.join(tempDir, "openclaw.sqlite"),
|
||||
});
|
||||
|
||||
expect(db["exec"]).toHaveBeenNthCalledWith(1, "PRAGMA journal_mode = WAL;");
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("parses Linux mount command filesystem names when proc mountinfo is unavailable", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sqlite-nfs-"));
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,8 @@ type IntervalHandle = ReturnType<typeof setInterval> & {
|
||||
};
|
||||
|
||||
type SqliteWalCheckpointMode = "PASSIVE" | "FULL" | "RESTART" | "TRUNCATE";
|
||||
type SqliteFilesystemJournalPolicy = "rollback" | "unsupported" | "wal";
|
||||
type MountEntry = { mountPoint: string; fsType: string; source?: string };
|
||||
|
||||
export type SqliteWalMaintenance = {
|
||||
checkpoint: () => boolean;
|
||||
@@ -55,19 +57,27 @@ function normalizeNonNegativeInteger(value: number, label: string): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function findExistingVolumePath(targetPath: string): string | null {
|
||||
function findExistingVolumePaths(
|
||||
targetPath: string,
|
||||
): { canonicalPath: string; originalPath: string } | null {
|
||||
let current = path.resolve(targetPath);
|
||||
while (true) {
|
||||
let stats: ReturnType<typeof fs.statSync>;
|
||||
try {
|
||||
const stats = fs.statSync(current);
|
||||
return stats.isDirectory() ? current : path.dirname(current);
|
||||
stats = fs.statSync(current);
|
||||
} catch {
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
const existingPath = fs.realpathSync(current);
|
||||
return {
|
||||
canonicalPath: stats.isDirectory() ? existingPath : path.dirname(existingPath),
|
||||
originalPath: stats.isDirectory() ? current : path.dirname(current),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +87,8 @@ function decodeMountPath(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function parseProcMountInfoEntries(
|
||||
contents: string,
|
||||
): Array<{ mountPoint: string; fsType: string }> {
|
||||
const entries: Array<{ mountPoint: string; fsType: string }> = [];
|
||||
function parseProcMountInfoEntries(contents: string): MountEntry[] {
|
||||
const entries: MountEntry[] = [];
|
||||
for (const line of contents.split("\n")) {
|
||||
const separator = line.indexOf(" - ");
|
||||
if (separator === -1) {
|
||||
@@ -91,34 +99,38 @@ function parseProcMountInfoEntries(
|
||||
const mountPoint = fields[4];
|
||||
const fsType = suffixFields[0];
|
||||
if (mountPoint && fsType) {
|
||||
entries.push({ mountPoint: decodeMountPath(mountPoint), fsType });
|
||||
entries.push({
|
||||
mountPoint: decodeMountPath(mountPoint),
|
||||
fsType,
|
||||
...(suffixFields[1] ? { source: decodeMountPath(suffixFields[1]) } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseMountCommandEntries(contents: string): Array<{ mountPoint: string; fsType: string }> {
|
||||
const entries: Array<{ mountPoint: string; fsType: string }> = [];
|
||||
function parseMountCommandEntries(contents: string): MountEntry[] {
|
||||
const entries: MountEntry[] = [];
|
||||
for (const line of contents.split("\n")) {
|
||||
const linuxMatch = /^.* on (.+) type ([^,\s)]+) \(/.exec(line);
|
||||
const linuxMatch = /^(.+) on (.+) type ([^,\s)]+) \(/.exec(line);
|
||||
if (linuxMatch) {
|
||||
entries.push({ mountPoint: linuxMatch[1], fsType: linuxMatch[2] });
|
||||
entries.push({ source: linuxMatch[1], mountPoint: linuxMatch[2], fsType: linuxMatch[3] });
|
||||
continue;
|
||||
}
|
||||
const bsdMatch = /^.* on (.+) \(([^,\s)]+)/.exec(line);
|
||||
const bsdMatch = /^(.+) on (.+) \(([^,\s)]+)/.exec(line);
|
||||
if (bsdMatch) {
|
||||
entries.push({ mountPoint: bsdMatch[1], fsType: bsdMatch[2] });
|
||||
entries.push({ source: bsdMatch[1], mountPoint: bsdMatch[2], fsType: bsdMatch[3] });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readMountEntries(): Array<{ mountPoint: string; fsType: string }> {
|
||||
function readMountEntries(): MountEntry[] {
|
||||
try {
|
||||
return parseProcMountInfoEntries(fs.readFileSync(PROC_MOUNTINFO_PATH, "utf8"));
|
||||
} catch {
|
||||
// macOS/BSD expose filesystem type names in `mount` output instead of
|
||||
// Linux superblock magic, so keep this fallback for non-Linux NFS mounts.
|
||||
// Linux superblock magic, so keep this fallback for named filesystem types.
|
||||
}
|
||||
try {
|
||||
return parseMountCommandEntries(String(childProcess.execFileSync("mount", [])));
|
||||
@@ -137,16 +149,54 @@ function isPathWithinMount(targetPath: string, mountPoint: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isNetworkMountType(fsType: string): boolean {
|
||||
const normalized = fsType.toLowerCase();
|
||||
return normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized);
|
||||
function isSshfsMountSource(source: string | undefined): boolean {
|
||||
if (!source) {
|
||||
return false;
|
||||
}
|
||||
const normalized = source.toLowerCase();
|
||||
return (
|
||||
normalized === "sshfs" ||
|
||||
normalized.startsWith("sshfs#") ||
|
||||
normalized.startsWith("sshfs@") ||
|
||||
/^(?:[^/\s:]+@)?[^/\s:]+:.*/u.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
function isNetworkMountEntryPath(targetPath: string): boolean {
|
||||
const mountEntry = readMountEntries()
|
||||
function resolveMountTypeJournalPolicy(entry: MountEntry): SqliteFilesystemJournalPolicy {
|
||||
const normalized = entry.fsType.toLowerCase();
|
||||
if (normalized.startsWith("nfs") || NETWORK_FILESYSTEM_TYPES.has(normalized)) {
|
||||
return "rollback";
|
||||
}
|
||||
if (normalized === "fuse.sshfs") {
|
||||
return "unsupported";
|
||||
}
|
||||
if ((normalized === "macfuse" || normalized === "osxfuse") && isSshfsMountSource(entry.source)) {
|
||||
return "unsupported";
|
||||
}
|
||||
return "wal";
|
||||
}
|
||||
|
||||
function resolveMountEntryJournalPolicy(
|
||||
targetPath: string,
|
||||
mountEntries: MountEntry[],
|
||||
): SqliteFilesystemJournalPolicy {
|
||||
const mountEntry = mountEntries
|
||||
.filter((entry) => isPathWithinMount(targetPath, entry.mountPoint))
|
||||
.toSorted((a, b) => b.mountPoint.length - a.mountPoint.length)[0];
|
||||
return mountEntry ? isNetworkMountType(mountEntry.fsType) : false;
|
||||
return mountEntry ? resolveMountTypeJournalPolicy(mountEntry) : "wal";
|
||||
}
|
||||
|
||||
function combineMountEntryJournalPolicies(
|
||||
targetPaths: readonly string[],
|
||||
): SqliteFilesystemJournalPolicy {
|
||||
const mountEntries = readMountEntries();
|
||||
const policies = new Set(
|
||||
targetPaths.map((targetPath) => resolveMountEntryJournalPolicy(targetPath, mountEntries)),
|
||||
);
|
||||
if (policies.has("unsupported")) {
|
||||
return "unsupported";
|
||||
}
|
||||
return policies.has("rollback") ? "rollback" : "wal";
|
||||
}
|
||||
|
||||
function isWindowsUncPath(targetPath: string): boolean {
|
||||
@@ -160,43 +210,46 @@ function isWindowsDrivePath(targetPath: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(targetPath) || /^\\\\\?\\[A-Za-z]:[\\/]/i.test(targetPath);
|
||||
}
|
||||
|
||||
function isNetworkBackedPath(targetPath: string): boolean {
|
||||
function resolvePathJournalPolicy(targetPath: string): SqliteFilesystemJournalPolicy {
|
||||
if (process.platform === "win32") {
|
||||
const normalizedTargetPath = path.win32.normalize(targetPath);
|
||||
if (isWindowsUncPath(normalizedTargetPath)) {
|
||||
return true;
|
||||
return "rollback";
|
||||
}
|
||||
if (isWindowsDrivePath(normalizedTargetPath)) {
|
||||
try {
|
||||
return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath)));
|
||||
return isWindowsUncPath(path.win32.normalize(fs.realpathSync.native(targetPath)))
|
||||
? "rollback"
|
||||
: "wal";
|
||||
} catch {
|
||||
// Windows can deny SMB path normalization when parent components are
|
||||
// unreadable. Treat an unclassifiable opened database as network-backed.
|
||||
return true;
|
||||
return "rollback";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof fs.statfsSync !== "function") {
|
||||
return isNetworkMountEntryPath(targetPath);
|
||||
const checkedPaths = findExistingVolumePaths(targetPath);
|
||||
if (!checkedPaths) {
|
||||
return "wal";
|
||||
}
|
||||
const checkedPath = findExistingVolumePath(targetPath);
|
||||
if (!checkedPath) {
|
||||
return false;
|
||||
const mountLookupPaths = [checkedPaths.originalPath, checkedPaths.canonicalPath];
|
||||
if (typeof fs.statfsSync !== "function") {
|
||||
return combineMountEntryJournalPolicies(mountLookupPaths);
|
||||
}
|
||||
try {
|
||||
const filesystemType = fs.statfsSync(checkedPath).type;
|
||||
const filesystemType = fs.statfsSync(checkedPaths.canonicalPath).type;
|
||||
if (
|
||||
filesystemType === LINUX_NFS_SUPER_MAGIC ||
|
||||
filesystemType === LINUX_SMB_SUPER_MAGIC ||
|
||||
filesystemType === LINUX_CIFS_SUPER_MAGIC ||
|
||||
filesystemType === LINUX_SMB2_SUPER_MAGIC
|
||||
) {
|
||||
return true;
|
||||
return "rollback";
|
||||
}
|
||||
} catch {
|
||||
return isNetworkMountEntryPath(checkedPath);
|
||||
return combineMountEntryJournalPolicies(mountLookupPaths);
|
||||
}
|
||||
return isNetworkMountEntryPath(checkedPath);
|
||||
return combineMountEntryJournalPolicies(mountLookupPaths);
|
||||
}
|
||||
|
||||
function readJournalModeResult(row: unknown): string | null {
|
||||
@@ -221,7 +274,15 @@ function requireRollbackJournalMode(db: DatabaseSync, options: SqliteWalMaintena
|
||||
}
|
||||
}
|
||||
|
||||
/** Configure WAL pragmas and return a handle for checkpoint/close maintenance. */
|
||||
function refuseUnsupportedFilesystem(options: SqliteWalMaintenanceOptions): never {
|
||||
const label = options.databaseLabel ?? "sqlite database";
|
||||
const location = options.databasePath ? ` at ${options.databasePath}` : "";
|
||||
throw new Error(
|
||||
`${label}${location} is on SSHFS, which cannot safely coordinate SQLite writes across mounts; refusing to open the database.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Configure safe journaling pragmas and return a handle for checkpoint/close maintenance. */
|
||||
export function configureSqliteWalMaintenance(
|
||||
db: DatabaseSync,
|
||||
options: SqliteWalMaintenanceOptions = {},
|
||||
@@ -237,7 +298,13 @@ export function configureSqliteWalMaintenance(
|
||||
const timerIntervalMs = Math.min(checkpointIntervalMs, MAX_TIMER_TIMEOUT_MS);
|
||||
const checkpointMode = options.checkpointMode ?? "TRUNCATE";
|
||||
const periodicCheckpointMode = options.checkpointMode ?? "PASSIVE";
|
||||
if (options.databasePath && isNetworkBackedPath(options.databasePath)) {
|
||||
const journalPolicy = options.databasePath
|
||||
? resolvePathJournalPolicy(options.databasePath)
|
||||
: "wal";
|
||||
if (journalPolicy === "unsupported") {
|
||||
refuseUnsupportedFilesystem(options);
|
||||
}
|
||||
if (journalPolicy === "rollback") {
|
||||
requireRollbackJournalMode(db, options);
|
||||
return {
|
||||
checkpoint: () => true,
|
||||
|
||||
Reference in New Issue
Block a user