feat: blockchain (#1408)
Co-authored-by: root <root@huahua.daoyoucloud.com> Co-authored-by: bai.zixv <bai.zixv@foxmail.com> Co-authored-by: sealday <zhanglin@daoyoucloud.com> Co-authored-by: TomyJan <TomyJan6@gmail.com> Co-authored-by: luliangqiang <2650321653@qq.com> Co-authored-by: yoona <1486343814@qq.com> Co-authored-by: wjh <wwwjh0710@163.com> Reviewed-on: daoyoucloud/tachybase#1408 Reviewed-by: sealday <zhanglin@daoyoucloud.com> Co-authored-by: hua <1494133104@qq.com> Co-committed-by: hua <1494133104@qq.com>
This commit is contained in:
parent
bcdf1e7972
commit
54a7f5dc40
2
packages/plugins/@tachybase/plugin-blockchain/.gitignore
vendored
Normal file
2
packages/plugins/@tachybase/plugin-blockchain/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
cache
|
||||
artifacts
|
2
packages/plugins/@tachybase/plugin-blockchain/.npmignore
Normal file
2
packages/plugins/@tachybase/plugin-blockchain/.npmignore
Normal file
@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/src
|
13
packages/plugins/@tachybase/plugin-blockchain/README.md
Normal file
13
packages/plugins/@tachybase/plugin-blockchain/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# @tachybase/plugin-blockchain
|
||||
|
||||
# 给文件添加执行权限(第一次要输入这些)
|
||||
chmod +x start_geth.sh
|
||||
chmod +x compile_contract.sh
|
||||
chmod +x deploy_contract.sh
|
||||
|
||||
# 启动私链
|
||||
pnpm run geth
|
||||
# 编译
|
||||
pnpm run compile
|
||||
# 部署
|
||||
pnpm run deploy
|
2
packages/plugins/@tachybase/plugin-blockchain/client.d.ts
vendored
Normal file
2
packages/plugins/@tachybase/plugin-blockchain/client.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/client';
|
||||
export { default } from './dist/client';
|
1
packages/plugins/@tachybase/plugin-blockchain/client.js
Normal file
1
packages/plugins/@tachybase/plugin-blockchain/client.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client/index.js');
|
@ -0,0 +1,3 @@
|
||||
{
|
||||
"DataStorage": "0x6176309fF5993b4f93a4De19d2E30bDE5cF0472c"
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
contract DataStorage {
|
||||
struct Data {
|
||||
string hashedData;
|
||||
string salt;
|
||||
}
|
||||
|
||||
mapping(uint256 => Data) private data;
|
||||
|
||||
event DataStored(uint256 indexed id, string hashedData, string salt);
|
||||
event DataDeleted(uint256 indexed id);
|
||||
|
||||
function storeData(uint256 id, string memory hashedData, string memory salt) public {
|
||||
require(bytes(data[id].hashedData).length == 0, "Data already exists for this ID");
|
||||
data[id] = Data(hashedData, salt);
|
||||
emit DataStored(id, hashedData, salt);
|
||||
}
|
||||
|
||||
function getData(uint256 id) public view returns (string memory hashedData, string memory salt) {
|
||||
Data storage item = data[id];
|
||||
require(bytes(item.hashedData).length != 0, "Data not found");
|
||||
return (item.hashedData, item.salt);
|
||||
}
|
||||
|
||||
function deleteData(uint256 id) public {
|
||||
require(bytes(data[id].hashedData).length != 0, "Data not found");
|
||||
delete data[id];
|
||||
emit DataDeleted(id);
|
||||
}
|
||||
}
|
29
packages/plugins/@tachybase/plugin-blockchain/deploy.js
Normal file
29
packages/plugins/@tachybase/plugin-blockchain/deploy.js
Normal file
@ -0,0 +1,29 @@
|
||||
// scripts/deploy.js
|
||||
const hre = require('hardhat');
|
||||
const fs = require('fs');
|
||||
|
||||
// npx hardhat compile
|
||||
// npx hardhat run scripts/deploy.js
|
||||
async function main() {
|
||||
const [deployer] = await hre.ethers.getSigners();
|
||||
console.log('Deploying contracts with the account:', deployer.address);
|
||||
|
||||
//部署DataStorage合约
|
||||
const DataStorage = await hre.ethers.getContractFactory('DataStorage');
|
||||
const dataStorage = await DataStorage.deploy();
|
||||
console.log('DataStorage deployed to:', dataStorage.target);
|
||||
|
||||
// 将DataStorage合约地址保存到一个JSON文件中
|
||||
const contractAddresses = {
|
||||
DataStorage: dataStorage.target,
|
||||
};
|
||||
|
||||
fs.writeFileSync('contract-addresses.json', JSON.stringify(contractAddresses, null, 2));
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
@ -0,0 +1,22 @@
|
||||
require('@nomicfoundation/hardhat-toolbox');
|
||||
/** @type import('hardhat/config').HardhatUserConfig */
|
||||
module.exports = {
|
||||
solidity: {
|
||||
version: '0.8.20',
|
||||
settings: {
|
||||
optimizer: {
|
||||
enabled: true,
|
||||
runs: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultNetwork: 'gethlocal',
|
||||
networks: {
|
||||
gethlocal: {
|
||||
url: 'http://127.0.0.1:8888/',
|
||||
chainId: 1337,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
//控制台运行 pnpm i @nomicfoundation/hardhat-toolbox
|
19
packages/plugins/@tachybase/plugin-blockchain/package.json
Normal file
19
packages/plugins/@tachybase/plugin-blockchain/package.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@tachybase/plugin-blockchain",
|
||||
"version": "0.21.80",
|
||||
"main": "dist/server/index.js",
|
||||
"dependencies": {
|
||||
"@nomicfoundation/hardhat-toolbox": "^5.0.0",
|
||||
"child_process": "^1.0.2",
|
||||
"crypto": "^1.0.1",
|
||||
"fs": "0.0.1-security",
|
||||
"hardhat": "^2.22.6",
|
||||
"path": "^0.12.7",
|
||||
"web3": "^4.11.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tachybase/client": "workspace:*",
|
||||
"@tachybase/server": "workspace:*",
|
||||
"@tachybase/test": "workspace:*"
|
||||
}
|
||||
}
|
2
packages/plugins/@tachybase/plugin-blockchain/server.d.ts
vendored
Normal file
2
packages/plugins/@tachybase/plugin-blockchain/server.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './dist/server';
|
||||
export { default } from './dist/server';
|
1
packages/plugins/@tachybase/plugin-blockchain/server.js
Normal file
1
packages/plugins/@tachybase/plugin-blockchain/server.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require('./dist/server/index.js');
|
@ -0,0 +1,21 @@
|
||||
import { Plugin } from '@tachybase/client';
|
||||
|
||||
export class PluginBlockchainClient extends Plugin {
|
||||
async afterAdd() {
|
||||
// await this.app.pm.add()
|
||||
}
|
||||
|
||||
async beforeLoad() {}
|
||||
|
||||
// You can get and modify the app instance here
|
||||
async load() {
|
||||
console.log(this.app);
|
||||
// this.app.addComponents({})
|
||||
// this.app.addScopes({})
|
||||
// this.app.addProvider()
|
||||
// this.app.addProviders()
|
||||
// this.app.router.add()
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginBlockchainClient;
|
@ -0,0 +1,2 @@
|
||||
export * from './server';
|
||||
export { default } from './server';
|
@ -0,0 +1,76 @@
|
||||
import { Context } from '@tachybase/actions';
|
||||
|
||||
import { generateSalt, hashWithSalt } from '../cryptoUtils';
|
||||
import { contractService } from '../web3/contractService';
|
||||
|
||||
// /api/blockchain:store
|
||||
/**
|
||||
* 存储数据的处理函数
|
||||
* @param {Context} ctx 请求上下文
|
||||
*/
|
||||
export const store = async (ctx: Context) => {
|
||||
const { data } = ctx.action.params.values;
|
||||
try {
|
||||
for (const item of data) {
|
||||
const salt = generateSalt();
|
||||
const jsonData = JSON.stringify(item);
|
||||
const hashedData = hashWithSalt(jsonData, salt);
|
||||
await contractService.storeData(item.id, hashedData, salt);
|
||||
}
|
||||
ctx.body = '数据存储成功';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ctx.status = 500;
|
||||
ctx.body = '数据存储出错';
|
||||
}
|
||||
};
|
||||
|
||||
// /api/blockchain:getData?id=xxxx
|
||||
/**
|
||||
* 获取数据的处理函数
|
||||
* @param {Context} ctx 请求上下文
|
||||
*/
|
||||
export const getDataHandler = async (ctx: Context) => {
|
||||
const id = ctx.action.params.id;
|
||||
try {
|
||||
const { hashedData, salt } = await contractService.getData(id);
|
||||
ctx.body = { hashedData, salt };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ctx.status = 500;
|
||||
ctx.body = '检索数据时出错';
|
||||
}
|
||||
};
|
||||
|
||||
// /api/blockchain:verify
|
||||
/**
|
||||
* 验证数据的处理函数
|
||||
* @param {Context} ctx 请求上下文
|
||||
*/
|
||||
export const verify = async (ctx: Context) => {
|
||||
const { data } = ctx.action.params.values;
|
||||
try {
|
||||
for (const item of data) {
|
||||
const { id } = item;
|
||||
// 获取链上数据
|
||||
const { hashedData: storedHashedData, salt } = await contractService.getData(id);
|
||||
const jsonData = JSON.stringify(item);
|
||||
const hashedData = hashWithSalt(jsonData, salt);
|
||||
|
||||
// 比较加密后的结果和链上的加密数据
|
||||
if (hashedData === storedHashedData) {
|
||||
console.log(`数据ID: ${id} 验证成功: 数据有效且未被篡改`);
|
||||
} else {
|
||||
console.log(`数据ID: ${id} 验证失败: 数据已被篡改或无效`);
|
||||
}
|
||||
}
|
||||
ctx.body = '数据验证完成';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
ctx.status = 500;
|
||||
ctx.body = '验证数据时出错';
|
||||
}
|
||||
};
|
||||
|
||||
// 事件监听在应用启动时调用
|
||||
contractService.setupEventListeners();
|
@ -0,0 +1,25 @@
|
||||
import { exec } from 'child_process';
|
||||
import path from 'path';
|
||||
import { Application } from '@tachybase/server';
|
||||
|
||||
export default function (app: Application) {
|
||||
app
|
||||
.command('compile')
|
||||
.option('-v, --version')
|
||||
.action(async (options) => {
|
||||
const scriptPath = path.join(__dirname, 'compile_contract.sh');
|
||||
|
||||
exec(`bash ${scriptPath}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`执行脚本时出错: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error(`stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(`stdout: ${stdout}`);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
cd packages/plugins/@tachybase/plugin-blockchain
|
||||
npx hardhat compile
|
@ -0,0 +1,25 @@
|
||||
import { exec } from 'child_process';
|
||||
import path from 'path';
|
||||
import { Application } from '@tachybase/server';
|
||||
|
||||
export default function (app: Application) {
|
||||
app
|
||||
.command('deploy')
|
||||
.option('-v, --version')
|
||||
.action(async (options) => {
|
||||
const scriptPath = path.join(__dirname, 'deploy_contract.sh');
|
||||
|
||||
exec(`bash ${scriptPath}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`执行脚本时出错: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error(`stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(`stdout: ${stdout}`);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
cd packages/plugins/@tachybase/plugin-blockchain
|
||||
npx hardhat run deploy.js
|
@ -0,0 +1,25 @@
|
||||
import { exec } from 'child_process';
|
||||
import path from 'path';
|
||||
import { Application } from '@tachybase/server';
|
||||
|
||||
export default function (app: Application) {
|
||||
app
|
||||
.command('geth')
|
||||
.option('-v, --version')
|
||||
.action(async (options) => {
|
||||
const scriptPath = path.join(__dirname, 'start_geth.sh');
|
||||
|
||||
exec(`bash ${scriptPath}`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`执行脚本时出错: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
console.error(`stderr: ${stderr}`);
|
||||
}
|
||||
|
||||
console.log(`stdout: ${stdout}`);
|
||||
});
|
||||
});
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
cd ../eth-private-chain/
|
||||
nohup geth --datadir "." --dev --dev.period 2 --http --http.api eth,web3,net --http.corsdomain "http://remix.ethereum.org" --password password.txt --http.port 8888 --ws --ws.api eth,web3,net --ws.origins "*" --ws.addr "127.0.0.1" --ws.port 8546 > geth_output.log 2>&1 &
|
@ -0,0 +1,22 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* 生成盐
|
||||
* @returns {string} 盐字符串
|
||||
*/
|
||||
export function generateSalt(): string {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用盐和数据进行加密
|
||||
* @param {string} data 数据字符串
|
||||
* @param {string} salt 盐字符串
|
||||
* @returns {string} 加密后的哈希值
|
||||
*/
|
||||
export function hashWithSalt(data: string, salt: string): string {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(data + salt)
|
||||
.digest('hex');
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { default } from './plugin';
|
@ -0,0 +1,33 @@
|
||||
import { Plugin } from '@tachybase/server';
|
||||
|
||||
import { getDataHandler, store, verify } from './actions/blockchain';
|
||||
import { contractService } from './web3/contractService';
|
||||
|
||||
export class PluginBlockchainServer extends Plugin {
|
||||
async afterAdd() {}
|
||||
|
||||
async beforeLoad() {}
|
||||
|
||||
async load() {
|
||||
this.app.resourcer.define({
|
||||
name: 'blockchain',
|
||||
actions: {
|
||||
store,
|
||||
getDataHandler,
|
||||
verify,
|
||||
},
|
||||
});
|
||||
|
||||
this.app.acl.allow('blockchain', '*', 'public');
|
||||
}
|
||||
|
||||
async install() {}
|
||||
|
||||
async afterEnable() {}
|
||||
|
||||
async afterDisable() {}
|
||||
|
||||
async remove() {}
|
||||
}
|
||||
|
||||
export default PluginBlockchainServer;
|
@ -0,0 +1,135 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { web3Provider } from './web3Provider';
|
||||
|
||||
class ContractService {
|
||||
private contract: any;
|
||||
private defaultAccount: string;
|
||||
private currentContractAbi: any;
|
||||
private currentContractAddress: string;
|
||||
|
||||
constructor() {
|
||||
this.initializeContract();
|
||||
setInterval(this.checkForContractAddressOrAbiChange.bind(this), 60000); // 每分钟检查一次
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化合约实例
|
||||
*/
|
||||
private initializeContract() {
|
||||
const web3 = web3Provider.web3;
|
||||
const account = web3.eth.accounts.privateKeyToAccount(process.env.PRIVATE_KEY as string);
|
||||
web3.eth.accounts.wallet.add(account);
|
||||
web3.eth.defaultAccount = account.address;
|
||||
|
||||
this.currentContractAbi = this.getContractAbi();
|
||||
this.currentContractAddress = this.getContractAddress();
|
||||
this.updateContractInstance(this.currentContractAbi, this.currentContractAddress);
|
||||
this.defaultAccount = account.address;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合约文件中ABI
|
||||
*/
|
||||
private getContractAbi(): any {
|
||||
const contractJson = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.resolve(__dirname, '../../../artifacts/contracts/DataStorage.sol/DataStorage.json'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
return contractJson.abi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取合约文件中地址
|
||||
*/
|
||||
private getContractAddress(): string {
|
||||
const contractAddresses = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, '../../../contract-addresses.json'), 'utf-8'),
|
||||
);
|
||||
return contractAddresses.DataStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查合约地址或ABI是否发生变化
|
||||
*/
|
||||
private checkForContractAddressOrAbiChange() {
|
||||
console.log('正在检查合约地址或 ABI 是否发生变化...');
|
||||
const contractABI = this.getContractAbi();
|
||||
const contractAddress = this.getContractAddress();
|
||||
|
||||
if (
|
||||
contractAddress !== this.currentContractAddress ||
|
||||
JSON.stringify(contractABI) !== JSON.stringify(this.currentContractAbi)
|
||||
) {
|
||||
console.log(`检测到合约地址或 ABI 发生变化。更新合约实例为新地址: ${contractAddress}`);
|
||||
this.updateContractInstance(contractABI, contractAddress);
|
||||
} else {
|
||||
console.log('未检测到合约地址或 ABI 发生变化。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新合约实例
|
||||
* @param {any} newAbi 新的合约 ABI
|
||||
* @param {string} newAddress 新的合约地址
|
||||
*/
|
||||
private updateContractInstance(newAbi: any, newAddress: string) {
|
||||
this.currentContractAbi = newAbi;
|
||||
this.currentContractAddress = newAddress;
|
||||
this.contract = new web3Provider.web3.eth.Contract(newAbi, newAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储数据到区块链
|
||||
* @param {string} id 数据的唯一标识符
|
||||
* @param {string} hashedData 加密后的数据
|
||||
* @param {string} salt 数据使用的盐
|
||||
*/
|
||||
async storeData(id: string, hashedData: string, salt: string): Promise<void> {
|
||||
this.checkForContractAddressOrAbiChange();
|
||||
await this.contract.methods.storeData(id, hashedData, salt).send({ from: this.defaultAccount });
|
||||
}
|
||||
|
||||
/**
|
||||
* 从区块链获取数据
|
||||
* @param {string} id 数据的唯一标识符
|
||||
* @returns {Promise<{ hashedData: string; salt: string }>} 返回哈希数据和盐
|
||||
*/
|
||||
async getData(id: string): Promise<{ hashedData: string; salt: string }> {
|
||||
this.checkForContractAddressOrAbiChange();
|
||||
return this.contract.methods.getData(id).call();
|
||||
}
|
||||
|
||||
// 监听合约事件
|
||||
setupEventListeners() {
|
||||
this.checkForContractAddressOrAbiChange();
|
||||
// 监听 DataStored 事件
|
||||
const dataStoredSubscription = this.contract.events.DataStored({ fromBlock: 'latest' });
|
||||
|
||||
dataStoredSubscription.on('data', (event) => {
|
||||
console.log('交易hash:' + event.transactionHash);
|
||||
console.log('区块高度:' + event.blockNumber);
|
||||
console.log('DataStored event:', event.returnValues);
|
||||
});
|
||||
dataStoredSubscription.on('error', (error) => {
|
||||
console.error('Error on DataStored event:', error);
|
||||
});
|
||||
|
||||
// 监听 DataDeleted 事件
|
||||
const dataDeletedSubscription = this.contract.events.DataDeleted({ fromBlock: 'latest' });
|
||||
|
||||
dataDeletedSubscription.on('data', (event) => {
|
||||
console.log('交易hash:' + event.transactionHash);
|
||||
console.log('区块高度:' + event.blockNumber);
|
||||
console.log('DataDeleted event:', event.returnValues);
|
||||
});
|
||||
dataDeletedSubscription.on('error', (error) => {
|
||||
console.error('Error on DataDeleted event:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const contractService = new ContractService();
|
@ -0,0 +1,44 @@
|
||||
import { Web3 } from 'web3';
|
||||
|
||||
export class Web3Provider {
|
||||
public web3: Web3;
|
||||
|
||||
constructor() {
|
||||
this.web3 = new Web3(new Web3.providers.WebsocketProvider('ws://127.0.0.1:8546'));
|
||||
this.initializeWebSocket();
|
||||
}
|
||||
|
||||
private initializeWebSocket() {
|
||||
this.web3.currentProvider.on('connect', () => {
|
||||
console.log('WebSocket connected');
|
||||
});
|
||||
|
||||
this.web3.currentProvider.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
|
||||
this.web3.currentProvider.on('end', () => {
|
||||
console.log('WebSocket connection closed. Reconnecting...');
|
||||
this.connectWebSocket();
|
||||
});
|
||||
}
|
||||
|
||||
private connectWebSocket() {
|
||||
this.web3.setProvider(new Web3.providers.WebsocketProvider('ws://127.0.0.1:8546'));
|
||||
|
||||
this.web3.currentProvider.on('connect', () => {
|
||||
console.log('WebSocket reconnected');
|
||||
});
|
||||
|
||||
this.web3.currentProvider.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
});
|
||||
|
||||
this.web3.currentProvider.on('end', () => {
|
||||
console.log('WebSocket connection closed again. Reconnecting...');
|
||||
this.connectWebSocket();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const web3Provider = new Web3Provider();
|
@ -20,7 +20,7 @@ async function request(config, context) {
|
||||
const { token, origin } = context;
|
||||
const { method = 'POST', data, timeout = 5000 } = config;
|
||||
|
||||
const originUrl = (config.url?.url || '').trim();
|
||||
const originUrl = config.url?.trim() || '';
|
||||
const url = originUrl.startsWith('http') ? originUrl : `${origin}${originUrl}`;
|
||||
|
||||
let headers = (config.headers ?? []).reduce((result, header) => {
|
||||
|
2479
pnpm-lock.yaml
2479
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user