fix(GOV-009): 完成脚手架审查整改
This commit is contained in:
@@ -3,21 +3,29 @@ import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import ts from 'typescript';
|
||||
|
||||
const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.vue']);
|
||||
const INTERNAL_MESSAGE_MARKERS = ['MessageEnvelope', 'message-contracts'];
|
||||
|
||||
async function collectTypeScriptFiles(directory) {
|
||||
async function collectSourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const nestedFiles = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) return collectTypeScriptFiles(entryPath);
|
||||
return TYPESCRIPT_EXTENSIONS.has(path.extname(entry.name)) ? [entryPath] : [];
|
||||
if (entry.isDirectory()) return collectSourceFiles(entryPath);
|
||||
return SOURCE_EXTENSIONS.has(path.extname(entry.name)) ? [entryPath] : [];
|
||||
}),
|
||||
);
|
||||
return nestedFiles.flat();
|
||||
}
|
||||
|
||||
function extractTypeScriptSource(sourcePath, sourceText) {
|
||||
if (path.extname(sourcePath) !== '.vue') return sourceText;
|
||||
// 边界门禁只需解析 SFC 的脚本块;模板和样式不会产生 TypeScript 模块依赖。
|
||||
return [...sourceText.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)]
|
||||
.map((match) => match[1])
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function collectModuleSpecifiers(sourceFile) {
|
||||
const specifiers = [];
|
||||
|
||||
@@ -64,11 +72,11 @@ export async function verifyProjectBoundaries(repositoryRoot) {
|
||||
const compilerOptions = configFile.error
|
||||
? { module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler }
|
||||
: ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.dirname(configPath)).options;
|
||||
const files = await collectTypeScriptFiles(webSourceRoot);
|
||||
const files = await collectSourceFiles(webSourceRoot);
|
||||
const violations = [];
|
||||
|
||||
for (const sourcePath of files) {
|
||||
const sourceText = await readFile(sourcePath, 'utf8');
|
||||
const sourceText = extractTypeScriptSource(sourcePath, await readFile(sourcePath, 'utf8'));
|
||||
const sourceFile = ts.createSourceFile(
|
||||
sourcePath,
|
||||
sourceText,
|
||||
|
||||
@@ -89,6 +89,25 @@ test('拒绝通过 tsconfig 路径别名依赖 Server', async () => {
|
||||
assert.match(result.stderr, /Web 禁止依赖 Server/);
|
||||
});
|
||||
|
||||
test('拒绝 Vue 单文件组件脚本越界依赖 Server', async () => {
|
||||
const repositoryRoot = await createFixture();
|
||||
await writeFile(
|
||||
path.join(repositoryRoot, 'web/src/IllegalView.vue'),
|
||||
`<script setup lang="ts">
|
||||
import type { Internal } from '../../server/libs/internal';
|
||||
const value: Internal | undefined = undefined;
|
||||
</script>
|
||||
<template><main>{{ value }}</main></template>
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await runChecker(repositoryRoot);
|
||||
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.match(result.stderr, /Web 禁止依赖 Server/);
|
||||
assert.match(result.stderr, /IllegalView\.vue/);
|
||||
});
|
||||
|
||||
test('接受只消费公开 OpenAPI 类型的 Web 工程', async () => {
|
||||
const repositoryRoot = await createFixture();
|
||||
await writeFile(
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* 生成命令可以覆盖旧文件,但覆盖不等于契约已同步提交;这里仅检查指定生成物,
|
||||
* 避免用户其他工作区改动干扰契约门禁。
|
||||
*/
|
||||
export function assertGeneratedContractsUnchanged(repositoryRoot, contractFiles) {
|
||||
try {
|
||||
execFileSync('git', ['diff', '--exit-code', '--', ...contractFiles], {
|
||||
cwd: repositoryRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`生成契约与 Git 提交版本不一致:${contractFiles.join('、')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(currentFile)) {
|
||||
assertGeneratedContractsUnchanged(process.cwd(), [
|
||||
'server/openapi/openapi.json',
|
||||
'web/src/api/generated/schema.ts',
|
||||
]);
|
||||
process.stdout.write('生成契约与 Git 提交版本一致\n');
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { assertGeneratedContractsUnchanged } from './verify-generated-contracts.mjs';
|
||||
|
||||
function createGitFixture() {
|
||||
const repositoryRoot = mkdtempSync(path.join(tmpdir(), 'a-share-contracts-'));
|
||||
execFileSync('git', ['init', '-q'], { cwd: repositoryRoot });
|
||||
execFileSync('git', ['config', 'user.name', 'Contract Test'], { cwd: repositoryRoot });
|
||||
execFileSync('git', ['config', 'user.email', 'contract-test@example.invalid'], {
|
||||
cwd: repositoryRoot,
|
||||
});
|
||||
writeFileSync(path.join(repositoryRoot, 'contract.json'), '{"version":1}\n');
|
||||
execFileSync('git', ['add', 'contract.json'], { cwd: repositoryRoot });
|
||||
execFileSync('git', ['commit', '-qm', '测试基线'], { cwd: repositoryRoot });
|
||||
return repositoryRoot;
|
||||
}
|
||||
|
||||
test('生成契约与提交版本一致时通过', () => {
|
||||
const repositoryRoot = createGitFixture();
|
||||
assert.doesNotThrow(() => assertGeneratedContractsUnchanged(repositoryRoot, ['contract.json']));
|
||||
});
|
||||
|
||||
test('生成契约发生漂移时以明确错误阻断', () => {
|
||||
const repositoryRoot = createGitFixture();
|
||||
writeFileSync(path.join(repositoryRoot, 'contract.json'), '{"version":2}\n');
|
||||
|
||||
assert.throws(
|
||||
() => assertGeneratedContractsUnchanged(repositoryRoot, ['contract.json']),
|
||||
/生成契约与 Git 提交版本不一致:contract\.json/,
|
||||
);
|
||||
});
|
||||
@@ -73,8 +73,8 @@ export function assertSafeDevelopmentConfiguration(config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端与服务端分别校验,防止用 MySQL 8 客户端生成带有 5.7 无法稳定恢复的语法,
|
||||
* 同时核对数据库自身字符规则,而不是信任 env 中的声明值。
|
||||
* 客户端保持 5.7 系列以生成兼容导出;服务端固定 5.7.42,避免开发、测试和生产
|
||||
* 由不同补丁行为产生漂移。同时核对数据库真实字符规则,而不是信任 env 声明。
|
||||
*/
|
||||
export function assertMysql57Metadata(metadata) {
|
||||
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.mysqlClientVersion ?? '')) {
|
||||
@@ -85,8 +85,8 @@ export function assertMysql57Metadata(metadata) {
|
||||
`mysqldump 客户端必须为 5.7.x,实际 ${metadata.mysqldumpClientVersion || '(空)'}`,
|
||||
);
|
||||
}
|
||||
if (!/^5\.7\.\d+(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) {
|
||||
throw new Error(`MySQL 服务端必须为 5.7.x,实际 ${metadata.serverVersion || '(空)'}`);
|
||||
if (!/^5\.7\.42(?:[-+].*)?$/.test(metadata.serverVersion ?? '')) {
|
||||
throw new Error(`MySQL 服务端必须为 5.7.42,实际 ${metadata.serverVersion || '(空)'}`);
|
||||
}
|
||||
requireExactValue(metadata.databaseCharset, REQUIRED_CHARSET, '开发数据库字符集不符合要求');
|
||||
requireExactValue(
|
||||
@@ -320,7 +320,9 @@ export function buildCapturedArtifacts(metadata, sql) {
|
||||
throw new Error(`应用版本格式无效:${metadata.applicationVersion || '(空)'}`);
|
||||
}
|
||||
const normalizedSql = normalizeSchemaSql(sql);
|
||||
if (!normalizedSql.trim()) {
|
||||
const objectCounts = countSchemaObjects(normalizedSql);
|
||||
// mysqldump 对空库仍会输出会话 SET 与头尾注释;结构对象计数为零才是空库判据。
|
||||
if (Object.values(objectCounts).every((count) => count === 0)) {
|
||||
return buildCapturedEmptyArtifacts(metadata);
|
||||
}
|
||||
const schemaSha256 = sha256(normalizedSql);
|
||||
@@ -328,7 +330,7 @@ export function buildCapturedArtifacts(metadata, sql) {
|
||||
...metadata,
|
||||
status: 'captured',
|
||||
captured: true,
|
||||
objectCounts: countSchemaObjects(normalizedSql),
|
||||
objectCounts,
|
||||
schemaSha256,
|
||||
});
|
||||
return Object.freeze({
|
||||
@@ -661,7 +663,7 @@ export function runBaselineGeneration(options) {
|
||||
assertMysql57Metadata({
|
||||
mysqlClientVersion,
|
||||
mysqldumpClientVersion,
|
||||
serverVersion: '5.7.0-pending-server-check',
|
||||
serverVersion: '5.7.42-pending-server-check',
|
||||
databaseCharset: REQUIRED_CHARSET,
|
||||
databaseCollation: REQUIRED_COLLATION,
|
||||
});
|
||||
@@ -713,11 +715,7 @@ export function getCurrentReleasedVersion(repositoryRoot) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* not_applicable 清单只记录生成当时 MySQL 未启用,不能永久替代真实结构基线。
|
||||
* 每次校验都重新读取固定开发环境配置;一旦开发库启用,旧清单立即失效,强制
|
||||
* 下一版本开发开始前先从开发库生成 captured 基线。
|
||||
*/
|
||||
/** 已发布清单是不可变历史;当前开发配置只决定下一次基线生成结果。 */
|
||||
export function assertNotApplicableMatchesDevelopmentConfiguration(repositoryRoot, result) {
|
||||
// 已发布清单记录的是发布当时的数据库启用状态。当前开发配置只能决定下一份
|
||||
// 基线如何生成,不能反向否定或改写已发布版本的历史事实。
|
||||
|
||||
@@ -39,7 +39,7 @@ const validMetadata = Object.freeze({
|
||||
gitCommit: '0a69aa1f6f5cc75d72f21c43d96f5ff648e2ec93',
|
||||
mysqlClientVersion: '5.7.44',
|
||||
mysqldumpClientVersion: '5.7.44',
|
||||
serverVersion: '5.7.44-log',
|
||||
serverVersion: '5.7.42-log',
|
||||
databaseCharset: 'utf8mb4',
|
||||
databaseCollation: 'utf8mb4_general_ci',
|
||||
generatedAt: '2026-08-13T03:00:00.000Z',
|
||||
@@ -132,11 +132,15 @@ test('拒绝错误字符集、排序规则和不完整的启用配置', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('只接受 MySQL 5.7 客户端、服务端和固定数据库字符规则', () => {
|
||||
test('只接受 MySQL 5.7 客户端、5.7.42 服务端和固定数据库字符规则', () => {
|
||||
assert.doesNotThrow(() => assertMysql57Metadata(validMetadata));
|
||||
assert.throws(
|
||||
() => assertMysql57Metadata({ ...validMetadata, serverVersion: '8.0.39' }),
|
||||
/服务端必须为 5\.7/,
|
||||
/服务端必须为 5\.7\.42/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertMysql57Metadata({ ...validMetadata, serverVersion: '5.7.41' }),
|
||||
/服务端必须为 5\.7\.42/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertMysql57Metadata({ ...validMetadata, mysqlClientVersion: '8.0.39' }),
|
||||
@@ -355,7 +359,7 @@ test('启用入口分别校验 mysql 与 mysqldump 客户端版本', () => {
|
||||
: 'mysqldump Ver 10.13 Distrib 5.7.44, for macos';
|
||||
}
|
||||
if (command === 'mysql') {
|
||||
return '5.7.44-log\tutf8mb4\tutf8mb4_general_ci\n';
|
||||
return '5.7.42-log\tutf8mb4\tutf8mb4_general_ci\n';
|
||||
}
|
||||
return 'CREATE TABLE `sample` (`id` int);\n';
|
||||
},
|
||||
@@ -423,6 +427,21 @@ test('启用的空开发库生成 captured_empty format v2 清单', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('仅含 mysqldump 会话设置的真实空库仍生成 captured_empty', () => {
|
||||
const emptyDump = `-- MySQL dump 10.13 Distrib 5.7.42, for Linux
|
||||
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||
/*!40101 SET NAMES utf8 */;
|
||||
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
|
||||
-- Dump completed on 2026-08-13 18:00:00
|
||||
`;
|
||||
|
||||
const artifacts = buildCapturedArtifacts(validMetadata, emptyDump);
|
||||
|
||||
assert.equal(artifacts.status, 'captured_empty');
|
||||
assert.deepEqual(Object.keys(artifacts.files), ['manifest.yaml']);
|
||||
});
|
||||
|
||||
test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => {
|
||||
const root = makeTemporaryDirectory();
|
||||
const envFile = path.join(root, 'development.env');
|
||||
@@ -444,7 +463,7 @@ test('启用入口把 mysqldump 空结构持久化为 captured_empty', () => {
|
||||
return `${command} Ver 14.14 Distrib 5.7.44, for Linux`;
|
||||
}
|
||||
if (command === 'mysql') {
|
||||
return '5.7.44-log\tutf8mb4\tutf8mb4_general_ci\n';
|
||||
return '5.7.42-log\tutf8mb4\tutf8mb4_general_ci\n';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
@@ -482,9 +501,9 @@ test('校验拒绝篡改成错误 MySQL 版本或伪造对象范围的真实清
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
||||
manifest.mysql_server_version = '8.0.39';
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /服务端必须为 5\.7/);
|
||||
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /服务端必须为 5\.7\.42/);
|
||||
|
||||
manifest.mysql_server_version = '5.7.44-log';
|
||||
manifest.mysql_server_version = '5.7.42-log';
|
||||
manifest.objects.events = false;
|
||||
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
assert.throws(() => verifyBaselineDirectory(target, 'v0.0.5'), /events/);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
|
||||
const suffix = `${process.pid}`;
|
||||
const webImage = `a-share-analysis-web:verify-${suffix}`;
|
||||
const serverImage = `a-share-analysis-server:verify-${suffix}`;
|
||||
const network = `a-share-analysis-verify-${suffix}`;
|
||||
const redis = `a-share-analysis-verify-redis-${suffix}`;
|
||||
const api = `a-share-analysis-verify-api-${suffix}`;
|
||||
const web = `a-share-analysis-verify-web-${suffix}`;
|
||||
const containers = [web, api, redis];
|
||||
|
||||
function docker(args, options = {}) {
|
||||
return execFileSync('docker', args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
function waitForContainerCommand(container, command, description) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
try {
|
||||
docker(['exec', container, ...command], { capture: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
||||
}
|
||||
}
|
||||
throw new Error(`${description} 未在 6 秒内就绪:${String(lastError?.stderr ?? '')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
docker(['build', '--file', 'web/Dockerfile', '--tag', webImage, '.']);
|
||||
docker(['build', '--file', 'server/Dockerfile', '--tag', serverImage, '.']);
|
||||
|
||||
const serverUser = docker(['image', 'inspect', serverImage, '--format', '{{.Config.User}}'], {
|
||||
capture: true,
|
||||
}).trim();
|
||||
if (serverUser !== 'node') throw new Error(`Server 镜像必须使用 node 用户,实际 ${serverUser}`);
|
||||
|
||||
for (const entry of ['api', 'scheduler', 'worker']) {
|
||||
docker(
|
||||
['run', '--rm', '--entrypoint', 'test', serverImage, '-f', `/app/dist/apps/${entry}/main.js`],
|
||||
{ capture: true },
|
||||
);
|
||||
}
|
||||
|
||||
docker(['network', 'create', network], { capture: true });
|
||||
docker(['run', '--detach', '--name', redis, '--network', network, 'redis:7.4.8-alpine3.21'], {
|
||||
capture: true,
|
||||
});
|
||||
docker(
|
||||
[
|
||||
'run',
|
||||
'--detach',
|
||||
'--name',
|
||||
api,
|
||||
'--network',
|
||||
network,
|
||||
'--network-alias',
|
||||
'api',
|
||||
'--env',
|
||||
'APP_ENV=production',
|
||||
'--env',
|
||||
'LOG_LEVEL=error',
|
||||
'--env',
|
||||
'API_HOST=0.0.0.0',
|
||||
'--env',
|
||||
'API_PORT=3000',
|
||||
'--env',
|
||||
`REDIS_HOST=${redis}`,
|
||||
'--env',
|
||||
'REDIS_PORT=6379',
|
||||
'--env',
|
||||
'REDIS_USERNAME=',
|
||||
'--env',
|
||||
'REDIS_PASSWORD=',
|
||||
'--env',
|
||||
'REDIS_DATABASE=0',
|
||||
'--env',
|
||||
'REDIS_TLS=false',
|
||||
'--env',
|
||||
'MYSQL_ENABLED=false',
|
||||
'--env',
|
||||
'MYSQL_HOST=127.0.0.1',
|
||||
'--env',
|
||||
'MYSQL_PORT=3306',
|
||||
'--env',
|
||||
'MYSQL_DATABASE=a_share_analysis_dev',
|
||||
'--env',
|
||||
'MYSQL_USERNAME=',
|
||||
'--env',
|
||||
'MYSQL_PASSWORD=',
|
||||
'--env',
|
||||
'MYSQL_CHARSET=utf8mb4',
|
||||
'--env',
|
||||
'MYSQL_COLLATION=utf8mb4_general_ci',
|
||||
'--env',
|
||||
'MYSQL_TIMEZONE=+08:00',
|
||||
'--env',
|
||||
'AI_ENABLED=false',
|
||||
'--env',
|
||||
'AI_BASE_URL=',
|
||||
'--env',
|
||||
'AI_API_KEY=',
|
||||
'--env',
|
||||
'AI_MODEL=',
|
||||
'--env',
|
||||
'AI_TIMEOUT_MS=30000',
|
||||
serverImage,
|
||||
],
|
||||
{ capture: true },
|
||||
);
|
||||
docker(['run', '--detach', '--name', web, '--network', network, webImage], {
|
||||
capture: true,
|
||||
});
|
||||
|
||||
waitForContainerCommand(
|
||||
api,
|
||||
[
|
||||
'node',
|
||||
'-e',
|
||||
"fetch('http://127.0.0.1:3000/api/v1/health').then(r=>{if(r.status!==200)process.exit(1)})",
|
||||
],
|
||||
'API 健康检查',
|
||||
);
|
||||
waitForContainerCommand(web, ['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/'], 'Web 首页');
|
||||
waitForContainerCommand(
|
||||
web,
|
||||
['wget', '-q', '-O', '/dev/null', 'http://127.0.0.1/api/v1/health'],
|
||||
'Web 到 API 的 Nginx 代理',
|
||||
);
|
||||
|
||||
process.stdout.write('Docker 镜像、非 root 用户、三进程入口与 Web/API 链路验证通过\n');
|
||||
} finally {
|
||||
for (const container of containers) {
|
||||
try {
|
||||
docker(['rm', '--force', container], { capture: true });
|
||||
} catch {
|
||||
// 容器未创建时保持清理幂等。
|
||||
}
|
||||
}
|
||||
try {
|
||||
docker(['network', 'rm', network], { capture: true });
|
||||
} catch {
|
||||
// 网络未创建或已自动移除时无需报错。
|
||||
}
|
||||
for (const image of [webImage, serverImage]) {
|
||||
try {
|
||||
docker(['image', 'rm', '--force', image], { capture: true });
|
||||
} catch {
|
||||
// 构建中断、镜像尚未产生时保持清理幂等。
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ test('Web API 根地址不重复 OpenAPI 自带的 api 路径', async () => {
|
||||
await readRepoFile('deploy/env/production/web.env.example'),
|
||||
);
|
||||
|
||||
assert.equal(development.get('VITE_API_BASE_URL'), 'http://127.0.0.1:3000');
|
||||
assert.equal(development.get('VITE_API_BASE_URL'), '');
|
||||
assert.equal(production.get('VITE_API_BASE_URL'), '');
|
||||
});
|
||||
|
||||
@@ -165,6 +165,9 @@ test('部署文档覆盖本地、Compose 生产和 Ubuntu 源码生产', async (
|
||||
assert.match(source, /Node\.js 22 LTS/);
|
||||
assert.match(source, /pnpm 10/);
|
||||
assert.match(source, /不负责安装/);
|
||||
for (const document of [local, compose, source]) {
|
||||
assert.match(document, /MySQL 5\.7\.42/);
|
||||
}
|
||||
});
|
||||
|
||||
test('生产 Compose 只接受显式镜像并解析为真实健康检查', async () => {
|
||||
|
||||
Reference in New Issue
Block a user