Compare commits

...
Author SHA1 Message Date
jelveh 6c2915c955 Merge remote-tracking branch 'origin/main' into feat/feedback-embed 2026-11-07 01:51:27 -07:00
jelveh 37e1bf35ca Name the feedback embed's user from the cached whoami
showFeedbackDialog() on a first-party page fetched /whoami before it
started loading the embed, adding a round trip to every call. It now
uses the user puter.js already cached for the session and asks the API
only when there is none.
2026-11-07 01:05:24 -07:00
jelveh 52fb91e8e2 Keep the framing page from scrolling behind the feedback embed
A wheel or touch scroll over the feedback embed's scrim chained out of
the frame and scrolled the first-party page underneath the open dialog.
The embed's root now stops overscroll from leaving the frame.
2026-11-07 01:05:10 -07:00
jelveh 67621d996a Show the app feedback dialog in a frame on first-party pages
puter.ui.showFeedbackDialog() on a page one label under the Puter origin
now frames /embed/feedback in a modal over the page, instead of opening a
puter.com popup. The embed uses the same framing rules, query redirect,
and host-user binding as /embed/apps, and sends the feedback for the
framing page's origin, which the server reads from the referrer and pins
with frame-ancestors. Without the named user's Puter session, or with an
email still to confirm, the embed reports itself unavailable and the SDK
falls back to the popup, which can sign in. A frame that never answers
(such as one the host's CSP blocks) falls back too.

The embed session check moves out of appsEmbed.js into a shared helper,
and the first-party page check out of <puter-user-menu> into puter.js's
lib, so both embeds use one copy of each.
2026-11-07 00:59:57 -07:00
21 changed files with 1053 additions and 140 deletions

No files matched your search

+27 -3
View File
@@ -124,7 +124,7 @@ localization system.
</script>
```
## Implementation and checks
## Implementation
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
@@ -134,14 +134,38 @@ injects desktop actions. `appsEmbed.js` supplies new-tab launching through
`launch_app`, and loads only the embed entry, its SDK, and shared styles. `embedHostUser` in
`src/gui/src/helpers/embedHostUser.js` reads the host's user from the fragment, and
`embedHostTheme.js` beside it applies the host's theme.
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
The normal GUI webpack build emits `bundle.min.js`, `apps-embed.min.js`, and `feedback-embed.min.js`.
The `/embed/apps` route puts the recommended list in the page's config as
`guestApps`. No puter.js API or app-list endpoint changes are needed.
## Feedback dialog
`GET /embed/feedback` is the app feedback dialog (`puter.ui.showFeedbackDialog()`)
for a first-party page, with the same framing rules, query redirect, and
host-user binding as `/embed/apps`. It sends the feedback for the framing
page's origin, which the server reads from the referrer and pins with
`frame-ancestors`; opened top-level, it does nothing. Its page is transparent
so the dialog's own scrim covers the host.
puter.js uses it on a page one label under the GUI origin when the page is
signed in, framing it in a modal `<dialog>` that stays hidden until the embed
is ready. The embed posts `{ msg: 'feedbackEmbed', state }` to its parent
(`loading`, then `ready` or `unavailable`) and finally
`{ msg: 'feedbackDialogClosed', sent }`. It reports `unavailable` without the
named user's session, or while that user's email still needs confirming, and
puter.js then uses the `/action/send-feedback` popup, which can sign in. So does
a frame that never answers, such as one the host's CSP blocks. `feedbackEmbed.js`
is the entry, sharing `embedSession` in `src/gui/src/helpers/embedSession.js`
with the app browser; `showFeedbackEmbed` in `src/puter-js/src/lib/guiEmbed.js`
is the host side.
## Checks
Run the GUI component/browser tests (with the SDK bundle built and Playwright
Chromium installed), and the backend route tests:
```sh
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
```
@@ -27,7 +27,7 @@ import type { Actor } from '../../core/actor.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { embedFrameAncestors } from './HomepageController.js';
import { embedFrameAncestors, embedHostOrigin } from './HomepageController.js';
// ── Test harness ────────────────────────────────────────────────────
//
@@ -550,3 +550,26 @@ describe('embedFrameAncestors', () => {
).toBe("'self'");
});
});
describe('embedHostOrigin', () => {
const config = {
origin: 'https://example.test',
domain: 'example.test',
api_base_url: 'https://backend.example.test',
static_hosting_domain: 'site.example.test',
};
it.each([
['https://sheets.example.test/doc/1', 'https://sheets.example.test'],
['https://backend.example.test/x', null],
['https://api.example.test/', null],
['https://someone.site.example.test/', null],
['https://a.sheets.example.test/', null],
['http://sheets.example.test/', null],
['https://example.test/', null],
['not a url', null],
[undefined, null],
])('%s frames as %s', (referer, expected) => {
expect(embedHostOrigin(config, referer)).toBe(expected);
});
});
@@ -61,32 +61,34 @@ export function appMetaDescription(text: string): string {
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}…`;
}
type EmbedFramingConfig = Pick<
IConfig,
| 'origin'
| 'domain'
| 'api_base_url'
| 'static_hosting_domain'
| 'static_hosting_domain_alt'
| 'private_app_hosting_domain'
| 'private_app_hosting_domain_alt'
>;
/**
* `frame-ancestors` for the app browser embed. A wildcard over `*.<domain>`
* The origin of a first-party page that may frame an `/embed/*` page, named
* exactly from the request's referrer, or null. A wildcard over `*.<domain>`
* would also admit user-hosted sites whenever the hosting domains sit under it,
* so the embedding page is named exactly, from the request's referrer. Only a
* single label under `<domain>` qualifies, and not one this server answers
* itself (the API, WebDAV, SDK and native-app hosts).
* so only a single label under `<domain>` qualifies, and not one this server
* answers itself (the API, WebDAV, SDK and native-app hosts).
*/
export function embedFrameAncestors(
config: Pick<
IConfig,
| 'origin'
| 'domain'
| 'api_base_url'
| 'static_hosting_domain'
| 'static_hosting_domain_alt'
| 'private_app_hosting_domain'
| 'private_app_hosting_domain_alt'
>,
export function embedHostOrigin(
config: EmbedFramingConfig,
referer: string | undefined,
): string {
): string | null {
const gui = new URL(config.origin ?? `https://${config.domain}`);
let parent: URL;
try {
parent = new URL(referer ?? '');
} catch {
return "'self'";
return null;
}
const host = parent.hostname.toLowerCase();
const label = subdomainLabelOf(host, config.domain);
@@ -105,7 +107,16 @@ export function embedFrameAncestors(
!isReservedSubdomain(label) &&
host !== new URL(config.api_base_url).hostname &&
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
return allowed ? `'self' ${parent.origin}` : "'self'";
return allowed ? parent.origin : null;
}
/** `frame-ancestors` for an `/embed/*` page: the GUI and {@link embedHostOrigin}. */
export function embedFrameAncestors(
config: EmbedFramingConfig,
referer: string | undefined,
): string {
const host = embedHostOrigin(config, referer);
return host ? `'self' ${host}` : "'self'";
}
/**
@@ -157,12 +168,20 @@ export class HomepageController extends PuterController {
router.get('/', {}, (req, res) => sendShell(req, res));
router.get('/embed/apps', {}, async (req, res) => {
// SDK bootstrap parameters must not change this page's account or API origin.
// Checked on the raw URL; `req.query` drops pairs past the parser's limit.
/**
* Headers shared by the `/embed/*` pages; false when the request was
* redirected instead. SDK bootstrap parameters must not change an
* embed's account or API origin, so a query string is dropped. Checked
* on the raw URL; `req.query` drops pairs past the parser's limit.
*/
const prepareEmbed = (
req: express.Request,
res: express.Response,
path: string,
): boolean => {
if (req.originalUrl.includes('?')) {
res.redirect('/embed/apps');
return;
res.redirect(path);
return false;
}
res.removeHeader('X-Frame-Options');
res.setHeader(
@@ -170,6 +189,11 @@ export class HomepageController extends PuterController {
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
);
res.setHeader('Cache-Control', 'no-store');
return true;
};
router.get('/embed/apps', {}, async (req, res) => {
if (!prepareEmbed(req, res, '/embed/apps')) return;
// Shown to visitors without a session; a signed-in embed fetches
// its own list, so a failure here only costs signed-out visitors.
const guestApps = await this.services.recommendedApps
@@ -178,6 +202,20 @@ export class HomepageController extends PuterController {
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
});
router.get('/embed/feedback', {}, (req, res) => {
if (!prepareEmbed(req, res, '/embed/feedback')) return;
// The feedback is recorded against this origin, which
// frame-ancestors has just pinned to the actual framing page.
res.send(
homepage.renderFeedbackEmbed({
hostOrigin: embedHostOrigin(
this.config,
req.get('referer'),
),
}),
);
});
router.get('/settings', {}, (req, res) => sendShell(req, res));
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
+40
View File
@@ -368,6 +368,46 @@ describe('PuterServer host header validation', () => {
expect(api.status).toBe(404);
});
it('lets a deployment subdomain frame the feedback embed and names it', async () => {
const parent = `http://spreadsheet.puter.localhost:${port}`;
const res = await request('/embed/feedback', {
host: `puter.localhost:${port}`,
referer: `${parent}/sheet/1`,
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBeUndefined();
expect(res.headers['content-security-policy']).toBe(
`frame-ancestors 'self' ${parent}`,
);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toContain('/dist/feedback-embed.min.js');
expect(res.body).not.toContain('/dist/bundle.min.js');
const config = JSON.parse(
res.body.match(/id="feedback-embed-config">(.*?)<\/script>/)[1],
);
expect(config).toEqual({
apiOrigin: `http://api.puter.localhost:${port}`,
hostOrigin: parent,
});
const hosted = await request('/embed/feedback', {
host: `puter.localhost:${port}`,
referer: `http://someone.site.puter.localhost:${port}/`,
});
expect(hosted.headers['content-security-policy']).toBe("frame-ancestors 'self'");
expect(
JSON.parse(
hosted.body.match(/id="feedback-embed-config">(.*?)<\/script>/)[1],
).hostOrigin,
).toBeNull();
const redirected = await request('/embed/feedback?puter.api_origin=https://other.test', {
host: `puter.localhost:${port}`,
});
expect(redirected.status).toBe(302);
expect(redirected.headers.location).toBe('/embed/feedback');
});
it.each([
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
@@ -107,6 +107,31 @@ describe('app browser embed shell', () => {
});
});
describe('feedback embed shell', () => {
it('carries the framing page and API origin, and loads only its own script', () => {
const html = makeService({
api_base_url: 'https://api.deployment.test',
gui_bundle: '/static/bundle.min.js?v=42',
}).renderFeedbackEmbed({ hostOrigin: 'https://notes.deployment.test' });
const configJson = html.match(/id="feedback-embed-config">(.*?)<\/script>/)![1];
expect(JSON.parse(configJson)).toEqual({
apiOrigin: 'https://api.deployment.test',
hostOrigin: 'https://notes.deployment.test',
});
expect(html).toContain('<script defer src="/static/feedback-embed.min.js?v=42"></script>');
expect(html).toContain('<link rel="stylesheet" href="/css/feedback-embed.css">');
expect(html).toContain('<main id="feedback-embed"></main>');
expect(html).not.toContain('bundle.min.js');
expect(html).not.toContain('apps-embed');
});
it('carries no framing page when none is allowed', () => {
const html = makeService({}).renderFeedbackEmbed({ hostOrigin: null });
const configJson = html.match(/id="feedback-embed-config">(.*?)<\/script>/)![1];
expect(JSON.parse(configJson).hostOrigin).toBeNull();
});
});
/** Capture what the service sends, and return the rendered HTML. */
const render = async (
service: PuterHomepageService,
@@ -171,15 +171,50 @@ export class PuterHomepageService extends PuterService {
renderAppBrowserEmbed({
guestApps = [],
}: { guestApps?: Array<Record<string, unknown>> } = {}): string {
const configJson = JSON.stringify({
apiOrigin: this.config.api_base_url,
sdkUrl:
this.config.env === 'dev'
? '/sdk/puter.dev.js'
: (this.config.gui_puterjs_bundle ??
'https://js.puter.com/v2/'),
guestApps,
}).replace(/</g, '\\u003c');
return this.#renderEmbed({
rootId: 'app-browser',
rootClass: 'dashboard',
stylesheet: 'apps-embed.css',
script: 'apps-embed.min.js',
config: {
apiOrigin: this.config.api_base_url,
sdkUrl:
this.config.env === 'dev'
? '/sdk/puter.dev.js'
: (this.config.gui_puterjs_bundle ??
'https://js.puter.com/v2/'),
guestApps,
},
});
}
/**
* `hostOrigin` is the first-party page framing the dialog, which the
* feedback is sent for; null when no allowed page frames it.
*/
renderFeedbackEmbed({ hostOrigin }: { hostOrigin: string | null }): string {
return this.#renderEmbed({
rootId: 'feedback-embed',
stylesheet: 'feedback-embed.css',
script: 'feedback-embed.min.js',
config: { apiOrigin: this.config.api_base_url, hostOrigin },
});
}
#renderEmbed({
rootId,
rootClass,
stylesheet,
script,
config,
}: {
rootId: string;
rootClass?: string;
stylesheet: string;
script: string;
config: Record<string, unknown>;
}): string {
const configJson = JSON.stringify(config).replace(/</g, '\\u003c');
return `<!DOCTYPE html>
<html lang="en">
@@ -190,27 +225,27 @@ export class PuterHomepageService extends PuterService {
<title>${encode(String(this.config.gui_params?.title ?? 'Puter'))}</title>
<link rel="stylesheet" href="/css/normalize.css">
<link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/apps-embed.css">
<link rel="stylesheet" href="/css/${stylesheet}">
</head>
<body>
<main id="app-browser" class="dashboard"></main>
<script type="application/json" id="app-browser-config">${configJson}</script>
<script defer src="${encode(this.#appBrowserEmbedScript())}"></script>
<main id="${rootId}"${rootClass ? ` class="${rootClass}"` : ''}></main>
<script type="application/json" id="${rootId}-config">${configJson}</script>
<script defer src="${encode(this.#embedScript(script))}"></script>
</body>
</html>`;
}
/**
* The embed's script is built beside the GUI bundle, so it is served from
* Embed scripts are built beside the GUI bundle, so they are served from
* wherever `gui_bundle` is (a CDN in production), with the same query.
*/
#appBrowserEmbedScript(): string {
#embedScript(name: string): string {
const base = 'http://relative.invalid';
const bundle = new URL(
this.config.gui_bundle ?? '/dist/bundle.min.js',
base,
);
const script = new URL('apps-embed.min.js', bundle);
const script = new URL(name, bundle);
script.search = bundle.search;
return script.origin === base
? `${script.pathname}${script.search}`
+3 -2
View File
@@ -1102,8 +1102,9 @@ interface IConfigOptional {
/** Force the bundled GUI even in dev. Default: false. */
use_bundled_gui: boolean;
/**
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The app
* browser embed loads `apps-embed.min.js` from the same directory.
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The
* `/embed/*` pages load `apps-embed.min.js` and `feedback-embed.min.js`
* from the same directory.
*/
gui_bundle: string;
/** Override the GUI CSS path when bundled. Default: `/dist/bundle.min.css`. */
+1 -1
View File
@@ -8,7 +8,7 @@ Opens a dialog where the user can send you, the app's developer, feedback about
The message is delivered by Puter: it is stored and emailed to the email address on your Puter account. The feedback never passes through your app's code, and the dialog tells the user what is shared with you: their username, plus their email address (as the email's reply-to, so you can respond) when their email is verified.
Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog (signing the user in first if needed).
Inside Puter, the dialog is rendered by the desktop environment. On a website, a puter.com popup hosts the dialog (signing the user in first if needed). Puter's own apps on puter.com subdomains show it in a frame over the page instead, when the user is signed in to both.
**Feedback is opt-in.** Users can only send feedback if it's enabled for your app. Apps created in the Dev Center have feedback enabled at creation — you can turn it off with the "User Feedback" toggle in the app's settings. Apps created through `puter.apps.create` default to off; enable it by setting `feedbackEnabled`:
+2 -1
View File
@@ -54,7 +54,8 @@ async function UIWindowAppFeedback (options) {
let sending = false;
let closed = false;
const authToken = puter.authToken ?? window.auth_token;
// The feedback embed runs without puter.js.
const authToken = globalThis.puter?.authToken ?? window.auth_token;
const target_params = options.app
? { app: options.app }
: { origin: options.origin };
+2 -46
View File
@@ -25,7 +25,7 @@ import { appTileLink } from './UI/Dashboard/appLink.js';
import { installAppIconFallback } from './helpers/appIcon.js';
import { guestAppLaunchUrl, launchAppInTab } from './helpers/appTabLaunch.js';
import { embedHostTheme, forceColorScheme } from './helpers/embedHostTheme.js';
import { embedHostUser } from './helpers/embedHostUser.js';
import { embedSession } from './helpers/embedSession.js';
window.$ = window.jQuery = jQuery;
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
@@ -104,56 +104,12 @@ function launchTile ({ appName, targetLink }) {
.finally(() => launching.delete(appName));
}
/**
* The GUI session, from local storage or else the session cookie. Safari keys a
* frame's local storage by the top-level origin, so under a sibling subdomain
* the GUI's stored token is out of reach while its cookie is still sent.
*/
async function readSession () {
let stored = null;
try {
stored = localStorage.getItem('auth_token_v2');
} catch { /* Storage the browser refuses holds no session; the cookie may. */ }
if ( stored ) return stored;
const response = await fetch('/get-gui-token', { credentials: 'include' });
// Only a 401 means there is no session; a rate limit or server error
// must not pass a signed-in user off as signed out.
if ( response.status === 401 ) return null;
if ( ! response.ok ) throw new Error(`Session lookup responded ${response.status}`);
return (await response.json())?.token || null;
}
/**
* The uuid of the user a GUI token belongs to, or null when the API refuses it.
*/
async function sessionUser (token) {
const response = await fetch(new URL('/whoami', config.apiOrigin), {
headers: { Authorization: `Bearer ${token}` },
});
if ( response.status === 401 ) return null;
if ( ! response.ok ) throw new Error(`Session check responded ${response.status}`);
const uuid = (await response.json())?.uuid;
return typeof uuid === 'string' ? uuid.toLowerCase() : null;
}
/**
* The GUI session, only when it belongs to the user the host page names. A
* host signed out, or signed in as someone else, gets the signed-out view.
*/
async function hostSession () {
const hostUser = embedHostUser(window.location.hash);
if ( ! hostUser ) return null;
const token = await readSession();
if ( ! token ) return null;
return await sessionUser(token) === hostUser ? token : null;
}
/**
* Signed in, the user's own apps through the SDK. Without a session, the
* recommended apps the page was rendered with, and no SDK.
*/
async function mount () {
window.auth_token = await hostSession();
window.auth_token = (await embedSession(config.apiOrigin, window.location.hash))?.token ?? null;
const guestApps = window.auth_token ? undefined : config.guestApps;
if ( !window.auth_token && !guestApps?.length ) throw new Error('No apps to show without a session');
+15
View File
@@ -0,0 +1,15 @@
/* Transparent, so the dialog's own scrim covers the framing page. */
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
font-family: "Inter", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
/* A scroll over the scrim must not scroll the framing page behind it. */
html {
overscroll-behavior: none;
}
+218
View File
@@ -0,0 +1,218 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium } from '@playwright/test';
import express from 'express';
import webpack from 'webpack';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
let browser;
let server;
let directory;
let origin;
let hostOrigin;
let port;
const requests = [];
const guiRoot = fileURLToPath(new URL('../', import.meta.url));
const sessionToken = 'feedback-embed-test-session';
const unconfirmedToken = 'feedback-embed-test-unconfirmed';
const sessionUser = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
const unconfirmedUser = '1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f';
const otherUser = '7d2e4f10-8a3b-4c5d-9e6f-0a1b2c3d4e5f';
beforeAll(async () => {
directory = await mkdtemp(path.join(tmpdir(), 'puter-feedback-embed-'));
await new Promise((resolve, reject) => {
const compiler = webpack({
mode: 'development',
entry: { 'feedback-embed': path.join(guiRoot, 'src/feedbackEmbed.js') },
output: { path: directory, filename: '[name].min.js' },
module: { rules: [{ test: /jquery-3\.6\.1\.min\.js$/, type: 'javascript/auto' }] },
});
compiler.run((error, stats) => compiler.close(closeError => {
if ( error || closeError ) return reject(error || closeError);
if ( stats.hasErrors() ) return reject(new Error(stats.toString()));
resolve();
}));
});
const app = express();
app.use(express.json());
app.use((req, res, next) => {
requests.push({ path: req.path, query: req.query, body: req.body, auth: req.headers.authorization });
next();
});
app.use('/dist', express.static(directory));
app.use('/css', express.static(path.join(guiRoot, 'src/css')));
app.get('/get-gui-token', (_req, res) => res.sendStatus(401));
const whoamiByToken = {
[sessionToken]: { uuid: sessionUser, username: 'session-user', email: '[email protected]', email_confirmed: true },
[unconfirmedToken]: {
uuid: unconfirmedUser, username: 'new-user', email_confirmed: false, requires_email_confirmation: true,
},
};
app.get('/whoami', (req, res) => {
const user = whoamiByToken[req.headers.authorization?.replace(/^Bearer /, '')];
if ( ! user ) return res.sendStatus(401);
res.json(user);
});
app.get('/app-feedback/target', (req, res) => {
if ( req.headers.authorization !== `Bearer ${sessionToken}` ) return res.sendStatus(401);
if ( req.query.origin !== hostOrigin ) return res.json({ enabled: false });
res.json({ enabled: true, app: { name: 'notes', title: 'Notes' } });
});
app.post('/app-feedback', (req, res) => {
if ( req.headers.authorization !== `Bearer ${sessionToken}` ) return res.sendStatus(401);
res.json({});
});
// A first-party page that frames the dialog as puter.js does, recording what the embed tells it.
app.get('/host', (req, res) => res.send(`<!DOCTYPE html><html><body style="margin:0;background:rgb(255,0,0)">
<script>
window.received = [];
addEventListener('message', (e) => {
if ( e.origin === '${origin}' ) window.received.push(e.data);
});
</script>
<iframe referrerpolicy="origin" title="Send feedback" src="${origin}/embed/feedback#user=${req.query.user ?? ''}"
style="display:block;border:0;width:100vw;height:100vh;color-scheme:normal"></iframe>
<div style="height:3000px"></div>
</body></html>`));
app.get('/embed/feedback', (_req, res) => {
res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${hostOrigin}`);
res.send(`<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/css/normalize.css"><link rel="stylesheet" href="/css/dashboard.css">
<link rel="stylesheet" href="/css/feedback-embed.css"></head><body>
<main id="feedback-embed"></main>
<script type="application/json" id="feedback-embed-config">${JSON.stringify({ apiOrigin: origin, hostOrigin })}</script>
<script defer src="/dist/feedback-embed.min.js"></script></body></html>`);
});
server = await new Promise(resolve => {
const listener = app.listen(0, '127.0.0.1', () => resolve(listener));
});
port = server.address().port;
origin = `http://puter.localhost:${port}`;
hostOrigin = `http://notes.puter.localhost:${port}`;
browser = await chromium.launch();
}, 60000);
afterAll(async () => {
await browser?.close();
if ( server ) await new Promise(resolve => server.close(resolve));
if ( directory ) await rm(directory, { recursive: true, force: true });
});
/** A browser whose GUI origin is signed in with `token`, if any. */
const signedIn = token => browser.newContext({ storageState: {
cookies: [],
origins: token ? [{ origin, localStorage: [{ name: 'auth_token_v2', value: token }] }] : [],
} });
const received = page => page.evaluate(() => window.received);
describe('feedback embed', () => {
it('sends feedback for the framing page as the user it names, then tells it', async () => {
const context = await signedIn(sessionToken);
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
const errors = [];
page.on('pageerror', error => errors.push(error.message));
requests.length = 0;
await page.goto(`${hostOrigin}/host?user=${sessionUser}`);
const frame = page.frameLocator('iframe');
await frame.locator('.app-feedback-form').waitFor();
expect(await frame.locator('.app-feedback-target-title').textContent()).toBe('Notes');
expect(await received(page)).toEqual([
{ msg: 'feedbackEmbed', state: 'loading' },
{ msg: 'feedbackEmbed', state: 'ready' },
]);
// The page behind shows through around the dialog, under its scrim.
expect(await frame.locator('body').evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
await page.screenshot({ path: path.join(tmpdir(), 'puter-feedback-embed.png'), animations: 'disabled' });
await frame.locator('.app-feedback-message').fill('Love the new editor');
await frame.locator('.app-feedback-send-btn').click();
await frame.locator('.app-feedback-success').waitFor();
await page.waitForFunction(() => window.received.some(m => m.msg === 'feedbackDialogClosed'));
expect((await received(page)).at(-1)).toEqual({ msg: 'feedbackDialogClosed', sent: true });
expect(await frame.locator('.app-feedback-overlay').count()).toBe(0);
const submit = requests.find(req => req.path === '/app-feedback');
expect(submit.body).toEqual({ origin: hostOrigin, message: 'Love the new editor', context: 'web' });
expect(submit.auth).toBe(`Bearer ${sessionToken}`);
expect(errors).toEqual([]);
} finally {
await context.close();
}
}, 30000);
it('answers not sent when the user dismisses it', async () => {
const context = await signedIn(sessionToken);
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`${hostOrigin}/host?user=${sessionUser}`);
const frame = page.frameLocator('iframe');
await frame.locator('.app-feedback-form').waitFor();
await frame.locator('.app-feedback-message').press('Escape');
await page.waitForFunction(() => window.received.some(m => m.msg === 'feedbackDialogClosed'));
expect((await received(page)).at(-1)).toEqual({ msg: 'feedbackDialogClosed', sent: false });
} finally {
await context.close();
}
}, 30000);
it('keeps the page behind from scrolling under the scrim', async () => {
const context = await signedIn(sessionToken);
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
await page.goto(`${hostOrigin}/host?user=${sessionUser}`);
await page.frameLocator('iframe').locator('.app-feedback-form').waitFor();
await page.mouse.move(10, 10);
await page.mouse.wheel(0, 600);
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.scrollY)).toBe(0);
} finally {
await context.close();
}
}, 30000);
it.each([
['no Puter session', null, sessionUser],
['a session for another user', sessionToken, otherUser],
['a host that names no user', sessionToken, ''],
['an email still to confirm', unconfirmedToken, unconfirmedUser],
])('tells the host it is unavailable with %s', async (_label, token, user) => {
const context = await signedIn(token);
try {
const page = await context.newPage();
page.setDefaultTimeout(6000);
requests.length = 0;
await page.goto(`${hostOrigin}/host?user=${user}`);
await page.waitForFunction(() => window.received.length >= 2);
expect(await received(page)).toEqual([
{ msg: 'feedbackEmbed', state: 'loading' },
{ msg: 'feedbackEmbed', state: 'unavailable' },
]);
expect(await page.frameLocator('iframe').locator('.app-feedback-overlay').count()).toBe(0);
expect(requests.some(req => req.path.startsWith('/app-feedback'))).toBe(false);
} finally {
await context.close();
}
}, 30000);
it('does nothing when opened on its own', async () => {
const context = await signedIn(sessionToken);
try {
const page = await context.newPage();
requests.length = 0;
await page.goto(`${origin}/embed/feedback#user=${sessionUser}`);
await page.waitForLoadState('networkidle');
expect(await page.locator('.app-feedback-overlay').count()).toBe(0);
expect(requests.some(req => req.path === '/whoami')).toBe(false);
} finally {
await context.close();
}
}, 30000);
});
+86
View File
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
import { encode } from 'html-entities';
import { loadLocale } from './i18n/embedI18n.js';
import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js';
import { embedSession } from './helpers/embedSession.js';
window.$ = window.jQuery = jQuery;
window.html_encode = value => encode(String(value ?? ''), { mode: 'nonAsciiPrintable' });
const config = JSON.parse(document.querySelector('#feedback-embed-config').textContent);
window.api_origin = config.apiOrigin;
window.locale = 'en';
try {
window.locale = JSON.parse(localStorage.getItem('user_preferences') || '{}')?.language || 'en';
} catch { /* Corrupt preferences must not prevent sending feedback. */ }
const localeReady = loadLocale(window.locale);
/** Answers only the page frame-ancestors pinned, which the feedback is for. */
const tellHost = (data) => window.parent.postMessage(data, config.hostOrigin);
/** Resolves once the dialog's overlay has left the page, after its success pane and exit. */
const overlayGone = () => new Promise((resolve) => {
const gone = () => !document.querySelector('.app-feedback-overlay');
if ( gone() ) return resolve();
const observer = new MutationObserver(() => {
if ( ! gone() ) return;
observer.disconnect();
resolve();
});
observer.observe(document.body, { childList: true });
});
/**
* Shows the app feedback dialog for the framing page, as the signed-in user it
* names. Without that user's session, or with an email still to confirm, the
* host is told the embed is unavailable and uses the popup, which can sign in.
*/
async function run () {
tellHost({ msg: 'feedbackEmbed', state: 'loading' });
let session = null;
try {
session = await embedSession(config.apiOrigin, window.location.hash);
} catch (e) {
console.error('feedback embed: session check failed', e);
}
const user = session?.user;
if ( !user || (user.requires_email_confirmation && !user.email_confirmed) ) {
tellHost({ msg: 'feedbackEmbed', state: 'unavailable' });
return;
}
window.auth_token = session.token;
window.user = user;
await localeReady;
tellHost({ msg: 'feedbackEmbed', state: 'ready' });
let sent = false;
try {
sent = await UIWindowAppFeedback({ origin: config.hostOrigin, source: 'web' });
await overlayGone();
} catch (e) {
console.error('feedback embed: dialog failed', e);
}
tellHost({ msg: 'feedbackDialogClosed', sent: sent === true });
}
// Opened on its own, there is no page to send feedback for or answer.
if ( config.hostOrigin && window.parent !== window ) run();
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { embedHostUser } from './embedHostUser.js';
/**
* @typedef {Object} EmbedSession
* @property {string} token - the GUI session's token
* @property {Record<string, unknown>} user - `/whoami` for that token
*/
/**
* The GUI session, from local storage or else the session cookie. Safari keys a
* frame's local storage by the top-level origin, so under a sibling subdomain
* the GUI's stored token is out of reach while its cookie is still sent.
*
* @returns {Promise<string | null>}
*/
async function readSession () {
let stored = null;
try {
stored = localStorage.getItem('auth_token_v2');
} catch { /* Storage the browser refuses holds no session; the cookie may. */ }
if ( stored ) return stored;
const response = await fetch('/get-gui-token', { credentials: 'include' });
// Only a 401 means there is no session; a rate limit or server error
// must not pass a signed-in user off as signed out.
if ( response.status === 401 ) return null;
if ( ! response.ok ) throw new Error(`Session lookup responded ${response.status}`);
return (await response.json())?.token || null;
}
/**
* `/whoami` for a GUI token, or null when the API refuses it.
*
* @param {string} token
* @param {string} apiOrigin
* @returns {Promise<Record<string, unknown> | null>}
*/
async function whoami (token, apiOrigin) {
const response = await fetch(new URL('/whoami', apiOrigin), {
headers: { Authorization: `Bearer ${token}` },
});
if ( response.status === 401 ) return null;
if ( ! response.ok ) throw new Error(`Session check responded ${response.status}`);
const user = await response.json();
return user && typeof user === 'object' ? user : null;
}
/**
* The GUI session, only when it belongs to the user the embedding page names
* in its `#user=<uuid>` fragment. A host signed out, or signed in as someone
* else, gets null. Rejects when the session can't be checked.
*
* @param {string} apiOrigin
* @param {string} hash - `location.hash`
* @returns {Promise<EmbedSession | null>}
*/
export async function embedSession (apiOrigin, hash) {
const hostUser = embedHostUser(hash);
if ( ! hostUser ) return null;
const token = await readSession();
if ( ! token ) return null;
const user = await whoami(token, apiOrigin);
const uuid = typeof user?.uuid === 'string' ? user.uuid.toLowerCase() : null;
return uuid === hostUser ? { token, user } : null;
}
+1
View File
@@ -93,6 +93,7 @@ module.exports = async (options = {}) => {
...entries,
],
'apps-embed': './src/appsEmbed.js',
'feedback-embed': './src/feedbackEmbed.js',
};
config.output = {
path: path.resolve(__dirname, '../dist'),
+129
View File
@@ -0,0 +1,129 @@
/**
* Puter GUI pages a first-party site can frame, under `/embed/*` on the GUI
* origin. The server lets only a page one label under that origin frame them.
*/
// After the frame loads, how long to wait for the embed's first message
// before treating it as refused (a CSP that blocks it still fires `load`).
const LOAD_GRACE_MS = 1000;
// How long the embed may take to check the session before the popup is used.
const READY_TIMEOUT_MS = 15_000;
/**
* Whether a page at `pageUrl` may frame an embed served by `guiOrigin`:
* exactly one label under its host, on the same scheme and port.
*
* @param {string | undefined} pageUrl
* @param {string} guiOrigin
* @returns {boolean}
*/
export const canFrameGuiEmbed = (pageUrl, guiOrigin) => {
let page;
let gui;
try {
page = new URL(pageUrl);
gui = new URL(guiOrigin);
} catch {
return false;
}
const host = page.hostname.replace(/\.$/, '').toLowerCase();
const suffix = `.${gui.hostname.toLowerCase()}`;
if ( page.protocol !== gui.protocol || page.port !== gui.port || !host.endsWith(suffix) ) return false;
const label = host.slice(0, -suffix.length);
return label.length > 0 && !label.includes('.');
};
/**
* Shows the app feedback dialog from `/embed/feedback` in a modal frame over
* the page, as the user named. Resolves `{ sent }` once the dialog closes, or
* null without showing anything when the embed can't serve this user (no
* matching Puter session, an email to confirm, or a refused frame), so the
* caller can use the popup instead. Never rejects.
*
* @param {Object} opts
* @param {string} opts.guiOrigin
* @param {string} opts.userUuid - the page's signed-in user
* @returns {Promise<{ sent: boolean } | null>}
*/
export const showFeedbackEmbed = ({ guiOrigin, userUuid }) => new Promise((resolve) => {
let origin;
try {
origin = new URL(guiOrigin).origin;
} catch {
resolve(null);
return;
}
const url = new URL('/embed/feedback', origin);
url.hash = new URLSearchParams({ user: userUuid }).toString();
const dialog = document.createElement('dialog');
dialog.className = 'puter-feedback-embed';
dialog.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;max-width:none;max-height:none;margin:0;padding:0;border:0;background:transparent;overflow:hidden;';
// The embed draws its own scrim.
const style = document.createElement('style');
style.textContent = 'dialog.puter-feedback-embed::backdrop{background:transparent}';
const frame = document.createElement('iframe');
frame.title = 'Send feedback';
// The embed allows a framing page it can name from the referrer.
frame.setAttribute('referrerpolicy', 'origin');
// A frame whose color-scheme differs from its document's is painted
// opaque; the embed declares none.
frame.style.cssText = 'display:block;width:100%;height:100%;border:0;background:transparent;color-scheme:normal;';
dialog.append(style, frame);
let settled = false;
let heard = false;
let shown = false;
let timer = null;
const restoreFocus = document.activeElement;
const wait = (ms) => {
clearTimeout(timer);
timer = setTimeout(() => finish(null), ms);
};
const finish = (result) => {
if ( settled ) return;
settled = true;
clearTimeout(timer);
window.removeEventListener('message', onMessage);
dialog.remove();
if ( shown ) restoreFocus?.focus?.({ preventScroll: true });
resolve(result);
};
const onMessage = (event) => {
if ( event.origin !== origin || event.source !== frame.contentWindow ) return;
const { msg, state, sent } = event.data ?? {};
if ( msg === 'feedbackDialogClosed' ) {
finish({ sent: sent === true });
} else if ( msg === 'feedbackEmbed' && state === 'loading' ) {
heard = true;
wait(READY_TIMEOUT_MS);
} else if ( msg === 'feedbackEmbed' && state === 'ready' ) {
heard = true;
clearTimeout(timer);
shown = true;
dialog.showModal();
frame.focus();
} else if ( msg === 'feedbackEmbed' && state === 'unavailable' ) {
finish(null);
}
};
window.addEventListener('message', onMessage);
frame.addEventListener('load', () => {
if ( ! heard ) wait(LOAD_GRACE_MS);
});
// Escape outside the frame would close the dialog with the embed's
// dialog still open inside it; the embed owns dismissal.
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
frame.focus();
});
try {
frame.src = url.href;
// Hidden until the embed is ready, so a refused session shows nothing.
document.body.append(dialog);
wait(READY_TIMEOUT_MS);
} catch {
finish(null);
}
});
+134
View File
@@ -0,0 +1,134 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { canFrameGuiEmbed, showFeedbackEmbed } from './guiEmbed.js';
const GUI_ORIGIN = 'https://puter.test';
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
// jsdom has no top-layer dialogs.
HTMLDialogElement.prototype.showModal = function () {
this.open = true;
};
const frame = () => document.querySelector('dialog.puter-feedback-embed iframe');
/** A message from the embed, by default from its frame on the GUI origin. */
const fromEmbed = (data, { origin = GUI_ORIGIN, source = frame().contentWindow } = {}) =>
window.dispatchEvent(new MessageEvent('message', { data, origin, source }));
beforeEach(() => {
document.body.innerHTML = '';
});
afterEach(() => {
vi.useRealTimers();
});
describe('canFrameGuiEmbed', () => {
it.each([
['https://notes.puter.com/a', 'https://puter.com', true],
['https://NOTES.puter.com./a', 'https://puter.com', true],
['http://notes.puter.localhost:4100/', 'http://puter.localhost:4100', true],
['https://puter.com/', 'https://puter.com', false],
['https://a.b.puter.com/', 'https://puter.com', false],
['https://notputer.com/', 'https://puter.com', false],
['https://notes.puter.com.evil.com/', 'https://puter.com', false],
['http://notes.puter.com/', 'https://puter.com', false],
['https://notes.puter.com:8443/', 'https://puter.com', false],
['https://notes.puter.site/', 'https://puter.com', false],
[undefined, 'https://puter.com', false],
])('%s under %s is %s', (page, gui, expected) => {
expect(canFrameGuiEmbed(page, gui)).toBe(expected);
});
});
describe('showFeedbackEmbed', () => {
it('loads the embed for the named user, hidden until it is ready', () => {
showFeedbackEmbed({ guiOrigin: `${GUI_ORIGIN}/`, userUuid: USER });
const url = new URL(frame().src);
expect(url.origin + url.pathname).toBe(`${GUI_ORIGIN}/embed/feedback`);
expect(url.search).toBe('');
expect(new URLSearchParams(url.hash.slice(1)).get('user')).toBe(USER);
expect(frame().getAttribute('referrerpolicy')).toBe('origin');
expect(frame().closest('dialog').open).toBe(false);
});
it('shows the dialog once ready and resolves what it reports, restoring focus', async () => {
const trigger = document.createElement('button');
document.body.append(trigger);
trigger.focus();
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
const dialog = frame().closest('dialog');
fromEmbed({ msg: 'feedbackEmbed', state: 'loading' });
expect(dialog.open).toBe(false);
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' });
expect(dialog.open).toBe(true);
expect(document.activeElement).toBe(frame());
fromEmbed({ msg: 'feedbackDialogClosed', sent: true });
await expect(result).resolves.toEqual({ sent: true });
expect(dialog.isConnected).toBe(false);
expect(document.activeElement).toBe(trigger);
});
it('resolves null without showing anything when the embed is unavailable', async () => {
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
const dialog = frame().closest('dialog');
fromEmbed({ msg: 'feedbackEmbed', state: 'loading' });
fromEmbed({ msg: 'feedbackEmbed', state: 'unavailable' });
await expect(result).resolves.toBeNull();
expect(dialog.open).toBe(false);
expect(dialog.isConnected).toBe(false);
});
it('ignores messages from another origin or another window', async () => {
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
fromEmbed({ msg: 'feedbackDialogClosed', sent: true }, { origin: 'https://other.test' });
fromEmbed({ msg: 'feedbackDialogClosed', sent: true }, { source: window });
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' }, { origin: 'https://notes.puter.test' });
expect(frame().closest('dialog').open).toBe(false);
fromEmbed({ msg: 'feedbackDialogClosed', sent: false });
await expect(result).resolves.toEqual({ sent: false });
});
it('resolves null when the frame loads without the embed answering', async () => {
vi.useFakeTimers();
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
frame().dispatchEvent(new Event('load'));
await vi.advanceTimersByTimeAsync(1000);
await expect(result).resolves.toBeNull();
expect(document.querySelector('dialog')).toBeNull();
});
it('gives a loading embed time to check the session, then gives up', async () => {
vi.useFakeTimers();
const result = showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
fromEmbed({ msg: 'feedbackEmbed', state: 'loading' });
frame().dispatchEvent(new Event('load'));
let settled = false;
result.then(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(5000);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(10_000);
await expect(result).resolves.toBeNull();
});
it('keeps the dialog open on Escape outside the frame, since the embed owns dismissal', () => {
showFeedbackEmbed({ guiOrigin: GUI_ORIGIN, userUuid: USER });
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' });
const dialog = frame().closest('dialog');
const cancel = new Event('cancel', { cancelable: true });
dialog.dispatchEvent(cancel);
expect(cancel.defaultPrevented).toBe(true);
expect(dialog.isConnected).toBe(true);
});
it('resolves null for an unusable GUI origin', async () => {
await expect(showFeedbackEmbed({ guiOrigin: 'not a url', userUuid: USER })).resolves.toBeNull();
expect(document.querySelector('dialog')).toBeNull();
});
});
+37 -3
View File
@@ -2,6 +2,7 @@ import EventListener from '../lib/EventListener.js';
import { PuterJSError } from '../lib/PuterJSError.js';
import FSItem from './FSItem.js';
import PuterDialog from './PuterDialog.js';
import { canFrameGuiEmbed, showFeedbackEmbed } from '../lib/guiEmbed.js';
import { checkPermissions } from './perms/lib/holds.js';
@@ -528,6 +529,9 @@ export class UIModule extends EventListener {
#guiFeatures = new URLSearchParams(globalThis.location?.search ?? '')
.get('puter.gui_features')?.split(',') ?? [];
// Set while the framed feedback dialog is open on a first-party page.
#feedbackEmbedOpen = false;
// Holds the callback functions for the various events
// that are triggered when a watched item has changed.
itemWatchCallbackFunctions = [];
@@ -2249,9 +2253,12 @@ export class UIModule extends EventListener {
* have opted in by setting `feedbackEnabled` on the app — otherwise the
* dialog tells the user feedback is unavailable.
*
* In the `app` environment the Puter desktop renders the dialog; in the
* `web` environment a puter.com popup hosts it (signing the user in first
* if needed). Every other environment resolves `false`.
* In the `app` environment the Puter desktop renders the dialog. In the
* `web` environment a first-party page one label under the Puter origin
* shows it in a frame over the page when the user's Puter session is the
* one the page is signed in as; otherwise a puter.com popup hosts it
* (signing the user in first if needed). Every other environment
* resolves `false`.
*
* @returns {Promise<boolean>} `true` when the user submitted feedback,
* `false` when the dialog was dismissed or feedback is unavailable.
@@ -2276,6 +2283,10 @@ export class UIModule extends EventListener {
if ( this.env !== 'web' ) {
return false;
}
const embedded = await this.#showFeedbackEmbed();
if ( embedded ) {
return embedded.sent;
}
// The app's identity is deliberately NOT in the popup URL: the GUI
// derives it from the browser-attested opener origin, so a link
// can't open a feedback dialog in another app's name.
@@ -2287,6 +2298,29 @@ export class UIModule extends EventListener {
return result?.sent === true;
};
/**
* The framed feedback dialog for a first-party page that is signed in, or
* null when the popup has to host it. One at a time: a call while one is
* open resolves `{ sent: false }`, as the desktop does.
*
* @returns {Promise<{ sent: boolean } | null>}
*/
async #showFeedbackEmbed () {
if ( ! globalThis.document?.body ) return null;
if ( ! canFrameGuiEmbed(globalThis.location?.href, puter.defaultGUIOrigin) ) return null;
if ( ! puter.auth?.isSignedIn?.() ) return null;
if ( this.#feedbackEmbedOpen ) return { sent: false };
this.#feedbackEmbedOpen = true;
try {
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
if ( typeof user?.uuid !== 'string' || ! user.uuid ) return null;
return await showFeedbackEmbed({ guiOrigin: puter.defaultGUIOrigin, userUuid: user.uuid });
} finally {
this.#feedbackEmbedOpen = false;
}
}
/**
* Opens `/action/<action>` on the GUI origin in a popup and waits for
* its single result message. Resolves the message's data, or `null` when
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { UIModule } = await import('./UI.js');
// jsdom has no top-layer dialogs.
HTMLDialogElement.prototype.showModal = function () {
this.open = true;
};
const GUI_ORIGIN = 'https://puter.test';
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
const makeUI = () => new UIModule(
{ env: 'web', authToken: 'token-1', APIOrigin: 'https://api.puter.test', util: {} },
{},
);
const embedFrame = () => document.querySelector('dialog.puter-feedback-embed iframe');
const fromEmbed = (data) => window.dispatchEvent(new MessageEvent('message', {
data, origin: GUI_ORIGIN, source: embedFrame().contentWindow,
}));
let popup;
const fakePuter = ({ guiOrigin = GUI_ORIGIN, signedIn = true } = {}) => {
globalThis.puter = {
defaultGUIOrigin: guiOrigin,
auth: {
isSignedIn: () => signedIn,
getUser: vi.fn(async () => ({ uuid: USER })),
},
};
};
beforeEach(() => {
document.body.innerHTML = '';
popup = { closed: false, close: vi.fn() };
window.open = vi.fn(() => popup);
Object.defineProperty(navigator, 'userActivation', {
configurable: true,
value: { hasBeenActive: true, isActive: true },
});
fakePuter();
});
describe('ui.showFeedbackDialog on a first-party page', () => {
it('shows the dialog in a frame instead of a popup', async () => {
const result = makeUI().showFeedbackDialog();
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
fromEmbed({ msg: 'feedbackEmbed', state: 'ready' });
fromEmbed({ msg: 'feedbackDialogClosed', sent: true });
await expect(result).resolves.toBe(true);
expect(window.open).not.toHaveBeenCalled();
});
it('names the user puter.js already knows, without asking the API again', async () => {
globalThis.puter.whoamiCache_ = Promise.resolve({ uuid: USER });
const result = makeUI().showFeedbackDialog();
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
expect(new URLSearchParams(new URL(embedFrame().src).hash.slice(1)).get('user')).toBe(USER);
expect(globalThis.puter.auth.getUser).not.toHaveBeenCalled();
fromEmbed({ msg: 'feedbackDialogClosed', sent: false });
await expect(result).resolves.toBe(false);
});
it('uses the popup when the embed cannot serve the user', async () => {
const result = makeUI().showFeedbackDialog();
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
fromEmbed({ msg: 'feedbackEmbed', state: 'unavailable' });
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
const url = new URL(window.open.mock.calls[0][0]);
expect(url.origin + url.pathname).toBe(`${GUI_ORIGIN}/action/send-feedback`);
window.dispatchEvent(new MessageEvent('message', {
data: { msg: 'feedbackDialogClosed', sent: true, original_msg_id: url.searchParams.get('msg_id') },
origin: GUI_ORIGIN,
source: popup,
}));
await expect(result).resolves.toBe(true);
});
it('answers false to a second call while the framed dialog is open', async () => {
const ui = makeUI();
const first = ui.showFeedbackDialog();
await vi.waitFor(() => expect(embedFrame()).not.toBeNull());
await expect(ui.showFeedbackDialog()).resolves.toBe(false);
expect(document.querySelectorAll('dialog.puter-feedback-embed')).toHaveLength(1);
fromEmbed({ msg: 'feedbackDialogClosed', sent: false });
await expect(first).resolves.toBe(false);
});
it('goes straight to the popup, which can sign in, when the page is signed out', async () => {
fakePuter({ signedIn: false });
makeUI().showFeedbackDialog();
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
expect(embedFrame()).toBeNull();
});
});
describe('ui.showFeedbackDialog on a third-party page', () => {
it('uses the popup', async () => {
fakePuter({ guiOrigin: 'https://other-puter.test' });
makeUI().showFeedbackDialog();
await vi.waitFor(() => expect(window.open).toHaveBeenCalledTimes(1));
expect(embedFrame()).toBeNull();
expect(globalThis.puter.auth.getUser).not.toHaveBeenCalled();
});
});
@@ -19,6 +19,7 @@
import PuterWebComponent from '../PuterWebComponent.js';
import { defaultFontFamily } from '../PuterDefaultStyles.js';
import { canFrameGuiEmbed } from '../../lib/guiEmbed.js';
const APPS_ICON = `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">${
[5, 12, 19].flatMap(y => [5, 12, 19].map(x => `<circle cx="${x}" cy="${y}" r="2"/>`)).join('')
@@ -37,30 +38,6 @@ const LABELS = {
const EDGE = 8;
const CARET_INSET = 20;
/**
* Whether a page at `pageUrl` may frame the app browser served by
* `guiOrigin`: exactly one label under its host, on the same scheme and port.
*
* @param {string | undefined} pageUrl
* @param {string} guiOrigin
* @returns {boolean}
*/
export const canFrameAppBrowser = (pageUrl, guiOrigin) => {
let page;
let gui;
try {
page = new URL(pageUrl);
gui = new URL(guiOrigin);
} catch {
return false;
}
const host = page.hostname.replace(/\.$/, '').toLowerCase();
const suffix = `.${gui.hostname.toLowerCase()}`;
if ( page.protocol !== gui.protocol || page.port !== gui.port || !host.endsWith(suffix) ) return false;
const label = host.slice(0, -suffix.length);
return label.length > 0 && !label.includes('.');
};
class PuterUserMenu extends PuterWebComponent {
static get observedAttributes () {
return Object.keys(LABELS);
@@ -133,7 +110,7 @@ class PuterUserMenu extends PuterWebComponent {
}
_appsAllowed () {
return canFrameAppBrowser(globalThis.location?.href, globalThis.puter.defaultGUIOrigin);
return canFrameGuiEmbed(globalThis.location?.href, globalThis.puter.defaultGUIOrigin);
}
/** A temporary account has nothing to sign out of, so it gets Sign in. */
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url": "https://notes.puter.com/sheet"}
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import PuterUserMenu, { canFrameAppBrowser } from './PuterUserMenu.js';
import PuterUserMenu from './PuterUserMenu.js';
const USER = { username: 'jelveh', uuid: '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40' };
const OTHER = { username: 'sam', uuid: '7d2e4f10-8a3b-4c5d-9e6f-0a1b2c3d4e5f' };
@@ -76,24 +76,6 @@ afterEach(() => {
vi.unstubAllGlobals();
});
describe('canFrameAppBrowser', () => {
it.each([
['https://notes.puter.com/a', 'https://puter.com', true],
['https://NOTES.puter.com./a', 'https://puter.com', true],
['http://notes.puter.localhost:4100/', 'http://puter.localhost:4100', true],
['https://puter.com/', 'https://puter.com', false],
['https://a.b.puter.com/', 'https://puter.com', false],
['https://notputer.com/', 'https://puter.com', false],
['https://notes.puter.com.evil.com/', 'https://puter.com', false],
['http://notes.puter.com/', 'https://puter.com', false],
['https://notes.puter.com:8443/', 'https://puter.com', false],
['https://notes.puter.site/', 'https://puter.com', false],
[undefined, 'https://puter.com', false],
])('%s under %s is %s', (page, gui, expected) => {
expect(canFrameAppBrowser(page, gui)).toBe(expected);
});
});
describe('<puter-user-menu>', () => {
it.each(['app', 'gui'])('renders nothing in the %s environment', async (env) => {
vi.stubGlobal('puter', { ...fakePuter({ token: 't', user: USER }), env });