feat(sync): 同步盘 + 关系层 (先建关系, 再做文件同步)
## 关系层 (src/main/relations.ts, relation-service.ts) 原来"发现即信任": UDP 广播包里明文带着 driveToken, 同网段任何进程拿到就能浏览 你的整个网盘, 而且没有"授权"这个概念。现在拆成三层: 发现 (临时) -> 关系 (持久, 用户显式建立) -> 授权 (分级, 双方各自独立) 建立关系的方式跟远程终端的授权闸门**完全一致** —— 对方直接弹窗, 不用邀请码: A 点"发起同步关系" -> WS 帧 relation:request -> B 侧弹窗 (复用 ApprovalDialog, kind='relation') -> 同意 -> relation:accept -> 双方落库并交换 per-peer 令牌。 - 令牌: myToken=我签发给对方的, peerToken=对方签发给我的 - **driveToken 不再进广播包**; file-server 在关系令牌匹配不上时回退到旧的全局 token, 但只允许"只读浏览网盘" —— 不给同步区, 也不给写/删 - 关系存 JSON 文件 (原子写), 不做 schema 迁移 - 授权能力: drive(浏览我的网盘) / sync(与我同步文件), 双方各自独立勾选 - 远程终端/端口转发/USB 仍走原来的 remoteAllowPeers 白名单, 没有并进关系层 - 同一对端 60s 内只弹一次窗 (WS 是"发现即建立"的, 不限流就能被刷屏) ## 同步区: 独立于网盘的第二套空间 用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘目录里。 /drive/* 网盘 -> 共享目录, 要 drive 授权 /sync/* 同步区 -> 某关系专属的同步目录, 要 sync 授权, 路径严格限制在该目录内 顶层目录可以同时和多个设备同步: 每个关系自动分配一个子目录 (~/备份/MECHREVO/, ~/备份/lenovo/), 物理隔开, 同名文件不会互相覆盖 —— "统一目录多个聊天备份"成立。 ## 文件关系表 (src/main/sync-manifest.ts) —— 同步盘地基 核心是**区分"文件被删了"和"文件本来就不存在"**: - **墓碑**: 删除不抹记录, 只标 state='deleted'。直接抹掉的话, 对方离线回来会把 已删文件当成"新增"重新下载 -> 删除永远删不掉; 对方从备份恢复也会让删除被 静默撤销。有墓碑 + seq 比较就都能挡住。 - **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定 删除方向全靠它。扫描顺序必须是 advanceClock -> reconcileLocal -> mergePeer (先收消息再产生事件), 否则跨机器的 seq 不可比, 删除方向会判反。 - **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线才退回 mtime 兜底。 - 墓碑留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。 - **删除传播**: 对方推关系表过来时, 比对基线确认我没改过才删本地那份; 改过则 报冲突 —— 绝不静默丢数据。 - 冲突: 手动同步弹窗对比让用户选方向; 自动同步跳过并记录, 不替用户决定。 ## 其余 - 自动同步 (src/main/auto-sync.ts): 60s 轮询 + 指数退避, 对方上线/开关打开时 立刻跑一轮。 - 同步面板 (SyncPanel.vue): 关系区 + 变更列表 (含"将删除对方/本机") + 冲突对比 弹窗 + 自动同步开关。 - 关系授权弹窗复用远程终端那套 ApprovalDialog。 - 目录穿越防护: 上传/下载/删除的路径都严格限制在各自根目录内, 且**软链归一后 再比一次** (同步目录是用户任意文件夹, 里面出现软链很常见)。 - 跨关系校验: 令牌与 relationId 绑定, A 关系的对方不能读写 B 关系的同步目录。 - 关系令牌在 IPC 返回给渲染层时剥掉, 界面拿不到凭据。 - 建关系/授权/同步/删除传播都写 audit_log。 ## 测试 - npm run selftest 64 通过 / 0 失败 (R关系 / M墓碑删除传播 / Y同步盘) - npm run selftest:sync 26 条全通过 (双节点端到端: 建立关系->同步->删->传播->防复活) ## 文档 docs/relation-and-sync.md —— 三层模型 / 关系建立 / 墓碑与 seq / 三方对比判定表 / 安全要点 / 已知不一致。
This commit is contained in:
+236
-15
@@ -18,6 +18,11 @@ import { ChatServer } from './chat-server'
|
||||
import { ChatClient } from './chat-client'
|
||||
import { FileServer, uploadFile, uploadBuffer } from './file-server'
|
||||
import { listDrive, getDriveRoot, downloadDriveFile, collectDriveFiles } from './drive'
|
||||
import { scanSync, applySyncItems, type SyncApplyItem } from './sync'
|
||||
import { RelationStore, type Grant } from './relations'
|
||||
import { ManifestStore } from './sync-manifest'
|
||||
import { AutoSync } from './auto-sync'
|
||||
import { RelationService } from './relation-service'
|
||||
import { broadcastToRenderer, focusMainWindow } from './window'
|
||||
import { rebuildMenu } from './tray'
|
||||
import { notify, setBadgeCount } from './notify'
|
||||
@@ -26,7 +31,7 @@ import { ForwardManager } from './remote/forward'
|
||||
import { UsbBridgeManager } from './remote/usb'
|
||||
import { enqueueApproval, listPendingApprovals, replyApproval } from './remote/approval'
|
||||
import { computeWsState } from './ws-state'
|
||||
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig } from './protocol'
|
||||
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage, ReplyRef, TerminalFrame, ForwardFrame, UsbFrame, UsbDeviceInfo, UsbDirection, UsbAttachConfig, WsFrame } from './protocol'
|
||||
import { DEFAULT_PORTS } from './protocol'
|
||||
|
||||
interface NetCtx {
|
||||
@@ -38,6 +43,11 @@ interface NetCtx {
|
||||
terminal: TerminalManager
|
||||
forward: ForwardManager
|
||||
usb: UsbBridgeManager
|
||||
relations: RelationStore
|
||||
manifestStore: ManifestStore
|
||||
relationService: RelationService
|
||||
/** 自动同步 (在 bindNetworkContext 里创建) */
|
||||
autoSync?: AutoSync
|
||||
}
|
||||
|
||||
let ctx: NetCtx | null = null
|
||||
@@ -93,8 +103,60 @@ function disarmPendingMetaTimer(fileId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 找一个对端 (优先用在线的 discovery 信息, 离线时退回设备表)。
|
||||
* 有关系时把 driveToken 换成关系签发的 per-peer 令牌。
|
||||
*/
|
||||
function findPeer(peerId: string): DeviceInfo | null {
|
||||
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
|
||||
if (p) {
|
||||
// 有关系时用关系签发的 per-peer 令牌 (广播包里已经不再带全局 driveToken 了)
|
||||
const rel = ctx?.relations.getActiveRelationByPeer(peerId)
|
||||
if (rel?.peerToken) return { ...(p as DeviceInfo), driveToken: rel.peerToken }
|
||||
return p as DeviceInfo
|
||||
}
|
||||
const d = listDevices().find(x => x.device_id === peerId) as any
|
||||
if (!d) return null
|
||||
return {
|
||||
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
|
||||
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
|
||||
driveToken: d.drive_token || undefined,
|
||||
} as DeviceInfo
|
||||
}
|
||||
|
||||
// 关系对渲染层的视图: **剥掉令牌** (界面不需要, 也不该拿到凭据)
|
||||
function publicRelation(r: any) {
|
||||
if (!r) return r
|
||||
const { myToken, peerToken, ...rest } = r
|
||||
return rest
|
||||
}
|
||||
|
||||
// 关系帧路由: 建立 / 接受 / 拒绝 (两个方向都走这里)
|
||||
// 返回 true = 这个帧已经处理完了, 调用方不用再管
|
||||
function routeRelationFrame(peer: DeviceInfo, frame: WsFrame): boolean { if (frame.type === 'relation:request') {
|
||||
ctx?.relationService.handleRelationRequest(peer, frame.payload)
|
||||
.catch((e) => console.warn('[relation] handle request error:', e?.message))
|
||||
return true
|
||||
}
|
||||
if (frame.type === 'relation:accept') { ctx?.relationService.handleRelationAccept(peer, frame.payload); return true }
|
||||
if (frame.type === 'relation:reject') { ctx?.relationService.handleRelationReject(peer, frame.payload); return true }
|
||||
if (frame.type === 'relation:revoked') { ctx?.relationService.handleRelationRevoked(peer, frame.payload.relationId); return true }
|
||||
return false
|
||||
}
|
||||
|
||||
export function bindNetworkContext(c: NetCtx) {
|
||||
ctx = c
|
||||
|
||||
// 自动同步: 关系开了开关就定时跑一轮 (冲突跳过, 不静默覆盖)
|
||||
c.autoSync = new AutoSync({
|
||||
relations: c.relations,
|
||||
manifestStore: c.manifestStore,
|
||||
findPeer: (peerId) => findPeer(peerId),
|
||||
notify: (event, payload) => broadcastToRenderer(event, payload),
|
||||
})
|
||||
c.autoSync.start()
|
||||
|
||||
// 网络事件 -> 渲染进程
|
||||
c.discovery.on('found', (d) => {
|
||||
// 用户已删除的设备 (ignored=1) — 静默忽略, 不进 sidebar, 不重连
|
||||
@@ -213,6 +275,7 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||
return
|
||||
}
|
||||
if (routeRelationFrame(peer, frame)) return
|
||||
})
|
||||
|
||||
// chat-server 收到的所有 ws 帧 (来自其他设备的入站)
|
||||
@@ -233,6 +296,7 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
ctx!.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||
return
|
||||
}
|
||||
if (routeRelationFrame(peer, frame)) return
|
||||
})
|
||||
|
||||
c.chatServer.on('connect', (_ws, peer) => {
|
||||
@@ -242,6 +306,8 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
|
||||
// 对端上线 -> 触发离线消息 flush
|
||||
flushPendingFor(peer.deviceId)
|
||||
// 对端上线 -> 立刻试一次同步 (不用等下一个轮询周期)
|
||||
c.autoSync?.trigger()
|
||||
})
|
||||
|
||||
// chat-client WS 状态不广播给渲染层, 渲染层只看 discovery 的 online/offline
|
||||
@@ -298,6 +364,18 @@ export function bindNetworkContext(c: NetCtx) {
|
||||
broadcastToRenderer('drive:inbound', { name: info.name, size: info.size, peerId: peer?.deviceId, peerName: who })
|
||||
})
|
||||
|
||||
// 对端往我的网盘上传了文件 (文件夹同步的上行) -> 本机提示 + 审计
|
||||
c.fileServer.on('driveUploaded', (info: { relPath: string; size: number; fromAddr: string }) => {
|
||||
const peer = ctx?.discovery.getPeers().find(p => p.address === info.fromAddr)
|
||||
const who = peer ? peer.name : info.fromAddr || '某台设备'
|
||||
recordAudit({
|
||||
action: 'drive.upload', source: peer?.deviceId || info.fromAddr,
|
||||
result: 'ok',
|
||||
payload: { size: info.size, path: info.relPath },
|
||||
})
|
||||
broadcastToRenderer('drive:inbound', { name: basename(info.relPath), size: info.size, peerId: peer?.deviceId, peerName: who })
|
||||
})
|
||||
|
||||
// 对端发起撤回 — 本地默默删 (含磁盘文件), 不弹框不提示
|
||||
c.chatServer.on('recall', (from, messageId) => {
|
||||
const row = getMessage(messageId)
|
||||
@@ -1034,23 +1112,13 @@ export function registerIpc() {
|
||||
// ===== 网盘 (浏览对方共享目录 + 选文件下载) =====
|
||||
|
||||
// 找 peer: 优先用 discovery 的在线列表, 退回 DB 里的历史记录
|
||||
function findPeer(peerId: string): DeviceInfo | null {
|
||||
const p = ctx?.discovery.getPeers().find(x => x.deviceId === peerId)
|
||||
if (p) return p as DeviceInfo
|
||||
const d = listDevices().find(x => x.device_id === peerId) as any
|
||||
if (!d) return null
|
||||
return {
|
||||
deviceId: d.device_id, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||
version: d.version, appVersion: d.app_version, address: d.last_ip || '127.0.0.1',
|
||||
chatPort: DEFAULT_PORTS.chat, filePort: DEFAULT_PORTS.file, ts: d.last_seen,
|
||||
driveToken: d.drive_token || undefined,
|
||||
} as DeviceInfo
|
||||
}
|
||||
|
||||
|
||||
ipcMain.handle('drive:list', async (_e, args: { peerId: string; path?: string }) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
|
||||
return listDrive(peer, args.path || '')
|
||||
})
|
||||
|
||||
@@ -1058,6 +1126,7 @@ export function registerIpc() {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
|
||||
return getDriveRoot(peer)
|
||||
})
|
||||
|
||||
@@ -1127,6 +1196,150 @@ export function registerIpc() {
|
||||
return r.filePaths[0]
|
||||
})
|
||||
|
||||
// ===== 文件夹同步 (本地目录 <-> 对方网盘目录) =====
|
||||
|
||||
ipcMain.handle('sync:pickLocalDir', async () => {
|
||||
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||
if (!w) return null
|
||||
const cur = getSettings().syncLocalDir
|
||||
const r = await dialog.showOpenDialog(w, {
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: cur && existsSync(cur) ? cur : undefined,
|
||||
})
|
||||
if (r.canceled || !r.filePaths[0]) return null
|
||||
return r.filePaths[0]
|
||||
})
|
||||
|
||||
// 扫描两边, 生成变更列表 (只读磁盘; 关系表会被更新, 但那只是记录"我这边变了")
|
||||
ipcMain.handle('sync:scan', async (_e, args: {
|
||||
peerId: string; localDir?: string; remotePath?: string; relationId?: string
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
|
||||
// 有关系就用关系签发的 per-peer 令牌; 没有关系退回旧的全局 token (老版本对端)
|
||||
let token = ''
|
||||
if (rel && rel.state === 'active') {
|
||||
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系' }
|
||||
token = rel.peerToken
|
||||
} else {
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系' }
|
||||
token = peer.driveToken
|
||||
}
|
||||
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
|
||||
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
|
||||
if (!localDir) return { ok: false, reason: '还没选本地目录' }
|
||||
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
|
||||
const r = await scanSync({
|
||||
peer, localDir, remotePath, token,
|
||||
relationId: rel?.relationId,
|
||||
store: ctx.manifestStore,
|
||||
selfDeviceId: ctx.self.deviceId,
|
||||
})
|
||||
if (r.ok && !rel) {
|
||||
updateSettings({ syncLocalDir: r.localDir, syncRemotePath: r.remotePath })
|
||||
}
|
||||
return r
|
||||
})
|
||||
|
||||
// 执行用户勾选的同步项
|
||||
ipcMain.handle('sync:apply', async (_e, args: {
|
||||
peerId: string; localDir?: string; remotePath?: string; relationId?: string;
|
||||
items: SyncApplyItem[]
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx', results: [], done: 0, failed: 0 }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备', results: [], done: 0, failed: 0 }
|
||||
const rel = args.relationId ? ctx.relations.getRelation(args.relationId) : undefined
|
||||
let token = ''
|
||||
if (rel && rel.state === 'active') {
|
||||
if (!rel.peerToken) return { ok: false, reason: '关系令牌缺失, 请重新建立关系', results: [], done: 0, failed: 0 }
|
||||
token = rel.peerToken
|
||||
} else {
|
||||
if (!peer.driveToken) return { ok: false, reason: '还没和对方建立关系', results: [], done: 0, failed: 0 }
|
||||
token = peer.driveToken
|
||||
}
|
||||
// 有关系时用**实际同步根** (顶层目录 + 按关系隔离的子目录)
|
||||
const localDir = args.localDir || (rel ? ctx.relationService.localRootOf(rel) : getSettings().syncLocalDir)
|
||||
if (!localDir) return { ok: false, reason: '还没选本地目录', results: [], done: 0, failed: 0 }
|
||||
const remotePath = args.remotePath ?? (rel ? ctx.relationService.remotePathOf(rel) : (getSettings().syncRemotePath ?? ''))
|
||||
const items = (args.items || []).filter(i => i && i.relPath)
|
||||
const r = await applySyncItems({
|
||||
peer, localDir, remotePath, token,
|
||||
relationId: rel?.relationId,
|
||||
store: ctx.manifestStore,
|
||||
selfDeviceId: ctx.self.deviceId,
|
||||
items,
|
||||
onProgress: (relPath, sent, total) => {
|
||||
broadcastToRenderer('sync:progress', { peerId: args.peerId, relPath, sent, total })
|
||||
},
|
||||
})
|
||||
recordAudit({
|
||||
action: 'sync.apply', target: args.peerId,
|
||||
result: r.failed === 0 ? 'ok' : 'error',
|
||||
payload: { count: items.length, done: r.done, failed: r.failed, localDir, remotePath },
|
||||
})
|
||||
return r
|
||||
})
|
||||
|
||||
// ===== 关系 (同步盘) =====
|
||||
//
|
||||
// 关系 = 两个设备之间的持久绑定 + 分级授权。
|
||||
// 建立方式跟远程终端一样: **对方直接弹窗**, 不用邀请码。
|
||||
|
||||
ipcMain.handle('relation:list', () => (ctx?.relations.listRelations() || []).map(publicRelation))
|
||||
|
||||
// 发起建立关系 -> 对方那边弹窗
|
||||
ipcMain.handle('relation:request', (_e, args: {
|
||||
peerId: string; syncLocalDir: string; syncRemotePath?: string; grants: Grant[]
|
||||
}) => {
|
||||
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||
const peer = findPeer(args.peerId)
|
||||
if (!peer) return { ok: false, reason: '找不到该设备' }
|
||||
return ctx.relationService.requestRelation({
|
||||
peerId: peer.deviceId,
|
||||
peerName: peer.name,
|
||||
peerAddress: peer.address,
|
||||
peerChatPort: peer.chatPort,
|
||||
peerFilePort: peer.filePort,
|
||||
syncLocalDir: args.syncLocalDir || '',
|
||||
syncRemotePath: args.syncRemotePath || '',
|
||||
grants: (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync'),
|
||||
})
|
||||
})
|
||||
|
||||
// 改我开放给对方的能力
|
||||
ipcMain.handle('relation:setGrants', (_e, args: { relationId: string; grants: Grant[] }) => {
|
||||
const grants = (args.grants || []).filter((g): g is Grant => g === 'drive' || g === 'sync')
|
||||
const r = ctx?.relations.setMyGrants(args.relationId, grants)
|
||||
return { ok: !!r, relation: publicRelation(r) }
|
||||
})
|
||||
|
||||
// 改同步配置 (本地目录 / 对方网盘子目录 / 自动同步开关)
|
||||
ipcMain.handle('relation:setSync', (_e, args: {
|
||||
relationId: string; syncLocalDir?: string; syncRemotePath?: string; syncEnabled?: boolean
|
||||
}) => {
|
||||
const patch: any = {}
|
||||
if (args.syncLocalDir !== undefined) patch.syncLocalDir = args.syncLocalDir
|
||||
if (args.syncRemotePath !== undefined) patch.syncRemotePath = args.syncRemotePath
|
||||
if (args.syncEnabled !== undefined) patch.syncEnabled = !!args.syncEnabled
|
||||
const r = ctx?.relations.setSyncConfig(args.relationId, patch)
|
||||
if (r?.syncEnabled) ctx?.autoSync?.trigger(r.relationId)
|
||||
return { ok: !!r, relation: publicRelation(r) }
|
||||
})
|
||||
|
||||
// 吊销关系 (令牌立刻失效)
|
||||
ipcMain.handle('relation:revoke', (_e, args: { relationId: string }) =>
|
||||
ctx?.relationService.revokeRelation(args.relationId) || { ok: false, reason: 'no ctx' })
|
||||
|
||||
// 彻底删除关系记录
|
||||
ipcMain.handle('relation:delete', (_e, args: { relationId: string }) => {
|
||||
const ok = ctx?.relations.deleteRelation(args.relationId) || false
|
||||
try { ctx?.manifestStore.deleteManifest(args.relationId) } catch {}
|
||||
return { ok }
|
||||
})
|
||||
|
||||
// ---- 应用控制 ----
|
||||
ipcMain.handle('app:hide', () => {
|
||||
const w = BrowserWindow.getAllWindows()[0]
|
||||
@@ -1284,8 +1497,16 @@ export function registerIpc() {
|
||||
|
||||
ipcMain.handle('remote:approval:list', () => listPendingApprovals())
|
||||
|
||||
ipcMain.handle('remote:approval:reply', (_e, args: { requestId: string; ok: boolean; remember?: boolean }) => {
|
||||
return replyApproval(args.requestId, args.ok, args.remember)
|
||||
ipcMain.handle('remote:approval:reply', (_e, args: {
|
||||
requestId: string; ok: boolean; remember?: boolean
|
||||
grants?: string[]; syncLocalDir?: string; syncRemotePath?: string
|
||||
}) => {
|
||||
return replyApproval(args.requestId, args.ok, {
|
||||
remember: args.remember,
|
||||
grants: args.grants,
|
||||
syncLocalDir: args.syncLocalDir,
|
||||
syncRemotePath: args.syncRemotePath,
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('audit:list', (_e, args: { limit?: number; since?: number; sessionId?: string } = {}) => {
|
||||
|
||||
Reference in New Issue
Block a user