@steve02081504/fount-p2p 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/core/bytes_codec.mjs +51 -0
- package/core/canonical_json.mjs +21 -0
- package/core/composite_key.mjs +91 -0
- package/core/constants.mjs +53 -0
- package/core/entity_id.mjs +53 -0
- package/core/entity_id_parse.mjs +40 -0
- package/core/evfs_logical_path.mjs +15 -0
- package/core/hexIds.mjs +73 -0
- package/core/hlc.mjs +114 -0
- package/core/random_id.mjs +9 -0
- package/crypto/channel.mjs +101 -0
- package/crypto/checkpoint_sign.mjs +67 -0
- package/crypto/crypto.mjs +158 -0
- package/crypto/key.mjs +431 -0
- package/dag/canonicalize_row.mjs +65 -0
- package/dag/event_query.mjs +15 -0
- package/dag/index.mjs +335 -0
- package/dag/storage.mjs +333 -0
- package/dag/strip_extensions.mjs +18 -0
- package/dag/tunables.json +6 -0
- package/discovery/bt.mjs +260 -0
- package/discovery/index.mjs +134 -0
- package/discovery/mdns.mjs +138 -0
- package/discovery/nostr.mjs +273 -0
- package/entity/files/acl.mjs +38 -0
- package/entity/files/evfs.mjs +189 -0
- package/entity/files/evfs_ref.mjs +37 -0
- package/entity/files/manifest_acl_registry.mjs +93 -0
- package/entity/files/replica_host_cache.mjs +46 -0
- package/entity/files/url.mjs +19 -0
- package/entity/hosting_registry.mjs +47 -0
- package/entity/localized_core.mjs +141 -0
- package/entity/logical_entity.mjs +25 -0
- package/entity/logical_entity_id_registry.mjs +40 -0
- package/entity/node_hash.mjs +15 -0
- package/entity/presentation_registry.mjs +44 -0
- package/entity/profile.mjs +256 -0
- package/entity/replica.mjs +20 -0
- package/entity/session_snapshot_registry.mjs +38 -0
- package/federation/chunk_fetch_pending.mjs +128 -0
- package/federation/chunk_fetch_scheduler.mjs +97 -0
- package/federation/dag_order_cache.mjs +124 -0
- package/federation/dedupe_slot.mjs +33 -0
- package/federation/message_rate_limit.mjs +23 -0
- package/federation/operator_key_chain.mjs +200 -0
- package/federation/topo_order_memo.mjs +44 -0
- package/federation/volatile_streams.mjs +52 -0
- package/federation/want_ids.mjs +158 -0
- package/files/assemble.mjs +220 -0
- package/files/assemble_stream.mjs +129 -0
- package/files/chunk_fetch.mjs +113 -0
- package/files/chunk_fetch_verify.mjs +23 -0
- package/files/chunk_provider_registry.mjs +70 -0
- package/files/chunk_responder.mjs +79 -0
- package/files/chunk_store.mjs +87 -0
- package/files/manifest.mjs +99 -0
- package/files/transfer_key.mjs +87 -0
- package/files/transfer_key_registry.mjs +108 -0
- package/governance/branch.mjs +230 -0
- package/governance/join_pow.mjs +165 -0
- package/governance/owner_succession_ballot.mjs +57 -0
- package/governance/tunables.json +7 -0
- package/index.mjs +39 -0
- package/link/channel_mux.mjs +222 -0
- package/link/frame.mjs +246 -0
- package/link/handshake.mjs +175 -0
- package/link/link.mjs +617 -0
- package/link/rtc.mjs +109 -0
- package/link/sdp_fingerprint.mjs +25 -0
- package/mailbox/consumer_registry.mjs +71 -0
- package/mailbox/deliver_or_store.mjs +190 -0
- package/mailbox/importance.mjs +72 -0
- package/mailbox/parse.mjs +73 -0
- package/mailbox/prune.mjs +106 -0
- package/mailbox/rate.mjs +86 -0
- package/mailbox/settings.mjs +53 -0
- package/mailbox/store.mjs +256 -0
- package/mailbox/tunables.json +12 -0
- package/mailbox/wire.mjs +40 -0
- package/node/denylist.mjs +309 -0
- package/node/entity_store.mjs +214 -0
- package/node/identity.mjs +99 -0
- package/node/instance.mjs +115 -0
- package/node/network.mjs +306 -0
- package/node/personal_block.mjs +286 -0
- package/node/reputation_store.mjs +326 -0
- package/node/retention_policy.mjs +82 -0
- package/node/signaling_config.mjs +32 -0
- package/node/storage.mjs +30 -0
- package/node/storage_plugins.mjs +71 -0
- package/node/user_paths.mjs +10 -0
- package/overlay/index.mjs +191 -0
- package/package.json +73 -0
- package/permissions/bitmask.mjs +35 -0
- package/permissions/evaluator.mjs +90 -0
- package/permissions/index.mjs +12 -0
- package/permissions/layered.mjs +39 -0
- package/registries/action.mjs +94 -0
- package/registries/event_type.mjs +75 -0
- package/registries/inbound.mjs +90 -0
- package/registries/p2p_viewer.mjs +35 -0
- package/registries/part_path.mjs +35 -0
- package/registries/room_provider.mjs +47 -0
- package/reputation/engine.mjs +458 -0
- package/reputation/math.mjs +73 -0
- package/reputation/pick_score.mjs +11 -0
- package/reputation/relay_dedupe.mjs +22 -0
- package/reputation/tunables.json +36 -0
- package/rooms/scoped_link.mjs +185 -0
- package/schemas/discovery.mjs +110 -0
- package/schemas/federation_pull.mjs +102 -0
- package/schemas/mailbox.mjs +37 -0
- package/schemas/remote_event.mjs +17 -0
- package/timeline/append_core.mjs +54 -0
- package/timeline/materialize_runner.mjs +26 -0
- package/timeline/prune.mjs +39 -0
- package/timeline/retention.mjs +54 -0
- package/timeline/retention_runner.mjs +35 -0
- package/timeline/verify_remote.mjs +25 -0
- package/transport/group_link_set.mjs +324 -0
- package/transport/ice_servers.mjs +72 -0
- package/transport/link_registry.mjs +749 -0
- package/transport/peer_identity_maps.mjs +94 -0
- package/transport/peer_pool.mjs +355 -0
- package/transport/remote_user_room.mjs +109 -0
- package/transport/room_scopes.mjs +2 -0
- package/transport/room_wire_action.mjs +19 -0
- package/transport/rtc_connection_budget.mjs +180 -0
- package/transport/rtc_mdns_filter.mjs +118 -0
- package/transport/stale_peer_log.mjs +50 -0
- package/transport/user_room.mjs +263 -0
- package/trust_graph/build.mjs +99 -0
- package/trust_graph/cache.mjs +44 -0
- package/trust_graph/engine.mjs +184 -0
- package/trust_graph/registry.mjs +38 -0
- package/trust_graph/resolve.mjs +105 -0
- package/trust_graph/send.mjs +88 -0
- package/trust_graph/tunables.json +13 -0
- package/utils/async_mutex.mjs +69 -0
- package/utils/debug_log.mjs +16 -0
- package/utils/duration.mjs +28 -0
- package/utils/json_io.mjs +57 -0
- package/utils/lru.mjs +22 -0
- package/utils/map_pool.mjs +27 -0
- package/wire/group_part.mjs +43 -0
- package/wire/ingress.mjs +58 -0
- package/wire/part_common.mjs +55 -0
- package/wire/part_fanout.mjs +71 -0
- package/wire/part_ingress.mjs +172 -0
- package/wire/part_invoke.mjs +81 -0
- package/wire/rate_bucket.mjs +49 -0
- package/wire/volatile_signature.mjs +76 -0
- package/wire/wait.mjs +127 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { createFsEntityStore } from './entity_store.mjs'
|
|
4
|
+
import { defaultSignalingRuntimeConfig, resolveSignalingRuntimeConfig } from './signaling_config.mjs'
|
|
5
|
+
|
|
6
|
+
/** @typedef {{ warn?: (...args: unknown[]) => void, error?: (...args: unknown[]) => void }} NodeLogger */
|
|
7
|
+
|
|
8
|
+
/** @typedef {import('./signaling_config.mjs').SignalingRuntimeConfig} SignalingRuntimeConfig */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{
|
|
12
|
+
* nodeDir: string
|
|
13
|
+
* entityStore: import('./entity_store.mjs').EntityStore
|
|
14
|
+
* logger: NodeLogger
|
|
15
|
+
* signaling: SignalingRuntimeConfig
|
|
16
|
+
* }} NodeRuntime
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {NodeRuntime | null} */
|
|
20
|
+
let runtime = null
|
|
21
|
+
|
|
22
|
+
/** @type {Set<(event: string, payload?: unknown) => void>} */
|
|
23
|
+
const changeListeners = new Set()
|
|
24
|
+
|
|
25
|
+
/** @type {NodeLogger} */
|
|
26
|
+
const noopLogger = {
|
|
27
|
+
/**
|
|
28
|
+
*
|
|
29
|
+
*/
|
|
30
|
+
warn() { },
|
|
31
|
+
/**
|
|
32
|
+
*
|
|
33
|
+
*/
|
|
34
|
+
error() { },
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {{ nodeDir: string, entityStore?: import('./entity_store.mjs').EntityStore, logger?: NodeLogger, signaling?: SignalingRuntimeConfig }} options 节点目录与可选注入
|
|
39
|
+
* @returns {NodeRuntime} 单节点运行时句柄
|
|
40
|
+
*/
|
|
41
|
+
export function initNode(options) {
|
|
42
|
+
const nodeDir = path.resolve(String(options.nodeDir || '').trim())
|
|
43
|
+
if (!nodeDir) throw new Error('p2p: initNode requires nodeDir')
|
|
44
|
+
const entityStore = options.entityStore ?? createFsEntityStore(path.join(nodeDir, 'entities'))
|
|
45
|
+
runtime = {
|
|
46
|
+
nodeDir,
|
|
47
|
+
entityStore,
|
|
48
|
+
logger: options.logger ?? console,
|
|
49
|
+
signaling: options.signaling ?? resolveSignalingRuntimeConfig(),
|
|
50
|
+
}
|
|
51
|
+
return runtime
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @returns {NodeRuntime} 已初始化的节点运行时
|
|
56
|
+
*/
|
|
57
|
+
export function getNode() {
|
|
58
|
+
if (!runtime) throw new Error('p2p: node not initialized — call initNode() first')
|
|
59
|
+
return runtime
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @returns {boolean} 是否已调用 initNode
|
|
64
|
+
*/
|
|
65
|
+
export function isNodeInitialized() {
|
|
66
|
+
return runtime != null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @returns {SignalingRuntimeConfig} 信令传输策略(未 init 时返回生产默认,不读测试 env)
|
|
71
|
+
*/
|
|
72
|
+
export function getSignalingRuntimeConfig() {
|
|
73
|
+
return runtime?.signaling ?? defaultSignalingRuntimeConfig()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @returns {string} 节点数据目录绝对路径
|
|
78
|
+
*/
|
|
79
|
+
export function getNodeDir() {
|
|
80
|
+
return getNode().nodeDir
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @returns {import('./entity_store.mjs').EntityStore} 注入的 EntityStore
|
|
85
|
+
*/
|
|
86
|
+
export function getEntityStore() {
|
|
87
|
+
return getNode().entityStore
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @returns {NodeLogger} 节点日志器(未 init 时为 no-op)
|
|
92
|
+
*/
|
|
93
|
+
export function getNodeLogger() {
|
|
94
|
+
return runtime?.logger ?? noopLogger
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} event 事件名
|
|
99
|
+
* @param {unknown} [payload] 载荷
|
|
100
|
+
* @returns {void}
|
|
101
|
+
*/
|
|
102
|
+
export function emitNodeChange(event, payload) {
|
|
103
|
+
for (const listener of changeListeners)
|
|
104
|
+
try { listener(event, payload) }
|
|
105
|
+
catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {(event: string, payload?: unknown) => void} listener 回调
|
|
110
|
+
* @returns {() => void} 取消订阅
|
|
111
|
+
*/
|
|
112
|
+
export function onNodeChange(listener) {
|
|
113
|
+
changeListeners.add(listener)
|
|
114
|
+
return () => changeListeners.delete(listener)
|
|
115
|
+
}
|
package/node/network.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { isEntityHash128 } from '../core/entity_id.mjs'
|
|
2
|
+
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
3
|
+
import { invalidateTrustGraphCache } from '../trust_graph/cache.mjs'
|
|
4
|
+
|
|
5
|
+
import { loadDenylist } from './denylist.mjs'
|
|
6
|
+
import { getNodeDir, isNodeInitialized } from './instance.mjs'
|
|
7
|
+
import { readNodeJsonSync, writeNodeJsonSync } from './storage.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 联邦术语(Wave 6):
|
|
12
|
+
* - **block**:Social 对外联邦公开拉黑(personal_block / timeline block 事件)
|
|
13
|
+
* - **hide**:纯本地隐藏(personal_hide,不联邦)
|
|
14
|
+
* - **deny**:节点连接拒绝(denylist.json,scope node/subject/entity)
|
|
15
|
+
* - **ban**:群成员治理(member_ban DAG + bannedMembers 物化态)
|
|
16
|
+
*
|
|
17
|
+
* @typedef {{
|
|
18
|
+
* trustedPeers: string[]
|
|
19
|
+
* explorePeers: string[]
|
|
20
|
+
* blockedPeers: string[]
|
|
21
|
+
* deniedNodes: string[]
|
|
22
|
+
* deniedSubjects: string[]
|
|
23
|
+
* deniedEntities: string[]
|
|
24
|
+
* lastRosterAt: number
|
|
25
|
+
* hintSources?: Map<string, string>
|
|
26
|
+
* }} PeerPoolView
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const DATA_NAME = 'network'
|
|
30
|
+
const MAX_EXPLORE = 500
|
|
31
|
+
const MAX_HINTS = 256
|
|
32
|
+
const MAX_HINTS_PER_SOURCE = 12
|
|
33
|
+
const DEFAULT_EXPLORE_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
34
|
+
|
|
35
|
+
/** @type {ReturnType<typeof normalizeNetwork> | null} network.json 内存缓存(唯一写路径 saveNetwork 负责刷新) */
|
|
36
|
+
let networkCache = null
|
|
37
|
+
/** @type {string | null} 缓存所属 nodeDir(切换节点/未初始化时失效) */
|
|
38
|
+
let networkCacheNodeDir = null
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {{ nodeHash: string, source: string, kind: string, weight?: number, expiresAt: number, groupId?: string }} NetworkHint
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} raw 磁盘 JSON
|
|
46
|
+
* @returns {{ trustedPeers: string[], explorePeers: string[], hints: NetworkHint[], lastRosterAt: number }} 规范化网络表
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeNetwork(raw) {
|
|
49
|
+
const file = raw ?? {}
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} key 字段名
|
|
52
|
+
* @returns {string[]} 去重 nodeHash 列表
|
|
53
|
+
*/
|
|
54
|
+
const pickIds = key => [...new Set(
|
|
55
|
+
(Array.isArray(file[key]) ? file[key] : [])
|
|
56
|
+
.map(id => normalizeHex64(id) || String(id).trim())
|
|
57
|
+
.filter(id => isHex64(id)),
|
|
58
|
+
)]
|
|
59
|
+
const hints = (Array.isArray(file.hints) ? file.hints : [])
|
|
60
|
+
.map(hint => ({
|
|
61
|
+
nodeHash: normalizeHex64(hint?.nodeHash) || '',
|
|
62
|
+
source: String(hint?.source || '').trim(),
|
|
63
|
+
kind: String(hint?.kind || '').trim(),
|
|
64
|
+
weight: Number.isFinite(Number(hint?.weight)) ? Number(hint.weight) : 0.1,
|
|
65
|
+
expiresAt: Number(hint?.expiresAt) || 0,
|
|
66
|
+
...hint?.groupId ? { groupId: String(hint.groupId).trim() } : {},
|
|
67
|
+
}))
|
|
68
|
+
.filter(hint => isHex64(hint.nodeHash))
|
|
69
|
+
return {
|
|
70
|
+
trustedPeers: pickIds('trustedPeers'),
|
|
71
|
+
explorePeers: pickIds('explorePeers'),
|
|
72
|
+
hints,
|
|
73
|
+
lastRosterAt: Number.isFinite(file.lastRosterAt) ? Number(file.lastRosterAt) : 0,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 节点级 P2P 网络(内存缓存;热路径 loadPeerPoolView 每首见事件都会调用,避免每次同步读盘)。
|
|
79
|
+
* @returns {{ trustedPeers: string[], explorePeers: string[], hints: NetworkHint[], lastRosterAt: number }} 节点级 P2P 网络
|
|
80
|
+
*/
|
|
81
|
+
export function loadNetwork() {
|
|
82
|
+
const nodeDir = isNodeInitialized() ? getNodeDir() : ''
|
|
83
|
+
if (networkCache && networkCacheNodeDir === nodeDir) return networkCache
|
|
84
|
+
networkCacheNodeDir = nodeDir
|
|
85
|
+
networkCache = normalizeNetwork(readNodeJsonSync(DATA_NAME))
|
|
86
|
+
return networkCache
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 限制同一 source 的 hint 数量,防止 PEX/单源灌满 explore。
|
|
91
|
+
* @param {NetworkHint[]} hints hint 列表
|
|
92
|
+
* @param {number} [maxPerSource=MAX_HINTS_PER_SOURCE] 每源上限
|
|
93
|
+
* @returns {NetworkHint[]} 裁剪后列表(保留较新条目)
|
|
94
|
+
*/
|
|
95
|
+
export function capHintsBySource(hints, maxPerSource = MAX_HINTS_PER_SOURCE) {
|
|
96
|
+
/** @type {Map<string, number>} */
|
|
97
|
+
const counts = new Map()
|
|
98
|
+
const out = []
|
|
99
|
+
for (const hint of [...hints].reverse()) {
|
|
100
|
+
const src = String(hint.source || 'unknown')
|
|
101
|
+
const n = counts.get(src) ?? 0
|
|
102
|
+
if (n >= maxPerSource) continue
|
|
103
|
+
counts.set(src, n + 1)
|
|
104
|
+
out.unshift(hint)
|
|
105
|
+
}
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {ReturnType<typeof normalizeNetwork>} data 网络表
|
|
111
|
+
* @returns {void}
|
|
112
|
+
*/
|
|
113
|
+
export function saveNetwork(data) {
|
|
114
|
+
const clean = normalizeNetwork(data)
|
|
115
|
+
const now = Date.now()
|
|
116
|
+
clean.hints = capHintsBySource(clean.hints.filter(h => !h.expiresAt || h.expiresAt > now)).slice(-MAX_HINTS)
|
|
117
|
+
clean.explorePeers = clean.explorePeers.slice(-MAX_EXPLORE)
|
|
118
|
+
writeNodeJsonSync(DATA_NAME, clean)
|
|
119
|
+
networkCache = clean
|
|
120
|
+
networkCacheNodeDir = isNodeInitialized() ? getNodeDir() : ''
|
|
121
|
+
invalidateTrustGraphCache()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {string} nodeHash 64 位十六进制
|
|
126
|
+
* @param {'trusted' | 'explore'} tier 池档位
|
|
127
|
+
* @returns {void}
|
|
128
|
+
*/
|
|
129
|
+
export function addNetworkPeer(nodeHash, tier = 'explore') {
|
|
130
|
+
const id = normalizeHex64(nodeHash)
|
|
131
|
+
if (!isHex64(id)) return
|
|
132
|
+
const net = loadNetwork()
|
|
133
|
+
const list = tier === 'trusted' ? net.trustedPeers : net.explorePeers
|
|
134
|
+
if (!list.includes(id)) list.push(id)
|
|
135
|
+
saveNetwork(net)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {{ nodeHash: string, source: string, kind: string, weight?: number, expiresAt?: number, ttlMs?: number, groupId?: string }} hint 扩边 hint
|
|
140
|
+
* @returns {void}
|
|
141
|
+
*/
|
|
142
|
+
export function applyNetworkHint(hint) {
|
|
143
|
+
const nodeHash = normalizeHex64(hint?.nodeHash)
|
|
144
|
+
if (!isHex64(nodeHash)) return
|
|
145
|
+
const net = loadNetwork()
|
|
146
|
+
const now = Date.now()
|
|
147
|
+
const ttlMs = Number.isFinite(hint.ttlMs) ? hint.ttlMs : DEFAULT_EXPLORE_TTL_MS
|
|
148
|
+
const expiresAt = Number.isFinite(hint.expiresAt) ? hint.expiresAt : now + ttlMs
|
|
149
|
+
const source = String(hint.source || 'unknown')
|
|
150
|
+
const priorSources = new Set(net.hints.filter(h => h.nodeHash === nodeHash).map(h => String(h.source || 'unknown')))
|
|
151
|
+
priorSources.add(source)
|
|
152
|
+
const multiSourceBoost = priorSources.size >= 2 ? 1.2 : 1
|
|
153
|
+
const baseWeight = Number.isFinite(hint.weight) ? hint.weight : 0.1
|
|
154
|
+
if (!net.explorePeers.includes(nodeHash))
|
|
155
|
+
net.explorePeers.push(nodeHash)
|
|
156
|
+
net.hints = net.hints.filter(h => h.nodeHash !== nodeHash || h.kind !== hint.kind)
|
|
157
|
+
net.hints.push({
|
|
158
|
+
nodeHash,
|
|
159
|
+
source,
|
|
160
|
+
kind: String(hint.kind || 'hint'),
|
|
161
|
+
weight: baseWeight * multiSourceBoost,
|
|
162
|
+
expiresAt,
|
|
163
|
+
...hint.groupId ? { groupId: String(hint.groupId).trim() } : {},
|
|
164
|
+
})
|
|
165
|
+
saveNetwork(net)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {{ remoteNodeHash?: string }[]} roster Trystero roster
|
|
170
|
+
* @param {string} [groupId] 来源群
|
|
171
|
+
* @param {string} [source='roster'] hint 来源标签
|
|
172
|
+
* @returns {void}
|
|
173
|
+
*/
|
|
174
|
+
export function recordExplorePeersFromRoster(roster, groupId = '', source = 'roster') {
|
|
175
|
+
if (!roster?.length) return
|
|
176
|
+
const net = loadNetwork()
|
|
177
|
+
const now = Date.now()
|
|
178
|
+
for (const peer of roster) {
|
|
179
|
+
const nodeHash = normalizeHex64(peer?.remoteNodeHash)
|
|
180
|
+
if (!isHex64(nodeHash)) continue
|
|
181
|
+
if (!net.explorePeers.includes(nodeHash))
|
|
182
|
+
net.explorePeers.push(nodeHash)
|
|
183
|
+
net.hints.push({
|
|
184
|
+
nodeHash,
|
|
185
|
+
source: String(source || 'roster'),
|
|
186
|
+
kind: 'roster',
|
|
187
|
+
weight: 0.1,
|
|
188
|
+
expiresAt: now + 24 * 60 * 60 * 1000,
|
|
189
|
+
...groupId ? { groupId: String(groupId).trim() } : {},
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
net.hints = capHintsBySource(net.hints).slice(-MAX_HINTS)
|
|
193
|
+
net.lastRosterAt = now
|
|
194
|
+
saveNetwork(net)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* 疑似分区/eclipse 后:用 trusted 锚点加宽 explore,便于恢复联邦可达。
|
|
199
|
+
* @returns {void}
|
|
200
|
+
*/
|
|
201
|
+
export function widenExploreFromTrustedAnchors() {
|
|
202
|
+
if (!isNodeInitialized()) return
|
|
203
|
+
const net = loadNetwork()
|
|
204
|
+
const now = Date.now()
|
|
205
|
+
for (const raw of net.trustedPeers.slice(0, 12)) {
|
|
206
|
+
const nodeHash = normalizeHex64(raw)
|
|
207
|
+
if (!isHex64(nodeHash)) continue
|
|
208
|
+
if (!net.explorePeers.includes(nodeHash))
|
|
209
|
+
net.explorePeers.push(nodeHash)
|
|
210
|
+
net.hints.push({
|
|
211
|
+
nodeHash,
|
|
212
|
+
source: 'recovery:trusted',
|
|
213
|
+
kind: 'partition_recovery',
|
|
214
|
+
weight: 0.35,
|
|
215
|
+
expiresAt: now + 6 * 60 * 60 * 1000,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
net.hints = capHintsBySource(net.hints).slice(-MAX_HINTS)
|
|
219
|
+
net.explorePeers = net.explorePeers.slice(-MAX_EXPLORE)
|
|
220
|
+
saveNetwork(net)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 增量合并 trusted/explore 池(不覆盖已有全局池)。
|
|
225
|
+
* @param {{ trustedPeers?: string[], explorePeers?: string[] }} patch 增量
|
|
226
|
+
* @returns {void}
|
|
227
|
+
*/
|
|
228
|
+
export function mergeNetworkPeerPools(patch = {}) {
|
|
229
|
+
const net = loadNetwork()
|
|
230
|
+
for (const raw of patch.trustedPeers || []) {
|
|
231
|
+
const id = normalizeHex64(raw)
|
|
232
|
+
if (isHex64(id) && !net.trustedPeers.includes(id)) net.trustedPeers.push(id)
|
|
233
|
+
}
|
|
234
|
+
for (const raw of patch.explorePeers || []) {
|
|
235
|
+
const id = normalizeHex64(raw)
|
|
236
|
+
if (isHex64(id) && !net.explorePeers.includes(id)) net.explorePeers.push(id)
|
|
237
|
+
}
|
|
238
|
+
net.lastRosterAt = Date.now()
|
|
239
|
+
saveNetwork(net)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {string[]} nodeHashes trusted 候选
|
|
244
|
+
* @returns {void}
|
|
245
|
+
*/
|
|
246
|
+
export function mergeTrustedPeers(nodeHashes) {
|
|
247
|
+
mergeNetworkPeerPools({ trustedPeers: nodeHashes })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {string} groupId 群 scope
|
|
252
|
+
* @param {'node' | 'subject' | 'entity'} scope denylist 作用域
|
|
253
|
+
* @returns {string[]} 规范化 value 列表
|
|
254
|
+
*/
|
|
255
|
+
function denyValuesForScope(groupId, scope) {
|
|
256
|
+
const gid = String(groupId || '').trim()
|
|
257
|
+
return [...new Set(
|
|
258
|
+
loadDenylist().blocked
|
|
259
|
+
.filter(entry => entry.scope === scope)
|
|
260
|
+
.filter(entry => scope === 'entity' || !entry.groupId || !gid || entry.groupId === gid)
|
|
261
|
+
.map(entry => entry.value),
|
|
262
|
+
)]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 节点级 network + 群 scope denylist 视图(供 peer_pool 选取)。
|
|
267
|
+
* deniedNodes 用于连接池过滤;deniedSubjects/deniedEntities 供入站校验。
|
|
268
|
+
* @param {string} [groupId] 群 scope;空则仅全局 deny
|
|
269
|
+
* @returns {PeerPoolView} 连接池视图
|
|
270
|
+
*/
|
|
271
|
+
export function loadPeerPoolView(groupId = '') {
|
|
272
|
+
const net = loadNetwork()
|
|
273
|
+
const deniedNodes = denyValuesForScope(groupId, 'node')
|
|
274
|
+
const deniedSubjects = denyValuesForScope(groupId, 'subject')
|
|
275
|
+
const deniedEntities = denyValuesForScope(groupId, 'entity')
|
|
276
|
+
/** @type {Map<string, string>} */
|
|
277
|
+
const hintSources = new Map()
|
|
278
|
+
for (const hint of net.hints)
|
|
279
|
+
if (!hintSources.has(hint.nodeHash))
|
|
280
|
+
hintSources.set(hint.nodeHash, hint.source)
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
trustedPeers: net.trustedPeers,
|
|
284
|
+
explorePeers: net.explorePeers,
|
|
285
|
+
blockedPeers: deniedNodes,
|
|
286
|
+
deniedNodes,
|
|
287
|
+
deniedSubjects,
|
|
288
|
+
deniedEntities,
|
|
289
|
+
lastRosterAt: net.lastRosterAt,
|
|
290
|
+
hintSources,
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {PeerPoolView} view 连接池视图
|
|
296
|
+
* @param {string} key nodeHash / pubKeyHash / entityHash 键
|
|
297
|
+
* @returns {boolean} 是否命中 denylist(按 scope 匹配)
|
|
298
|
+
*/
|
|
299
|
+
export function isPeerPoolKeyBlocked(view, key) {
|
|
300
|
+
const normalized = String(key || '').trim().toLowerCase()
|
|
301
|
+
if (!normalized) return false
|
|
302
|
+
if (view.deniedNodes.includes(normalized)) return true
|
|
303
|
+
if (view.deniedSubjects.includes(normalized)) return true
|
|
304
|
+
if (isEntityHash128(normalized) && view.deniedEntities.includes(normalized)) return true
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按实体的个人列表(Chat 与 Social 共享)。
|
|
3
|
+
* **block**:对外联邦公开拉黑(timeline block 事件 + personal_block 索引);
|
|
4
|
+
* **hide**:纯本地隐藏(personal_hide.json,永不联邦)。
|
|
5
|
+
*/
|
|
6
|
+
import { parseEntityHash } from '../core/entity_id.mjs'
|
|
7
|
+
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
8
|
+
import { isWritableLocalEntity } from '../entity/replica.mjs'
|
|
9
|
+
|
|
10
|
+
import { isNodeInitialized, getEntityStore } from './instance.mjs'
|
|
11
|
+
|
|
12
|
+
/** @typedef {'subject' | 'entity'} PersonalListScope */
|
|
13
|
+
|
|
14
|
+
const HIDE_JSON = 'personal_hide.json'
|
|
15
|
+
const MUTE_JSON = 'personal_mute.json'
|
|
16
|
+
const BLOCK_INDEX_JSON = 'personal_block.json'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} targetEntityHash 128 位十六进制
|
|
20
|
+
* @returns {Array<{ scope: PersonalListScope, value: string }>} 规范化条目
|
|
21
|
+
*/
|
|
22
|
+
export function entriesForTargetEntityHash(targetEntityHash) {
|
|
23
|
+
const parsed = parseEntityHash(targetEntityHash)
|
|
24
|
+
if (!parsed) return []
|
|
25
|
+
/** @type {Map<string, { scope: PersonalListScope, value: string }>} */
|
|
26
|
+
const byKey = new Map()
|
|
27
|
+
byKey.set(`entity:${parsed.entityHash}`, { scope: 'entity', value: parsed.entityHash })
|
|
28
|
+
if (isHex64(parsed.subjectHash))
|
|
29
|
+
byKey.set(`subject:${parsed.subjectHash}`, { scope: 'subject', value: parsed.subjectHash })
|
|
30
|
+
return [...byKey.values()]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {Array<{ scope?: string, value?: string }>} raw 原始条目
|
|
35
|
+
* @returns {Array<{ scope: PersonalListScope, value: string }>} 去重规范化
|
|
36
|
+
*/
|
|
37
|
+
export function normalizePersonalListEntries(raw) {
|
|
38
|
+
/** @type {Map<string, { scope: PersonalListScope, value: string }>} */
|
|
39
|
+
const byKey = new Map()
|
|
40
|
+
for (const entry of raw || []) {
|
|
41
|
+
const scope = String(entry?.scope || '').trim().toLowerCase()
|
|
42
|
+
const value = String(entry?.value || '').trim().toLowerCase()
|
|
43
|
+
if (scope === 'entity' && parseEntityHash(value))
|
|
44
|
+
byKey.set(`entity:${value}`, { scope: 'entity', value })
|
|
45
|
+
else if (scope === 'subject' && isHex64(normalizeHex64(value)))
|
|
46
|
+
byKey.set(`subject:${normalizeHex64(value)}`, { scope: 'subject', value: normalizeHex64(value) })
|
|
47
|
+
}
|
|
48
|
+
return [...byKey.values()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {Array<{ scope: PersonalListScope, value: string }>} entries 列表
|
|
53
|
+
* @param {object} subject 待检主体
|
|
54
|
+
* @param {string} [subject.entityHash] 作者实体
|
|
55
|
+
* @param {string} [subject.pubKeyHash] 用户公钥哈希
|
|
56
|
+
* @param {string} [subject.subjectHash] 主体哈希
|
|
57
|
+
* @param {string} [subject.nodeHash] 节点哈希
|
|
58
|
+
* @returns {boolean} 是否命中列表
|
|
59
|
+
*/
|
|
60
|
+
export function matchesPersonalListEntries(entries, subject) {
|
|
61
|
+
const entity = String(subject?.entityHash || '').trim().toLowerCase()
|
|
62
|
+
const pk = normalizeHex64(subject?.pubKeyHash || subject?.subjectHash || '')
|
|
63
|
+
if (entity) {
|
|
64
|
+
for (const entry of entries)
|
|
65
|
+
if (entry.scope === 'entity' && entry.value === entity) return true
|
|
66
|
+
const parsed = parseEntityHash(entity)
|
|
67
|
+
if (parsed)
|
|
68
|
+
for (const entry of entries)
|
|
69
|
+
if (entry.scope === 'subject' && entry.value === parsed.subjectHash) return true
|
|
70
|
+
}
|
|
71
|
+
if (isHex64(pk))
|
|
72
|
+
for (const entry of entries)
|
|
73
|
+
if (entry.scope === 'subject' && entry.value === pk) return true
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
79
|
+
* @returns {Promise<Array<{ scope: PersonalListScope, value: string }>>} 隐藏条目
|
|
80
|
+
*/
|
|
81
|
+
export async function loadPersonalHideEntries(viewerEntityHash) {
|
|
82
|
+
if (!isNodeInitialized()) return []
|
|
83
|
+
const data = await getEntityStore().readEntityJson(viewerEntityHash, HIDE_JSON)
|
|
84
|
+
return normalizePersonalListEntries(data?.hidden || [])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
89
|
+
* @returns {Promise<Array<{ scope: PersonalListScope, value: string }>>} 静音条目
|
|
90
|
+
*/
|
|
91
|
+
export async function loadPersonalMuteEntries(viewerEntityHash) {
|
|
92
|
+
if (!isNodeInitialized()) return []
|
|
93
|
+
const data = await getEntityStore().readEntityJson(viewerEntityHash, MUTE_JSON)
|
|
94
|
+
return normalizePersonalListEntries(data?.muted || [])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
99
|
+
* @returns {Promise<Array<{ scope: PersonalListScope, value: string }>>} 拉黑条目
|
|
100
|
+
*/
|
|
101
|
+
export async function loadPersonalBlockEntries(viewerEntityHash) {
|
|
102
|
+
if (!isNodeInitialized()) return []
|
|
103
|
+
const data = await getEntityStore().readEntityJson(viewerEntityHash, BLOCK_INDEX_JSON)
|
|
104
|
+
return normalizePersonalListEntries(data?.blocked || [])
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
109
|
+
* @param {object} subject 待检主体
|
|
110
|
+
* @returns {Promise<boolean>} 是否被隐藏
|
|
111
|
+
*/
|
|
112
|
+
export async function isHiddenBy(viewerEntityHash, subject) {
|
|
113
|
+
return matchesPersonalListEntries(await loadPersonalHideEntries(viewerEntityHash), subject)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
118
|
+
* @param {object} subject 待检主体
|
|
119
|
+
* @returns {Promise<boolean>} 是否被拉黑
|
|
120
|
+
*/
|
|
121
|
+
export async function isBlockedBy(viewerEntityHash, subject) {
|
|
122
|
+
return matchesPersonalListEntries(await loadPersonalBlockEntries(viewerEntityHash), subject)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
127
|
+
* @param {object} subject 待检主体
|
|
128
|
+
* @returns {Promise<boolean>} 是否应过滤
|
|
129
|
+
*/
|
|
130
|
+
export async function isFilteredByPersonalLists(viewerEntityHash, subject) {
|
|
131
|
+
if (!viewerEntityHash) return false
|
|
132
|
+
const [blocked, hidden, muted] = await Promise.all([
|
|
133
|
+
loadPersonalBlockEntries(viewerEntityHash),
|
|
134
|
+
loadPersonalHideEntries(viewerEntityHash),
|
|
135
|
+
loadPersonalMuteEntries(viewerEntityHash),
|
|
136
|
+
])
|
|
137
|
+
return matchesPersonalListEntries(blocked, subject)
|
|
138
|
+
|| matchesPersonalListEntries(hidden, subject)
|
|
139
|
+
|| matchesPersonalListEntries(muted, subject)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} viewerEntityHash 本地可写实体
|
|
144
|
+
* @param {string} targetEntityHash 目标
|
|
145
|
+
* @param {boolean} hide true=隐藏
|
|
146
|
+
* @returns {Promise<boolean>} 当前是否隐藏
|
|
147
|
+
*/
|
|
148
|
+
export async function setPersonalHidden(viewerEntityHash, targetEntityHash, hide) {
|
|
149
|
+
if (!isWritableLocalEntity(viewerEntityHash)) throw new Error('entity not writable on this replica')
|
|
150
|
+
const target = String(targetEntityHash || '').trim().toLowerCase()
|
|
151
|
+
if (!parseEntityHash(target)) throw new Error('invalid targetEntityHash')
|
|
152
|
+
const store = getEntityStore()
|
|
153
|
+
const current = normalizePersonalListEntries((await store.readEntityJson(viewerEntityHash, HIDE_JSON))?.hidden || [])
|
|
154
|
+
const addEntries = entriesForTargetEntityHash(target)
|
|
155
|
+
const addKeys = new Set(addEntries.map(e => `${e.scope}:${e.value}`))
|
|
156
|
+
const next = hide
|
|
157
|
+
? normalizePersonalListEntries([...current, ...addEntries])
|
|
158
|
+
: current.filter(entry => !addKeys.has(`${entry.scope}:${entry.value}`))
|
|
159
|
+
await store.writeEntityJson(viewerEntityHash, HIDE_JSON, { hidden: next })
|
|
160
|
+
return hide
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {string} viewerEntityHash 本地可写实体
|
|
165
|
+
* @param {string} targetEntityHash 目标
|
|
166
|
+
* @param {boolean} mute true=静音
|
|
167
|
+
* @returns {Promise<boolean>} 当前是否静音
|
|
168
|
+
*/
|
|
169
|
+
export async function setPersonalMuted(viewerEntityHash, targetEntityHash, mute) {
|
|
170
|
+
if (!isWritableLocalEntity(viewerEntityHash)) throw new Error('entity not writable on this replica')
|
|
171
|
+
const target = String(targetEntityHash || '').trim().toLowerCase()
|
|
172
|
+
if (!parseEntityHash(target)) throw new Error('invalid targetEntityHash')
|
|
173
|
+
const store = getEntityStore()
|
|
174
|
+
const current = normalizePersonalListEntries((await store.readEntityJson(viewerEntityHash, MUTE_JSON))?.muted || [])
|
|
175
|
+
const addEntries = entriesForTargetEntityHash(target)
|
|
176
|
+
const addKeys = new Set(addEntries.map(e => `${e.scope}:${e.value}`))
|
|
177
|
+
const next = mute
|
|
178
|
+
? normalizePersonalListEntries([...current, ...addEntries])
|
|
179
|
+
: current.filter(entry => !addKeys.has(`${entry.scope}:${entry.value}`))
|
|
180
|
+
await store.writeEntityJson(viewerEntityHash, MUTE_JSON, { muted: next })
|
|
181
|
+
return mute
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
186
|
+
* @param {object} subject 待检主体
|
|
187
|
+
* @returns {Promise<boolean>} 是否被静音
|
|
188
|
+
*/
|
|
189
|
+
export async function isMutedBy(viewerEntityHash, subject) {
|
|
190
|
+
return matchesPersonalListEntries(await loadPersonalMuteEntries(viewerEntityHash), subject)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 从物化公开拉黑名单同步本地索引(真相源 = 时间线 blocked 集)。
|
|
195
|
+
* @param {string} viewerEntityHash 实体
|
|
196
|
+
* @param {string[]} blockedEntityHashes 物化 blocked entityHash 列表
|
|
197
|
+
* @returns {Promise<void>}
|
|
198
|
+
*/
|
|
199
|
+
export async function rebuildPersonalBlockIndex(viewerEntityHash, blockedEntityHashes) {
|
|
200
|
+
if (!isWritableLocalEntity(viewerEntityHash)) return
|
|
201
|
+
/** @type {Map<string, { scope: PersonalListScope, value: string }>} */
|
|
202
|
+
const byKey = new Map()
|
|
203
|
+
for (const raw of blockedEntityHashes || [])
|
|
204
|
+
for (const entry of entriesForTargetEntityHash(raw))
|
|
205
|
+
byKey.set(`${entry.scope}:${entry.value}`, entry)
|
|
206
|
+
|
|
207
|
+
await getEntityStore().writeEntityJson(viewerEntityHash, BLOCK_INDEX_JSON, {
|
|
208
|
+
blocked: [...byKey.values()],
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 将 personal-lists API `{ entries }` 转为内存过滤集。
|
|
214
|
+
* @param {Array<{ scope?: string, kind?: string, value?: string }>} entries API 条目
|
|
215
|
+
* @returns {{ blockedEntityHashes: Set<string>, blockedSubjects: Set<string>, hiddenEntityHashes: Set<string>, hiddenSubjects: Set<string> }} 过滤集
|
|
216
|
+
*/
|
|
217
|
+
export function filterSetsFromPersonalListEntries(entries) {
|
|
218
|
+
/** @type {Set<string>} */
|
|
219
|
+
const blockedEntityHashes = new Set()
|
|
220
|
+
/** @type {Set<string>} */
|
|
221
|
+
const blockedSubjects = new Set()
|
|
222
|
+
/** @type {Set<string>} */
|
|
223
|
+
const hiddenEntityHashes = new Set()
|
|
224
|
+
/** @type {Set<string>} */
|
|
225
|
+
const hiddenSubjects = new Set()
|
|
226
|
+
/** @type {Set<string>} */
|
|
227
|
+
const mutedEntityHashes = new Set()
|
|
228
|
+
/** @type {Set<string>} */
|
|
229
|
+
const mutedSubjects = new Set()
|
|
230
|
+
for (const entry of entries || []) {
|
|
231
|
+
const kind = String(entry?.kind || '').trim().toLowerCase()
|
|
232
|
+
const scope = String(entry?.scope || '').trim().toLowerCase()
|
|
233
|
+
const value = String(entry?.value || '').trim().toLowerCase()
|
|
234
|
+
if (!value || (scope !== 'entity' && scope !== 'subject')) continue
|
|
235
|
+
if (kind === 'block')
|
|
236
|
+
if (scope === 'entity') blockedEntityHashes.add(value)
|
|
237
|
+
else blockedSubjects.add(value)
|
|
238
|
+
|
|
239
|
+
else if (kind === 'hide')
|
|
240
|
+
if (scope === 'entity') hiddenEntityHashes.add(value)
|
|
241
|
+
else hiddenSubjects.add(value)
|
|
242
|
+
else if (kind === 'mute')
|
|
243
|
+
if (scope === 'entity') mutedEntityHashes.add(value)
|
|
244
|
+
else mutedSubjects.add(value)
|
|
245
|
+
|
|
246
|
+
}
|
|
247
|
+
return { blockedEntityHashes, blockedSubjects, hiddenEntityHashes, hiddenSubjects, mutedEntityHashes, mutedSubjects }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {string} viewerEntityHash 观看者实体
|
|
252
|
+
* @returns {Promise<{ blockedEntityHashes: Set<string>, blockedSubjects: Set<string>, hiddenEntityHashes: Set<string>, hiddenSubjects: Set<string> }>} 过滤集
|
|
253
|
+
*/
|
|
254
|
+
export async function loadPersonalFilterSets(viewerEntityHash) {
|
|
255
|
+
if (!viewerEntityHash)
|
|
256
|
+
return filterSetsFromPersonalListEntries([])
|
|
257
|
+
const [blockedEntries, hiddenEntries, mutedEntries] = await Promise.all([
|
|
258
|
+
loadPersonalBlockEntries(viewerEntityHash),
|
|
259
|
+
loadPersonalHideEntries(viewerEntityHash),
|
|
260
|
+
loadPersonalMuteEntries(viewerEntityHash),
|
|
261
|
+
])
|
|
262
|
+
return filterSetsFromPersonalListEntries([
|
|
263
|
+
...blockedEntries.map(entry => ({ ...entry, kind: 'block' })),
|
|
264
|
+
...hiddenEntries.map(entry => ({ ...entry, kind: 'hide' })),
|
|
265
|
+
...mutedEntries.map(entry => ({ ...entry, kind: 'mute' })),
|
|
266
|
+
])
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {object} filterSets loadPersonalFilterSets 结果
|
|
271
|
+
* @param {string} authorEntityHash 作者实体
|
|
272
|
+
* @returns {boolean} 是否应过滤
|
|
273
|
+
*/
|
|
274
|
+
export function isAuthorFilteredByPersonalSets(filterSets, authorEntityHash) {
|
|
275
|
+
const entity = String(authorEntityHash || '').trim().toLowerCase()
|
|
276
|
+
if (!entity) return false
|
|
277
|
+
if (filterSets.blockedEntityHashes.has(entity) || filterSets.hiddenEntityHashes.has(entity) || filterSets.mutedEntityHashes?.has(entity))
|
|
278
|
+
return true
|
|
279
|
+
const parsed = parseEntityHash(entity)
|
|
280
|
+
if (!parsed) return false
|
|
281
|
+
if (filterSets.blockedSubjects.has(parsed.subjectHash)
|
|
282
|
+
|| filterSets.hiddenSubjects.has(parsed.subjectHash)
|
|
283
|
+
|| filterSets.mutedSubjects?.has(parsed.subjectHash))
|
|
284
|
+
return true
|
|
285
|
+
return false
|
|
286
|
+
}
|