@steve02081504/fount-p2p 0.0.2 → 0.0.3
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/entity/files/evfs.mjs +19 -0
- package/federation/manifest_fetch_pending.mjs +90 -0
- package/files/chunk_fetch.mjs +2 -33
- package/files/chunk_responder.mjs +60 -2
- package/files/fetch_fanout.mjs +38 -0
- package/files/manifest_fetch.mjs +97 -0
- package/files/public_manifest.mjs +153 -0
- package/link/channel_mux.mjs +2 -0
- package/package.json +1 -1
- package/transport/rtc_connection_budget.mjs +2 -0
- package/entity/files/url.mjs +0 -19
- package/entity/localized_core.mjs +0 -141
- package/entity/presentation_registry.mjs +0 -44
- package/entity/profile.mjs +0 -256
package/entity/files/evfs.mjs
CHANGED
|
@@ -187,3 +187,22 @@ export async function putFileManifestFromStream(params) {
|
|
|
187
187
|
await saveFileManifest(manifest)
|
|
188
188
|
return manifest
|
|
189
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 读取实体公开文件:本地 miss 时经网络取回签名 manifest,chunk miss 走既有 fetchChunk。
|
|
193
|
+
* @param {string} replicaUsername 副本用户名
|
|
194
|
+
* @param {string} entityHash owner entityHash
|
|
195
|
+
* @param {string} logicalPath EVFS 逻辑路径
|
|
196
|
+
* @param {{ username?: string, fetchChunk?: Function }} [opts] miss 拉取
|
|
197
|
+
* @returns {Promise<Buffer | null>} 明文或 null
|
|
198
|
+
*/
|
|
199
|
+
export async function readPublicFile(replicaUsername, entityHash, logicalPath, opts = {}) {
|
|
200
|
+
const { fetchPublicManifest } = await import('../../files/manifest_fetch.mjs')
|
|
201
|
+
const manifest = await fetchPublicManifest({
|
|
202
|
+
username: opts.username || replicaUsername,
|
|
203
|
+
ownerEntityHash: entityHash,
|
|
204
|
+
logicalPath,
|
|
205
|
+
})
|
|
206
|
+
if (!manifest) return null
|
|
207
|
+
return readManifestPlaintext(replicaUsername, manifest, opts)
|
|
208
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { verifySignedPublicManifest } from '../files/public_manifest.mjs'
|
|
2
|
+
|
|
3
|
+
/** @type {Map<string, { expectedKey: string, timer: ReturnType<typeof setTimeout>, resolve: (v: object | null) => void }>} */
|
|
4
|
+
export const pendingManifestFetches = new Map()
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_PENDING_MANIFEST_FETCHES = 512
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} ownerEntityHash owner
|
|
13
|
+
* @param {string} logicalPath 路径
|
|
14
|
+
* @returns {string} 期望键
|
|
15
|
+
*/
|
|
16
|
+
export function manifestFetchExpectedKey(ownerEntityHash, logicalPath) {
|
|
17
|
+
return `${String(ownerEntityHash || '').trim().toLowerCase()}\0${String(logicalPath || '').trim().replace(/^\/+/, '')}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} key requestId
|
|
22
|
+
* @param {string} expectedKey owner+path 复合键
|
|
23
|
+
* @param {number} timeoutMs 超时毫秒
|
|
24
|
+
* @returns {{ done: Promise<object | null>, cancel: () => void }} 等待 Promise 与取消
|
|
25
|
+
*/
|
|
26
|
+
export function registerManifestFetchWait(key, expectedKey, timeoutMs) {
|
|
27
|
+
if (!key || pendingManifestFetches.size >= MAX_PENDING_MANIFEST_FETCHES)
|
|
28
|
+
return {
|
|
29
|
+
done: Promise.resolve(null),
|
|
30
|
+
/**
|
|
31
|
+
*
|
|
32
|
+
*/
|
|
33
|
+
cancel: () => { },
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @type {(value: object | null) => void} */
|
|
37
|
+
let settle
|
|
38
|
+
const done = new Promise(resolve => {
|
|
39
|
+
settle = resolve
|
|
40
|
+
})
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
pendingManifestFetches.delete(key)
|
|
43
|
+
settle(null)
|
|
44
|
+
}, timeoutMs)
|
|
45
|
+
|
|
46
|
+
pendingManifestFetches.set(key, {
|
|
47
|
+
expectedKey,
|
|
48
|
+
timer,
|
|
49
|
+
/**
|
|
50
|
+
* @param {object | null} value 验签后的 manifest,超时/取消为 null
|
|
51
|
+
*/
|
|
52
|
+
resolve: value => {
|
|
53
|
+
clearTimeout(timer)
|
|
54
|
+
pendingManifestFetches.delete(key)
|
|
55
|
+
settle(value)
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
done,
|
|
61
|
+
/**
|
|
62
|
+
*
|
|
63
|
+
*/
|
|
64
|
+
cancel: () => {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
pendingManifestFetches.delete(key)
|
|
67
|
+
settle(null)
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 处理 fed_manifest_data:验签通过后 resolve pending。
|
|
74
|
+
* @param {object} payload 入站载荷
|
|
75
|
+
* @returns {Promise<boolean>} 是否命中并完成等待
|
|
76
|
+
*/
|
|
77
|
+
export async function resolvePendingManifestFetch(payload) {
|
|
78
|
+
const requestId = String(payload?.requestId || '')
|
|
79
|
+
if (!requestId) return false
|
|
80
|
+
const entry = pendingManifestFetches.get(requestId)
|
|
81
|
+
if (!entry) return false
|
|
82
|
+
|
|
83
|
+
const verified = await verifySignedPublicManifest(payload?.manifest)
|
|
84
|
+
if (!verified) return false
|
|
85
|
+
const key = manifestFetchExpectedKey(verified.ownerEntityHash, verified.logicalPath)
|
|
86
|
+
if (key !== entry.expectedKey) return false
|
|
87
|
+
|
|
88
|
+
entry.resolve(verified)
|
|
89
|
+
return true
|
|
90
|
+
}
|
package/files/chunk_fetch.mjs
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
|
|
3
3
|
import { u8ToB64 } from '../core/bytes_codec.mjs'
|
|
4
|
-
import { FEDERATION_CHUNK_FETCH_FANOUT_K } from '../core/constants.mjs'
|
|
5
4
|
import {
|
|
6
5
|
MAX_PENDING_CHUNK_FETCHES,
|
|
7
6
|
pendingChunkFetches,
|
|
8
7
|
registerChunkFetchWait,
|
|
9
8
|
} from '../federation/chunk_fetch_pending.mjs'
|
|
10
|
-
import { loadNetwork } from '../node/network.mjs'
|
|
11
|
-
import { ensureLinkToNode, listLinks } from '../transport/link_registry.mjs'
|
|
12
|
-
import { DEFAULT_TRUST_GRAPH_OWNER, requireTrustGraphProvider } from '../trust_graph/registry.mjs'
|
|
13
9
|
|
|
14
10
|
import { verifiedChunkBytes } from './chunk_fetch_verify.mjs'
|
|
15
11
|
import { fetchFederationChunk, resolveNodeHash } from './chunk_provider_registry.mjs'
|
|
16
12
|
import { getChunk, hasChunk, putChunk } from './chunk_store.mjs'
|
|
13
|
+
import { fanoutFedFetch } from './fetch_fanout.mjs'
|
|
17
14
|
|
|
18
15
|
/**
|
|
19
16
|
* @typedef {{
|
|
@@ -24,22 +21,6 @@ import { getChunk, hasChunk, putChunk } from './chunk_store.mjs'
|
|
|
24
21
|
* }} FetchChunkContext
|
|
25
22
|
*/
|
|
26
23
|
|
|
27
|
-
/**
|
|
28
|
-
* @returns {string[]} 全局 CAS miss 时应尝试拨号/发送的 nodeHash 列表
|
|
29
|
-
*/
|
|
30
|
-
function chunkFetchPeerTargets() {
|
|
31
|
-
/** @type {Set<string>} */
|
|
32
|
-
const targets = new Set()
|
|
33
|
-
for (const { nodeHash } of listLinks())
|
|
34
|
-
if (nodeHash) targets.add(String(nodeHash).toLowerCase())
|
|
35
|
-
const net = loadNetwork()
|
|
36
|
-
for (const nodeHash of [...net.trustedPeers || [], ...net.explorePeers || []])
|
|
37
|
-
if (nodeHash) targets.add(String(nodeHash).toLowerCase())
|
|
38
|
-
for (const hint of net.hints || [])
|
|
39
|
-
if (hint?.nodeHash) targets.add(String(hint.nodeHash).toLowerCase())
|
|
40
|
-
return [...targets]
|
|
41
|
-
}
|
|
42
|
-
|
|
43
24
|
/**
|
|
44
25
|
* @param {FetchChunkContext} context 上下文
|
|
45
26
|
* @returns {Promise<Uint8Array | null>} 密文块
|
|
@@ -72,19 +53,7 @@ export async function fetchChunk(context) {
|
|
|
72
53
|
chunkHash: hash,
|
|
73
54
|
ownerEntityHash: context.ownerEntityHash,
|
|
74
55
|
}
|
|
75
|
-
|
|
76
|
-
const peerTargets = chunkFetchPeerTargets()
|
|
77
|
-
await Promise.all(peerTargets.map(nodeHash => ensureLinkToNode(nodeHash).catch(() => null)))
|
|
78
|
-
const tg = requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER)
|
|
79
|
-
// 已直连 / follow hint peer 可能不在 trust-graph top-K(非成员 emoji CAS / Social 预览路径)。
|
|
80
|
-
for (const nodeHash of peerTargets)
|
|
81
|
-
void tg.sendToNode(username, nodeHash, 'fed_chunk_get', payload, graph)
|
|
82
|
-
await tg.fanoutToTopNodes(
|
|
83
|
-
username,
|
|
84
|
-
'fed_chunk_get',
|
|
85
|
-
payload,
|
|
86
|
-
FEDERATION_CHUNK_FETCH_FANOUT_K,
|
|
87
|
-
)
|
|
56
|
+
await fanoutFedFetch(username, 'fed_chunk_get', payload)
|
|
88
57
|
const result = await done
|
|
89
58
|
const verified = verifiedChunkBytes(hash, result)
|
|
90
59
|
if (verified) {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { resolvePendingChunkFetch } from '../federation/chunk_fetch_pending.mjs'
|
|
2
|
+
import { resolvePendingManifestFetch } from '../federation/manifest_fetch_pending.mjs'
|
|
2
3
|
|
|
3
4
|
import { handleIncomingChunkGet } from './chunk_fetch.mjs'
|
|
5
|
+
import { handleIncomingManifestGet } from './manifest_fetch.mjs'
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* @param {string} username 副本用户名 用户名
|
|
@@ -22,7 +24,26 @@ export function handleFedChunkDataIngress(data) {
|
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
|
-
*
|
|
27
|
+
* @param {string} username 副本用户名
|
|
28
|
+
* @param {object} data 入站 fed_manifest_get
|
|
29
|
+
* @param {string} peerId 对端 id
|
|
30
|
+
* @param {(resp: object, peerId: string) => void} sendManifestData 发送 fed_manifest_data
|
|
31
|
+
* @returns {Promise<void>}
|
|
32
|
+
*/
|
|
33
|
+
export async function handleFedManifestGetIngress(username, data, peerId, sendManifestData) {
|
|
34
|
+
await handleIncomingManifestGet(username, data, sendManifestData, peerId)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} data 入站 fed_manifest_data
|
|
39
|
+
* @returns {Promise<void>}
|
|
40
|
+
*/
|
|
41
|
+
export async function handleFedManifestDataIngress(data) {
|
|
42
|
+
await resolvePendingManifestFetch(data)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* node scope user-room wire:注册 fed_chunk_* + fed_manifest_*。
|
|
26
47
|
* @param {string} username 副本用户名 用户名
|
|
27
48
|
* @param {{ on: (name: string, handler: (payload: unknown, peerId: string) => void) => void, send: (name: string, payload: unknown, peerId: string | null) => void }} wire action 表
|
|
28
49
|
* @returns {void}
|
|
@@ -35,10 +56,19 @@ export function attachNodeScopeFedChunkResponder(username, wire) {
|
|
|
35
56
|
})
|
|
36
57
|
})
|
|
37
58
|
wire.on('fed_chunk_data', handleFedChunkDataIngress)
|
|
59
|
+
wire.on('fed_manifest_get', (data, peerId) => {
|
|
60
|
+
void handleFedManifestGetIngress(username, data, peerId, (resp, pid) => {
|
|
61
|
+
try { wire.send('fed_manifest_data', resp, pid) }
|
|
62
|
+
catch { /* disconnected */ }
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
wire.on('fed_manifest_data', data => {
|
|
66
|
+
void handleFedManifestDataIngress(data)
|
|
67
|
+
})
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
/**
|
|
41
|
-
* Trystero room:注册带 requestId 的
|
|
71
|
+
* Trystero room:注册带 requestId 的 fed_chunk_* + fed_manifest_*(TrustGraph 全局 miss)。
|
|
42
72
|
* @param {string} username 用户
|
|
43
73
|
* @param {object} room Trystero room
|
|
44
74
|
* @param {{ enqueue: (prio: number, fn: () => void) => void }} [fedOut] 出站队列
|
|
@@ -50,6 +80,8 @@ export function attachNodeScopeFedChunkResponder(username, wire) {
|
|
|
50
80
|
export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardGet, rtcLimits = {}, roomKey = '') {
|
|
51
81
|
const [sendChunkData, getChunkData] = room.makeAction('fed_chunk_data')
|
|
52
82
|
const [, getChunkGet] = room.makeAction('fed_chunk_get')
|
|
83
|
+
const [sendManifestData, getManifestData] = room.makeAction('fed_manifest_data')
|
|
84
|
+
const [, getManifestGet] = room.makeAction('fed_manifest_get')
|
|
53
85
|
|
|
54
86
|
getChunkGet((data, peerId) => {
|
|
55
87
|
if (guardGet && !guardGet(roomKey, 'fed_chunk_get', rtcLimits)) return
|
|
@@ -76,4 +108,30 @@ export function attachTrustGraphFedChunkResponder(username, room, fedOut, guardG
|
|
|
76
108
|
if (!data || typeof data !== 'object' || !data.requestId) return
|
|
77
109
|
handleFedChunkDataIngress(data)
|
|
78
110
|
})
|
|
111
|
+
|
|
112
|
+
getManifestGet((data, peerId) => {
|
|
113
|
+
if (guardGet && !guardGet(roomKey, 'fed_manifest_get', rtcLimits)) return
|
|
114
|
+
void (async () => {
|
|
115
|
+
if (!data || typeof data !== 'object') return
|
|
116
|
+
if (!String(data.requestId || '')) return
|
|
117
|
+
await handleFedManifestGetIngress(username, data, peerId, (resp, pid) => {
|
|
118
|
+
/**
|
|
119
|
+
*
|
|
120
|
+
*/
|
|
121
|
+
const send = () => {
|
|
122
|
+
try { sendManifestData(resp, pid) }
|
|
123
|
+
catch (error) {
|
|
124
|
+
console.warn('federation: trust-graph manifest response failed', error)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (fedOut) fedOut.enqueue(6, send)
|
|
128
|
+
else send()
|
|
129
|
+
})
|
|
130
|
+
})().catch(error => console.warn('federation: trust-graph manifest handler failed', error))
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
getManifestData(data => {
|
|
134
|
+
if (!data || typeof data !== 'object' || !data.requestId) return
|
|
135
|
+
void handleFedManifestDataIngress(data)
|
|
136
|
+
})
|
|
79
137
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { FEDERATION_CHUNK_FETCH_FANOUT_K } from '../core/constants.mjs'
|
|
2
|
+
import { loadNetwork } from '../node/network.mjs'
|
|
3
|
+
import { ensureLinkToNode, listLinks } from '../transport/link_registry.mjs'
|
|
4
|
+
import { DEFAULT_TRUST_GRAPH_OWNER, requireTrustGraphProvider } from '../trust_graph/registry.mjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @returns {string[]} 全局 miss 时应尝试拨号/发送的 nodeHash 列表
|
|
8
|
+
*/
|
|
9
|
+
function fetchPeerTargets() {
|
|
10
|
+
/** @type {Set<string>} */
|
|
11
|
+
const targets = new Set()
|
|
12
|
+
for (const { nodeHash } of listLinks())
|
|
13
|
+
if (nodeHash) targets.add(String(nodeHash).toLowerCase())
|
|
14
|
+
const net = loadNetwork()
|
|
15
|
+
for (const nodeHash of [...net.trustedPeers || [], ...net.explorePeers || []])
|
|
16
|
+
if (nodeHash) targets.add(String(nodeHash).toLowerCase())
|
|
17
|
+
for (const hint of net.hints || [])
|
|
18
|
+
if (hint?.nodeHash) targets.add(String(hint.nodeHash).toLowerCase())
|
|
19
|
+
return [...targets]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 全局 miss 请求扇出:先向已知 peer 定向发送,再 trust-graph top-K fanout。
|
|
24
|
+
* 已直连 / follow hint peer 可能不在 trust-graph top-K(非成员 emoji CAS / Social 预览路径),故先定向发送。
|
|
25
|
+
* @param {string} username 用户
|
|
26
|
+
* @param {string} action wire action 名
|
|
27
|
+
* @param {object} payload 请求载荷
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
export async function fanoutFedFetch(username, action, payload) {
|
|
31
|
+
const tg = requireTrustGraphProvider(DEFAULT_TRUST_GRAPH_OWNER)
|
|
32
|
+
const graph = await tg.buildMergedGraph(username)
|
|
33
|
+
const peerTargets = fetchPeerTargets()
|
|
34
|
+
await Promise.all(peerTargets.map(nodeHash => ensureLinkToNode(nodeHash).catch(() => null)))
|
|
35
|
+
for (const nodeHash of peerTargets)
|
|
36
|
+
void tg.sendToNode(username, nodeHash, action, payload, graph)
|
|
37
|
+
await tg.fanoutToTopNodes(username, action, payload, FEDERATION_CHUNK_FETCH_FANOUT_K)
|
|
38
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { loadFileManifest, saveFileManifest } from '../entity/files/evfs.mjs'
|
|
4
|
+
import { isWritableLocalEntity } from '../entity/replica.mjs'
|
|
5
|
+
import {
|
|
6
|
+
manifestFetchExpectedKey,
|
|
7
|
+
MAX_PENDING_MANIFEST_FETCHES,
|
|
8
|
+
pendingManifestFetches,
|
|
9
|
+
registerManifestFetchWait,
|
|
10
|
+
} from '../federation/manifest_fetch_pending.mjs'
|
|
11
|
+
import { getEntityStore } from '../node/instance.mjs'
|
|
12
|
+
|
|
13
|
+
import { resolveNodeHash } from './chunk_provider_registry.mjs'
|
|
14
|
+
import { fanoutFedFetch } from './fetch_fanout.mjs'
|
|
15
|
+
import { normalizeFileManifest } from './manifest.mjs'
|
|
16
|
+
import { shouldPreferIncomingPublicManifest } from './public_manifest.mjs'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {{ username: string, ownerEntityHash: string, logicalPath: string }} context 上下文
|
|
20
|
+
* @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签后的公开清单
|
|
21
|
+
*/
|
|
22
|
+
export async function fetchPublicManifest(context) {
|
|
23
|
+
const ownerEntityHash = String(context.ownerEntityHash || '').trim().toLowerCase()
|
|
24
|
+
const logicalPath = String(context.logicalPath || '').trim().replace(/^\/+/, '')
|
|
25
|
+
const { username } = context
|
|
26
|
+
if (!ownerEntityHash || !logicalPath || !username) return null
|
|
27
|
+
|
|
28
|
+
const local = await loadFileManifest(ownerEntityHash, logicalPath)
|
|
29
|
+
if (local?.transferKeyDescriptor?.type === 'public' && local?.meta?.publicSig)
|
|
30
|
+
return local
|
|
31
|
+
|
|
32
|
+
if (pendingManifestFetches.size >= MAX_PENDING_MANIFEST_FETCHES) return null
|
|
33
|
+
|
|
34
|
+
const requestId = randomUUID()
|
|
35
|
+
const { done } = registerManifestFetchWait(
|
|
36
|
+
requestId,
|
|
37
|
+
manifestFetchExpectedKey(ownerEntityHash, logicalPath),
|
|
38
|
+
8000,
|
|
39
|
+
)
|
|
40
|
+
const { nodeHash } = await resolveNodeHash(username)
|
|
41
|
+
const payload = {
|
|
42
|
+
requestId,
|
|
43
|
+
nodeHash,
|
|
44
|
+
ownerEntityHash,
|
|
45
|
+
logicalPath,
|
|
46
|
+
}
|
|
47
|
+
await fanoutFedFetch(username, 'fed_manifest_get', payload)
|
|
48
|
+
const result = await done
|
|
49
|
+
if (!result) return null
|
|
50
|
+
|
|
51
|
+
await maybeCacheIncomingPublicManifest(ownerEntityHash, logicalPath, result)
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {string} ownerEntityHash owner
|
|
57
|
+
* @param {string} logicalPath 路径
|
|
58
|
+
* @param {import('./manifest.mjs').FileManifest} incoming 已验签入站清单
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async function maybeCacheIncomingPublicManifest(ownerEntityHash, logicalPath, incoming) {
|
|
62
|
+
if (isWritableLocalEntity(ownerEntityHash)) return
|
|
63
|
+
const store = getEntityStore()
|
|
64
|
+
const existing = await store.readManifest(ownerEntityHash, logicalPath)
|
|
65
|
+
if (existing && !shouldPreferIncomingPublicManifest(existing, incoming)) return
|
|
66
|
+
await saveFileManifest(incoming)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 若本机有已签名公开 manifest 则响应 fed_manifest_get。
|
|
71
|
+
* @param {string} username 用户
|
|
72
|
+
* @param {object} payload 请求
|
|
73
|
+
* @param {(response: object, peerId: string) => void} sendResponse 发送
|
|
74
|
+
* @param {string} peerId 对端
|
|
75
|
+
* @returns {Promise<void>}
|
|
76
|
+
*/
|
|
77
|
+
export async function handleIncomingManifestGet(username, payload, sendResponse, peerId) {
|
|
78
|
+
void username
|
|
79
|
+
const ownerEntityHash = String(payload?.ownerEntityHash || '').trim().toLowerCase()
|
|
80
|
+
const logicalPath = String(payload?.logicalPath || '').trim().replace(/^\/+/, '')
|
|
81
|
+
const requestId = String(payload?.requestId || '')
|
|
82
|
+
if (!ownerEntityHash || !logicalPath || !requestId) return
|
|
83
|
+
|
|
84
|
+
const raw = await getEntityStore().readManifest(ownerEntityHash, logicalPath)
|
|
85
|
+
const manifest = normalizeFileManifest(raw)
|
|
86
|
+
if (!manifest) return
|
|
87
|
+
if (manifest.transferKeyDescriptor.type !== 'public') return
|
|
88
|
+
if (!raw?.meta?.publicSig) return
|
|
89
|
+
|
|
90
|
+
sendResponse({
|
|
91
|
+
requestId,
|
|
92
|
+
manifest: {
|
|
93
|
+
...manifest,
|
|
94
|
+
meta: { publicSig: raw.meta.publicSig },
|
|
95
|
+
},
|
|
96
|
+
}, peerId)
|
|
97
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
|
|
3
|
+
import { canonicalStringify } from '../core/canonical_json.mjs'
|
|
4
|
+
import { hashFromPubKeyHex, parseEntityHash } from '../core/entity_id.mjs'
|
|
5
|
+
import { assertSafeEvfsLogicalPath } from '../core/evfs_logical_path.mjs'
|
|
6
|
+
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
7
|
+
import { sign, verify } from '../crypto/crypto.mjs'
|
|
8
|
+
|
|
9
|
+
import { normalizeFileManifest, publicTransferKeyDescriptor } from './manifest.mjs'
|
|
10
|
+
|
|
11
|
+
/** 实体公开 manifest 签名域 */
|
|
12
|
+
export const ENTITY_PUBLIC_MANIFEST_DOMAIN = 'fount-entity-public-manifest'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} fields 待签名字段
|
|
16
|
+
* @returns {Buffer} 签名消息字节
|
|
17
|
+
*/
|
|
18
|
+
export function publicManifestSignBytes(fields) {
|
|
19
|
+
return Buffer.from(canonicalStringify([
|
|
20
|
+
ENTITY_PUBLIC_MANIFEST_DOMAIN,
|
|
21
|
+
String(fields.ownerEntityHash || '').trim().toLowerCase(),
|
|
22
|
+
assertSafeEvfsLogicalPath(fields.logicalPath),
|
|
23
|
+
Number(fields.publishedAt) || 0,
|
|
24
|
+
String(fields.contentHash || '').trim().toLowerCase(),
|
|
25
|
+
Number(fields.size) || 0,
|
|
26
|
+
String(fields.mimeType || ''),
|
|
27
|
+
String(fields.name || ''),
|
|
28
|
+
String(fields.ceMode || ''),
|
|
29
|
+
fields.parts || [],
|
|
30
|
+
]), 'utf8')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {import('./manifest.mjs').FileManifest} manifest 清单
|
|
35
|
+
* @param {number} publishedAt 发布时间
|
|
36
|
+
* @param {Uint8Array | Buffer} entitySecretKey recovery 私钥种子
|
|
37
|
+
* @param {string} entityPubKeyHex recovery 公钥 hex
|
|
38
|
+
* @returns {Promise<import('./manifest.mjs').FileManifest>} 带 publicSig 的清单
|
|
39
|
+
*/
|
|
40
|
+
export async function attachPublicManifestSig(manifest, publishedAt, entitySecretKey, entityPubKeyHex) {
|
|
41
|
+
const pubKeyHex = normalizeHex64(entityPubKeyHex)
|
|
42
|
+
const message = publicManifestSignBytes({
|
|
43
|
+
ownerEntityHash: manifest.ownerEntityHash,
|
|
44
|
+
logicalPath: manifest.logicalPath,
|
|
45
|
+
publishedAt,
|
|
46
|
+
contentHash: manifest.contentHash,
|
|
47
|
+
size: manifest.size,
|
|
48
|
+
mimeType: manifest.mimeType,
|
|
49
|
+
name: manifest.name,
|
|
50
|
+
ceMode: manifest.ceMode,
|
|
51
|
+
parts: manifest.parts,
|
|
52
|
+
})
|
|
53
|
+
const sigHex = Buffer.from(await sign(message, entitySecretKey)).toString('hex')
|
|
54
|
+
return {
|
|
55
|
+
...manifest,
|
|
56
|
+
meta: {
|
|
57
|
+
...manifest.meta || {},
|
|
58
|
+
publicSig: { publishedAt, pubKeyHex, sigHex },
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {unknown} input 原始 manifest
|
|
65
|
+
* @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签通过的清单;非法为 null
|
|
66
|
+
*/
|
|
67
|
+
export async function verifySignedPublicManifest(input) {
|
|
68
|
+
const manifest = normalizeFileManifest(input)
|
|
69
|
+
if (!manifest) return null
|
|
70
|
+
if (manifest.transferKeyDescriptor.type !== 'public') return null
|
|
71
|
+
|
|
72
|
+
const publicSig = input?.meta?.publicSig
|
|
73
|
+
if (!publicSig || typeof publicSig !== 'object') return null
|
|
74
|
+
const publishedAt = Number(publicSig.publishedAt) || 0
|
|
75
|
+
const pubKeyHex = normalizeHex64(publicSig.pubKeyHex)
|
|
76
|
+
const sigHex = String(publicSig.sigHex || '').trim().toLowerCase()
|
|
77
|
+
if (!isHex64(pubKeyHex) || !/^[\da-f]{128}$/u.test(sigHex) || publishedAt <= 0) return null
|
|
78
|
+
|
|
79
|
+
const parsed = parseEntityHash(manifest.ownerEntityHash)
|
|
80
|
+
if (!parsed) return null
|
|
81
|
+
if (hashFromPubKeyHex(pubKeyHex) !== parsed.subjectHash) return null
|
|
82
|
+
|
|
83
|
+
const message = publicManifestSignBytes({
|
|
84
|
+
ownerEntityHash: manifest.ownerEntityHash,
|
|
85
|
+
logicalPath: manifest.logicalPath,
|
|
86
|
+
publishedAt,
|
|
87
|
+
contentHash: manifest.contentHash,
|
|
88
|
+
size: manifest.size,
|
|
89
|
+
mimeType: manifest.mimeType,
|
|
90
|
+
name: manifest.name,
|
|
91
|
+
ceMode: manifest.ceMode,
|
|
92
|
+
parts: manifest.parts,
|
|
93
|
+
})
|
|
94
|
+
const ok = await verify(Buffer.from(sigHex, 'hex'), message, Buffer.from(pubKeyHex, 'hex'))
|
|
95
|
+
if (!ok) return null
|
|
96
|
+
|
|
97
|
+
// 签名只覆盖内容字段:入站 meta 一律丢弃,仅保留 publicSig,
|
|
98
|
+
// 防止中继注入 dagParts/groupId 等本地扩展改写读取路径。
|
|
99
|
+
return {
|
|
100
|
+
...manifest,
|
|
101
|
+
meta: { publicSig: { publishedAt, pubKeyHex, sigHex } },
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {object | null | undefined} localManifest 本地已有清单
|
|
107
|
+
* @param {import('./manifest.mjs').FileManifest} incoming 入站已验签清单
|
|
108
|
+
* @returns {boolean} 是否应以 incoming 覆盖本地缓存
|
|
109
|
+
*/
|
|
110
|
+
export function shouldPreferIncomingPublicManifest(localManifest, incoming) {
|
|
111
|
+
const incomingAt = Number(incoming?.meta?.publicSig?.publishedAt) || 0
|
|
112
|
+
if (incomingAt <= 0) return false
|
|
113
|
+
const localAt = Number(localManifest?.meta?.publicSig?.publishedAt) || 0
|
|
114
|
+
return incomingAt > localAt
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} params 参数
|
|
119
|
+
* @param {string} params.ownerEntityHash owner
|
|
120
|
+
* @param {string} params.logicalPath 路径
|
|
121
|
+
* @param {Buffer | Uint8Array} params.plaintext 明文
|
|
122
|
+
* @param {string} [params.name] 文件名
|
|
123
|
+
* @param {string} [params.mimeType] MIME
|
|
124
|
+
* @param {Uint8Array | Buffer} params.entitySecretKey recovery 私钥种子
|
|
125
|
+
* @param {string} params.entityPubKeyHex recovery 公钥 hex
|
|
126
|
+
* @param {number} [params.publishedAt] 发布时间(默认 Date.now)
|
|
127
|
+
* @returns {Promise<import('./manifest.mjs').FileManifest>} 已签名并落盘的公开清单
|
|
128
|
+
*/
|
|
129
|
+
export async function publishPublicFile(params) {
|
|
130
|
+
const {
|
|
131
|
+
ownerEntityHash,
|
|
132
|
+
logicalPath,
|
|
133
|
+
plaintext,
|
|
134
|
+
name,
|
|
135
|
+
mimeType,
|
|
136
|
+
entitySecretKey,
|
|
137
|
+
entityPubKeyHex,
|
|
138
|
+
publishedAt = Date.now(),
|
|
139
|
+
} = params
|
|
140
|
+
const { putFileManifest, saveFileManifest } = await import('../entity/files/evfs.mjs')
|
|
141
|
+
const base = await putFileManifest({
|
|
142
|
+
ownerEntityHash,
|
|
143
|
+
logicalPath,
|
|
144
|
+
plaintext,
|
|
145
|
+
name,
|
|
146
|
+
mimeType,
|
|
147
|
+
ceMode: 'convergent',
|
|
148
|
+
transferKeyDescriptor: publicTransferKeyDescriptor(),
|
|
149
|
+
})
|
|
150
|
+
const signed = await attachPublicManifestSig(base, publishedAt, entitySecretKey, entityPubKeyHex)
|
|
151
|
+
await saveFileManifest(signed)
|
|
152
|
+
return signed
|
|
153
|
+
}
|
package/link/channel_mux.mjs
CHANGED
package/package.json
CHANGED
package/entity/files/url.mjs
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* EVFS 文件 URL 辅助。
|
|
3
|
-
* @param {string} entityHash 128 位十六进制
|
|
4
|
-
* @param {string} logicalPath EVFS 逻辑路径
|
|
5
|
-
* @returns {string} HTTP 地址
|
|
6
|
-
*/
|
|
7
|
-
export function entityFileUrl(entityHash, logicalPath) {
|
|
8
|
-
const path = String(logicalPath || '').trim().replace(/^\/+/, '')
|
|
9
|
-
return `/api/p2p/entities/${encodeURIComponent(entityHash)}/files/${path.split('/').map(encodeURIComponent).join('/')}`
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* profile 头像 EVFS 路径 URL。
|
|
14
|
-
* @param {string} entityHash 128 位十六进制
|
|
15
|
-
* @returns {string} profile 头像 HTTP 地址
|
|
16
|
-
*/
|
|
17
|
-
export function profileAvatarFileUrl(entityHash) {
|
|
18
|
-
return entityFileUrl(entityHash, 'profile/avatar')
|
|
19
|
-
}
|
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { isEntityHash128 } from '../core/entity_id.mjs'
|
|
2
|
-
|
|
3
|
-
import { profileAvatarFileUrl } from './files/url.mjs'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @param {unknown} linkItem 链接项
|
|
7
|
-
* @returns {object | null} 规范化链接项或 null
|
|
8
|
-
*/
|
|
9
|
-
function normalizeLink(linkItem) {
|
|
10
|
-
const url = String(linkItem?.url || '').trim()
|
|
11
|
-
if (!url) return null
|
|
12
|
-
return {
|
|
13
|
-
icon: String(linkItem.icon || '').trim(),
|
|
14
|
-
name: String(linkItem.name || '').trim(),
|
|
15
|
-
url,
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {unknown} localizedInput 原始 localized 字段
|
|
21
|
-
* @returns {Record<string, object>} locale → 切片
|
|
22
|
-
*/
|
|
23
|
-
export function normalizeLocalizedMap(localizedInput) {
|
|
24
|
-
if (!localizedInput) return {}
|
|
25
|
-
/** @type {Record<string, object>} */
|
|
26
|
-
const out = {}
|
|
27
|
-
for (const [key, value] of Object.entries(localizedInput)) {
|
|
28
|
-
const localeKey = String(key || '').trim()
|
|
29
|
-
if (!localeKey || !value) continue
|
|
30
|
-
const tags = Array.isArray(value.tags)
|
|
31
|
-
? value.tags.map(t => String(t).trim()).filter(Boolean)
|
|
32
|
-
: undefined
|
|
33
|
-
const links = Array.isArray(value.links)
|
|
34
|
-
? value.links.map(normalizeLink).filter(Boolean)
|
|
35
|
-
: undefined
|
|
36
|
-
/** @type {Record<string, unknown>} */
|
|
37
|
-
const slice = {}
|
|
38
|
-
if (value.name != null) slice.name = String(value.name).trim()
|
|
39
|
-
if (value.avatar) slice.avatar = String(value.avatar).trim()
|
|
40
|
-
if (value.description != null) slice.description = String(value.description)
|
|
41
|
-
if (value.description_markdown != null) slice.description_markdown = String(value.description_markdown)
|
|
42
|
-
if (value.version) slice.version = String(value.version).trim()
|
|
43
|
-
if (value.author) slice.author = String(value.author).trim()
|
|
44
|
-
if (value.home_page) slice.home_page = String(value.home_page).trim()
|
|
45
|
-
if (value.issue_page) slice.issue_page = String(value.issue_page).trim()
|
|
46
|
-
if (tags?.length) slice.tags = tags
|
|
47
|
-
if (links?.length) slice.links = links
|
|
48
|
-
if (Object.keys(slice).length) out[localeKey] = slice
|
|
49
|
-
}
|
|
50
|
-
return out
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {Record<string, object>} localized 多语言切片
|
|
55
|
-
* @param {string[]} locales 区域设置优先级
|
|
56
|
-
* @returns {object | null} 匹配的语言切片
|
|
57
|
-
*/
|
|
58
|
-
function pickLocalizedSlice(localized, locales) {
|
|
59
|
-
const keys = Object.keys(localized || {})
|
|
60
|
-
if (!keys.length) return null
|
|
61
|
-
for (const locale of locales || []) {
|
|
62
|
-
if (localized[locale]) return localized[locale]
|
|
63
|
-
const prefix = String(locale).split('-')[0]
|
|
64
|
-
const hit = keys.find(k => k === prefix || k.startsWith(`${prefix}-`))
|
|
65
|
-
if (hit) return localized[hit]
|
|
66
|
-
}
|
|
67
|
-
return localized[keys[0]]
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* @param {string} displayName 展示名
|
|
72
|
-
* @param {{ subjectHash?: string }} profile 资料对象
|
|
73
|
-
* @returns {boolean} 是否为占位展示名
|
|
74
|
-
*/
|
|
75
|
-
export function isPlaceholderDisplayName(displayName, profile) {
|
|
76
|
-
const name = String(displayName || '').trim()
|
|
77
|
-
if (!name) return true
|
|
78
|
-
const subjectHash = String(profile?.subjectHash || '').trim().toLowerCase()
|
|
79
|
-
if (!subjectHash || subjectHash.length < 12) return false
|
|
80
|
-
const placeholder = `${subjectHash.slice(0, 8)}…${subjectHash.slice(-4)}`
|
|
81
|
-
return name === placeholder
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* @param {object} stored 磁盘上的 profile 对象
|
|
86
|
-
* @param {string[]} locales 查看者区域设置
|
|
87
|
-
* @param {object} infoDefaults part 默认
|
|
88
|
-
* @returns {object} 合并后的展示字段
|
|
89
|
-
*/
|
|
90
|
-
export function resolveProfilePresentation(stored, locales, infoDefaults) {
|
|
91
|
-
const localized = normalizeLocalizedMap(stored?.localized)
|
|
92
|
-
const slice = pickLocalizedSlice(localized, locales) || {}
|
|
93
|
-
|
|
94
|
-
let name = slice.name?.trim() || infoDefaults.name
|
|
95
|
-
if (name && isPlaceholderDisplayName(name, stored))
|
|
96
|
-
name = infoDefaults.name
|
|
97
|
-
|
|
98
|
-
const description = slice.description != null
|
|
99
|
-
? String(slice.description)
|
|
100
|
-
: infoDefaults.description
|
|
101
|
-
const description_markdown = slice.description_markdown != null
|
|
102
|
-
? String(slice.description_markdown)
|
|
103
|
-
: slice.description != null ? String(slice.description) : infoDefaults.description_markdown
|
|
104
|
-
|
|
105
|
-
const tags = slice.tags?.length ? slice.tags : infoDefaults.tags
|
|
106
|
-
const links = slice.links?.length ? slice.links : infoDefaults.links
|
|
107
|
-
|
|
108
|
-
let avatar = slice.avatar?.trim() || infoDefaults.avatar
|
|
109
|
-
if (avatar && !avatar.startsWith('http') && isEntityHash128(stored?.entityHash))
|
|
110
|
-
avatar = profileAvatarFileUrl(stored.entityHash)
|
|
111
|
-
else if (!avatar && isEntityHash128(stored?.entityHash) && stored?.localized)
|
|
112
|
-
avatar = profileAvatarFileUrl(stored.entityHash)
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
name: name || infoDefaults.name,
|
|
116
|
-
avatar: avatar || '',
|
|
117
|
-
description: description || '',
|
|
118
|
-
description_markdown: description_markdown || '',
|
|
119
|
-
version: slice.version?.trim() || infoDefaults.version || '',
|
|
120
|
-
author: slice.author?.trim() || infoDefaults.author || '',
|
|
121
|
-
home_page: slice.home_page?.trim() || infoDefaults.home_page || '',
|
|
122
|
-
issue_page: slice.issue_page?.trim() || infoDefaults.issue_page || '',
|
|
123
|
-
tags: [...tags],
|
|
124
|
-
links: [...links],
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* @param {Record<string, object>} localized 多语言表
|
|
130
|
-
* @param {string} avatarUrl 头像 URL
|
|
131
|
-
* @returns {Record<string, object>} 带头像 URL 的多语言表
|
|
132
|
-
*/
|
|
133
|
-
export function applyAvatarToAllLocales(localized, avatarUrl) {
|
|
134
|
-
const keys = Object.keys(localized)
|
|
135
|
-
if (!keys.length) return { '': { avatar: avatarUrl } }
|
|
136
|
-
/** @type {Record<string, object>} */
|
|
137
|
-
const out = {}
|
|
138
|
-
for (const key of keys)
|
|
139
|
-
out[key] = { ...localized[key], avatar: avatarUrl }
|
|
140
|
-
return out
|
|
141
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/** @typedef {{ query?: { locales?: string | string[] } }} ExpressLikeRequest */
|
|
2
|
-
|
|
3
|
-
/** @type {((replicaUsername: string, entityHash: string, locales: string[]) => Promise<object>) | null} */
|
|
4
|
-
let infoDefaultsProvider = null
|
|
5
|
-
|
|
6
|
-
/** @type {((req: ExpressLikeRequest, replicaUsername: string) => string[]) | null} */
|
|
7
|
-
let localesFromRequestProvider = null
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @param {(replicaUsername: string, entityHash: string, locales: string[]) => Promise<object>} provider 资料默认值解析器
|
|
11
|
-
* @returns {void}
|
|
12
|
-
*/
|
|
13
|
-
export function registerEntityPresentationProvider(provider) {
|
|
14
|
-
infoDefaultsProvider = provider
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param {(req: ExpressLikeRequest, replicaUsername: string) => string[]} provider 请求区域设置解析器
|
|
19
|
-
* @returns {void}
|
|
20
|
-
*/
|
|
21
|
-
export function registerLocalesFromRequestProvider(provider) {
|
|
22
|
-
localesFromRequestProvider = provider
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @param {string} replicaUsername 查看者 replica
|
|
27
|
-
* @param {string} entityHash 目标实体
|
|
28
|
-
* @param {string[]} locales 区域设置优先级
|
|
29
|
-
* @returns {Promise<object | null>} 展示默认值或 null
|
|
30
|
-
*/
|
|
31
|
-
export async function resolveInfoDefaultsForEntity(replicaUsername, entityHash, locales) {
|
|
32
|
-
if (!infoDefaultsProvider) return null
|
|
33
|
-
return infoDefaultsProvider(replicaUsername, entityHash, locales)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* @param {ExpressLikeRequest} req HTTP 请求
|
|
38
|
-
* @param {string} replicaUsername 查看者 replica
|
|
39
|
-
* @returns {string[]} 区域设置列表
|
|
40
|
-
*/
|
|
41
|
-
export function localesFromRequest(req, replicaUsername) {
|
|
42
|
-
if (localesFromRequestProvider) return localesFromRequestProvider(req, replicaUsername)
|
|
43
|
-
return ['zh-CN', 'en-UK']
|
|
44
|
-
}
|
package/entity/profile.mjs
DELETED
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
import { parseEntityHash } from '../core/entity_id.mjs'
|
|
2
|
-
import { getEntityStore } from '../node/instance.mjs'
|
|
3
|
-
|
|
4
|
-
import { profileAvatarFileUrl } from './files/url.mjs'
|
|
5
|
-
import {
|
|
6
|
-
applyAvatarToAllLocales,
|
|
7
|
-
normalizeLocalizedMap,
|
|
8
|
-
resolveProfilePresentation,
|
|
9
|
-
} from './localized_core.mjs'
|
|
10
|
-
import { resolveInfoDefaultsForEntity } from './presentation_registry.mjs'
|
|
11
|
-
import { isWritableLocalEntity } from './replica.mjs'
|
|
12
|
-
|
|
13
|
-
/** 超过该毫秒未心跳则视为离线 */
|
|
14
|
-
const HEARTBEAT_STALE_MS = 120_000
|
|
15
|
-
|
|
16
|
-
const MANUAL_STATUSES = new Set(['online', 'idle', 'dnd', 'invisible', 'away', 'busy', 'offline'])
|
|
17
|
-
const PROFILE_JSON = 'profile.json'
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @param {string} entityHash 128 位 entityHash
|
|
21
|
-
* @param {{ nodeHash: string, subjectHash: string }} parsed parseEntityHash 结果
|
|
22
|
-
* @returns {object} 默认资料
|
|
23
|
-
*/
|
|
24
|
-
function getDefaultProfile(entityHash, parsed) {
|
|
25
|
-
return {
|
|
26
|
-
entityHash,
|
|
27
|
-
nodeHash: parsed.nodeHash,
|
|
28
|
-
subjectHash: parsed.subjectHash,
|
|
29
|
-
localized: {},
|
|
30
|
-
status: 'online',
|
|
31
|
-
customStatus: '',
|
|
32
|
-
lastSeenAt: 0,
|
|
33
|
-
stats: {
|
|
34
|
-
joinedAt: Date.now(),
|
|
35
|
-
messageCount: 0,
|
|
36
|
-
groupCount: 0,
|
|
37
|
-
channelCount: 0,
|
|
38
|
-
},
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @param {object} profileData 原始对象
|
|
44
|
-
* @returns {object} 可写入磁盘的资料
|
|
45
|
-
*/
|
|
46
|
-
function toStoredProfile(profileData) {
|
|
47
|
-
return {
|
|
48
|
-
entityHash: profileData.entityHash,
|
|
49
|
-
nodeHash: profileData.nodeHash,
|
|
50
|
-
subjectHash: profileData.subjectHash,
|
|
51
|
-
localized: normalizeLocalizedMap(profileData.localized),
|
|
52
|
-
status: profileData.status || 'online',
|
|
53
|
-
customStatus: String(profileData.customStatus || '').trim(),
|
|
54
|
-
lastSeenAt: profileData.lastSeenAt || 0,
|
|
55
|
-
stats: {
|
|
56
|
-
joinedAt: profileData.stats?.joinedAt || Date.now(),
|
|
57
|
-
messageCount: profileData.stats?.messageCount || 0,
|
|
58
|
-
groupCount: profileData.stats?.groupCount || 0,
|
|
59
|
-
channelCount: profileData.stats?.channelCount || 0,
|
|
60
|
-
},
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* @param {object} profile 用户资料
|
|
66
|
-
* @param {string} [viewerEntityHash] 查看者 entityHash
|
|
67
|
-
* @param {{ isSelf?: boolean }} [options] isSelf 为 true 时隐身对本人可见
|
|
68
|
-
* @returns {string} 有效状态
|
|
69
|
-
*/
|
|
70
|
-
export function computeEffectiveStatus(profile, viewerEntityHash, options = {}) {
|
|
71
|
-
const stored = String(profile?.status || 'online')
|
|
72
|
-
const isSelf = options.isSelf
|
|
73
|
-
?? (viewerEntityHash && profile?.entityHash === viewerEntityHash)
|
|
74
|
-
const lastSeen = profile?.lastSeenAt || 0
|
|
75
|
-
const recentlySeen = lastSeen > 0 && Date.now() - lastSeen < HEARTBEAT_STALE_MS
|
|
76
|
-
|
|
77
|
-
if (stored === 'invisible')
|
|
78
|
-
return isSelf ? 'invisible' : 'offline'
|
|
79
|
-
|
|
80
|
-
if (!recentlySeen)
|
|
81
|
-
return 'offline'
|
|
82
|
-
|
|
83
|
-
return stored
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @param {string} entityHash 128 位 entityHash
|
|
88
|
-
* @param {string | null} [replicaUsername] 展示默认字段用
|
|
89
|
-
* @param {{ groupId?: string, skipPresentation?: boolean, locales?: string[], infoDefaults?: object }} [options] 选项
|
|
90
|
-
* @returns {Promise<object>} 资料对象
|
|
91
|
-
*/
|
|
92
|
-
export async function getProfile(entityHash, replicaUsername = null, options = {}) {
|
|
93
|
-
const parsed = parseEntityHash(entityHash)
|
|
94
|
-
if (!parsed) throw new Error('invalid entityHash')
|
|
95
|
-
|
|
96
|
-
const store = getEntityStore()
|
|
97
|
-
const defaultProfile = getDefaultProfile(parsed.entityHash, parsed)
|
|
98
|
-
let stored = defaultProfile
|
|
99
|
-
|
|
100
|
-
const onDisk = await store.readEntityJson(parsed.entityHash, PROFILE_JSON)
|
|
101
|
-
if (onDisk)
|
|
102
|
-
stored = toStoredProfile({ ...defaultProfile, ...onDisk })
|
|
103
|
-
else if (isWritableLocalEntity(parsed.entityHash))
|
|
104
|
-
await store.writeEntityJson(parsed.entityHash, PROFILE_JSON, stored)
|
|
105
|
-
|
|
106
|
-
const locales = options.locales || ['zh-CN', 'en-UK']
|
|
107
|
-
const merged = {
|
|
108
|
-
...stored,
|
|
109
|
-
entityHash: parsed.entityHash,
|
|
110
|
-
nodeHash: parsed.nodeHash,
|
|
111
|
-
subjectHash: parsed.subjectHash,
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
if (options.skipPresentation) return merged
|
|
115
|
-
|
|
116
|
-
let { infoDefaults } = options
|
|
117
|
-
if (!infoDefaults && replicaUsername)
|
|
118
|
-
infoDefaults = await resolveInfoDefaultsForEntity(replicaUsername, parsed.entityHash, locales)
|
|
119
|
-
if (!infoDefaults)
|
|
120
|
-
infoDefaults = { name: `${parsed.subjectHash.slice(0, 8)}…${parsed.subjectHash.slice(-4)}`, avatar: '', description: '', description_markdown: '', version: '', author: '', home_page: '', issue_page: '', tags: [], links: [] }
|
|
121
|
-
|
|
122
|
-
const resolved = resolveProfilePresentation(merged, locales, infoDefaults)
|
|
123
|
-
return {
|
|
124
|
-
...merged,
|
|
125
|
-
...resolved,
|
|
126
|
-
infoDefaults,
|
|
127
|
-
localeKeys: Object.keys(merged.localized),
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* @param {string} replicaUsername 副本用户名 所有者
|
|
133
|
-
* @param {string} entityHash 128 位 entityHash
|
|
134
|
-
* @returns {Promise<void>}
|
|
135
|
-
*/
|
|
136
|
-
export async function recordHeartbeat(replicaUsername, entityHash) {
|
|
137
|
-
void replicaUsername
|
|
138
|
-
const profile = await getProfile(entityHash, null, { skipPresentation: true })
|
|
139
|
-
profile.lastSeenAt = Date.now()
|
|
140
|
-
await getEntityStore().writeEntityJson(entityHash, PROFILE_JSON, toStoredProfile(profile))
|
|
141
|
-
return { lastSeenAt: profile.lastSeenAt }
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* @param {string} replicaUsername 副本用户名 所有者
|
|
146
|
-
* @param {string} entityHash 128 位 entityHash
|
|
147
|
-
* @param {object} updates 更新内容
|
|
148
|
-
* @param {{ groupId?: string, skipPresentation?: boolean, locales?: string[] }} [options] 选项
|
|
149
|
-
* @returns {Promise<object>} 更新后的资料
|
|
150
|
-
*/
|
|
151
|
-
export async function updateProfile(replicaUsername, entityHash, updates, options = {}) {
|
|
152
|
-
if (!isWritableLocalEntity(entityHash))
|
|
153
|
-
throw new Error('entity not writable on this replica')
|
|
154
|
-
|
|
155
|
-
const profile = await getProfile(entityHash, replicaUsername, {
|
|
156
|
-
groupId: options.groupId,
|
|
157
|
-
skipPresentation: true,
|
|
158
|
-
})
|
|
159
|
-
const parsed = parseEntityHash(entityHash)
|
|
160
|
-
|
|
161
|
-
const localized = updates.localized != null
|
|
162
|
-
? normalizeLocalizedMap(updates.localized)
|
|
163
|
-
: profile.localized
|
|
164
|
-
|
|
165
|
-
const updatedProfile = toStoredProfile({
|
|
166
|
-
...profile,
|
|
167
|
-
entityHash: parsed.entityHash,
|
|
168
|
-
nodeHash: parsed.nodeHash,
|
|
169
|
-
subjectHash: parsed.subjectHash,
|
|
170
|
-
localized,
|
|
171
|
-
status: updates.status != null ? updates.status : profile.status,
|
|
172
|
-
customStatus: updates.customStatus != null ? updates.customStatus : profile.customStatus,
|
|
173
|
-
lastSeenAt: updates.lastSeenAt != null ? updates.lastSeenAt : profile.lastSeenAt,
|
|
174
|
-
stats: updates.stats ? { ...profile.stats, ...updates.stats } : profile.stats,
|
|
175
|
-
})
|
|
176
|
-
|
|
177
|
-
await getEntityStore().writeEntityJson(entityHash, PROFILE_JSON, updatedProfile)
|
|
178
|
-
if (options.skipPresentation) return updatedProfile
|
|
179
|
-
const locales = options.locales || ['zh-CN', 'en-UK']
|
|
180
|
-
const infoDefaults = await resolveInfoDefaultsForEntity(replicaUsername, entityHash, locales)
|
|
181
|
-
const resolved = resolveProfilePresentation(updatedProfile, locales, infoDefaults)
|
|
182
|
-
return { ...updatedProfile, ...resolved, infoDefaults, localeKeys: Object.keys(updatedProfile.localized) }
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* @param {string} replicaUsername 副本用户名 所有者
|
|
187
|
-
* @param {string} entityHash 128 位 entityHash
|
|
188
|
-
* @param {Buffer} fileBuffer 文件缓冲区
|
|
189
|
-
* @param {string} filename 文件名
|
|
190
|
-
* @returns {Promise<string>} 头像 URL
|
|
191
|
-
*/
|
|
192
|
-
export async function uploadAvatar(replicaUsername, entityHash, fileBuffer, filename) {
|
|
193
|
-
if (!isWritableLocalEntity(entityHash))
|
|
194
|
-
throw new Error('entity not writable on this replica')
|
|
195
|
-
|
|
196
|
-
const { putFileManifest } = await import('./files/evfs.mjs')
|
|
197
|
-
await putFileManifest({
|
|
198
|
-
ownerEntityHash: entityHash,
|
|
199
|
-
logicalPath: 'profile/avatar',
|
|
200
|
-
plaintext: fileBuffer,
|
|
201
|
-
name: filename || 'avatar',
|
|
202
|
-
mimeType: 'image/png',
|
|
203
|
-
ceMode: 'convergent',
|
|
204
|
-
})
|
|
205
|
-
|
|
206
|
-
const avatarUrl = profileAvatarFileUrl(entityHash)
|
|
207
|
-
const profile = await getProfile(entityHash, replicaUsername, { skipPresentation: true })
|
|
208
|
-
await updateProfile(replicaUsername, entityHash, {
|
|
209
|
-
localized: applyAvatarToAllLocales(profile.localized, avatarUrl),
|
|
210
|
-
}, { skipPresentation: true })
|
|
211
|
-
return avatarUrl
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* @param {string} entityHash 128 位 entityHash
|
|
216
|
-
* @returns {Promise<object>} 统计字段
|
|
217
|
-
*/
|
|
218
|
-
export async function getStats(entityHash) {
|
|
219
|
-
const profile = await getProfile(entityHash)
|
|
220
|
-
return profile.stats
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* @param {string} replicaUsername 副本用户名 所有者
|
|
225
|
-
* @param {string} entityHash 128 位 entityHash
|
|
226
|
-
* @param {string} status 状态
|
|
227
|
-
* @param {string} [customStatus] 自定义状态
|
|
228
|
-
* @returns {Promise<{ status: string, customStatus: string, lastSeenAt: number }>} 更新后的状态字段
|
|
229
|
-
*/
|
|
230
|
-
export async function updateStatus(replicaUsername, entityHash, status, customStatus = '') {
|
|
231
|
-
if (!MANUAL_STATUSES.has(status))
|
|
232
|
-
throw new Error('invalid status')
|
|
233
|
-
const updated = await updateProfile(replicaUsername, entityHash, {
|
|
234
|
-
status,
|
|
235
|
-
customStatus,
|
|
236
|
-
lastSeenAt: Date.now(),
|
|
237
|
-
}, { skipPresentation: true })
|
|
238
|
-
return {
|
|
239
|
-
status: updated.status,
|
|
240
|
-
customStatus: updated.customStatus,
|
|
241
|
-
lastSeenAt: updated.lastSeenAt,
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* 确保本节点操作者实体目录存在。
|
|
247
|
-
* @param {string} replicaUsername 副本用户名 所有者
|
|
248
|
-
* @param {string} entityHash 128 位 entityHash
|
|
249
|
-
* @returns {Promise<object>} 本节点实体资料
|
|
250
|
-
*/
|
|
251
|
-
export async function ensureLocalEntityProfile(replicaUsername, entityHash) {
|
|
252
|
-
void replicaUsername
|
|
253
|
-
if (!isWritableLocalEntity(entityHash))
|
|
254
|
-
throw new Error('entity not on local node')
|
|
255
|
-
return getProfile(entityHash, replicaUsername, { skipPresentation: true })
|
|
256
|
-
}
|