mirror of
https://github.com/alibaba/anyproxy.git
synced 2025-05-10 14:58:27 +00:00
feat: refact recorder.js into TS
This commit is contained in:
parent
5bf6a55323
commit
4497160284
@ -5,7 +5,7 @@ const http = require('http'),
|
|||||||
async = require('async'),
|
async = require('async'),
|
||||||
color = require('colorful'),
|
color = require('colorful'),
|
||||||
certMgr = require('./certMgr').default,
|
certMgr = require('./certMgr').default,
|
||||||
Recorder = require('./recorder'),
|
Recorder = require('./recorder').default,
|
||||||
logUtil = require('./log'),
|
logUtil = require('./log'),
|
||||||
util = require('./util').default,
|
util = require('./util').default,
|
||||||
events = require('events'),
|
events = require('events'),
|
||||||
|
@ -1,44 +1,73 @@
|
|||||||
'use strict'
|
'use strict';
|
||||||
|
|
||||||
|
import * as Datastore from 'nedb';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as events from 'events';
|
||||||
|
import * as iconv from 'iconv-lite';
|
||||||
|
import * as fastJson from 'fast-json-stringify';
|
||||||
|
import logUtil from './log';
|
||||||
|
import proxyUtil from './util';
|
||||||
|
|
||||||
|
// //start recording and share a list when required
|
||||||
|
// const Datastore = require('nedb'),
|
||||||
|
// path = require('path'),
|
||||||
|
// fs = require('fs'),
|
||||||
|
// logUtil = require('./log'),
|
||||||
|
// events = require('events'),
|
||||||
|
// iconv = require('iconv-lite'),
|
||||||
|
// fastJson = require('fast-json-stringify'),
|
||||||
|
// proxyUtil = require('./util').default;
|
||||||
|
|
||||||
|
declare interface ISingleRecord {
|
||||||
|
_id?: number;
|
||||||
|
id?: number;
|
||||||
|
url?: string;
|
||||||
|
host?: string;
|
||||||
|
path?: string;
|
||||||
|
method?: string;
|
||||||
|
reqHeader?: OneLevelObjectType;
|
||||||
|
startTime?: number;
|
||||||
|
reqBody?: string;
|
||||||
|
protocol?: string;
|
||||||
|
statusCode?: number | string;
|
||||||
|
endTime?: number | string;
|
||||||
|
resHeader?: OneLevelObjectType;
|
||||||
|
length?: number | string;
|
||||||
|
mime?: string;
|
||||||
|
duration?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
//start recording and share a list when required
|
|
||||||
const Datastore = require('nedb'),
|
|
||||||
path = require('path'),
|
|
||||||
fs = require('fs'),
|
|
||||||
logUtil = require('./log'),
|
|
||||||
events = require('events'),
|
|
||||||
iconv = require('iconv-lite'),
|
|
||||||
fastJson = require('fast-json-stringify'),
|
|
||||||
proxyUtil = require('./util').default;
|
|
||||||
|
|
||||||
const wsMessageStingify = fastJson({
|
const wsMessageStingify = fastJson({
|
||||||
title: 'ws message stringify',
|
title: 'ws message stringify',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
time: {
|
time: {
|
||||||
type: 'integer'
|
type: 'integer',
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
type: 'string'
|
type: 'string',
|
||||||
},
|
},
|
||||||
isToServer: {
|
isToServer: {
|
||||||
type: 'boolean'
|
type: 'boolean',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const BODY_FILE_PRFIX = 'res_body_';
|
const BODY_FILE_PRFIX = 'res_body_';
|
||||||
const WS_MESSAGE_FILE_PRFIX = 'ws_message_';
|
const WS_MESSAGE_FILE_PRFIX = 'ws_message_';
|
||||||
const CACHE_DIR_PREFIX = 'cache_r';
|
const CACHE_DIR_PREFIX = 'cache_r';
|
||||||
function getCacheDir() {
|
function getCacheDir(): string {
|
||||||
const rand = Math.floor(Math.random() * 1000000),
|
const rand = Math.floor(Math.random() * 1000000);
|
||||||
cachePath = path.join(proxyUtil.getAnyProxyPath('cache'), './' + CACHE_DIR_PREFIX + rand);
|
const cachePath = path.join(proxyUtil.getAnyProxyPath('cache'), './' + CACHE_DIR_PREFIX + rand);
|
||||||
|
|
||||||
fs.mkdirSync(cachePath);
|
fs.mkdirSync(cachePath);
|
||||||
return cachePath;
|
return cachePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeInfo(id, info) {
|
function normalizeInfo(id: number, info: AnyProxyRecorder.ResourceInfo): ISingleRecord {
|
||||||
const singleRecord = {};
|
const singleRecord: ISingleRecord = {};
|
||||||
|
|
||||||
// general
|
// general
|
||||||
singleRecord._id = id;
|
singleRecord._id = id;
|
||||||
@ -71,7 +100,7 @@ function normalizeInfo(id, info) {
|
|||||||
} else {
|
} else {
|
||||||
singleRecord.statusCode = '';
|
singleRecord.statusCode = '';
|
||||||
singleRecord.endTime = '';
|
singleRecord.endTime = '';
|
||||||
singleRecord.resHeader = '';
|
singleRecord.resHeader = {};
|
||||||
singleRecord.length = '';
|
singleRecord.length = '';
|
||||||
singleRecord.mime = '';
|
singleRecord.mime = '';
|
||||||
singleRecord.duration = '';
|
singleRecord.duration = '';
|
||||||
@ -81,17 +110,20 @@ function normalizeInfo(id, info) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Recorder extends events.EventEmitter {
|
class Recorder extends events.EventEmitter {
|
||||||
constructor(config) {
|
private globalId: number;
|
||||||
super(config);
|
private cachePath: string;
|
||||||
|
private db: Datastore;
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
this.globalId = 1;
|
this.globalId = 1;
|
||||||
this.cachePath = getCacheDir();
|
this.cachePath = getCacheDir();
|
||||||
this.db = new Datastore();
|
this.db = new Datastore();
|
||||||
this.db.persistence.setAutocompactionInterval(5001);
|
this.db.persistence.setAutocompactionInterval(5001);
|
||||||
|
|
||||||
this.recordBodyMap = []; // id - body
|
// this.recordBodyMap = []; // id - body
|
||||||
}
|
}
|
||||||
|
|
||||||
emitUpdate(id, info) {
|
public emitUpdate(id: number, info?: ISingleRecord): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
if (info) {
|
if (info) {
|
||||||
self.emit('update', info);
|
self.emit('update', info);
|
||||||
@ -104,12 +136,17 @@ class Recorder extends events.EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitUpdateLatestWsMessage(id, message) {
|
public emitUpdateLatestWsMessage(id: number, message: {
|
||||||
|
id: number,
|
||||||
|
message: AnyProxyRecorder.WsResourceInfo,
|
||||||
|
}): void {
|
||||||
this.emit('updateLatestWsMsg', message);
|
this.emit('updateLatestWsMsg', message);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRecord(id, info) {
|
public updateRecord(id: number, info: AnyProxyRecorder.ResourceInfo): void {
|
||||||
if (id < 0) return;
|
if (id < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const self = this;
|
const self = this;
|
||||||
const db = self.db;
|
const db = self.db;
|
||||||
|
|
||||||
@ -125,36 +162,42 @@ class Recorder extends events.EventEmitter {
|
|||||||
* This method shall be called at each time there are new message
|
* This method shall be called at each time there are new message
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
updateRecordWsMessage(id, message) {
|
public updateRecordWsMessage(id: number, message: AnyProxyRecorder.WsResourceInfo): void {
|
||||||
const cachePath = this.cachePath;
|
const cachePath = this.cachePath;
|
||||||
if (id < 0) return;
|
if (id < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const recordWsMessageFile = path.join(cachePath, WS_MESSAGE_FILE_PRFIX + id);
|
const recordWsMessageFile = path.join(cachePath, WS_MESSAGE_FILE_PRFIX + id);
|
||||||
|
|
||||||
fs.appendFile(recordWsMessageFile, wsMessageStingify(message) + ',', () => {});
|
fs.appendFile(recordWsMessageFile, wsMessageStingify(message) + ',', (err) => {
|
||||||
|
if (err) {
|
||||||
|
logUtil.error(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
logUtil.error(e.message + e.stack);
|
logUtil.error(e.message + e.stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitUpdateLatestWsMessage(id, {
|
this.emitUpdateLatestWsMessage(id, {
|
||||||
id: id,
|
id,
|
||||||
message: message
|
message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateExtInfo(id, extInfo) {
|
// public updateExtInfo(id: number , extInfo: any): void {
|
||||||
const self = this;
|
// const self = this;
|
||||||
const db = self.db;
|
// const db = self.db;
|
||||||
|
|
||||||
db.update({ _id: id }, { $set: { ext: extInfo } }, {}, (err, nums) => {
|
// db.update({ _id: id }, { $set: { ext: extInfo } }, {}, (err, nums) => {
|
||||||
if (!err) {
|
// if (!err) {
|
||||||
self.emitUpdate(id);
|
// self.emitUpdate(id);
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
appendRecord(info) {
|
public appendRecord(info: AnyProxyRecorder.ResourceInfo): number {
|
||||||
if (info.req.headers.anyproxy_web_req) { // TODO request from web interface
|
if (info.req.headers.anyproxy_web_req) { // TODO request from web interface
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@ -170,33 +213,43 @@ class Recorder extends events.EventEmitter {
|
|||||||
return thisId;
|
return thisId;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRecordBody(id, info) {
|
public updateRecordBody(id: number, info: AnyProxyRecorder.ResourceInfo): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
const cachePath = self.cachePath;
|
const cachePath = self.cachePath;
|
||||||
|
|
||||||
if (id === -1) return;
|
if (id === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!id || typeof info.resBody === 'undefined') return;
|
if (!id || typeof info.resBody === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// add to body map
|
// add to body map
|
||||||
// ignore image data
|
// ignore image data
|
||||||
const bodyFile = path.join(cachePath, BODY_FILE_PRFIX + id);
|
const bodyFile = path.join(cachePath, BODY_FILE_PRFIX + id);
|
||||||
fs.writeFile(bodyFile, info.resBody, () => {});
|
fs.writeFile(bodyFile, info.resBody, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logUtil.error(err.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* get body and websocket file
|
* get body and websocket file
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
getBody(id, cb) {
|
public getBody(id: number, cb: (err: Error, content?: Buffer | string) => void): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
const cachePath = self.cachePath;
|
const cachePath = self.cachePath;
|
||||||
|
|
||||||
if (id < 0) {
|
if (id < 0) {
|
||||||
cb && cb('');
|
cb && cb(null, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const bodyFile = path.join(cachePath, BODY_FILE_PRFIX + id);
|
const bodyFile = path.join(cachePath, BODY_FILE_PRFIX + id);
|
||||||
fs.access(bodyFile, fs.F_OK || fs.R_OK, (err) => {
|
// node exported the `constants` from fs to maintain all the state constans since V7
|
||||||
|
// but the property `constants` does not exists in versions below 7, so we keep the way
|
||||||
|
fs.access(bodyFile, (fs as any).F_OK || (fs as any).R_OK, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
cb && cb(err);
|
cb && cb(err);
|
||||||
} else {
|
} else {
|
||||||
@ -205,13 +258,22 @@ class Recorder extends events.EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getDecodedBody(id, cb) {
|
public getDecodedBody(id: number, cb: (err: Error, result?: {
|
||||||
|
method?: string;
|
||||||
|
type?: string;
|
||||||
|
mime?: string;
|
||||||
|
content?: string;
|
||||||
|
fileName?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
}) => void): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
const result = {
|
const result = {
|
||||||
method: '',
|
method: '',
|
||||||
type: 'unknown',
|
type: 'unknown',
|
||||||
mime: '',
|
mime: '',
|
||||||
content: ''
|
content: '',
|
||||||
|
fileName: undefined,
|
||||||
|
statusCode: undefined,
|
||||||
};
|
};
|
||||||
self.getSingleRecord(id, (err, doc) => {
|
self.getSingleRecord(id, (err, doc) => {
|
||||||
// check whether this record exists
|
// check whether this record exists
|
||||||
@ -229,26 +291,27 @@ class Recorder extends events.EventEmitter {
|
|||||||
} else if (!bodyContent) {
|
} else if (!bodyContent) {
|
||||||
cb(null, result);
|
cb(null, result);
|
||||||
} else {
|
} else {
|
||||||
const record = doc[0],
|
const record = doc[0];
|
||||||
resHeader = record.resHeader || {};
|
const resHeader = record.resHeader || {};
|
||||||
try {
|
try {
|
||||||
const headerStr = JSON.stringify(resHeader),
|
const headerStr = JSON.stringify(resHeader);
|
||||||
charsetMatch = headerStr.match(/charset='?([a-zA-Z0-9-]+)'?/),
|
const charsetMatch = headerStr.match(/charset='?([a-zA-Z0-9-]+)'?/);
|
||||||
contentType = resHeader && (resHeader['content-type'] || resHeader['Content-Type']);
|
const contentType = resHeader && (resHeader['content-type'] || resHeader['Content-Type']);
|
||||||
|
|
||||||
if (charsetMatch && charsetMatch.length) {
|
if (charsetMatch && charsetMatch.length) {
|
||||||
const currentCharset = charsetMatch[1].toLowerCase();
|
const currentCharset = charsetMatch[1].toLowerCase();
|
||||||
if (currentCharset !== 'utf-8' && iconv.encodingExists(currentCharset)) {
|
if (currentCharset !== 'utf-8' && iconv.encodingExists(currentCharset)) {
|
||||||
bodyContent = iconv.decode(bodyContent, currentCharset);
|
result.content = iconv.decode((bodyContent as Buffer), currentCharset);
|
||||||
|
} else {
|
||||||
|
result.content = bodyContent.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
result.mime = contentType;
|
result.mime = contentType;
|
||||||
result.content = bodyContent.toString();
|
|
||||||
result.type = contentType && /application\/json/i.test(contentType) ? 'json' : 'text';
|
result.type = contentType && /application\/json/i.test(contentType) ? 'json' : 'text';
|
||||||
} else if (contentType && /image/i.test(contentType)) {
|
} else if (contentType && /image/i.test(contentType)) {
|
||||||
result.type = 'image';
|
result.type = 'image';
|
||||||
result.mime = contentType;
|
result.mime = contentType;
|
||||||
result.content = bodyContent;
|
result.content = (bodyContent as string);
|
||||||
} else {
|
} else {
|
||||||
result.type = contentType;
|
result.type = contentType;
|
||||||
result.mime = contentType;
|
result.mime = contentType;
|
||||||
@ -269,16 +332,16 @@ class Recorder extends events.EventEmitter {
|
|||||||
* get decoded WebSoket messages
|
* get decoded WebSoket messages
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
getDecodedWsMessage(id, cb) {
|
public getDecodedWsMessage(id: number, cb: (err: Error, messages?: AnyProxyRecorder.WsResourceInfo[]) => void): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
const cachePath = self.cachePath;
|
const cachePath = self.cachePath;
|
||||||
|
|
||||||
if (id < 0) {
|
if (id < 0) {
|
||||||
cb && cb([]);
|
cb && cb(null, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsMessageFile = path.join(cachePath, WS_MESSAGE_FILE_PRFIX + id);
|
const wsMessageFile = path.join(cachePath, WS_MESSAGE_FILE_PRFIX + id);
|
||||||
fs.access(wsMessageFile, fs.F_OK || fs.R_OK, (err) => {
|
fs.access(wsMessageFile, (fs as any).F_OK || (fs as any).R_OK, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
cb && cb(err);
|
cb && cb(err);
|
||||||
} else {
|
} else {
|
||||||
@ -303,34 +366,33 @@ class Recorder extends events.EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getSingleRecord(id, cb) {
|
public getSingleRecord(id: number, cb: (err: Error, result: ISingleRecord) => void): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
const db = self.db;
|
const db = self.db;
|
||||||
db.find({ _id: parseInt(id, 10) }, cb);
|
db.find({ _id: id }, cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSummaryList(cb) {
|
public getSummaryList(cb: (err: Error, records: ISingleRecord[]) => void): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
const db = self.db;
|
const db = self.db;
|
||||||
db.find({}, cb);
|
db.find({}, cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRecords(idStart, limit, cb) {
|
public getRecords(idStart: number | string, limit: number, cb: (err: Error, records: ISingleRecord[]) => void): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
const db = self.db;
|
const db = self.db;
|
||||||
limit = limit || 10;
|
limit = limit || 10;
|
||||||
idStart = typeof idStart === 'number' ? idStart : (self.globalId - limit);
|
idStart = typeof idStart === 'number' ? idStart : (self.globalId - limit);
|
||||||
db.find({ _id: { $gte: parseInt(idStart, 10) } })
|
db.find({ _id: { $gte: idStart } })
|
||||||
.sort({ _id: 1 })
|
.sort({ _id: 1 })
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.exec(cb);
|
.exec(cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
public clear(): void {
|
||||||
const self = this;
|
const self = this;
|
||||||
proxyUtil.deleteFolderContentsRecursive(self.cachePath, true);
|
proxyUtil.deleteFolderContentsRecursive(self.cachePath, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Recorder;
|
export default Recorder;
|
||||||
module.exports.default = Recorder;
|
|
@ -92,7 +92,7 @@ class webInterface extends events.EventEmitter {
|
|||||||
|
|
||||||
app.get('/downloadBody', (req, res) => {
|
app.get('/downloadBody', (req, res) => {
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
recorder.getDecodedBody(query.id, (err, result) => {
|
recorder.getDecodedBody(parseInt(query.id, 10), (err, result) => {
|
||||||
if (err || !result || !result.content) {
|
if (err || !result || !result.content) {
|
||||||
res.json({});
|
res.json({});
|
||||||
} else if (result.mime) {
|
} else if (result.mime) {
|
||||||
@ -116,7 +116,7 @@ class webInterface extends events.EventEmitter {
|
|||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
if (query && query.id) {
|
if (query && query.id) {
|
||||||
recorder.getDecodedBody(query.id, (err, result) => {
|
recorder.getDecodedBody(parseInt(query.id, 10), (err, result) => {
|
||||||
// 返回下载信息
|
// 返回下载信息
|
||||||
const _resDownload = function (isDownload) {
|
const _resDownload = function (isDownload) {
|
||||||
isDownload = typeof isDownload === 'boolean' ? isDownload : true;
|
isDownload = typeof isDownload === 'boolean' ? isDownload : true;
|
||||||
@ -169,7 +169,7 @@ class webInterface extends events.EventEmitter {
|
|||||||
app.get('/fetchReqBody', (req, res) => {
|
app.get('/fetchReqBody', (req, res) => {
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
if (query && query.id) {
|
if (query && query.id) {
|
||||||
recorder.getSingleRecord(query.id, (err, doc) => {
|
recorder.getSingleRecord(parseInt(query.id, 10), (err, doc) => {
|
||||||
if (err || !doc[0]) {
|
if (err || !doc[0]) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.end('');
|
res.end('');
|
||||||
@ -188,7 +188,7 @@ class webInterface extends events.EventEmitter {
|
|||||||
app.get('/fetchWsMessages', (req, res) => {
|
app.get('/fetchWsMessages', (req, res) => {
|
||||||
const query = req.query;
|
const query = req.query;
|
||||||
if (query && query.id) {
|
if (query && query.id) {
|
||||||
recorder.getDecodedWsMessage(query.id, (err, messages) => {
|
recorder.getDecodedWsMessage(parseInt(query.id, 10), (err, messages) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.json([]);
|
res.json([]);
|
||||||
|
3
typings/index.d.ts
vendored
3
typings/index.d.ts
vendored
@ -38,11 +38,14 @@ declare namespace AnyProxyRecorder {
|
|||||||
statusCode?: number,
|
statusCode?: number,
|
||||||
resHeader?: ResponseHeader,
|
resHeader?: ResponseHeader,
|
||||||
host?: string,
|
host?: string,
|
||||||
|
protocol?: string,
|
||||||
method?: string,
|
method?: string,
|
||||||
path?: string,
|
path?: string,
|
||||||
url?: string,
|
url?: string,
|
||||||
startTime?: number,
|
startTime?: number,
|
||||||
endTime?: number,
|
endTime?: number,
|
||||||
|
req?: any,
|
||||||
|
reqBody?: string,
|
||||||
res?: {
|
res?: {
|
||||||
statusCode: number,
|
statusCode: number,
|
||||||
headers: ResponseHeader
|
headers: ResponseHeader
|
||||||
|
Loading…
x
Reference in New Issue
Block a user