feat: auth surface exposée + ChatService partagé (v0.19.0)
Auth login fort : mot de passe scrypt + TOTP 2FA (otplib) + JWT HS256 (jose), login tout-ou-rien sans indice. ChatService factorise le tour d'agent pour toutes les surfaces (Telegram refactoré). 60 tests, 0 vuln. Palier de risque : reversible (logique d'auth ; surface API câblée en v0.20). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.19.0] — 2026-06-23 — début Phase 4 (UI : auth + service chat)
|
||||||
|
### Added
|
||||||
|
- `src/api/auth.ts` : auth surface exposée — mot de passe scrypt (`node:crypto`),
|
||||||
|
TOTP 2FA (`otplib`), JWT HS256 (`jose`), `login` tout-ou-rien. 10 tests.
|
||||||
|
- `src/agent/chat-service.ts` : `ChatService` (un tour d'agent par message),
|
||||||
|
partagé par toutes les surfaces. Telegram refactoré pour l'utiliser.
|
||||||
|
- Deps épinglées `jose` 6.2.3, `otplib` 13.4.1 (`npm audit` 0 vuln).
|
||||||
|
|
||||||
## [0.18.0] — 2026-06-23 — fin Phase 3 (alertes)
|
## [0.18.0] — 2026-06-23 — fin Phase 3 (alertes)
|
||||||
### Added
|
### Added
|
||||||
- `workflows-n8n/chlova-alerts.v1.0.0.json` : workflow n8n webhook → mail (formate
|
- `workflows-n8n/chlova-alerts.v1.0.0.json` : workflow n8n webhook → mail (formate
|
||||||
|
|||||||
Generated
+94
-1
@@ -10,11 +10,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
|
"jose": "^6.2.3",
|
||||||
|
"otplib": "^13.4.1",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.13.2",
|
"@types/node": "24.13.2",
|
||||||
"tsx": "4.22.4",
|
"tsx": "4.22.4",
|
||||||
"typescript": "5.7.3",
|
"typescript": "5.7.3",
|
||||||
"vitest": "4.1.9"
|
"vitest": "4.1.9"
|
||||||
@@ -706,6 +708,74 @@
|
|||||||
"@emnapi/runtime": "^1.7.1"
|
"@emnapi/runtime": "^1.7.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@noble/hashes": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/core": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/hotp": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/hotp/-/hotp-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-base32-scure": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-base32-scure/-/plugin-base32-scure-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@scure/base": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/plugin-crypto-noble": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto-noble/-/plugin-crypto-noble-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@noble/hashes": "^2.2.0",
|
||||||
|
"@otplib/core": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/totp": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/totp/-/totp-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/hotp": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@otplib/uri": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@otplib/uri/-/uri-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.133.0",
|
"version": "0.133.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||||
@@ -1004,6 +1074,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@scure/base": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2573,6 +2652,20 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/otplib": {
|
||||||
|
"version": "13.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
|
||||||
|
"integrity": "sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@otplib/core": "13.4.1",
|
||||||
|
"@otplib/hotp": "13.4.1",
|
||||||
|
"@otplib/plugin-base32-scure": "13.4.1",
|
||||||
|
"@otplib/plugin-crypto-noble": "13.4.1",
|
||||||
|
"@otplib/totp": "13.4.1",
|
||||||
|
"@otplib/uri": "13.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"fastify": "5.8.5",
|
"fastify": "5.8.5",
|
||||||
|
"jose": "6.2.3",
|
||||||
|
"otplib": "13.4.1",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"zod": "3.24.1"
|
"zod": "3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Logger } from "pino";
|
||||||
|
import { OllamaClient } from "../llm/ollama.js";
|
||||||
|
import { runAgentTurn } from "./loop.js";
|
||||||
|
import type { Guard, ToolHandle } from "./types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service de conversation : un tour d'agent par message. Partagé par toutes les
|
||||||
|
* surfaces (Telegram, API/UI) pour garantir le MÊME comportement et le même
|
||||||
|
* contrôle (Guard, audit) quelle que soit l'entrée.
|
||||||
|
*/
|
||||||
|
export interface ChatServiceDeps {
|
||||||
|
client: OllamaClient;
|
||||||
|
tools: ToolHandle[];
|
||||||
|
guard: Guard;
|
||||||
|
systemPrompt: string;
|
||||||
|
logger: Logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatService {
|
||||||
|
constructor(private readonly deps: ChatServiceDeps) {}
|
||||||
|
|
||||||
|
async handle(actor: string, text: string): Promise<string> {
|
||||||
|
const { reply, steps } = await runAgentTurn({
|
||||||
|
client: this.deps.client,
|
||||||
|
system: this.deps.systemPrompt,
|
||||||
|
userText: text,
|
||||||
|
tools: this.deps.tools,
|
||||||
|
actor,
|
||||||
|
guard: this.deps.guard,
|
||||||
|
logger: this.deps.logger,
|
||||||
|
});
|
||||||
|
this.deps.logger.info({ actor, steps }, "tour d'agent terminé");
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||||
|
import { verifySync as totpVerifySync } from "otplib";
|
||||||
|
import { SignJWT, jwtVerify } from "jose";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentification de la surface exposée (Phase 4 — login fort).
|
||||||
|
*
|
||||||
|
* Modèle propriétaire mono-utilisateur : mot de passe (scrypt, `node:crypto`)
|
||||||
|
* + TOTP 2FA (otplib) → JWT court (jose). Aucun secret en clair : le hash du
|
||||||
|
* mot de passe et le secret TOTP viennent de l'environnement (docs/security.md).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCRYPT_KEYLEN = 64;
|
||||||
|
|
||||||
|
/** Génère un hash stockable `scrypt$<saltHex>$<hashHex>` (pour provisionner l'env). */
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const hash = scryptSync(password, salt, SCRYPT_KEYLEN);
|
||||||
|
return `scrypt$${salt.toString("hex")}$${hash.toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vérifie un mot de passe contre un hash stocké, en temps constant. */
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const parts = stored.split("$");
|
||||||
|
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||||
|
const salt = Buffer.from(parts[1]!, "hex");
|
||||||
|
const expected = Buffer.from(parts[2]!, "hex");
|
||||||
|
const actual = scryptSync(password, salt, expected.length);
|
||||||
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vérifie un code TOTP. */
|
||||||
|
export function verifyTotp(token: string, secret: string): boolean {
|
||||||
|
return totpVerifySync({ token: token.trim(), secret }).valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthConfig {
|
||||||
|
adminUser: string;
|
||||||
|
adminPasswordHash: string;
|
||||||
|
totpSecret: string;
|
||||||
|
jwtSecret: string;
|
||||||
|
jwtTtlSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginInput {
|
||||||
|
user: string;
|
||||||
|
password: string;
|
||||||
|
totp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
|
||||||
|
/** Émet un JWT signé (HS256) pour l'utilisateur. */
|
||||||
|
export async function issueJwt(cfg: AuthConfig): Promise<string> {
|
||||||
|
const ttl = cfg.jwtTtlSeconds ?? 3600;
|
||||||
|
return new SignJWT({ role: "owner" })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setSubject(cfg.adminUser)
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${ttl}s`)
|
||||||
|
.sign(enc.encode(cfg.jwtSecret));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vérifie un JWT ; retourne le sujet ou null. */
|
||||||
|
export async function verifyJwt(token: string, jwtSecret: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, enc.encode(jwtSecret));
|
||||||
|
return typeof payload.sub === "string" ? payload.sub : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flux de login : user + mot de passe + TOTP, tout-ou-rien (pas d'indice sur ce
|
||||||
|
* qui a échoué). Retourne un JWT en cas de succès, sinon null.
|
||||||
|
*/
|
||||||
|
export async function login(cfg: AuthConfig, input: LoginInput): Promise<string | null> {
|
||||||
|
const okUser = input.user === cfg.adminUser;
|
||||||
|
const okPass = verifyPassword(input.password, cfg.adminPasswordHash);
|
||||||
|
const okTotp = verifyTotp(input.totp, cfg.totpSecret);
|
||||||
|
if (!okUser || !okPass || !okTotp) return null;
|
||||||
|
return issueJwt(cfg);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { HttpAlertSender, NullAlertSender } from "./alerts/sender.js";
|
|||||||
import { startAlertScheduler } from "./alerts/scheduler.js";
|
import { startAlertScheduler } from "./alerts/scheduler.js";
|
||||||
import type { AlertSender } from "./alerts/types.js";
|
import type { AlertSender } from "./alerts/types.js";
|
||||||
import type { Guard, ToolHandle } from "./agent/types.js";
|
import type { Guard, ToolHandle } from "./agent/types.js";
|
||||||
import { runAgentTurn } from "./agent/loop.js";
|
import { ChatService } from "./agent/chat-service.js";
|
||||||
import { buildSystemPrompt } from "./agent/system-prompt.js";
|
import { buildSystemPrompt } from "./agent/system-prompt.js";
|
||||||
import { TelegramSurface } from "./surfaces/telegram.js";
|
import { TelegramSurface } from "./surfaces/telegram.js";
|
||||||
|
|
||||||
@@ -91,6 +91,7 @@ async function main(): Promise<void> {
|
|||||||
apiKey: cfg.ollamaApiKey,
|
apiKey: cfg.ollamaApiKey,
|
||||||
model: cfg.ollamaModel,
|
model: cfg.ollamaModel,
|
||||||
});
|
});
|
||||||
|
const chat = new ChatService({ client, tools, guard, systemPrompt, logger });
|
||||||
|
|
||||||
// ── Surface Telegram (long-polling) ─────────────────────────────────────
|
// ── Surface Telegram (long-polling) ─────────────────────────────────────
|
||||||
const telegram = new TelegramSurface(
|
const telegram = new TelegramSurface(
|
||||||
@@ -129,17 +130,7 @@ async function main(): Promise<void> {
|
|||||||
if (review && isCommand(text)) {
|
if (review && isCommand(text)) {
|
||||||
return handleReviewCommand(review, text);
|
return handleReviewCommand(review, text);
|
||||||
}
|
}
|
||||||
const { reply, steps } = await runAgentTurn({
|
return chat.handle(`telegram:${userId}`, text);
|
||||||
client,
|
|
||||||
system: systemPrompt,
|
|
||||||
userText: text,
|
|
||||||
tools,
|
|
||||||
actor: `telegram:${userId}`,
|
|
||||||
guard,
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
logger.info({ actor: `telegram:${userId}`, steps }, "tour d'agent terminé");
|
|
||||||
return reply;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { generateSecret, generateSync } from "otplib";
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
verifyTotp,
|
||||||
|
issueJwt,
|
||||||
|
verifyJwt,
|
||||||
|
login,
|
||||||
|
type AuthConfig,
|
||||||
|
} from "../src/api/auth.js";
|
||||||
|
|
||||||
|
describe("password (scrypt)", () => {
|
||||||
|
it("vérifie un mot de passe correct et rejette un faux", () => {
|
||||||
|
const h = hashPassword("s3cr3t!");
|
||||||
|
expect(verifyPassword("s3cr3t!", h)).toBe(true);
|
||||||
|
expect(verifyPassword("wrong", h)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejette un hash malformé", () => {
|
||||||
|
expect(verifyPassword("x", "pasunhash")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TOTP", () => {
|
||||||
|
it("valide un code généré pour le secret", () => {
|
||||||
|
const secret = generateSecret();
|
||||||
|
const code = generateSync({ secret: secret });
|
||||||
|
expect(verifyTotp(code, secret)).toBe(true);
|
||||||
|
expect(verifyTotp("000000", secret)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = (): AuthConfig => ({
|
||||||
|
adminUser: "kantin",
|
||||||
|
adminPasswordHash: hashPassword("pw"),
|
||||||
|
totpSecret: generateSecret(),
|
||||||
|
jwtSecret: "a-very-long-jwt-secret-value-32+chars",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JWT", () => {
|
||||||
|
it("émet et vérifie un JWT", async () => {
|
||||||
|
const c = cfg();
|
||||||
|
const token = await issueJwt(c);
|
||||||
|
expect(await verifyJwt(token, c.jwtSecret)).toBe("kantin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejette un JWT avec mauvais secret", async () => {
|
||||||
|
const c = cfg();
|
||||||
|
const token = await issueJwt(c);
|
||||||
|
expect(await verifyJwt(token, "autre-secret-completement-different")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login (mdp + TOTP, tout-ou-rien)", () => {
|
||||||
|
it("réussit avec les bons facteurs", async () => {
|
||||||
|
const c = cfg();
|
||||||
|
const token = await login(c, {
|
||||||
|
user: "kantin",
|
||||||
|
password: "pw",
|
||||||
|
totp: generateSync({ secret: c.totpSecret }),
|
||||||
|
});
|
||||||
|
expect(token).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("échoue si un seul facteur est faux", async () => {
|
||||||
|
const c = cfg();
|
||||||
|
const good = generateSync({ secret: c.totpSecret });
|
||||||
|
expect(await login(c, { user: "kantin", password: "bad", totp: good })).toBeNull();
|
||||||
|
expect(await login(c, { user: "kantin", password: "pw", totp: "000000" })).toBeNull();
|
||||||
|
expect(await login(c, { user: "intrus", password: "pw", totp: good })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user