feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
1 parent
1d49e6f5ee
commit
18101cf11a
636 files changed
+58517
-9519
No files matched your search
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* MqttClient.uts
|
||||
* ------------------------------------------------------------------
|
||||
* uni-app x (uvue) 最小 MQTT 3.1.1 over WebSocket 客户端
|
||||
*
|
||||
* 依赖:uni.connectSocket / SocketTask 二进制收发(App 平台 4.61+ 支持 ArrayBuffer)
|
||||
*
|
||||
* 用法:
|
||||
* import { MqttClient } from './MqttClient.uts'
|
||||
*
|
||||
* const client = new MqttClient({
|
||||
* url: 'ws://192.168.1.10:8083', // 后台「服务设置」里 WebSocket 端口,默认 8083
|
||||
* clientId: 'app-' + Date.now().toString(),
|
||||
* username: '会员账号', // allow_member_login 开启时用框架会员账号;否则用后台「认证账号」
|
||||
* password: '密码',
|
||||
* autoReconnect: true
|
||||
* })
|
||||
* client.onConnAck = (code: number) => { console.log('连接结果 code=' + code) } // 0=成功
|
||||
* client.onMessage = (topic: string, payload: string) => { console.log(topic, payload) }
|
||||
* client.onSubAck = (topic: string, code: number) => { console.log('订阅结果', topic, code) } // 128=被拒
|
||||
* client.connect()
|
||||
* client.subscribe('/devices/+/state', 0)
|
||||
* client.publish('/devices/DEV001/cmd', '{"on":true}', 0, false)
|
||||
* client.disconnect()
|
||||
* ------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export interface MqttClientOptions {
|
||||
/** WebSocket 地址,形如 ws://192.168.1.10:8083 */
|
||||
url: string
|
||||
/** 客户端 ID,需全局唯一,建议 app-时间戳-随机数 */
|
||||
clientId: string
|
||||
/** MQTT 账号:allow_member_login=1 时为框架会员账号,否则为后台「认证账号」;匿名连接可留空 */
|
||||
username?: string | null
|
||||
password?: string | null
|
||||
/** 心跳间隔秒,默认 10(App 后台 JS 定时器会暂停,缩短心跳可让服务端更快判定掉线、回前台更快重连) */
|
||||
keepalive?: number
|
||||
/** 会话清除,默认 true */
|
||||
cleanSession?: boolean
|
||||
/** 断线自动重连,默认 true(指数退避 1s~30s) */
|
||||
autoReconnect?: boolean
|
||||
}
|
||||
|
||||
interface Packet {
|
||||
cmd: number
|
||||
length: number
|
||||
body: Uint8Array
|
||||
}
|
||||
|
||||
interface PublishInfo {
|
||||
topic: string
|
||||
payload: string
|
||||
}
|
||||
|
||||
export class MqttClient {
|
||||
private opt: MqttClientOptions
|
||||
private task: SocketTask | null = null
|
||||
private connected: boolean = false
|
||||
private closedByUser: boolean = false
|
||||
private pingTimer: number | null = null
|
||||
private reconnectTimer: number | null = null
|
||||
private retryMs: number = 1000
|
||||
private rxBuf: Uint8Array = new Uint8Array(0)
|
||||
private subscribedTopics: string[] = []
|
||||
// SUBACK 只回 packetId,用两个平行数组记住「发出的订阅」以便把结果对回主题
|
||||
private subPendMid: number[] = []
|
||||
private subPendTopic: string[] = []
|
||||
|
||||
/** 连接结果回调:code=0 成功,1~5 见 MQTT CONNACK 返回码 */
|
||||
onConnAck: ((code: number) => void) | null = null
|
||||
/** 连接成功(CONNACK=0)后触发 */
|
||||
onOpen: (() => void) | null = null
|
||||
/** 收到消息回调 */
|
||||
onMessage: ((topic: string, payload: string) => void) | null = null
|
||||
/** 订阅结果回调:code=0/1/2 为授予的 QoS,128(0x80) 表示服务端拒绝(ACL/归属校验不通过) */
|
||||
onSubAck: ((topic: string, code: number) => void) | null = null
|
||||
/** 连接关闭回调 */
|
||||
onClose: ((code: number) => void) | null = null
|
||||
/** 错误回调 */
|
||||
onError: ((msg: string) => void) | null = null
|
||||
|
||||
constructor(options: MqttClientOptions) {
|
||||
this.opt = options
|
||||
if (this.opt.keepalive == null || this.opt.keepalive <= 0) {
|
||||
this.opt.keepalive = 60
|
||||
}
|
||||
if (this.opt.cleanSession == null) {
|
||||
this.opt.cleanSession = true
|
||||
}
|
||||
if (this.opt.autoReconnect == null) {
|
||||
this.opt.autoReconnect = true
|
||||
}
|
||||
this.rxBuf = new Uint8Array(0)
|
||||
this.subscribedTopics = []
|
||||
this.subPendMid = []
|
||||
this.subPendTopic = []
|
||||
this.pingTimer = null
|
||||
this.reconnectTimer = null
|
||||
this.retryMs = 1000
|
||||
this.connected = false
|
||||
this.closedByUser = false
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.task != null) {
|
||||
return
|
||||
}
|
||||
this.closedByUser = false
|
||||
this.retryMs = 1000
|
||||
this.consoleLog('connecting ' + this.opt.url)
|
||||
const task = uni.connectSocket({
|
||||
url: this.opt.url,
|
||||
success: () => {
|
||||
},
|
||||
fail: (err) => {
|
||||
this.emitError('connect fail: ' + JSON.stringify(err))
|
||||
if (!this.closedByUser && this.opt.autoReconnect === true) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
})
|
||||
this.task = task
|
||||
task.onOpen(() => {
|
||||
this.handleOpen()
|
||||
})
|
||||
task.onMessage((res) => {
|
||||
this.handleMessage(res.data)
|
||||
})
|
||||
task.onError(() => {
|
||||
this.emitError('socket error')
|
||||
})
|
||||
task.onClose((res) => {
|
||||
this.handleClose(res.code)
|
||||
})
|
||||
}
|
||||
|
||||
/** 断开连接(不再自动重连) */
|
||||
disconnect(): void {
|
||||
this.closedByUser = true
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
if (this.reconnectTimer != null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.task != null) {
|
||||
try {
|
||||
// 发送 DISCONNECT(14)
|
||||
this.send(this.buildPacket(14, new Uint8Array(0)))
|
||||
} catch (e) {
|
||||
}
|
||||
this.task.close({ code: 1000, reason: 'bye' })
|
||||
this.task = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 立即重连(App 回前台时调用)。后台期间 JS 定时器暂停、心跳停发,连接可能已被服务端断开,
|
||||
* 此时不等指数退避定时器,直接清理旧连接并重新连接 */
|
||||
reconnectNow(): void {
|
||||
this.closedByUser = false
|
||||
this.retryMs = 1000
|
||||
if (this.reconnectTimer != null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
if (this.task != null) {
|
||||
try {
|
||||
this.task.close({ code: 1000, reason: 'reconnect' })
|
||||
} catch (e) {
|
||||
}
|
||||
this.task = null
|
||||
}
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
this.connect()
|
||||
}
|
||||
|
||||
/** 订阅主题,qos 0 或 1 */
|
||||
subscribe(topic: string, qos: number = 0): void {
|
||||
if (topic.length === 0) {
|
||||
return
|
||||
}
|
||||
if (!this.connected) {
|
||||
this.emitError('not connected, cannot subscribe ' + topic)
|
||||
return
|
||||
}
|
||||
this.sendSubscribe(topic, qos)
|
||||
if (this.subscribedTopics.indexOf(topic) < 0) {
|
||||
this.subscribedTopics.push(topic)
|
||||
}
|
||||
}
|
||||
|
||||
/** 发布消息,retain 仅对设备命令慎用 */
|
||||
publish(topic: string, payload: string, qos: number = 0, retain: boolean = false): void {
|
||||
if (!this.connected) {
|
||||
this.emitError('not connected, cannot publish')
|
||||
return
|
||||
}
|
||||
const flags = ((qos & 0x03) << 1) | (retain ? 0x01 : 0x00)
|
||||
const body = this.join([this.encodeStr(topic), this.utf8Encode(payload)])
|
||||
const pkt = this.buildPacket(3, body)
|
||||
pkt[0] = (pkt[0] & 0xf0) | flags
|
||||
this.send(pkt)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有:WebSocket 生命周期
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private handleOpen(): void {
|
||||
// 打开后立刻发送 MQTT CONNECT(1)
|
||||
const body = this.buildConnect()
|
||||
this.send(this.buildPacket(1, body))
|
||||
}
|
||||
|
||||
private handleMessage(data: string | ArrayBuffer): void {
|
||||
let u8: Uint8Array
|
||||
if (data instanceof ArrayBuffer) {
|
||||
u8 = new Uint8Array(data)
|
||||
} else {
|
||||
u8 = this.utf8Encode(data)
|
||||
}
|
||||
this.rxBuf = this.join([this.rxBuf, u8])
|
||||
while (this.rxBuf.length > 0) {
|
||||
const pkt = this.parsePacket(this.rxBuf)
|
||||
if (pkt == null) {
|
||||
break
|
||||
}
|
||||
// 移除已消费字节
|
||||
const restLen = this.rxBuf.length - pkt.length
|
||||
const rest = new Uint8Array(restLen)
|
||||
for (let i = 0; i < restLen; i++) {
|
||||
rest[i] = this.rxBuf[pkt.length + i]
|
||||
}
|
||||
this.rxBuf = rest
|
||||
this.handleCmd(pkt.cmd, pkt.body)
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(code: number): void {
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
this.task = null
|
||||
if (this.onClose != null) {
|
||||
this.onClose(code)
|
||||
}
|
||||
if (!this.closedByUser && this.opt.autoReconnect === true) {
|
||||
this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer != null) {
|
||||
return
|
||||
}
|
||||
this.consoleLog('reconnect in ' + this.retryMs + 'ms')
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
}, this.retryMs)
|
||||
this.retryMs = Math.min(this.retryMs * 2, 30000)
|
||||
}
|
||||
|
||||
private startPing(): void {
|
||||
this.stopPing()
|
||||
const ka = this.opt.keepalive != null ? this.opt.keepalive : 10
|
||||
const period = Math.max(10, Math.floor(ka / 2)) * 1000
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.connected) {
|
||||
// PINGREQ(12)
|
||||
this.send(this.buildPacket(12, new Uint8Array(0)))
|
||||
}
|
||||
}, period)
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer != null) {
|
||||
clearInterval(this.pingTimer)
|
||||
this.pingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有:MQTT 报文
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private buildConnect(): Uint8Array {
|
||||
const vh: Uint8Array[] = []
|
||||
// 协议名 "MQTT"
|
||||
vh.push(new Uint8Array([0x00, 0x04]))
|
||||
vh.push(this.utf8Encode('MQTT'))
|
||||
// 协议级别 4(MQTT 3.1.1)
|
||||
vh.push(new Uint8Array([0x04]))
|
||||
// 连接标志
|
||||
const user = this.opt.username != null ? this.opt.username : ''
|
||||
const pass = this.opt.password != null ? this.opt.password : ''
|
||||
let flags = this.opt.cleanSession === true ? 0x02 : 0x00
|
||||
if (user.length > 0) {
|
||||
flags |= 0x80
|
||||
}
|
||||
if (pass.length > 0) {
|
||||
flags |= 0x40
|
||||
}
|
||||
const ka = this.opt.keepalive != null ? this.opt.keepalive : 10
|
||||
vh.push(new Uint8Array([flags, (ka >> 8) & 0xff, ka & 0xff]))
|
||||
// Payload:clientId + username + password
|
||||
vh.push(this.encodeStr(this.opt.clientId))
|
||||
if (user.length > 0) {
|
||||
vh.push(this.encodeStr(user))
|
||||
}
|
||||
if (pass.length > 0) {
|
||||
vh.push(this.encodeStr(pass))
|
||||
}
|
||||
return this.join(vh)
|
||||
}
|
||||
|
||||
private sendSubscribe(topic: string, qos: number): void {
|
||||
const mid = Math.floor(Math.random() * 65535) + 1
|
||||
const body = this.join([
|
||||
new Uint8Array([(mid >> 8) & 0xff, mid & 0xff]),
|
||||
this.encodeStr(topic),
|
||||
new Uint8Array([qos & 0x03])
|
||||
])
|
||||
const pkt = this.buildPacket(8, body)
|
||||
this.subPendMid.push(mid)
|
||||
this.subPendTopic.push(topic)
|
||||
// SUBSCRIBE 固定头固定为 0x82
|
||||
pkt[0] = 0x82
|
||||
this.send(pkt)
|
||||
}
|
||||
|
||||
private handleCmd(cmd: number, body: Uint8Array): void {
|
||||
if (cmd === 2) {
|
||||
// CONNACK
|
||||
const code = body.length >= 2 ? body[1] : -1
|
||||
if (code === 0) {
|
||||
this.connected = true
|
||||
this.retryMs = 1000
|
||||
// 恢复订阅
|
||||
for (let i = 0; i < this.subscribedTopics.length; i++) {
|
||||
this.sendSubscribe(this.subscribedTopics[i], 0)
|
||||
}
|
||||
this.startPing()
|
||||
if (this.onConnAck != null) {
|
||||
this.onConnAck(0)
|
||||
}
|
||||
if (this.onOpen != null) {
|
||||
this.onOpen()
|
||||
}
|
||||
} else {
|
||||
this.connected = false
|
||||
this.emitError('CONNACK reject code=' + code)
|
||||
if (this.onConnAck != null) {
|
||||
this.onConnAck(code)
|
||||
}
|
||||
this.disconnect()
|
||||
}
|
||||
} else if (cmd === 3) {
|
||||
// PUBLISH
|
||||
const p = this.parsePublish(body)
|
||||
if (p != null && this.onMessage != null) {
|
||||
this.onMessage(p.topic, p.payload)
|
||||
}
|
||||
} else if (cmd === 9) {
|
||||
// SUBACK:body = [packetIdHi, packetIdLo, code...]
|
||||
this.parseSubAck(body)
|
||||
}
|
||||
// PINGRESP(13) / PUBACK(4) 联调场景忽略
|
||||
}
|
||||
|
||||
/** 解析 SUBACK,把 code 对回到主题:128(0x80)=服务端拒绝订阅(ACL 未放行 / 非本人设备) */
|
||||
private parseSubAck(body: Uint8Array): void {
|
||||
if (body.length < 3) {
|
||||
return
|
||||
}
|
||||
const mid = (body[0] << 8) | body[1]
|
||||
const code = body[2]
|
||||
let idx = -1
|
||||
for (let i = 0; i < this.subPendMid.length; i++) {
|
||||
if (this.subPendMid[i] === mid) {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
const topic = this.subPendTopic[idx]
|
||||
this.subPendMid.splice(idx, 1)
|
||||
this.subPendTopic.splice(idx, 1)
|
||||
// 注意:订阅被拒**不是**连接错误,不走 emitError,否则调用方会把连接标记为失败
|
||||
if (this.onSubAck != null) {
|
||||
this.onSubAck(topic, code)
|
||||
}
|
||||
}
|
||||
|
||||
private parsePublish(body: Uint8Array): PublishInfo | null {
|
||||
if (body.length < 2) {
|
||||
return null
|
||||
}
|
||||
const tlen = (body[0] << 8) | body[1]
|
||||
if (body.length < 2 + tlen) {
|
||||
return null
|
||||
}
|
||||
const topic = this.utf8Decode(this.sliceU8(body, 2, tlen))
|
||||
const payload = this.utf8Decode(this.sliceU8(body, 2 + tlen, body.length - 2 - tlen))
|
||||
return { topic: topic, payload: payload }
|
||||
}
|
||||
|
||||
private parsePacket(buf: Uint8Array): Packet | null {
|
||||
if (buf.length < 2) {
|
||||
return null
|
||||
}
|
||||
const cmd = (buf[0] >> 4) & 0x0f
|
||||
let multiplier = 1
|
||||
let remLen = 0
|
||||
let idx = 1
|
||||
let more = true
|
||||
while (more) {
|
||||
if (idx >= buf.length) {
|
||||
return null
|
||||
}
|
||||
const b = buf[idx]
|
||||
remLen += (b & 0x7f) * multiplier
|
||||
idx += 1
|
||||
multiplier *= 128
|
||||
more = (b & 0x80) !== 0
|
||||
if (multiplier > 128 * 128 * 128 * 128) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const total = idx + remLen
|
||||
if (buf.length < total) {
|
||||
return null
|
||||
}
|
||||
const body = this.sliceU8(buf, idx, remLen)
|
||||
return { cmd: cmd, length: total, body: body }
|
||||
}
|
||||
|
||||
private buildPacket(cmd: number, body: Uint8Array): Uint8Array {
|
||||
let len = body.length
|
||||
const rem: number[] = []
|
||||
do {
|
||||
let b = len % 128
|
||||
len = Math.floor(len / 128)
|
||||
if (len > 0) {
|
||||
b |= 0x80
|
||||
}
|
||||
rem.push(b)
|
||||
} while (len > 0)
|
||||
const header = new Uint8Array(rem.length + 1)
|
||||
header[0] = (cmd << 4) & 0xff
|
||||
for (let i = 0; i < rem.length; i++) {
|
||||
header[i + 1] = rem[i]
|
||||
}
|
||||
return this.join([header, body])
|
||||
}
|
||||
|
||||
private encodeStr(str: string): Uint8Array {
|
||||
const u8 = this.utf8Encode(str)
|
||||
const out = new Uint8Array(u8.length + 2)
|
||||
out[0] = (u8.length >> 8) & 0xff
|
||||
out[1] = u8.length & 0xff
|
||||
out.set(u8, 2)
|
||||
return out
|
||||
}
|
||||
|
||||
private send(data: Uint8Array): void {
|
||||
if (this.task == null) {
|
||||
return
|
||||
}
|
||||
const ab = new ArrayBuffer(data.length)
|
||||
const copy = new Uint8Array(ab)
|
||||
copy.set(data, 0)
|
||||
this.task.send({ data: ab })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有:字节工具(不依赖 TextEncoder,保证 uni-app x 兼容)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private join(parts: Uint8Array[]): Uint8Array {
|
||||
let total = 0
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
total += parts[i].length
|
||||
}
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
out.set(parts[i], off)
|
||||
off += parts[i].length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private sliceU8(buf: Uint8Array, start: number, len: number): Uint8Array {
|
||||
const out = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i++) {
|
||||
out[i] = buf[start + i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private utf8Encode(str: string): Uint8Array {
|
||||
const enc = encodeURIComponent(str)
|
||||
const bytes: number[] = []
|
||||
let i = 0
|
||||
while (i < enc.length) {
|
||||
const c = enc.charAt(i)
|
||||
if (c === '%') {
|
||||
bytes.push(parseInt(enc.substring(i + 1, i + 3), 16))
|
||||
i += 3
|
||||
} else {
|
||||
bytes.push(c.charCodeAt(0))
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(bytes.length)
|
||||
for (let j = 0; j < bytes.length; j++) {
|
||||
out[j] = bytes[j]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private utf8Decode(bytes: Uint8Array): string {
|
||||
let s = ''
|
||||
let i = 0
|
||||
while (i < bytes.length) {
|
||||
const b = bytes[i]
|
||||
if (b < 0x80) {
|
||||
s += String.fromCharCode(b)
|
||||
i += 1
|
||||
} else if (b >= 0xc0 && b < 0xe0 && i + 1 < bytes.length) {
|
||||
s += String.fromCharCode(((b & 0x1f) << 6) | (bytes[i + 1] & 0x3f))
|
||||
i += 2
|
||||
} else if (b >= 0xe0 && b < 0xf0 && i + 2 < bytes.length) {
|
||||
s += String.fromCharCode(((b & 0x0f) << 12) | ((bytes[i + 1] & 0x3f) << 6) | (bytes[i + 2] & 0x3f))
|
||||
i += 3
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
private emitError(msg: string): void {
|
||||
this.consoleLog('ERROR ' + msg)
|
||||
if (this.onError != null) {
|
||||
this.onError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
private consoleLog(msg: string): void {
|
||||
console.log('[Mqtt] ' + msg)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user