@steve02081504/fount-p2p 0.0.2 → 0.0.4
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 +1 -1
- package/{entity → core}/logical_entity.mjs +2 -1
- package/federation/manifest_fetch_pending.mjs +90 -0
- package/{entity/files → files}/acl.mjs +3 -3
- package/files/chunk_fetch.mjs +2 -33
- package/files/chunk_responder.mjs +60 -2
- package/{entity/files → files}/evfs.mjs +39 -21
- package/{entity/files → files}/evfs_ref.mjs +3 -5
- package/files/fetch_fanout.mjs +38 -0
- package/{entity/files → files}/manifest_acl_registry.mjs +4 -4
- package/files/manifest_fetch.mjs +97 -0
- package/files/public_manifest.mjs +153 -0
- package/link/channel_mux.mjs +2 -0
- package/node/identity.mjs +33 -2
- package/node/personal_block.mjs +1 -1
- package/package.json +1 -3
- package/transport/rtc_connection_budget.mjs +2 -0
- package/entity/files/replica_host_cache.mjs +0 -46
- package/entity/files/url.mjs +0 -19
- package/entity/hosting_registry.mjs +0 -47
- package/entity/localized_core.mjs +0 -141
- package/entity/logical_entity_id_registry.mjs +0 -40
- package/entity/node_hash.mjs +0 -15
- package/entity/presentation_registry.mjs +0 -44
- package/entity/profile.mjs +0 -256
- package/entity/replica.mjs +0 -20
- package/entity/session_snapshot_registry.mjs +0 -38
- package/registries/p2p_viewer.mjs +0 -35
|
@@ -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 { putFileManifest, saveFileManifest } from './evfs.mjs'
|
|
10
|
+
import { normalizeFileManifest, publicTransferKeyDescriptor } from './manifest.mjs'
|
|
11
|
+
|
|
12
|
+
/** 实体公开 manifest 签名域 */
|
|
13
|
+
export const ENTITY_PUBLIC_MANIFEST_DOMAIN = 'fount-entity-public-manifest'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} fields 待签名字段
|
|
17
|
+
* @returns {Buffer} 签名消息字节
|
|
18
|
+
*/
|
|
19
|
+
export function publicManifestSignBytes(fields) {
|
|
20
|
+
return Buffer.from(canonicalStringify([
|
|
21
|
+
ENTITY_PUBLIC_MANIFEST_DOMAIN,
|
|
22
|
+
String(fields.ownerEntityHash || '').trim().toLowerCase(),
|
|
23
|
+
assertSafeEvfsLogicalPath(fields.logicalPath),
|
|
24
|
+
Number(fields.publishedAt) || 0,
|
|
25
|
+
String(fields.contentHash || '').trim().toLowerCase(),
|
|
26
|
+
Number(fields.size) || 0,
|
|
27
|
+
String(fields.mimeType || ''),
|
|
28
|
+
String(fields.name || ''),
|
|
29
|
+
String(fields.ceMode || ''),
|
|
30
|
+
fields.parts || [],
|
|
31
|
+
]), 'utf8')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {import('./manifest.mjs').FileManifest} manifest 清单
|
|
36
|
+
* @param {number} publishedAt 发布时间
|
|
37
|
+
* @param {Uint8Array | Buffer} entitySecretKey recovery 私钥种子
|
|
38
|
+
* @param {string} entityPubKeyHex recovery 公钥 hex
|
|
39
|
+
* @returns {Promise<import('./manifest.mjs').FileManifest>} 带 publicSig 的清单
|
|
40
|
+
*/
|
|
41
|
+
export async function attachPublicManifestSig(manifest, publishedAt, entitySecretKey, entityPubKeyHex) {
|
|
42
|
+
const pubKeyHex = normalizeHex64(entityPubKeyHex)
|
|
43
|
+
const message = publicManifestSignBytes({
|
|
44
|
+
ownerEntityHash: manifest.ownerEntityHash,
|
|
45
|
+
logicalPath: manifest.logicalPath,
|
|
46
|
+
publishedAt,
|
|
47
|
+
contentHash: manifest.contentHash,
|
|
48
|
+
size: manifest.size,
|
|
49
|
+
mimeType: manifest.mimeType,
|
|
50
|
+
name: manifest.name,
|
|
51
|
+
ceMode: manifest.ceMode,
|
|
52
|
+
parts: manifest.parts,
|
|
53
|
+
})
|
|
54
|
+
const sigHex = Buffer.from(await sign(message, entitySecretKey)).toString('hex')
|
|
55
|
+
return {
|
|
56
|
+
...manifest,
|
|
57
|
+
meta: {
|
|
58
|
+
...manifest.meta || {},
|
|
59
|
+
publicSig: { publishedAt, pubKeyHex, sigHex },
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {unknown} input 原始 manifest
|
|
66
|
+
* @returns {Promise<import('./manifest.mjs').FileManifest | null>} 验签通过的清单;非法为 null
|
|
67
|
+
*/
|
|
68
|
+
export async function verifySignedPublicManifest(input) {
|
|
69
|
+
const manifest = normalizeFileManifest(input)
|
|
70
|
+
if (!manifest) return null
|
|
71
|
+
if (manifest.transferKeyDescriptor.type !== 'public') return null
|
|
72
|
+
|
|
73
|
+
const publicSig = input?.meta?.publicSig
|
|
74
|
+
if (!publicSig || typeof publicSig !== 'object') return null
|
|
75
|
+
const publishedAt = Number(publicSig.publishedAt) || 0
|
|
76
|
+
const pubKeyHex = normalizeHex64(publicSig.pubKeyHex)
|
|
77
|
+
const sigHex = String(publicSig.sigHex || '').trim().toLowerCase()
|
|
78
|
+
if (!isHex64(pubKeyHex) || !/^[\da-f]{128}$/u.test(sigHex) || publishedAt <= 0) return null
|
|
79
|
+
|
|
80
|
+
const parsed = parseEntityHash(manifest.ownerEntityHash)
|
|
81
|
+
if (!parsed) return null
|
|
82
|
+
if (hashFromPubKeyHex(pubKeyHex) !== parsed.subjectHash) return null
|
|
83
|
+
|
|
84
|
+
const message = publicManifestSignBytes({
|
|
85
|
+
ownerEntityHash: manifest.ownerEntityHash,
|
|
86
|
+
logicalPath: manifest.logicalPath,
|
|
87
|
+
publishedAt,
|
|
88
|
+
contentHash: manifest.contentHash,
|
|
89
|
+
size: manifest.size,
|
|
90
|
+
mimeType: manifest.mimeType,
|
|
91
|
+
name: manifest.name,
|
|
92
|
+
ceMode: manifest.ceMode,
|
|
93
|
+
parts: manifest.parts,
|
|
94
|
+
})
|
|
95
|
+
const ok = await verify(Buffer.from(sigHex, 'hex'), message, Buffer.from(pubKeyHex, 'hex'))
|
|
96
|
+
if (!ok) return null
|
|
97
|
+
|
|
98
|
+
// 签名只覆盖内容字段:入站 meta 一律丢弃,仅保留 publicSig,
|
|
99
|
+
// 防止中继注入 dagParts/groupId 等本地扩展改写读取路径。
|
|
100
|
+
return {
|
|
101
|
+
...manifest,
|
|
102
|
+
meta: { publicSig: { publishedAt, pubKeyHex, sigHex } },
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {object | null | undefined} localManifest 本地已有清单
|
|
108
|
+
* @param {import('./manifest.mjs').FileManifest} incoming 入站已验签清单
|
|
109
|
+
* @returns {boolean} 是否应以 incoming 覆盖本地缓存
|
|
110
|
+
*/
|
|
111
|
+
export function shouldPreferIncomingPublicManifest(localManifest, incoming) {
|
|
112
|
+
const incomingAt = Number(incoming?.meta?.publicSig?.publishedAt) || 0
|
|
113
|
+
if (incomingAt <= 0) return false
|
|
114
|
+
const localAt = Number(localManifest?.meta?.publicSig?.publishedAt) || 0
|
|
115
|
+
return incomingAt > localAt
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {object} params 参数
|
|
120
|
+
* @param {string} params.ownerEntityHash owner
|
|
121
|
+
* @param {string} params.logicalPath 路径
|
|
122
|
+
* @param {Buffer | Uint8Array} params.plaintext 明文
|
|
123
|
+
* @param {string} [params.name] 文件名
|
|
124
|
+
* @param {string} [params.mimeType] MIME
|
|
125
|
+
* @param {Uint8Array | Buffer} params.entitySecretKey recovery 私钥种子
|
|
126
|
+
* @param {string} params.entityPubKeyHex recovery 公钥 hex
|
|
127
|
+
* @param {number} [params.publishedAt] 发布时间(默认 Date.now)
|
|
128
|
+
* @returns {Promise<import('./manifest.mjs').FileManifest>} 已签名并落盘的公开清单
|
|
129
|
+
*/
|
|
130
|
+
export async function publishPublicFile(params) {
|
|
131
|
+
const {
|
|
132
|
+
ownerEntityHash,
|
|
133
|
+
logicalPath,
|
|
134
|
+
plaintext,
|
|
135
|
+
name,
|
|
136
|
+
mimeType,
|
|
137
|
+
entitySecretKey,
|
|
138
|
+
entityPubKeyHex,
|
|
139
|
+
publishedAt = Date.now(),
|
|
140
|
+
} = params
|
|
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/node/identity.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
1
2
|
import { randomBytes } from 'node:crypto'
|
|
2
3
|
|
|
3
|
-
import { entityHashFromRecoveryPubKeyHex } from '../core/entity_id.mjs'
|
|
4
|
+
import { entityHashFromRecoveryPubKeyHex, parseEntityHash } from '../core/entity_id.mjs'
|
|
4
5
|
import { isHex64 } from '../core/hexIds.mjs'
|
|
5
|
-
import {
|
|
6
|
+
import { keyPairFromSeed, pubKeyHash } from '../crypto/crypto.mjs'
|
|
6
7
|
import { normalizeMailboxSettings } from '../mailbox/settings.mjs'
|
|
7
8
|
|
|
8
9
|
import { emitNodeChange } from './instance.mjs'
|
|
@@ -11,6 +12,18 @@ import { readNodeJsonSync, writeNodeJsonSync } from './storage.mjs'
|
|
|
11
12
|
const NODE_SEED_HEX_RE = /^[\da-f]{64}$/iu
|
|
12
13
|
const NODE_JSON = 'node'
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* 由持久化 nodeSeed 派生 nodeHash(64 hex)。
|
|
17
|
+
* @param {string} seedHex 32 字节 hex
|
|
18
|
+
* @returns {string} 节点哈希
|
|
19
|
+
*/
|
|
20
|
+
export function nodeHashFromSeed(seedHex) {
|
|
21
|
+
const seed = Buffer.from(String(seedHex).trim(), 'hex')
|
|
22
|
+
if (seed.length !== 32) throw new Error('invalid node seed')
|
|
23
|
+
const { publicKey } = keyPairFromSeed(seed)
|
|
24
|
+
return pubKeyHash(publicKey)
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
/**
|
|
15
28
|
* @returns {object} 节点配置磁盘对象
|
|
16
29
|
*/
|
|
@@ -97,3 +110,21 @@ export function entityHashFromKeys(nodeHash, recoveryPubKeyHex) {
|
|
|
97
110
|
if (!isHex64(nodeHash) || !isHex64(pub)) return null
|
|
98
111
|
return entityHashFromRecoveryPubKeyHex(nodeHash, pub)
|
|
99
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {string} recoveryPubKeyHex 64 位十六进制 recovery 公钥
|
|
116
|
+
* @returns {string | null} 本节点 entityHash
|
|
117
|
+
*/
|
|
118
|
+
export function resolveLocalEntityHashFromRecoveryPubKeyHex(recoveryPubKeyHex) {
|
|
119
|
+
return entityHashFromKeys(getNodeHash(), recoveryPubKeyHex)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} entityHash 目标 entityHash
|
|
124
|
+
* @returns {boolean} 是否为本节点可写实体
|
|
125
|
+
*/
|
|
126
|
+
export function isWritableLocalEntity(entityHash) {
|
|
127
|
+
const parsed = parseEntityHash(entityHash)
|
|
128
|
+
if (!parsed) return false
|
|
129
|
+
return parsed.nodeHash === getNodeHash()
|
|
130
|
+
}
|
package/node/personal_block.mjs
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { parseEntityHash } from '../core/entity_id.mjs'
|
|
7
7
|
import { isHex64, normalizeHex64 } from '../core/hexIds.mjs'
|
|
8
|
-
import { isWritableLocalEntity } from '../entity/replica.mjs'
|
|
9
8
|
|
|
9
|
+
import { isWritableLocalEntity } from './identity.mjs'
|
|
10
10
|
import { isNodeInitialized, getEntityStore } from './instance.mjs'
|
|
11
11
|
|
|
12
12
|
/** @typedef {'subject' | 'entity'} PersonalListScope */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve02081504/fount-p2p",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"network",
|
|
@@ -45,8 +45,6 @@
|
|
|
45
45
|
"./rooms/*": "./rooms/*.mjs",
|
|
46
46
|
"./overlay": "./overlay/index.mjs",
|
|
47
47
|
"./overlay/*": "./overlay/*.mjs",
|
|
48
|
-
"./entity/*": "./entity/*.mjs",
|
|
49
|
-
"./entity/files/*": "./entity/files/*.mjs",
|
|
50
48
|
"./trust_graph/*": "./trust_graph/*.mjs",
|
|
51
49
|
"./mailbox/*": "./mailbox/*.mjs",
|
|
52
50
|
"./dag": "./dag/index.mjs",
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { getEntityStore } from '../../node/instance.mjs'
|
|
2
|
-
|
|
3
|
-
/** @type {Map<string, { replica: string | null, at: number }>} */
|
|
4
|
-
const replicaHostCache = new Map()
|
|
5
|
-
|
|
6
|
-
const REPLICA_HOST_CACHE_MS = 60_000
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @param {string | string[]} [entityHash] owner(单个或批量)
|
|
10
|
-
* @returns {void}
|
|
11
|
-
*/
|
|
12
|
-
export function invalidateReplicaHostCache(entityHash) {
|
|
13
|
-
if (Array.isArray(entityHash)) {
|
|
14
|
-
for (const eh of entityHash)
|
|
15
|
-
replicaHostCache.delete(String(eh).trim().toLowerCase())
|
|
16
|
-
return
|
|
17
|
-
}
|
|
18
|
-
if (entityHash) {
|
|
19
|
-
replicaHostCache.delete(String(entityHash).trim().toLowerCase())
|
|
20
|
-
return
|
|
21
|
-
}
|
|
22
|
-
replicaHostCache.clear()
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @param {string} ownerEntityHash 所有者
|
|
27
|
-
* @returns {Promise<string | null>} 托管 manifest 的 replica
|
|
28
|
-
*/
|
|
29
|
-
export async function findReplicaHostingEntityFiles(ownerEntityHash) {
|
|
30
|
-
const eh = String(ownerEntityHash).trim().toLowerCase()
|
|
31
|
-
const cached = replicaHostCache.get(eh)
|
|
32
|
-
if (cached && Date.now() - cached.at < REPLICA_HOST_CACHE_MS)
|
|
33
|
-
return cached.replica
|
|
34
|
-
|
|
35
|
-
const store = getEntityStore()
|
|
36
|
-
if (typeof store.findHostingUser === 'function') {
|
|
37
|
-
const replica = await store.findHostingUser(eh)
|
|
38
|
-
replicaHostCache.set(eh, { replica, at: Date.now() })
|
|
39
|
-
return replica
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const files = await store.listEntityFiles(eh)
|
|
43
|
-
const replica = files.length ? '__node__' : null
|
|
44
|
-
replicaHostCache.set(eh, { replica, at: Date.now() })
|
|
45
|
-
return replica
|
|
46
|
-
}
|
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,47 +0,0 @@
|
|
|
1
|
-
/** @type {((username: string, entityHash: string) => string | null) | null} */
|
|
2
|
-
let resolveAgentCharPartName = null
|
|
3
|
-
|
|
4
|
-
/** @type {((username: string) => { entityHash: string, charPartName: string }[]) | null} */
|
|
5
|
-
let listLocalAgents = null
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Chat Load 时注册:解析本地 agent 的 chars 目录名。
|
|
9
|
-
* @param {(username: string, entityHash: string) => string | null} resolver 解析函数
|
|
10
|
-
* @returns {void}
|
|
11
|
-
*/
|
|
12
|
-
export function registerAgentCharResolver(resolver) {
|
|
13
|
-
resolveAgentCharPartName = resolver
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Chat Load 时注册:枚举 replica 下本地 agent 实体。
|
|
18
|
-
* @param {(username: string) => { entityHash: string, charPartName: string }[]} provider 枚举函数
|
|
19
|
-
* @returns {void}
|
|
20
|
-
*/
|
|
21
|
-
export function registerListLocalAgentsProvider(provider) {
|
|
22
|
-
listLocalAgents = provider
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** @returns {void} */
|
|
26
|
-
export function unregisterAgentCharResolver() {
|
|
27
|
-
resolveAgentCharPartName = null
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** @returns {void} */
|
|
31
|
-
export function unregisterListLocalAgentsProvider() {
|
|
32
|
-
listLocalAgents = null
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* @returns {((username: string, entityHash: string) => string | null) | null} 已注册解析器
|
|
37
|
-
*/
|
|
38
|
-
export function getAgentCharResolver() {
|
|
39
|
-
return resolveAgentCharPartName
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* @returns {((username: string) => { entityHash: string, charPartName: string }[]) | null} 已注册枚举器
|
|
44
|
-
*/
|
|
45
|
-
export function getListLocalAgentsProvider() {
|
|
46
|
-
return listLocalAgents
|
|
47
|
-
}
|
|
@@ -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,40 +0,0 @@
|
|
|
1
|
-
/** @type {Map<string, (username: string, entityHash: string) => Promise<string | null>>} */
|
|
2
|
-
const resolversByOwner = new Map()
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* @param {string} ownerId 注册方
|
|
6
|
-
* @param {(username: string, entityHash: string) => Promise<string | null>} resolver logical entity id 反查
|
|
7
|
-
* @returns {void}
|
|
8
|
-
*/
|
|
9
|
-
export function registerLogicalEntityIdResolver(ownerId, resolver) {
|
|
10
|
-
resolversByOwner.set(String(ownerId), resolver)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* @param {string} ownerId 注册方
|
|
15
|
-
* @returns {void}
|
|
16
|
-
*/
|
|
17
|
-
export function unregisterLogicalEntityIdResolver(ownerId) {
|
|
18
|
-
resolversByOwner.delete(String(ownerId))
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** @returns {void} */
|
|
22
|
-
export function clearLogicalEntityIdRegistry() {
|
|
23
|
-
resolversByOwner.clear()
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @param {string} username 副本用户名
|
|
28
|
-
* @param {string} entityHash 128 位十六进制 logical entity
|
|
29
|
-
* @returns {Promise<string | null>} 逻辑实体 id
|
|
30
|
-
*/
|
|
31
|
-
export async function resolveLogicalEntityId(username, entityHash) {
|
|
32
|
-
for (const resolver of resolversByOwner.values())
|
|
33
|
-
try {
|
|
34
|
-
const id = await resolver(username, entityHash)
|
|
35
|
-
if (id) return id
|
|
36
|
-
}
|
|
37
|
-
catch { /* next */ }
|
|
38
|
-
|
|
39
|
-
return null
|
|
40
|
-
}
|
package/entity/node_hash.mjs
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { Buffer } from 'node:buffer'
|
|
2
|
-
|
|
3
|
-
import { keyPairFromSeed, pubKeyHash } from '../crypto/crypto.mjs'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 由持久化 nodeSeed 派生 nodeHash(64 hex)。
|
|
7
|
-
* @param {string} seedHex 32 字节 hex
|
|
8
|
-
* @returns {string} 节点哈希
|
|
9
|
-
*/
|
|
10
|
-
export function nodeHashFromSeed(seedHex) {
|
|
11
|
-
const seed = Buffer.from(String(seedHex).trim(), 'hex')
|
|
12
|
-
if (seed.length !== 32) throw new Error('invalid node seed')
|
|
13
|
-
const { publicKey } = keyPairFromSeed(seed)
|
|
14
|
-
return pubKeyHash(publicKey)
|
|
15
|
-
}
|
|
@@ -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
|
-
}
|