Skip to content

Commit 3ea506d

Browse files
committed
Add getAttachmentUrl toolkit utility, fix avatar URL in HDSProfile
- New toolkit/getAttachmentUrl: builds browser-safe attachment URLs using connection.endpoint + attachment.readToken (no embedded credentials) - HDSProfile.resolveAvatarUrl now delegates to getAttachmentUrl - Fix: browsers blocked old URLs with embedded credentials in <img> src - Update HDSProfile tests for readToken-based URLs - Add 6 unit tests for getAttachmentUrl
1 parent 9e33711 commit 3ea506d

5 files changed

Lines changed: 117 additions & 11 deletions

File tree

tests/HDSProfile.test.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,7 @@ describe('[HDSP] HDSProfile (dev API)', function () {
217217
const avatarUrl = HDSProfile.getAvatarUrl();
218218
assert.ok(avatarUrl, 'Should have avatar URL');
219219
assert.ok(avatarUrl.includes('/events/'), 'Should be an attachment URL');
220-
assert.ok(avatarUrl.includes('red-pixel.png'), 'Should contain filename');
221-
assert.ok(avatarUrl.includes('auth='), 'Should contain auth token');
220+
assert.ok(avatarUrl.includes('readToken='), 'Should contain readToken');
222221

223222
// Verify persistence
224223
await HDSProfile.reload();
@@ -237,7 +236,7 @@ describe('[HDSP] HDSProfile (dev API)', function () {
237236

238237
const newUrl = HDSProfile.getAvatarUrl();
239238
assert.ok(newUrl, 'Should have new avatar URL');
240-
assert.ok(newUrl.includes('new-avatar.png'), 'Should contain new filename');
239+
assert.ok(newUrl.includes('readToken='), 'Should contain readToken');
241240
assert.notStrictEqual(newUrl, previousUrl, 'URL should differ from previous');
242241
});
243242
});
@@ -293,7 +292,7 @@ describe('[HDSP] HDSProfile (dev API)', function () {
293292
const profile = await HDSProfile.readFromConnection(sharedConnection);
294293
assert.ok(profile.avatar, 'Should have avatar URL');
295294
assert.ok(profile.avatar.includes('/events/'), 'Avatar should be an attachment URL');
296-
assert.ok(profile.avatar.includes('auth='), 'Avatar URL should contain auth token');
295+
assert.ok(profile.avatar.includes('readToken='), 'Avatar URL should contain readToken');
297296
});
298297

299298
it('[HDSP-X3] does not affect singleton state', async () => {

tests/getAttachmentUrl.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { assert } from './test-utils/deps-node.js';
2+
import { getAttachmentUrl } from '../ts/toolkit/getAttachmentUrl.ts';
3+
4+
describe('[GAU] getAttachmentUrl', function () {
5+
const fakeConnection = {
6+
endpoint: 'https://demo.datasafe.dev/testuser/',
7+
apiEndpoint: 'https://token123@demo.datasafe.dev/testuser/',
8+
token: 'token123'
9+
};
10+
11+
const eventWithAttachment = {
12+
id: 'evt123',
13+
attachments: [
14+
{ id: 'att-001', fileName: 'photo.jpg', readToken: 'rt-abc-123' },
15+
{ id: 'att-002', fileName: 'thumb.jpg', readToken: 'rt-def-456' }
16+
]
17+
};
18+
19+
it('[GAU1] builds URL with readToken from first attachment', function () {
20+
const url = getAttachmentUrl(fakeConnection, eventWithAttachment);
21+
assert.strictEqual(
22+
url,
23+
'https://demo.datasafe.dev/testuser/events/evt123/att-001?readToken=rt-abc-123'
24+
);
25+
});
26+
27+
it('[GAU2] builds URL for specific attachment index', function () {
28+
const url = getAttachmentUrl(fakeConnection, eventWithAttachment, 1);
29+
assert.strictEqual(
30+
url,
31+
'https://demo.datasafe.dev/testuser/events/evt123/att-002?readToken=rt-def-456'
32+
);
33+
});
34+
35+
it('[GAU3] returns null when no attachments', function () {
36+
const url = getAttachmentUrl(fakeConnection, { id: 'evt456' });
37+
assert.strictEqual(url, null);
38+
});
39+
40+
it('[GAU4] returns null for out-of-range index', function () {
41+
const url = getAttachmentUrl(fakeConnection, eventWithAttachment, 5);
42+
assert.strictEqual(url, null);
43+
});
44+
45+
it('[GAU5] strips trailing slash from endpoint', function () {
46+
const url = getAttachmentUrl(fakeConnection, eventWithAttachment);
47+
assert.ok(!url.includes('//events'), 'Should not have double slash before events');
48+
});
49+
50+
it('[GAU6] uses connection.endpoint (no embedded credentials)', function () {
51+
const url = getAttachmentUrl(fakeConnection, eventWithAttachment);
52+
assert.ok(!url.includes('@'), 'URL should not contain embedded credentials');
53+
assert.ok(!url.includes('auth='), 'URL should use readToken, not auth');
54+
assert.ok(url.includes('readToken='), 'URL should contain readToken');
55+
});
56+
});

ts/settings/HDSProfile.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { pryv } from '../patchedPryv.ts';
2+
import { getAttachmentUrl } from '../toolkit/getAttachmentUrl.ts';
23

34
type Connection = InstanceType<typeof pryv.Connection>;
45

@@ -46,6 +47,8 @@ let _cache: Partial<Record<ProfileKey, any>> = {};
4647
let _values: ProfileValues = { ...DEFAULTS };
4748
/** @internal */
4849
let _hooked = false;
50+
/** @internal — tracks which streams have been verified/created */
51+
let _ensuredStreams: Set<string> = new Set();
4952

5053
/**
5154
* Check if event type matches a field's eventType (supports `picture/*` wildcard).
@@ -75,13 +78,9 @@ function matchField (event: any): ProfileKey | null {
7578
* Handles: attachment (construct API URL), data URL in content, plain URL in content.
7679
*/
7780
function resolveAvatarUrl (event: any, conn: Connection | null = _connection): string | null {
78-
// Attachment — construct URL from connection endpoint
81+
// Attachment — use shared getAttachmentUrl utility
7982
if (event.attachments?.length > 0 && conn) {
80-
const att = event.attachments[0];
81-
const endpoint = (conn as any).apiEndpoint || '';
82-
const token = (conn as any).token || '';
83-
const fileName = att.fileName || att.id;
84-
return `${endpoint}events/${event.id}/${fileName}?auth=${token}`;
83+
return getAttachmentUrl(conn, event);
8584
}
8685
// Content is a string (data URL or plain URL)
8786
if (typeof event.content === 'string' && event.content.length > 0) {
@@ -135,6 +134,30 @@ async function trashExistingAvatar (): Promise<void> {
135134
}
136135
}
137136

137+
/**
138+
* Ensure a profile child stream exists, creating the parent `profile` stream and the child if needed.
139+
*/
140+
async function ensureStream (streamId: string): Promise<void> {
141+
if (_ensuredStreams.has(streamId) || !_connection) return;
142+
try {
143+
// Create parent 'profile' stream (idempotent — ignores "already exists" error)
144+
await _connection.apiOne(
145+
'streams.create',
146+
{ id: 'profile', name: 'Profile' },
147+
'stream'
148+
).catch(() => { /* already exists — ok */ });
149+
// Create child stream
150+
await _connection.apiOne(
151+
'streams.create',
152+
{ id: streamId, name: streamId, parentId: 'profile' },
153+
'stream'
154+
).catch(() => { /* already exists — ok */ });
155+
_ensuredStreams.add(streamId);
156+
} catch {
157+
// best effort — the subsequent events.create will fail with a clear error if stream is truly missing
158+
}
159+
}
160+
138161
async function load (): Promise<void> {
139162
if (!_connection) return;
140163

@@ -231,6 +254,7 @@ const HDSProfile = {
231254
);
232255
_cache[key] = updated;
233256
} else {
257+
await ensureStream(field.streamId);
234258
const created = await _connection.apiOne(
235259
'events.create',
236260
{ streamIds: [field.streamId], type: field.eventType, content: value },
@@ -264,6 +288,7 @@ const HDSProfile = {
264288
throw new Error('HDSProfile: call hookToConnection() first');
265289
}
266290
await trashExistingAvatar();
291+
await ensureStream(PROFILE_FIELDS.avatar.streamId);
267292
const result = await (_connection as any).createEventWithFileFromBuffer(
268293
{ type: 'picture/attached', streamIds: [PROFILE_FIELDS.avatar.streamId] },
269294
fileData,
@@ -284,6 +309,7 @@ const HDSProfile = {
284309
throw new Error('HDSProfile: call hookToConnection() first');
285310
}
286311
await trashExistingAvatar();
312+
await ensureStream(PROFILE_FIELDS.avatar.streamId);
287313
const created = await _connection.apiOne(
288314
'events.create',
289315
{ streamIds: [PROFILE_FIELDS.avatar.streamId], type: 'picture/base64', content: dataUrl },
@@ -315,6 +341,7 @@ const HDSProfile = {
315341
_cache = {};
316342
_values = { ...DEFAULTS };
317343
_hooked = false;
344+
_ensuredStreams = new Set();
318345
},
319346
};
320347

ts/toolkit/getAttachmentUrl.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { pryv } from '../patchedPryv.ts';
2+
3+
type Connection = InstanceType<typeof pryv.Connection>;
4+
5+
/**
6+
* Build a browser-safe URL for an event attachment.
7+
* Uses the attachment's `readToken` for auth (no embedded credentials in the URL).
8+
*
9+
* @param connection - Pryv Connection (uses `.endpoint` for the clean base URL)
10+
* @param event - Pryv event with attachments
11+
* @param attachmentIndex - index of the attachment (default 0)
12+
* @returns URL string or null if no attachment at the given index
13+
*/
14+
export function getAttachmentUrl (
15+
connection: Connection,
16+
event: any,
17+
attachmentIndex: number = 0
18+
): string | null {
19+
if (!event.attachments?.[attachmentIndex]) return null;
20+
const attachment = event.attachments[attachmentIndex];
21+
const baseUrl = ((connection as any).endpoint || '').replace(/\/$/, '');
22+
return `${baseUrl}/events/${event.id}/${attachment.id}?readToken=${attachment.readToken}`;
23+
}

ts/toolkit/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { StreamsAutoCreate } from './StreamsAutoCreate.ts';
22
import * as StreamTools from './StreamsTools.ts';
3+
import { getAttachmentUrl } from './getAttachmentUrl.ts';
34

4-
export { StreamsAutoCreate, StreamTools };
5+
export { StreamsAutoCreate, StreamTools, getAttachmentUrl };

0 commit comments

Comments
 (0)