@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.
@@ -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
+ }
@@ -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.groupId
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.entityHash
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
@@ -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.nonce
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()
@@ -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.getChannel
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 ICE transport/i.test(String(error?.message ?? error ?? ''))
329
+ return /without ice transport/i.test(String(error?.message ?? error ?? ''))
330
330
  }
331
331
 
332
332
  /**
@@ -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+([0-9A-Fa-f:]+)$/mu)?.[1]
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.tier
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).blocked
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).blocked
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 { userEntityHashFromRecoveryPubKeyHex } from '../core/entity_id.mjs'
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 = /^[0-9a-f]{64}$/iu
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} 操作者 entityHash
93
+ * @returns {string | null} entityHash(非法 hex 时 null)
94
94
  */
95
- export function operatorEntityHashFromKeys(nodeHash, recoveryPubKeyHex) {
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 userEntityHashFromRecoveryPubKeyHex(nodeHash, pub)
98
+ return entityHashFromRecoveryPubKeyHex(nodeHash, pub)
99
99
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 按实体的个人列表(Chat 与 Social 共享)。
2
+ * 按实体的个人列表。
3
3
  * **block**:对外联邦公开拉黑(timeline block 事件 + personal_block 索引);
4
4
  * **hide**:纯本地隐藏(personal_hide.json,永不联邦)。
5
5
  */
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.secretKey
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.1",
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
  },
@@ -159,6 +159,8 @@ const NON_CRITICAL_FED_ACTIONS = new Set([
159
159
  'fed_chunk_get',
160
160
  'fed_chunk_data',
161
161
  'fed_chunk_ack',
162
+ 'fed_manifest_get',
163
+ 'fed_manifest_data',
162
164
  'part_invoke',
163
165
  'discovery_announce',
164
166
  'discovery_query',
@@ -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
  /**
@@ -76,6 +76,6 @@ export function normalizePartpath(value) {
76
76
  */
77
77
  export function isPartInvoke(value) {
78
78
  if (!isPlainObject(value)) return false
79
- const kind = value.kind
79
+ const { kind } = value
80
80
  return typeof kind === 'string' && kind.length > 0
81
81
  }
@@ -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.maxCount
21
+ const { maxCount } = limits
22
22
  const now = Date.now()
23
23
  let bucket = buckets.get(bucketKey)
24
24
  if (!bucket) {
@@ -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
- }