feat(puterjs): add defensive profile picture lookup (#3899)

This commit is contained in:
Nariman Jelveh
2026-09-17 17:16:54 -07:00
committed by GitHub
parent 0be3bc55c2
commit 766379ae9f
6 changed files with 262 additions and 0 deletions
+1
View File
@@ -101,6 +101,7 @@ These authentication features are supported out of the box when using Puter.js:
- **[`puter.auth.signOut()`](/Auth/signOut/)** - Sign out the current user
- **[`puter.auth.isSignedIn()`](/Auth/isSignedIn/)** - Check if a user is signed in
- **[`puter.auth.getUser()`](/Auth/getUser/)** - Get information about the current user
- **[`puter.auth.getProfilePicture()`](/Auth/getProfilePicture/)** - Get a user's public profile picture, when available
- **[`puter.auth.getMonthlyUsage()`](/Auth/getMonthlyUsage/)** - Get the user's current monthly resource usage
- **[`puter.auth.getDetailedAppUsage()`](/Auth/getDetailedAppUsage/)** - Get detailed usage statistics for an application
+51
View File
@@ -0,0 +1,51 @@
---
title: puter.auth.getProfilePicture()
description: Read a user's public profile picture, returning null when unavailable.
platforms: [websites, apps, nodejs, workers]
---
Reads the `picture` field from `/<username>/Public/.profile`. The file must contain a JSON object with a base64 image data URL, such as `{"picture":"data:image/png;base64,..."}`.
## Syntax
```js
puter.auth.getProfilePicture()
puter.auth.getProfilePicture(username)
```
## Parameters
### `username` (optional)
The username to look up. Defaults to the signed-in user's username. Pass a username, not a filesystem path.
## Return value
A promise that resolves to the picture's base64 image data URL, or `null` if no picture is available. The method checks the data URL's format but does not decode or verify the image itself.
Missing users, directories or files, invalid usernames, malformed JSON, missing or invalid `picture` fields, permission errors, and request failures all resolve to `null`. The method uses the caller's existing filesystem permissions and does not create or modify the profile file.
When signed out, it returns `null` without opening a sign-in prompt. Sign in first if needed.
## Example
```html;auth-get-profile-picture
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<button id="show-picture">Show my profile picture</button>
<p id="status"></p>
<img id="picture" alt="Your profile picture" width="96" height="96" hidden>
<script>
document.getElementById('show-picture').addEventListener('click', async () => {
if (!puter.auth.isSignedIn()) await puter.auth.signIn();
const picture = await puter.auth.getProfilePicture();
const image = document.getElementById('picture');
image.hidden = !picture;
if (picture) image.src = picture;
document.getElementById('status').textContent = picture ? '' : 'No profile picture available.';
});
</script>
</body>
</html>
```
+8
View File
@@ -253,6 +253,14 @@ let sidebar = [
source: '/Auth/getUser.md',
path: '/Auth/getUser',
},
{
title: '<code>getProfilePicture()</code>',
page_title: '<code>puter.auth.getProfilePicture()</code>',
title_tag: 'puter.auth.getProfilePicture()',
icon: '/assets/img/function.svg',
source: '/Auth/getProfilePicture.md',
path: '/Auth/getProfilePicture',
},
{
title: '<code>getMonthlyUsage()</code>',
page_title: '<code>puter.auth.getMonthlyUsage()</code>',
+33
View File
@@ -330,6 +330,39 @@ export class AuthModule extends PuterModule {
});
};
/**
* Reads a user's public profile picture. Resolves to null when unavailable
* or invalid, including authentication, file-read, and JSON parsing failures.
*
* @param {string} [username] Defaults to the signed-in user's username.
* @returns {Promise<string | null>} A base64 image data URL, or null.
*/
async getProfilePicture (username) {
try {
// An optional avatar lookup must not open a sign-in prompt.
if ( ! this.authToken ) return null;
if ( username === undefined ) {
username = (await this.getUser()).username;
}
if ( typeof username !== 'string' || ! /^[a-z0-9_-]+$/i.test(username) ) {
return null;
}
const blob = await this.puter.fs.read(`/${username}/Public/.profile`);
const profile = JSON.parse(await blob.text());
if ( ! profile || typeof profile !== 'object' || Array.isArray(profile) ) {
return null;
}
const picture = profile.picture;
return typeof picture === 'string' &&
/^data:image\/[a-z0-9.+-]+;base64,[a-z0-9+/]+={0,2}$/i.test(picture)
? picture : null;
} catch {
return null;
}
}
/**
* Signs the user out of this app by discarding its auth token.
*
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from 'vitest';
import { AuthModule } from './Auth.js';
const picture = 'data:image/png;base64,iVBORw0KGgo=';
const makeAuth = () => {
const puter = {
authToken: 'test-token',
fs: { read: vi.fn().mockResolvedValue(new Blob([JSON.stringify({ picture })])) },
};
return new AuthModule(puter);
};
describe('getProfilePicture', () => {
it('reads only the requested public profile and returns its picture', async () => {
const auth = makeAuth();
expect(await auth.getProfilePicture('alice')).toBe(picture);
expect(auth.puter.fs.read).toHaveBeenCalledExactlyOnceWith('/alice/Public/.profile');
});
it.each([null, '', ' ', 42, {}, [], '.', '..', '../alice', 'alice/Public', 'alice\\Public', 'alice\u0000'])(
'returns null for an invalid username (%j) without reading a file', async username => {
const auth = makeAuth();
expect(await auth.getProfilePicture(username)).toBeNull();
expect(auth.puter.fs.read).not.toHaveBeenCalled();
},
);
it('returns null without reading a file when signed out', async () => {
const auth = makeAuth();
auth.puter.authToken = null;
expect(await auth.getProfilePicture('alice')).toBeNull();
expect(await auth.getProfilePicture()).toBeNull();
expect(auth.puter.fs.read).not.toHaveBeenCalled();
});
it('returns null when the file request fails', async () => {
const auth = makeAuth();
auth.puter.fs.read.mockRejectedValue(new Error('Network unavailable'));
expect(await auth.getProfilePicture('alice')).toBeNull();
});
it('returns null when reading the blob fails', async () => {
const auth = makeAuth();
auth.puter.fs.read.mockResolvedValue({ text: async () => { throw new Error('Read failed'); } });
expect(await auth.getProfilePicture('alice')).toBeNull();
});
});
+121
View File
@@ -7,6 +7,7 @@ import type { TestContext } from '../harness/types.ts';
* migration, and the SDK event bus those two report through.
*/
type PuterAuthInternals = {
APIOrigin: string;
authToken: string | null;
env: string;
triggerReauth: (signal?: {
@@ -32,6 +33,126 @@ const withoutToken = async (t: TestContext, fn: () => Promise<void>) => {
};
export default suite('auth', {
'getProfilePicture handles missing files, valid pictures, and malformed profiles':
async (t) => {
const username = t.env.users.user.username;
const directory = `/${username}/Public`;
const path = `${directory}/.profile`;
const picture =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=';
t.assert.equal(
await t.puter.auth.getProfilePicture(username),
null,
);
await t.puter.fs.mkdir(directory);
try {
t.assert.equal(
await t.puter.auth.getProfilePicture(username),
null,
);
await t.puter.fs.write(
path,
JSON.stringify({ picture, name: 'Ignored' }),
);
t.assert.equal(
await t.puter.auth.getProfilePicture(username),
picture,
);
t.assert.equal(await t.puter.auth.getProfilePicture(), picture);
for (const content of [
'',
'{broken',
'null',
'[]',
'true',
'42',
'"text"',
'{}',
...[
null,
false,
123,
{},
[],
'',
' ',
'https://example.com/avatar.png',
'data:text/html;base64,SGk=',
'data:image/png;base64,',
'data:image/png;base64,%%%',
].map((picture) => JSON.stringify({ picture })),
]) {
await t.puter.fs.write(path, content);
t.assert.equal(
await t.puter.auth.getProfilePicture(username),
null,
);
}
await t.puter.fs.delete(path);
await t.puter.fs.mkdir(path);
t.assert.equal(
await t.puter.auth.getProfilePicture(username),
null,
);
} finally {
await t.puter.fs.delete(directory, { recursive: true });
}
},
'getProfilePicture respects access to another user profile': async (t) => {
const directory = `/${t.env.users.user.username}/Public`;
const path = `${directory}/.profile`;
const picture = 'data:image/png;base64,iVBORw0KGgo=';
await t.puter.fs.mkdir(directory);
try {
await t.puter.fs.write(path, JSON.stringify({ picture }));
t.puter.setAuthToken(t.env.users.other.token);
t.assert.equal(
await t.puter.auth.getProfilePicture(t.env.users.user.username),
null,
);
t.puter.setAuthToken(t.env.users.user.token);
await t.puter.fs.share(path, t.env.users.other.username, 'read');
t.puter.setAuthToken(t.env.users.other.token);
t.assert.equal(
await t.puter.auth.getProfilePicture(t.env.users.user.username),
picture,
);
} finally {
t.puter.setAuthToken(t.env.users.user.token);
await t.puter.fs.delete(directory, { recursive: true });
}
},
'getProfilePicture returns null while signed out or when user lookup fails':
async (t) => {
await withoutToken(t, async () => {
t.assert.equal(await t.puter.auth.getProfilePicture(), null);
t.assert.equal(
await t.puter.auth.getProfilePicture(
t.env.users.user.username,
),
null,
);
});
const p = internals(t);
const origin = p.APIOrigin;
try {
p.APIOrigin = `${origin}/missing-profile-api`;
t.assert.equal(await t.puter.auth.getProfilePicture(), null);
t.assert.equal(
await t.puter.auth.getProfilePicture(
t.env.users.user.username,
),
null,
);
} finally {
p.APIOrigin = origin;
}
},
'getUser returns the authenticated user': async (t) => {
const user = await t.puter.auth.getUser();
t.assert.equal(user.username, t.env.users.user.username);