mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-29 08:38:06 +00:00
feat(puterjs): add defensive profile picture lookup (#3899)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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>',
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user