fix(relationships): enforce friends-only galaxy scope

This commit is contained in:
digua committed 2026-07-23 22:05:18 +08:00
1 parent 42dec0b75e
commit c6676e68a5
8 files changed
+112 -11

No files matched your search

@@ -75,7 +75,7 @@ function emptyNeighborhoodResponse(
class FakePeopleRelationshipsService implements PeopleRelationshipsService {
graphCalls: Array<{ acceptStale?: boolean; timeRangePreset?: string; query?: string; graphScope?: string }> = []
recomputeCalls: Array<{ timeRangePreset?: string; query?: string; graphScope?: string }> = []
neighborhoodCalls: Array<{ key: string; acceptStale?: boolean; timeRangePreset?: string }> = []
neighborhoodCalls: Array<{ key: string; acceptStale?: boolean; timeRangePreset?: string; graphScope?: string }> = []
closeCalls = 0
getGraph(options?: {
@@ -95,9 +95,14 @@ class FakePeopleRelationshipsService implements PeopleRelationshipsService {
getNeighborhood(
key: string,
options?: { acceptStale?: boolean; timeRangePreset?: string }
options?: { acceptStale?: boolean; timeRangePreset?: string; graphScope?: string }
): PeopleRelationshipsNeighborhoodResponse {
this.neighborhoodCalls.push({ key, acceptStale: options?.acceptStale, timeRangePreset: options?.timeRangePreset })
this.neighborhoodCalls.push({
key,
acceptStale: options?.acceptStale,
timeRangePreset: options?.timeRangePreset,
graphScope: options?.graphScope,
})
return emptyNeighborhoodResponse('missing')
}
@@ -199,7 +204,7 @@ test('POST /_web/people/relationships/recompute forwards time range, search quer
assert.deepEqual(service.recomputeCalls, [{ timeRangePreset: '3y', query: 'Bob', graphScope: 'close' }])
})
test('GET /_web/people/relationships/:key/neighborhood forwards decoded contact key', async (t) => {
test('GET /_web/people/relationships/:key/neighborhood forwards contact key and graph scope', async (t) => {
const service = new FakePeopleRelationshipsService()
const app = Fastify()
t.after(async () => app.close())
@@ -208,11 +213,15 @@ test('GET /_web/people/relationships/:key/neighborhood forwards decoded contact
const response = await app.inject({
method: 'GET',
url: `/_web/people/relationships/${encodeURIComponent('weixin:alice')}/neighborhood?acceptStale=1&timeRange=5y`,
url: `/_web/people/relationships/${encodeURIComponent(
'weixin:alice'
)}/neighborhood?acceptStale=1&timeRange=5y&scope=friends`,
})
assert.equal(response.statusCode, 200)
assert.deepEqual(service.neighborhoodCalls, [{ key: 'weixin:alice', acceptStale: true, timeRangePreset: '5y' }])
assert.deepEqual(service.neighborhoodCalls, [
{ key: 'weixin:alice', acceptStale: true, timeRangePreset: '5y', graphScope: 'friends' },
])
})
test('closes people relationships service when Fastify app closes', async () => {
@@ -57,6 +57,7 @@ export function registerPeopleRelationshipsRoutes(server: FastifyInstance, ctx:
return service.getNeighborhood(request.params.key, {
acceptStale: isTruthy(request.query.acceptStale),
timeRangePreset: parseContactsTimeRangePreset(request.query.timeRange),
graphScope: parsePeopleRelationshipsGraphScope(request.query.scope),
})
}
)
@@ -407,6 +407,56 @@ test('returns a friends relationships graph without groupmate nodes while keepin
}
})
test('keeps friend neighborhoods limited to friends and their relationships', () => {
const dir = makeTempDir()
try {
const service = createPeopleRelationshipsService({
adapter: makeAdapter(),
systemDir: dir,
runner: async () => {
throw new Error('runner should not be called for fresh injected snapshot')
},
})
const snapshot = makeSnapshot(makeFreshSignature())
const friend = makeNode({
key: 'weixin:friend',
displayName: 'Friend',
pool: 'friend',
friendSource: 'private',
rank: 5,
score: 60,
})
snapshot.nodes = [...snapshot.nodes, friend]
snapshot.edges = [...snapshot.edges, makeEdge('weixin:alice', friend.key, 10)]
service.replaceSnapshotForTests?.(snapshot)
const response = service.getNeighborhood('weixin:alice', {
acceptStale: true,
graphScope: 'friends',
})
const keys = response.graph.nodes.map((node) => node.key)
assert.deepEqual(keys, ['weixin:alice', friend.key])
assert.equal(
response.graph.nodes.every((node) => node.kind === 'owner' || node.pool === 'friend'),
true
)
assert.deepEqual(
response.graph.edges.map((edge) => [edge.sourceKey, edge.targetKey]),
[['weixin:alice', friend.key]]
)
const groupmateResponse = service.getNeighborhood('weixin:bob', {
acceptStale: true,
graphScope: 'friends',
})
assert.equal(groupmateResponse.contact, null)
assert.deepEqual(groupmateResponse.graph, { nodes: [], edges: [], communities: [] })
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('applies manual contact friend overrides to people relationships graph scopes', () => {
const dir = makeTempDir()
try {
@@ -126,8 +126,11 @@ class DefaultPeopleRelationshipsService implements PeopleRelationshipsService {
const snapshot = this.getSnapshotForResponse(timeRangePreset)
const status = this.getCacheStatus(signature, timeRangePreset)
const includeSnapshot = shouldIncludeSnapshot(status, options.acceptStale)
const graph = includeSnapshot && snapshot ? buildPeopleRelationshipsNeighborhoodGraph(snapshot, key) : emptyGraph()
const contact = includeSnapshot ? (snapshot?.nodes.find((node) => node.key === key) ?? null) : null
const graphScope = normalizePeopleRelationshipsGraphScope(options.graphScope)
const scopedSnapshot = snapshot ? buildNeighborhoodSnapshotForScope(snapshot, graphScope) : null
const graph =
includeSnapshot && scopedSnapshot ? buildPeopleRelationshipsNeighborhoodGraph(scopedSnapshot, key) : emptyGraph()
const contact = includeSnapshot ? (scopedSnapshot?.nodes.find((node) => node.key === key) ?? null) : null
return {
contact,
graph,
@@ -369,6 +372,21 @@ function buildFriendsRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot):
}
}
function buildNeighborhoodSnapshotForScope(
snapshot: PeopleRelationshipsSnapshot,
scope: PeopleRelationshipsGraphScope
): PeopleRelationshipsSnapshot {
if (scope !== 'friends') return snapshot
const graph = buildFriendsRelationshipsGraph(snapshot)
return {
...snapshot,
nodes: graph.nodes,
edges: graph.edges,
communities: graph.communities,
}
}
function buildCloseRelationshipsGraph(snapshot: PeopleRelationshipsSnapshot): PeopleRelationshipsGraphData {
const selectedKeys = new Set<string>()
for (const node of snapshot.nodes) {
@@ -61,6 +61,7 @@ const props = withDefaults(
selectedKey?: string | null
privacyMode?: boolean
safeInsetRight?: number
emphasizeEdges?: boolean
label: string
ownerLabel: string
}>(),
@@ -68,6 +69,7 @@ const props = withDefaults(
selectedKey: null,
privacyMode: false,
safeInsetRight: 0,
emphasizeEdges: false,
}
)
@@ -344,7 +346,7 @@ function renderGraph(shouldFit = false) {
const targetX = target.x + offsetX
const targetY = target.y + offsetY
const alpha = edge.visibility === 2 ? 0.22 : 0.07
const alpha = edge.visibility === 2 ? (props.emphasizeEdges ? 0.26 : 0.22) : props.emphasizeEdges ? 0.1 : 0.07
const width = Math.min(1.4, Math.max(0.4, Math.log10(edge.weight + 1) * 0.75))
const edgeColor = getNodeColor(source)
edgeLayer.moveTo(sourceX, sourceY).lineTo(targetX, targetY).stroke({ color: edgeColor, width, alpha })
@@ -773,7 +775,14 @@ onMounted(async () => {
})
watch(
() => [props.graph.nodes, props.graph.edges, props.selectedKey, props.privacyMode, props.safeInsetRight],
() => [
props.graph.nodes,
props.graph.edges,
props.selectedKey,
props.privacyMode,
props.safeInsetRight,
props.emphasizeEdges,
],
() => {
renderGraph(false)
},
@@ -63,6 +63,7 @@ const props = withDefaults(
selectedKey?: string | null
privacyMode?: boolean
safeInsetRight?: number
emphasizeEdges?: boolean
label: string
ownerLabel: string
}>(),
@@ -70,6 +71,7 @@ const props = withDefaults(
selectedKey: null,
privacyMode: false,
safeInsetRight: 0,
emphasizeEdges: false,
}
)
@@ -194,7 +196,7 @@ function addThinEdgePaths(
const material = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: bucket === 'highlight' ? 0.62 : bucket === 'normal' ? 0.21 : 0.05,
opacity: bucket === 'highlight' ? 0.62 : bucket === 'normal' ? (props.emphasizeEdges ? 0.27 : 0.21) : 0.05,
linewidth,
blending: THREE.NormalBlending,
depthTest: true,
@@ -847,6 +849,14 @@ watch(
{ flush: 'post' }
)
watch(
() => props.emphasizeEdges,
() => {
renderGraph(false)
},
{ flush: 'post' }
)
watch(
() => props.privacyMode,
() => {
+3
View File
@@ -485,6 +485,7 @@ async function loadNeighborhood(key: string) {
const next = await dataService.getPeopleRelationshipNeighborhood(key, {
acceptStale: true,
timeRangePreset: timeRangePreset.value,
graphScope: graphScope.value,
})
if (requestId !== neighborhoodRequestId.value) return
@@ -661,6 +662,7 @@ onBeforeUnmount(() => {
:selected-key="canvasSelectedKey"
:privacy-mode="privacyMode"
:safe-inset-right="detailPanelSafeInsetRight"
:emphasize-edges="graphScope === 'friends'"
:label="t('relationships.canvas.label3d')"
:owner-label="t('relationships.owner.me')"
@fallback="handleThreeCanvasFallback"
@@ -673,6 +675,7 @@ onBeforeUnmount(() => {
:selected-key="canvasSelectedKey"
:privacy-mode="privacyMode"
:safe-inset-right="detailPanelSafeInsetRight"
:emphasize-edges="graphScope === 'friends'"
:label="t('relationships.canvas.label')"
:owner-label="t('relationships.owner.me')"
@select-node="selectNode"
+1
View File
@@ -207,6 +207,7 @@ export class FetchDataAdapter implements DataAdapter {
const params = new URLSearchParams()
if (options?.acceptStale) params.set('acceptStale', '1')
if (options?.timeRangePreset) params.set('timeRange', options.timeRangePreset)
if (options?.graphScope) params.set('scope', options.graphScope)
const qs = params.toString()
return get(`/people/relationships/${encodeURIComponent(key)}/neighborhood${qs ? `?${qs}` : ''}`)
}