feat: add create-only root certificate API (#7033)
* feat: add create-only root certificate API [skip ci] * fix: harden root certificate API handling
This commit is contained in:
1 parent
67cff1fbc0
commit
a17845aa1c
8 files changed
+542
-2
No files matched your search
@@ -54,6 +54,20 @@ on every layout, and caps width to the available columns.
|
||||
`board.updateItemLayout` (`PATCH /api/boards/{boardId}/items/{itemId}/layouts/{layoutId}`) moves or resizes one item in
|
||||
one layout without replacing the board. It cannot move the item outside its section or overlap another item/container.
|
||||
|
||||
## Root CA automation
|
||||
|
||||
Administrators can inspect a named CA with `GET /api/certificates/{fileName}` (`certificates.getCertificate`) or
|
||||
create it with `POST /api/certificates/root` (`certificates.ensureRootCertificate`), using JSON `{ fileName, certificate }`.
|
||||
Names are limited to 128 characters, start with a letter or digit, contain only letters, digits, dots, underscores and
|
||||
hyphens, and end in `.crt` or `.pem`. Supply one currently valid self-signed PEM root CA, at most 64 KiB, without a chain
|
||||
or private key.
|
||||
|
||||
Inspection returns public metadata or `null` when absent. Creation returns `{ created, certificate }`; identical PEM
|
||||
bytes return `created: false`, while different existing bytes or unsafe file paths return `CONFLICT` without replacement.
|
||||
`sha256` hashes the exact UTF-8 PEM bytes, including whitespace; `fingerprint256` identifies the DER certificate.
|
||||
Dates are ISO 8601. Certificate contents are never returned. Expired stored roots remain inspectable. Trust applies to
|
||||
subsequent integration requests without a restart; it does not bypass hostname verification.
|
||||
|
||||
## Docker targets
|
||||
|
||||
Docker actions require `{ endpointId, id }` targets from `docker.getContainers`; `endpointId` distinguishes hosts.
|
||||
|
||||
@@ -5545,6 +5545,273 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/certificates/{fileName}": {
|
||||
"get": {
|
||||
"operationId": "certificates-getCertificate",
|
||||
"summary": "Inspect a named custom root CA certificate",
|
||||
"description": "Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.",
|
||||
"tags": [
|
||||
"certificates"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"apikey": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "fileName",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"fingerprint256": {
|
||||
"type": "string"
|
||||
},
|
||||
"ca": {
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
},
|
||||
"validFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"validTo": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fileName",
|
||||
"sha256",
|
||||
"fingerprint256",
|
||||
"ca",
|
||||
"validFrom",
|
||||
"validTo"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.BAD_REQUEST"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authorization not provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.UNAUTHORIZED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Insufficient access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.FORBIDDEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.NOT_FOUND"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/certificates/root": {
|
||||
"post": {
|
||||
"operationId": "certificates-ensureRootCertificate",
|
||||
"summary": "Ensure a named custom root CA certificate without overwriting",
|
||||
"description": "Accept one currently valid self-signed PEM root CA (64 KiB maximum). Identical bytes are idempotent; different existing bytes return CONFLICT. Requires admin permission.",
|
||||
"tags": [
|
||||
"certificates"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"apikey": []
|
||||
}
|
||||
],
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
|
||||
},
|
||||
"certificate": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 65536
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fileName",
|
||||
"certificate"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"certificate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*\\.(crt|pem)$"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"fingerprint256": {
|
||||
"type": "string"
|
||||
},
|
||||
"ca": {
|
||||
"type": "boolean",
|
||||
"const": true
|
||||
},
|
||||
"validFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"validTo": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"fileName",
|
||||
"sha256",
|
||||
"fingerprint256",
|
||||
"ca",
|
||||
"validFrom",
|
||||
"validTo"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"created",
|
||||
"certificate"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input data",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.BAD_REQUEST"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Authorization not provided",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.UNAUTHORIZED"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Insufficient access",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.FORBIDDEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/integrations/request": {
|
||||
"post": {
|
||||
"operationId": "integration-request",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
|
||||
|
||||
import { appRouter } from "./router/app";
|
||||
import { boardRouter } from "./router/board";
|
||||
import { ensureRootCertificateProcedure, getCertificateProcedure } from "./router/certificates/certificate-router";
|
||||
import { integrationRequestProcedure } from "./router/integration/integration-request";
|
||||
import { integrationTestStoredConnectionProcedure } from "./router/integration/integration-test-stored-connection";
|
||||
import { infoRouter } from "./router/info";
|
||||
@@ -21,6 +22,10 @@ export const openApiRouter = createTRPCRouter({
|
||||
inviteRouter,
|
||||
serverSettingsRouter,
|
||||
userRouter,
|
||||
certificates: createTRPCRouter({
|
||||
getCertificate: getCertificateProcedure,
|
||||
ensureRootCertificate: ensureRootCertificateProcedure,
|
||||
}),
|
||||
integration: createTRPCRouter({
|
||||
request: integrationRequestProcedure,
|
||||
testConnection: integrationTestStoredConnectionProcedure,
|
||||
|
||||
@@ -5,18 +5,83 @@ import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
addCustomRootCertificateAsync,
|
||||
ensureCustomRootCertificateAsync,
|
||||
getCustomRootCertificateAsync,
|
||||
removeCustomRootCertificateAsync,
|
||||
RootCertificateError,
|
||||
} from "@homarr/core/infrastructure/certificates";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { trustedCertificateHostnames } from "@homarr/db/schema";
|
||||
import { certificateValidFileNameSchema, checkCertificateFile } from "@homarr/validation/certificates";
|
||||
import {
|
||||
certificateValidFileNameSchema,
|
||||
checkCertificateFile,
|
||||
rootCertificateFileNameSchema,
|
||||
rootCertificateMetadataSchema,
|
||||
} from "@homarr/validation/certificates";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "certificateRouter" });
|
||||
|
||||
const withCertificateErrorsAsync = async <T>(operation: () => Promise<T>) => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof RootCertificateError) {
|
||||
throw new TRPCError({ code: error.code, message: error.message });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCertificateProcedure = permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/api/certificates/{fileName}",
|
||||
tags: ["certificates"],
|
||||
protect: true,
|
||||
summary: "Inspect a named custom root CA certificate",
|
||||
description:
|
||||
"Return public metadata or null when absent. Requires admin permission. Does not return PEM contents.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ fileName: rootCertificateFileNameSchema }))
|
||||
.output(rootCertificateMetadataSchema.nullable())
|
||||
.query(({ input }) => withCertificateErrorsAsync(() => getCustomRootCertificateAsync(input.fileName)));
|
||||
|
||||
export const ensureRootCertificateProcedure = permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "POST",
|
||||
path: "/api/certificates/root",
|
||||
tags: ["certificates"],
|
||||
protect: true,
|
||||
summary: "Ensure a named custom root CA certificate without overwriting",
|
||||
description:
|
||||
"Accept one currently valid self-signed PEM root CA (64 KiB maximum). Identical bytes are idempotent; different existing bytes return CONFLICT. Requires admin permission.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
fileName: rootCertificateFileNameSchema,
|
||||
certificate: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64 * 1024),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ created: z.boolean(), certificate: rootCertificateMetadataSchema }))
|
||||
.mutation(({ input }) =>
|
||||
withCertificateErrorsAsync(() => ensureCustomRootCertificateAsync(input.fileName, input.certificate)),
|
||||
);
|
||||
|
||||
export const certificateRouter = createTRPCRouter({
|
||||
getCertificate: getCertificateProcedure,
|
||||
ensureRootCertificate: ensureRootCertificateProcedure,
|
||||
addCertificate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { rootCertificates } from "node:tls";
|
||||
|
||||
const getCertificateFolder = () => {
|
||||
export const getCertificateFolder = () => {
|
||||
if (process.env.NODE_ENV !== "production") return process.env.LOCAL_CERTIFICATE_PATH;
|
||||
return process.env.LOCAL_CERTIFICATE_PATH ?? path.join("/appdata", "trusted-certificates");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { createHash, randomUUID, X509Certificate } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { createLogger } from "../../logs";
|
||||
import { getCertificateFolder } from "./index";
|
||||
|
||||
const logger = createLogger({ module: "rootCertificate" });
|
||||
const maxCertificateBytes = 64 * 1024;
|
||||
const fileNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/;
|
||||
const pemPattern = /^\s*-----BEGIN CERTIFICATE-----\s+([A-Za-z0-9+/=][A-Za-z0-9+/=\s]*)-----END CERTIFICATE-----\s*$/;
|
||||
|
||||
export class RootCertificateError extends Error {
|
||||
constructor(
|
||||
public readonly code: "BAD_REQUEST" | "CONFLICT",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const validateFileName = (fileName: string) => {
|
||||
if (fileName.length > 128 || !fileNamePattern.test(fileName)) {
|
||||
throw new RootCertificateError("BAD_REQUEST", "Invalid certificate file name");
|
||||
}
|
||||
};
|
||||
|
||||
const describeCertificate = (fileName: string, content: Buffer, requireCurrent: boolean) => {
|
||||
if (content.length > maxCertificateBytes) {
|
||||
throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB");
|
||||
}
|
||||
const pem = pemPattern.exec(content.toString("utf8"));
|
||||
if (!pem?.[1]) {
|
||||
throw new RootCertificateError("BAD_REQUEST", "Expected one PEM root CA certificate, at most 64 KiB");
|
||||
}
|
||||
|
||||
let certificate: X509Certificate;
|
||||
try {
|
||||
certificate = new X509Certificate(content);
|
||||
// Reject extra DER data as well as PEM chains, keys, leaf certificates and non-root CAs.
|
||||
const der = Buffer.from(pem[1].replace(/\s/g, ""), "base64");
|
||||
if (
|
||||
!certificate.raw.equals(der) ||
|
||||
!certificate.ca ||
|
||||
!certificate.checkIssued(certificate) ||
|
||||
!certificate.verify(certificate.publicKey)
|
||||
) {
|
||||
throw new Error("Expected a self-signed root CA certificate");
|
||||
}
|
||||
} catch {
|
||||
throw new RootCertificateError("BAD_REQUEST", "Expected a self-signed root CA certificate");
|
||||
}
|
||||
|
||||
const validFrom = new Date(certificate.validFrom);
|
||||
const validTo = new Date(certificate.validTo);
|
||||
const now = Date.now();
|
||||
if (requireCurrent && (validFrom.getTime() > now || validTo.getTime() <= now)) {
|
||||
throw new RootCertificateError("BAD_REQUEST", "Root CA certificate is not currently valid");
|
||||
}
|
||||
|
||||
return {
|
||||
fileName,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
fingerprint256: certificate.fingerprint256,
|
||||
ca: true as const,
|
||||
validFrom: validFrom.toISOString(),
|
||||
validTo: validTo.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const hasCode = (error: unknown, code: string) => {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
};
|
||||
|
||||
const readCertificateAsync = async (fullPath: string) => {
|
||||
let file;
|
||||
try {
|
||||
// NONBLOCK prevents opening a FIFO from hanging before the regular-file check.
|
||||
file = await fs.open(fullPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
if (hasCode(error, "ENOENT")) return null;
|
||||
if (hasCode(error, "ELOOP") || hasCode(error, "ENXIO")) {
|
||||
throw new RootCertificateError("CONFLICT", "Certificate path is not a regular file");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await file.stat();
|
||||
if (!stat.isFile() || stat.size > maxCertificateBytes) {
|
||||
throw new RootCertificateError("CONFLICT", "Certificate path is not a bounded regular file");
|
||||
}
|
||||
const content = Buffer.alloc(maxCertificateBytes + 1);
|
||||
let size = 0;
|
||||
while (size < content.length) {
|
||||
const { bytesRead } = await file.read(content, size, content.length - size, size);
|
||||
if (bytesRead === 0) break;
|
||||
size += bytesRead;
|
||||
}
|
||||
if (size > maxCertificateBytes) {
|
||||
throw new RootCertificateError("CONFLICT", "Certificate file exceeds 64 KiB");
|
||||
}
|
||||
return content.subarray(0, size);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const getCustomRootCertificateAsync = async (fileName: string) => {
|
||||
validateFileName(fileName);
|
||||
const folder = getCertificateFolder();
|
||||
if (!folder) return null;
|
||||
const content = await readCertificateAsync(path.join(folder, fileName));
|
||||
if (!content) return null;
|
||||
return describeCertificate(fileName, content, false);
|
||||
};
|
||||
|
||||
export const ensureCustomRootCertificateAsync = async (fileName: string, pem: string) => {
|
||||
validateFileName(fileName);
|
||||
const content = Buffer.from(pem, "utf8");
|
||||
const certificate = describeCertificate(fileName, content, true);
|
||||
const folder = getCertificateFolder();
|
||||
if (!folder) {
|
||||
throw new Error("Set LOCAL_CERTIFICATE_PATH to an absolute path to manage local certificates");
|
||||
}
|
||||
await fs.mkdir(folder, { recursive: true });
|
||||
const fullPath = path.join(folder, fileName);
|
||||
const existing = await readCertificateAsync(fullPath);
|
||||
if (existing) {
|
||||
if (!existing.equals(content)) {
|
||||
throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name");
|
||||
}
|
||||
return { created: false, certificate };
|
||||
}
|
||||
|
||||
// Publish a complete file atomically without replacing an existing directory entry.
|
||||
const temporaryPath = path.join(folder, `.root-certificate-${randomUUID()}.tmp`);
|
||||
const temporaryFile = await fs.open(
|
||||
temporaryPath,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||
0o644,
|
||||
);
|
||||
try {
|
||||
try {
|
||||
await temporaryFile.writeFile(content);
|
||||
await temporaryFile.sync();
|
||||
} finally {
|
||||
await temporaryFile.close();
|
||||
}
|
||||
try {
|
||||
await fs.link(temporaryPath, fullPath);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, "EEXIST")) throw error;
|
||||
const concurrent = await readCertificateAsync(fullPath);
|
||||
if (!concurrent?.equals(content)) {
|
||||
throw new RootCertificateError("CONFLICT", "A different certificate already exists at this file name");
|
||||
}
|
||||
return { created: false, certificate };
|
||||
}
|
||||
return { created: true, certificate };
|
||||
} finally {
|
||||
// Cleanup must not turn a published certificate into a failed operation.
|
||||
try {
|
||||
await fs.unlink(temporaryPath);
|
||||
} catch (error) {
|
||||
logger.warn("Failed to remove temporary root certificate file", { error });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
export { getTrustedCertificateHostnamesAsync } from "./hostnames";
|
||||
export {
|
||||
ensureCustomRootCertificateAsync,
|
||||
getCustomRootCertificateAsync,
|
||||
RootCertificateError,
|
||||
} from "./files/root-certificate";
|
||||
export {
|
||||
addCustomRootCertificateAsync,
|
||||
removeCustomRootCertificateAsync,
|
||||
|
||||
@@ -4,6 +4,20 @@ import { createCustomErrorParams } from "./form/i18n";
|
||||
|
||||
export const certificateValidFileNameSchema = z.string().regex(/^[\w\-. ]+$/);
|
||||
|
||||
export const rootCertificateFileNameSchema = z
|
||||
.string()
|
||||
.max(128)
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.(crt|pem)$/);
|
||||
|
||||
export const rootCertificateMetadataSchema = z.object({
|
||||
fileName: rootCertificateFileNameSchema,
|
||||
sha256: z.string(),
|
||||
fingerprint256: z.string(),
|
||||
ca: z.literal(true),
|
||||
validFrom: z.string(),
|
||||
validTo: z.string(),
|
||||
});
|
||||
|
||||
export const checkCertificateFile: z.core.CheckFn<File> = (context) => {
|
||||
const result = certificateValidFileNameSchema.safeParse(context.value.name);
|
||||
if (!result.success) {
|
||||
|
||||
Reference in new issue
Block a user