@steve02081504/fount-p2p 0.0.1 → 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/README.md +2 -1
- package/core/entity_id.mjs +4 -4
- package/deno.json +4 -0
- package/discovery/bt.mjs +1 -1
- package/entity/files/evfs.mjs +19 -0
- package/entity/replica.mjs +4 -4
- package/federation/{operator_key_chain.mjs → entity_key_chain.mjs} +32 -33
- package/federation/manifest_fetch_pending.mjs +90 -0
- package/files/chunk_fetch.mjs +3 -34
- 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/files/transfer_key.mjs +2 -4
- package/governance/join_pow.mjs +1 -1
- package/link/channel_mux.mjs +3 -1
- package/link/link.mjs +1 -1
- package/link/sdp_fingerprint.mjs +1 -1
- package/mailbox/store.mjs +1 -1
- package/node/denylist.mjs +2 -2
- package/node/identity.mjs +5 -5
- package/node/personal_block.mjs +1 -1
- package/overlay/index.mjs +1 -1
- package/package.json +2 -1
- package/transport/rtc_connection_budget.mjs +2 -0
- package/transport/stale_peer_log.mjs +0 -3
- package/wire/part_invoke.mjs +1 -1
- package/wire/rate_bucket.mjs +1 -1
- 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/utils/debug_log.mjs +0 -16
|
@@ -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/files/transfer_key.mjs
CHANGED
|
@@ -24,8 +24,7 @@ export async function resolveContentKey(descriptor, manifest, deps = {}) {
|
|
|
24
24
|
return null
|
|
25
25
|
|
|
26
26
|
if (type === 'file-master-key-wrap') {
|
|
27
|
-
const groupId = descriptor
|
|
28
|
-
const fileId = descriptor.fileId
|
|
27
|
+
const { groupId, fileId } = descriptor
|
|
29
28
|
if (!groupId || !fileId || !descriptor.wrappedKey || !deps.getGroupFileMasterKey) return null
|
|
30
29
|
const groupKey = await deps.getGroupFileMasterKey(String(groupId), descriptor.keyGeneration)
|
|
31
30
|
if (!groupKey) return null
|
|
@@ -33,8 +32,7 @@ export async function resolveContentKey(descriptor, manifest, deps = {}) {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
if (type === 'vault-wrap') {
|
|
36
|
-
const entityHash = descriptor
|
|
37
|
-
const fileId = descriptor.fileId
|
|
35
|
+
const { entityHash, fileId } = descriptor
|
|
38
36
|
if (!entityHash || !fileId || !descriptor.wrappedKey || !deps.getVaultMasterKey) return null
|
|
39
37
|
const vaultKey = await deps.getVaultMasterKey(String(entityHash))
|
|
40
38
|
if (!vaultKey) return null
|
package/governance/join_pow.mjs
CHANGED
|
@@ -101,7 +101,7 @@ export function verifyJoinPow(powSolution, opts) {
|
|
|
101
101
|
if (!powSolution || typeof powSolution !== 'object') return { ok: false, achievedBits: 0 }
|
|
102
102
|
|
|
103
103
|
const anchorRef = String(powSolution.anchorRef ?? '').trim()
|
|
104
|
-
const nonce = powSolution
|
|
104
|
+
const { nonce } = powSolution
|
|
105
105
|
const epoch = Number(powSolution.epoch)
|
|
106
106
|
const joinerNodeHash = String(powSolution.joinerNodeHash ?? opts.senderNodeHash ?? '').trim().toLowerCase()
|
|
107
107
|
const senderNodeHash = String(opts.senderNodeHash ?? '').trim().toLowerCase()
|
package/link/channel_mux.mjs
CHANGED
|
@@ -43,6 +43,8 @@ const DEFAULT_ACTION_PRIORITIES = Object.freeze({
|
|
|
43
43
|
fed_chunk_get: 5,
|
|
44
44
|
fed_chunk_data: 5,
|
|
45
45
|
fed_chunk_ack: 5,
|
|
46
|
+
fed_manifest_get: 5,
|
|
47
|
+
fed_manifest_data: 5,
|
|
46
48
|
fed_partition_bridge: 5,
|
|
47
49
|
fed_volatile: 10,
|
|
48
50
|
})
|
|
@@ -131,7 +133,7 @@ export function onBufferedAmountLow(channel, cb) {
|
|
|
131
133
|
* @returns {{ enqueue: (action: string, bytes: Uint8Array, preferredChannel?: 'control' | 'bulk') => void, flush: (channelName?: 'control' | 'bulk') => void, pending: () => { control: number, bulk: number }, clear: () => void }} 发送队列 API
|
|
132
134
|
*/
|
|
133
135
|
export function createChannelSendQueues(opts) {
|
|
134
|
-
const getChannel = opts
|
|
136
|
+
const { getChannel } = opts
|
|
135
137
|
const highWatermarkBytes = Math.max(CHANNEL_LOW_THRESHOLD_BYTES, Number(opts.highWatermarkBytes) || CHANNEL_HIGH_WATERMARK_BYTES)
|
|
136
138
|
/** @type {{ control: Array<{ priority: number, seq: number, bytes: Uint8Array }>, bulk: Array<{ priority: number, seq: number, bytes: Uint8Array }> }} */
|
|
137
139
|
const queues = { control: [], bulk: [] }
|
package/link/link.mjs
CHANGED
|
@@ -326,7 +326,7 @@ export async function createLink(opts) {
|
|
|
326
326
|
* @returns {boolean} 应重新入队则 true
|
|
327
327
|
*/
|
|
328
328
|
function shouldRetryQueuedIce(error) {
|
|
329
|
-
return /without
|
|
329
|
+
return /without ice transport/i.test(String(error?.message ?? error ?? ''))
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
/**
|
package/link/sdp_fingerprint.mjs
CHANGED
|
@@ -20,6 +20,6 @@ export function normalizeDtlsFingerprint(value) {
|
|
|
20
20
|
* @returns {string | null} 规范化 fingerprint,未找到时返回 null
|
|
21
21
|
*/
|
|
22
22
|
export function extractDtlsFingerprint(sdp) {
|
|
23
|
-
const line = String(sdp || '').match(/^a=fingerprint:sha-256\s+([
|
|
23
|
+
const line = String(sdp || '').match(/^a=fingerprint:sha-256\s+([\d:A-Fa-f]+)$/mu)?.[1]
|
|
24
24
|
return normalizeDtlsFingerprint(line)
|
|
25
25
|
}
|
package/mailbox/store.mjs
CHANGED
|
@@ -162,7 +162,7 @@ export async function storeMailboxRecord(record) {
|
|
|
162
162
|
const toPubKeyHash = normalizeHex64(record.toPubKeyHash)
|
|
163
163
|
if (!isHex64(toPubKeyHash)) return false
|
|
164
164
|
const hop = normalizeMailboxHop(record.hop)
|
|
165
|
-
const tier = record
|
|
165
|
+
const { tier } = record
|
|
166
166
|
if (!['trusted', 'normal', 'quarantine'].includes(tier)) return false
|
|
167
167
|
const app = String(record.app || '').trim()
|
|
168
168
|
if (!app) return false
|
package/node/denylist.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function invalidateDenylistIndex() {
|
|
|
69
69
|
function getDenylistIndex() {
|
|
70
70
|
if (cachedIndex) return cachedIndex
|
|
71
71
|
const raw = readNodeJsonSync(DATA_NAME)
|
|
72
|
-
const blocked = normalizeDenylist(raw)
|
|
72
|
+
const { blocked } = normalizeDenylist(raw)
|
|
73
73
|
cachedIndex = buildDenylistIndex(blocked)
|
|
74
74
|
return cachedIndex
|
|
75
75
|
}
|
|
@@ -111,7 +111,7 @@ export function loadDenylist() {
|
|
|
111
111
|
* @returns {void}
|
|
112
112
|
*/
|
|
113
113
|
export function saveDenylist(list) {
|
|
114
|
-
const blocked = normalizeDenylist(list)
|
|
114
|
+
const { blocked } = normalizeDenylist(list)
|
|
115
115
|
writeNodeJsonSync(DATA_NAME, { blocked })
|
|
116
116
|
cachedIndex = buildDenylistIndex(blocked)
|
|
117
117
|
}
|
package/node/identity.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { entityHashFromRecoveryPubKeyHex } from '../core/entity_id.mjs'
|
|
4
4
|
import { isHex64 } from '../core/hexIds.mjs'
|
|
5
5
|
import { nodeHashFromSeed } from '../entity/node_hash.mjs'
|
|
6
6
|
import { normalizeMailboxSettings } from '../mailbox/settings.mjs'
|
|
@@ -8,7 +8,7 @@ import { normalizeMailboxSettings } from '../mailbox/settings.mjs'
|
|
|
8
8
|
import { emitNodeChange } from './instance.mjs'
|
|
9
9
|
import { readNodeJsonSync, writeNodeJsonSync } from './storage.mjs'
|
|
10
10
|
|
|
11
|
-
const NODE_SEED_HEX_RE = /^[
|
|
11
|
+
const NODE_SEED_HEX_RE = /^[\da-f]{64}$/iu
|
|
12
12
|
const NODE_JSON = 'node'
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -90,10 +90,10 @@ export function ensureNodeDefaults() {
|
|
|
90
90
|
/**
|
|
91
91
|
* @param {string} nodeHash 64 位十六进制
|
|
92
92
|
* @param {string} recoveryPubKeyHex 64 位十六进制 recovery 公钥(稳定身份锚)
|
|
93
|
-
* @returns {string | null}
|
|
93
|
+
* @returns {string | null} entityHash(非法 hex 时 null)
|
|
94
94
|
*/
|
|
95
|
-
export function
|
|
95
|
+
export function entityHashFromKeys(nodeHash, recoveryPubKeyHex) {
|
|
96
96
|
const pub = String(recoveryPubKeyHex || '').trim().toLowerCase().replace(/^0x/iu, '')
|
|
97
97
|
if (!isHex64(nodeHash) || !isHex64(pub)) return null
|
|
98
|
-
return
|
|
98
|
+
return entityHashFromRecoveryPubKeyHex(nodeHash, pub)
|
|
99
99
|
}
|
package/node/personal_block.mjs
CHANGED
package/overlay/index.mjs
CHANGED
|
@@ -25,7 +25,7 @@ function routeSignBytes(reqId, path) {
|
|
|
25
25
|
export function createOverlayRouter(registry, ttl = 3) {
|
|
26
26
|
const selfNodeHash = registry.localIdentity.nodeHash
|
|
27
27
|
const selfPubKey = registry.localIdentity.nodePubKey
|
|
28
|
-
const secretKey = registry.localIdentity
|
|
28
|
+
const { secretKey } = registry.localIdentity
|
|
29
29
|
const seenReqs = createLruMap(4096)
|
|
30
30
|
/** @type {Map<string, { resolve: (path: string[]) => void, reject: (err: Error) => void, timer: number }>} */
|
|
31
31
|
const pendingRoutes = new Map()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve02081504/fount-p2p",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "fount federation P2P layer — link, trust graph, mailbox, DAG, EVFS.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"network",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"test": "node --test --test-concurrency=1 --test-force-exit test/pure/*.test.mjs test/integration/*.test.mjs",
|
|
22
|
+
"test:fount": "deno test -A --config deno.json test/fount/",
|
|
22
23
|
"test:live": "node --test --test-concurrency=1 --test-force-exit test/live/*.test.mjs",
|
|
23
24
|
"test:sim": "node --test --test-concurrency=1 --test-force-exit sim/test/*.test.mjs"
|
|
24
25
|
},
|
|
@@ -3,9 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 无回退行为——仅在发生处记录异常「身份映射滞后于活跃连接」
|
|
5
5
|
* (群/房间 + peerId + nodeHash),以便追踪 onPeerLeave 遗漏。
|
|
6
|
-
* 计数通过 catchup stats 暴露给测试;磁盘记录可在 debug_logs/ 下 grep。
|
|
7
6
|
*/
|
|
8
|
-
import { debugLog } from '../utils/debug_log.mjs'
|
|
9
7
|
|
|
10
8
|
/** @type {Map<string, number>} scopeId → 累计修剪次数 */
|
|
11
9
|
const pruneCounts = new Map()
|
|
@@ -31,7 +29,6 @@ export function recordStalePeerPrune(scope, staleEntries, meta = {}) {
|
|
|
31
29
|
lines.push(JSON.stringify(record))
|
|
32
30
|
}
|
|
33
31
|
while (recent.length > RECENT_CAP) recent.shift()
|
|
34
|
-
void debugLog('federation_stale_peer', `${lines.join('\n')}\n`).catch(() => { /* best-effort observability */ })
|
|
35
32
|
}
|
|
36
33
|
|
|
37
34
|
/**
|
package/wire/part_invoke.mjs
CHANGED
package/wire/rate_bucket.mjs
CHANGED
|
@@ -18,7 +18,7 @@ const buckets = createLruMap(BUCKET_MAP_MAX)
|
|
|
18
18
|
export function consumeWireRateBucket(bucketKey, limits) {
|
|
19
19
|
const byteCount = Math.max(0, Number(limits.byteCount) || 0)
|
|
20
20
|
const maxBytes = Number(limits.maxBytesPerWindow) || 0
|
|
21
|
-
const maxCount = limits
|
|
21
|
+
const { maxCount } = limits
|
|
22
22
|
const now = Date.now()
|
|
23
23
|
let bucket = buckets.get(bucketKey)
|
|
24
24
|
if (!bucket) {
|
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
|
-
}
|