From 766379ae9f4d77ceeba0158b18b030ea13d7d4a0 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 17 Sep 2026 17:16:54 -0700 Subject: [PATCH] feat(puterjs): add defensive profile picture lookup (#3899) --- src/docs/src/Auth.md | 1 + src/docs/src/Auth/getProfilePicture.md | 51 +++++++++ src/docs/src/sidebar.js | 8 ++ src/puter-js/src/modules/Auth.js | 33 ++++++ src/puter-js/src/modules/Auth.test.js | 48 ++++++++ src/puter-js/tests/api/suites/auth.suite.ts | 121 ++++++++++++++++++++ 6 files changed, 262 insertions(+) create mode 100644 src/docs/src/Auth/getProfilePicture.md create mode 100644 src/puter-js/src/modules/Auth.test.js diff --git a/src/docs/src/Auth.md b/src/docs/src/Auth.md index 9d3c67e79..149af56f0 100644 --- a/src/docs/src/Auth.md +++ b/src/docs/src/Auth.md @@ -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 diff --git a/src/docs/src/Auth/getProfilePicture.md b/src/docs/src/Auth/getProfilePicture.md new file mode 100644 index 000000000..7bcfd5a68 --- /dev/null +++ b/src/docs/src/Auth/getProfilePicture.md @@ -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 `//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 + + + + +

+ + + + +``` diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js index d2708eb3b..e02ed7e5b 100755 --- a/src/docs/src/sidebar.js +++ b/src/docs/src/sidebar.js @@ -253,6 +253,14 @@ let sidebar = [ source: '/Auth/getUser.md', path: '/Auth/getUser', }, + { + title: 'getProfilePicture()', + page_title: 'puter.auth.getProfilePicture()', + title_tag: 'puter.auth.getProfilePicture()', + icon: '/assets/img/function.svg', + source: '/Auth/getProfilePicture.md', + path: '/Auth/getProfilePicture', + }, { title: 'getMonthlyUsage()', page_title: 'puter.auth.getMonthlyUsage()', diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index fb845d9ae..605a33b78 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -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} 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. * diff --git a/src/puter-js/src/modules/Auth.test.js b/src/puter-js/src/modules/Auth.test.js new file mode 100644 index 000000000..d7e8c5d71 --- /dev/null +++ b/src/puter-js/src/modules/Auth.test.js @@ -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(); + }); +}); diff --git a/src/puter-js/tests/api/suites/auth.suite.ts b/src/puter-js/tests/api/suites/auth.suite.ts index a3004e181..1a8d98ac8 100644 --- a/src/puter-js/tests/api/suites/auth.suite.ts +++ b/src/puter-js/tests/api/suites/auth.suite.ts @@ -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) => { }; 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);