fix: harden onboarding completion
This commit is contained in:
1 parent
e41605640b
commit
dc71084323
44 files changed
+3003
-1000
No files matched your search
@@ -24,6 +24,101 @@ import { findMigrationsFolder } from "../shared";
|
||||
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
|
||||
const ALGORITHM = "aes-256-cbc";
|
||||
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const MAX_COMPRESSED_BACKUP_BYTES = 256 * MEBIBYTE;
|
||||
const MAX_MULTIPART_REQUEST_BYTES = MAX_COMPRESSED_BACKUP_BYTES + MEBIBYTE;
|
||||
const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE;
|
||||
const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE;
|
||||
const MAX_METADATA_BYTES = MEBIBYTE;
|
||||
const MAX_ENCRYPTION_KEY_BYTES = 1024;
|
||||
const RESTART_DELAY_MS = 500;
|
||||
|
||||
let restoreInProgress = false;
|
||||
|
||||
class BackupTooLargeError extends Error {}
|
||||
|
||||
const readBoundedRequestBodyAsync = async (request: Request): Promise<Uint8Array<ArrayBuffer>> => {
|
||||
const declaredLength = request.headers.get("content-length");
|
||||
if (declaredLength !== null) {
|
||||
const parsedLength = Number(declaredLength);
|
||||
if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) {
|
||||
throw new Error("Invalid Content-Length header");
|
||||
}
|
||||
if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.body) return new Uint8Array();
|
||||
|
||||
const reader = request.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit");
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const body = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const parseBoundedFormDataAsync = async (request: Request): Promise<FormData> => {
|
||||
const contentType = request.headers.get("content-type");
|
||||
if (!contentType) throw new Error("Missing Content-Type header");
|
||||
|
||||
const body = await readBoundedRequestBodyAsync(request);
|
||||
return new Response(body, { headers: { "content-type": contentType } }).formData();
|
||||
};
|
||||
|
||||
const assertArchiveSizeLimits = (zip: AdmZip) => {
|
||||
const entries = zip.getEntries();
|
||||
let totalSize = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const entrySize = entry.header.size;
|
||||
if (!Number.isSafeInteger(entrySize) || entrySize < 0) {
|
||||
throw new Error(`Invalid uncompressed size for ${entry.entryName}`);
|
||||
}
|
||||
totalSize += entrySize;
|
||||
if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit");
|
||||
}
|
||||
}
|
||||
|
||||
const dbEntry = zip.getEntry("db.sqlite");
|
||||
if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) {
|
||||
throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit");
|
||||
}
|
||||
|
||||
const metadataEntry = zip.getEntry("metadata.json");
|
||||
if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) {
|
||||
throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit");
|
||||
}
|
||||
|
||||
const encryptionKeyEntry = zip.getEntry("encryption-key.txt");
|
||||
if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) {
|
||||
throw new BackupTooLargeError("Backup encryption key entry is too large");
|
||||
}
|
||||
};
|
||||
|
||||
const reEncryptSecrets = (tempDb: InstanceType<typeof Database>, importedKeyHex: string) => {
|
||||
const currentKeyHex = env.SECRET_ENCRYPTION_KEY;
|
||||
@@ -105,41 +200,62 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "Database path not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(Buffer.from(arrayBuffer));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().map((e) => e.entryName);
|
||||
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
||||
if (missingEntries.length > 0) {
|
||||
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
const migrationsFolder = findMigrationsFolder();
|
||||
if (!migrationsFolder) {
|
||||
return NextResponse.json({ error: "Migration files not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite");
|
||||
if (restoreInProgress) {
|
||||
return NextResponse.json({ error: "A database restore is already in progress" }, { status: 409 });
|
||||
}
|
||||
|
||||
restoreInProgress = true;
|
||||
let tempDirectory: string | null = null;
|
||||
let tempDb: InstanceType<typeof Database> | null = null;
|
||||
|
||||
try {
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await parseBoundedFormDataAsync(req);
|
||||
} catch (error) {
|
||||
if (error instanceof BackupTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
return NextResponse.json({ error: "Invalid form data" }, { status: 400 });
|
||||
}
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_COMPRESSED_BACKUP_BYTES) {
|
||||
return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 });
|
||||
}
|
||||
|
||||
let zip: AdmZip;
|
||||
try {
|
||||
zip = new AdmZip(Buffer.from(await file.arrayBuffer()));
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const entries = zip.getEntries().map((entry) => entry.entryName);
|
||||
const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name));
|
||||
if (missingEntries.length > 0) {
|
||||
return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
assertArchiveSizeLimits(zip);
|
||||
} catch (error) {
|
||||
if (error instanceof BackupTooLargeError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 413 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-"));
|
||||
const tempPath = path.join(tempDirectory, "db.sqlite");
|
||||
const dbEntry = zip.getEntry("db.sqlite");
|
||||
if (!dbEntry) {
|
||||
return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 });
|
||||
@@ -187,23 +303,33 @@ export async function POST(req: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const restartTimer = setTimeout(() => {
|
||||
console.log("Database restored, restarting server...");
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
}, RESTART_DELAY_MS);
|
||||
restartTimer.unref();
|
||||
|
||||
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Database restored. Server is restarting...",
|
||||
restartAfterMs: RESTART_DELAY_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Restore failed:", error);
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
|
||||
} finally {
|
||||
tempDb?.close();
|
||||
if (fs.existsSync(tempPath)) {
|
||||
restoreInProgress = false;
|
||||
try {
|
||||
tempDb?.close();
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Failed to close temporary database:", error);
|
||||
}
|
||||
if (tempDirectory) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// temp file may have been renamed already
|
||||
fs.rmSync(tempDirectory, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
console.error("[backup/import] Failed to remove temporary restore directory:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import AdmZip from "adm-zip";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
|
||||
|
||||
const routeMocks = vi.hoisted(() => ({
|
||||
auth: vi.fn(),
|
||||
claimAllowed: vi.fn(),
|
||||
onboardingFindFirst: vi.fn(),
|
||||
dbEnv: {
|
||||
DRIVER: "better-sqlite3",
|
||||
URL: "",
|
||||
},
|
||||
commonEnv: {
|
||||
SECRET_ENCRYPTION_KEY: "1".repeat(64),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/next", () => ({ auth: routeMocks.auth }));
|
||||
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
|
||||
vi.mock("@homarr/api/onboarding-claim", () => ({
|
||||
getOnboardingClaimTokenFromCookieHeader: () => "test-claim",
|
||||
isClaimOnlyOnboardingAccessAllowedAsync: routeMocks.claimAllowed,
|
||||
}));
|
||||
vi.mock("@homarr/common/env", () => ({ env: routeMocks.commonEnv }));
|
||||
vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: routeMocks.dbEnv }));
|
||||
vi.mock("@homarr/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
onboarding: {
|
||||
findFirst: routeMocks.onboardingFindFirst,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../import/route";
|
||||
|
||||
const MEBIBYTE = 1024 * 1024;
|
||||
const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite";
|
||||
|
||||
const createMigratedDatabase = (databasePath: string, boardId: string, boardName: string) => {
|
||||
const sqlite = new Database(databasePath);
|
||||
migrate(drizzle(sqlite, { casing: DB_CASING }), { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
sqlite.exec(`
|
||||
INSERT INTO "board" (
|
||||
"id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius",
|
||||
"background_image_attachment", "background_image_repeat", "background_image_size", "disable_status"
|
||||
) VALUES (
|
||||
'${boardId}', '${boardName}', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0
|
||||
)
|
||||
`);
|
||||
sqlite.close();
|
||||
};
|
||||
|
||||
const createBackup = (directory: string, name: string, boardName: string) => {
|
||||
const sourcePath = path.join(directory, `${name}.sqlite`);
|
||||
createMigratedDatabase(sourcePath, `board-${name}`, boardName);
|
||||
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", fs.readFileSync(sourcePath));
|
||||
zip.addFile(
|
||||
"metadata.json",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
dbDialect: "sqlite",
|
||||
encryptionKey: routeMocks.commonEnv.SECRET_ENCRYPTION_KEY,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return zip.toBuffer();
|
||||
};
|
||||
|
||||
const createImportRequest = (backup: Buffer) => {
|
||||
const formData = new FormData();
|
||||
formData.set("file", new File([Uint8Array.from(backup)], "homarr-backup.zip", { type: "application/zip" }));
|
||||
return new Request("http://homarr.test/api/backup/import", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
};
|
||||
|
||||
const readBoardNames = (databasePath: string) => {
|
||||
const sqlite = new Database(databasePath, { readonly: true });
|
||||
const rows = sqlite.prepare('SELECT "name" FROM "board" ORDER BY "name"').all() as { name: string }[];
|
||||
sqlite.close();
|
||||
return rows.map((row) => row.name);
|
||||
};
|
||||
|
||||
describe("POST /api/backup/import", () => {
|
||||
let temporaryDirectory: string;
|
||||
let activeDatabasePath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-import-route-test-"));
|
||||
activeDatabasePath = path.join(temporaryDirectory, "db.sqlite");
|
||||
routeMocks.dbEnv.DRIVER = "better-sqlite3";
|
||||
routeMocks.dbEnv.URL = activeDatabasePath;
|
||||
routeMocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } });
|
||||
routeMocks.claimAllowed.mockResolvedValue(false);
|
||||
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "start" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("accepts the legacy import step through normalized onboarding claim access", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "legacy", "Restored board");
|
||||
routeMocks.auth.mockResolvedValue(null);
|
||||
routeMocks.claimAllowed.mockResolvedValue(true);
|
||||
routeMocks.onboardingFindFirst.mockResolvedValue({ step: "import" });
|
||||
|
||||
const response = await POST(createImportRequest(backup));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ success: true, restartAfterMs: 500 });
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored board"]);
|
||||
});
|
||||
|
||||
it("rejects an oversized declared upload before reading its body", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const request = new Request("http://homarr.test/api/backup/import", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-length": String(258 * MEBIBYTE),
|
||||
"content-type": "multipart/form-data; boundary=oversized",
|
||||
},
|
||||
body: "--oversized--\r\n",
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("rejects an oversized uncompressed SQLite entry without replacing the active database", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
|
||||
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
|
||||
const databaseEntry = zip.getEntry("db.sqlite");
|
||||
if (!databaseEntry) throw new Error("Expected database entry");
|
||||
databaseEntry.header.size = 513 * MEBIBYTE;
|
||||
|
||||
const response = await POST(createImportRequest(zip.toBuffer()));
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("rejects an oversized uncompressed archive before extracting any entry", async () => {
|
||||
const originalDatabase = Buffer.from("active database remains untouched");
|
||||
fs.writeFileSync(activeDatabasePath, originalDatabase);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("db.sqlite", Buffer.from("small compressed payload"));
|
||||
zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}'));
|
||||
zip.addFile("unexpected.bin", Buffer.from("small compressed payload"));
|
||||
const unexpectedEntry = zip.getEntry("unexpected.bin");
|
||||
if (!unexpectedEntry) throw new Error("Expected oversized test entry");
|
||||
unexpectedEntry.header.size = 515 * MEBIBYTE;
|
||||
|
||||
const response = await POST(createImportRequest(zip.toBuffer()));
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase);
|
||||
});
|
||||
|
||||
it("uses and cleans a unique temporary directory for every restore", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const createdDirectories: string[] = [];
|
||||
const makeTemporaryDirectory = fs.mkdtempSync.bind(fs);
|
||||
vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => {
|
||||
const directory = makeTemporaryDirectory(prefix, options);
|
||||
createdDirectories.push(directory);
|
||||
return directory;
|
||||
});
|
||||
|
||||
const firstResponse = await POST(createImportRequest(createBackup(temporaryDirectory, "first", "First restore")));
|
||||
const secondResponse = await POST(
|
||||
createImportRequest(createBackup(temporaryDirectory, "second", "Second restore")),
|
||||
);
|
||||
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
expect(new Set(createdDirectories).size).toBe(2);
|
||||
expect(createdDirectories.every((directory) => !fs.existsSync(directory))).toBe(true);
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Second restore"]);
|
||||
});
|
||||
|
||||
it("keeps a successful restore response when temporary directory cleanup fails", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "cleanup-failure", "Restored despite cleanup failure");
|
||||
const removeDirectory = fs.rmSync.bind(fs);
|
||||
const cleanupError = new Error("simulated cleanup failure");
|
||||
const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
|
||||
if (String(target).includes(".homarr-restore-")) throw cleanupError;
|
||||
return removeDirectory(target, options);
|
||||
});
|
||||
|
||||
const response = await POST(createImportRequest(backup));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toMatchObject({ success: true });
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Restored despite cleanup failure"]);
|
||||
expect(errorLog).toHaveBeenCalledWith(
|
||||
"[backup/import] Failed to remove temporary restore directory:",
|
||||
cleanupError,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a concurrent restore while the first request owns the restore lock", async () => {
|
||||
createMigratedDatabase(activeDatabasePath, "board-current", "Current board");
|
||||
const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore");
|
||||
const originalArrayBuffer = File.prototype.arrayBuffer;
|
||||
let signalStarted!: () => void;
|
||||
let releaseFirstRequest!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
signalStarted = resolve;
|
||||
});
|
||||
const release = new Promise<void>((resolve) => {
|
||||
releaseFirstRequest = resolve;
|
||||
});
|
||||
vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) {
|
||||
signalStarted();
|
||||
await release;
|
||||
return originalArrayBuffer.call(this);
|
||||
});
|
||||
|
||||
const firstRestore = POST(createImportRequest(backup));
|
||||
await started;
|
||||
const concurrentResponse = await POST(createImportRequest(backup));
|
||||
releaseFirstRequest();
|
||||
const firstResponse = await firstRestore;
|
||||
|
||||
expect(concurrentResponse.status).toBe(409);
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(readBoardNames(activeDatabasePath)).toEqual(["Concurrent restore"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user