mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c2915c955 | ||
|
|
af35783df4 | ||
|
|
37e1bf35ca | ||
|
|
52fb91e8e2 | ||
|
|
67621d996a | ||
|
|
cb214f28cc | ||
|
|
8ed439e897 | ||
|
|
672a704193 | ||
|
|
35ae464d86 | ||
|
|
79f24ed3df | ||
|
|
f30d2134b1 | ||
|
|
c24c8589cb | ||
|
|
2f8f81e621 | ||
|
|
7d02c7f622 |
No files matched your search
@@ -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));
|
||||
|
||||
|
||||
@@ -187,9 +187,9 @@ describe('createUserSubdomainNotFound', () => {
|
||||
});
|
||||
|
||||
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`,
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `about`, `apps`,
|
||||
// `puter-app-icons`, etc. all pass through.
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
for (const sub of ['api', 'js', 'docs', 'about', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
@@ -865,13 +865,13 @@ describe('createNativeAppStatic', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(nodePath.join(tmpdir(), 'native-apps-'));
|
||||
mkdirSync(nodePath.join(root, 'editor', 'assets'), { recursive: true });
|
||||
mkdirSync(nodePath.join(root, 'about', 'assets'), { recursive: true });
|
||||
writeFileSync(
|
||||
nodePath.join(root, 'editor', 'index.html'),
|
||||
'<h1>editor</h1>',
|
||||
nodePath.join(root, 'about', 'index.html'),
|
||||
'<h1>about</h1>',
|
||||
);
|
||||
writeFileSync(
|
||||
nodePath.join(root, 'editor', 'assets', 'app.js'),
|
||||
nodePath.join(root, 'about', 'assets', 'app.js'),
|
||||
'console.log(1);',
|
||||
);
|
||||
mkdirSync(nodePath.join(root, 'docs', 'dist'), { recursive: true });
|
||||
@@ -893,7 +893,7 @@ describe('createNativeAppStatic', () => {
|
||||
|
||||
it('leaves a native app name alone on a host outside our domains', async () => {
|
||||
const req = staticReq({
|
||||
subdomains: ['com', 'example', 'editor'],
|
||||
subdomains: ['com', 'example', 'about'],
|
||||
path: '/index.html',
|
||||
});
|
||||
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
|
||||
@@ -907,7 +907,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
createNativeAppStatic(config),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
@@ -915,17 +915,24 @@ describe('createNativeAppStatic', () => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes through subdomains that are not native apps', async () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'api'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// `editor`, `markus` and `pdf` are web apps on standard host routing,
|
||||
// even with a folder of their own under the native apps root.
|
||||
it.each(['api', 'editor', 'markus', 'pdf'])(
|
||||
'passes %s through, as it is not a native app',
|
||||
async (sub) => {
|
||||
mkdirSync(nodePath.join(root, sub), { recursive: true });
|
||||
writeFileSync(nodePath.join(root, sub, 'index.html'), sub);
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', sub],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('passes through when there is no subdomain at all', async () => {
|
||||
const { next } = await runStatic(
|
||||
@@ -939,13 +946,13 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toEqual({
|
||||
path: '/index.html',
|
||||
root: nodePath.join(root, 'editor'),
|
||||
root: nodePath.join(root, 'about'),
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -954,11 +961,11 @@ describe('createNativeAppStatic', () => {
|
||||
const { out } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'EDITOR'],
|
||||
subdomains: ['localhost', 'puter', 'ABOUT'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent?.root).toBe(nodePath.join(root, 'editor'));
|
||||
expect(out.sent?.root).toBe(nodePath.join(root, 'about'));
|
||||
});
|
||||
|
||||
it('serves docs out of its dist/ subdirectory, not the app root', async () => {
|
||||
@@ -979,7 +986,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/assets',
|
||||
originalUrl: '/assets?v=2',
|
||||
}),
|
||||
@@ -995,7 +1002,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/assets/',
|
||||
}),
|
||||
);
|
||||
@@ -1007,7 +1014,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/nope.html',
|
||||
}),
|
||||
);
|
||||
@@ -1019,7 +1026,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
new Error('send failed'),
|
||||
@@ -1032,7 +1039,7 @@ describe('createNativeAppStatic', () => {
|
||||
runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
path: '/../../etc/passwd',
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -368,12 +368,52 @@ 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/'],
|
||||
['the API host', 'http://api.puter.localhost:PORT/'],
|
||||
['the WebDAV host', 'http://dav.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://editor.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://about.puter.localhost:PORT/'],
|
||||
['a nested subdomain', 'http://a.spreadsheet.puter.localhost:PORT/'],
|
||||
['another site', 'http://evil.test:PORT/'],
|
||||
['another port', 'http://spreadsheet.puter.localhost:1/'],
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ export function pushAccountPickerScreen (flow) {
|
||||
const accounts = Array.isArray(window.logged_in_users) ? window.logged_in_users : [];
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('sign_in_with_puter', [], false));
|
||||
// Everyone here has signed in to Puter before; no need to explain it.
|
||||
h += authHeadHtml(flow, i18n('sign_in_with_puter', [], false), { openerNotice: false });
|
||||
h += `<ul class="auth-accounts" aria-label="${i18n('sign_in_with_puter')}">`;
|
||||
h += accounts.map(accountRowHtml).join('');
|
||||
h += '</ul>';
|
||||
|
||||
@@ -104,7 +104,8 @@ export function pushLoginScreen (flow, options = {}) {
|
||||
const id = `auth-login-${window.uuidv4?.() ?? Date.now()}`;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('log_in', [], false));
|
||||
// Logging in means already having a Puter account; no need to explain it.
|
||||
h += authHeadHtml(flow, i18n('log_in', [], false), { openerNotice: false });
|
||||
h += '<form class="auth-form login-form" novalidate>';
|
||||
h += '<div class="dashboard-form-status" role="status" aria-live="polite"></div>';
|
||||
h += formFieldHtml({
|
||||
@@ -152,6 +153,7 @@ export function pushLoginScreen (flow, options = {}) {
|
||||
|
||||
renderProviderButtons($screen.find('.auth-provider-list'), {
|
||||
kind: 'login',
|
||||
flow,
|
||||
referrer: () => flow.options.referrer ?? window.referrerStr ?? window.openerOrigin,
|
||||
}).then((offered) => {
|
||||
if ( offered ) $screen.find('.auth-providers').prop('hidden', false);
|
||||
|
||||
@@ -60,6 +60,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('login screen', () => {
|
||||
it('leaves out the opener notice in a sign-in popup', () => {
|
||||
window.embedded_in_popup = true;
|
||||
window.openerOrigin = 'https://builder.puter.com';
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
expect(screen().find('.auth-title')).toHaveLength(1);
|
||||
expect(screen().find('.auth-opener-notice')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('asks for each field before sending anything', () => {
|
||||
const calls = captureAjax();
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
|
||||
@@ -36,11 +36,13 @@ import openAuthFlow, {
|
||||
submitButtonHtml,
|
||||
wireAuthHead,
|
||||
} from './authFlow.js';
|
||||
import { renderProviderButtons } from './authProviders.js';
|
||||
import { mayOfferProviders, renderProviderButtons } from './authProviders.js';
|
||||
import { pushLoginScreen } from './UIAuthLogin.js';
|
||||
|
||||
const SIGNUP_TIMEOUT_MS = 30000;
|
||||
const TURNSTILE_POLL_MS = 100;
|
||||
// How long the email form waits for the provider lookup before showing anyway.
|
||||
const PROVIDERS_WAIT_MS = 1500;
|
||||
|
||||
/**
|
||||
* @typedef {object} SignupScreenOptions
|
||||
@@ -187,6 +189,9 @@ export const signupRedirectUrl = (redirect_url) => {
|
||||
export function pushSignupScreen (flow, options = {}) {
|
||||
const id = `auth-signup-${window.uuidv4?.() ?? Date.now()}`;
|
||||
const turnstile_key = window.gui_params?.turnstileSiteKey;
|
||||
// The email form stays hidden until the providers are known, so it
|
||||
// doesn't show and then fold away behind "Sign up with email".
|
||||
let awaiting_providers = mayOfferProviders() && ! options.username && ! options.email;
|
||||
|
||||
let h = '<div class="dashboard-dialog-body auth-body">';
|
||||
h += authHeadHtml(flow, i18n('create_free_account', [], false));
|
||||
@@ -200,7 +205,7 @@ export function pushSignupScreen (flow, options = {}) {
|
||||
h += '<div class="signup-with-email-wrapper" hidden>';
|
||||
h += `<button type="button" class="dashboard-dialog-btn auth-provider-btn signup-with-email-btn">${i18n('sign_up_with_email')}</button>`;
|
||||
h += '</div>';
|
||||
h += '<form class="auth-form signup-form" novalidate>';
|
||||
h += `<form class="auth-form signup-form" novalidate${awaiting_providers ? ' hidden' : ''}>`;
|
||||
h += formFieldHtml({
|
||||
name: 'username',
|
||||
label: i18n('username'),
|
||||
@@ -307,11 +312,32 @@ export function pushSignupScreen (flow, options = {}) {
|
||||
})();
|
||||
}
|
||||
|
||||
// With focus on the card itself, nothing on the screen was focusable yet.
|
||||
const focusWasParked = () => document.activeElement === flow.$dialog.get(0);
|
||||
const stopAwaitingProviders = () => {
|
||||
const was_awaiting = awaiting_providers;
|
||||
awaiting_providers = false;
|
||||
clearTimeout(form_fallback);
|
||||
return was_awaiting;
|
||||
};
|
||||
const revealForm = () => {
|
||||
if ( ! stopAwaitingProviders() || flow.isClosed() ) return;
|
||||
showForm();
|
||||
if ( focusWasParked() ) flow.focus();
|
||||
};
|
||||
const form_fallback = awaiting_providers ? setTimeout(revealForm, PROVIDERS_WAIT_MS) : undefined;
|
||||
|
||||
renderProviderButtons($screen.find('.auth-provider-list'), {
|
||||
kind: 'signup',
|
||||
flow,
|
||||
referrer: () => flow.options.referrer ?? window.referrerStr,
|
||||
}).then((offered) => {
|
||||
if ( ! offered || flow.isClosed() ) return;
|
||||
if ( ! offered ) {
|
||||
revealForm();
|
||||
return;
|
||||
}
|
||||
stopAwaitingProviders();
|
||||
if ( flow.isClosed() ) return;
|
||||
offers_providers = true;
|
||||
$screen.find('.oidc-providers-wrapper').prop('hidden', false);
|
||||
if ( offered.signupOidcOnly ) {
|
||||
@@ -327,7 +353,7 @@ export function pushSignupScreen (flow, options = {}) {
|
||||
const had_focus = $form.get(0).contains(document.activeElement);
|
||||
$form.prop('hidden', true);
|
||||
$screen.find('.signup-with-email-wrapper').prop('hidden', false);
|
||||
if ( had_focus ) flow.focus();
|
||||
if ( had_focus || focusWasParked() ) flow.focus();
|
||||
});
|
||||
|
||||
$screen.find('.signup-with-email-btn').on('click', () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ const { activeAuthFlow } = await import('./authFlow.js');
|
||||
const { default: UIAuthSignup, signupRedirectUrl } = await import('./UIAuthSignup.js');
|
||||
const { default: UIAuthAccountPicker } = await import('./UIAuthAccountPicker.js');
|
||||
const { default: UIAuthRecoverPassword } = await import('./UIAuthRecoverPassword.js');
|
||||
const { default: UIAuthLogin } = await import('./UIAuthLogin.js');
|
||||
|
||||
const providers = (body) => {
|
||||
globalThis.fetch = vi.fn(async () => reply(200, body));
|
||||
@@ -110,7 +111,12 @@ describe('signup when only OIDC signups are open', () => {
|
||||
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
|
||||
void UIAuthSignup();
|
||||
// A slow lookup gives up the wait and shows the form.
|
||||
vi.advanceTimersByTime(1500);
|
||||
vi.useRealTimers();
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
$('[name="username"]').val('started');
|
||||
answer(reply(200, { providers: ['google'] }));
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
@@ -118,6 +124,50 @@ describe('signup when only OIDC signups are open', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('signup while the providers are looked up', () => {
|
||||
/** Lets promise callbacks run, but no timer, so nothing a paint could show in between. */
|
||||
const settlePromises = async () => {
|
||||
for ( let i = 0; i < 10; i++ ) await Promise.resolve();
|
||||
};
|
||||
|
||||
it('keeps the email form hidden until the providers are known', async () => {
|
||||
let answer;
|
||||
globalThis.fetch = vi.fn(() => new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
void UIAuthSignup();
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
answer(reply(200, { providers: ['google'] }));
|
||||
await vi.waitFor(() => expect($('.oidc-google-btn')).toHaveLength(1));
|
||||
expect($('.signup-form').prop('hidden')).toBe(true);
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the email form when no providers are offered', async () => {
|
||||
void UIAuthSignup();
|
||||
await vi.waitFor(() => expect($('.signup-form').prop('hidden')).toBe(false));
|
||||
expect($('.signup-with-email-wrapper').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a prefilled email form straight away', () => {
|
||||
globalThis.fetch = vi.fn(() => new Promise(() => {}));
|
||||
void UIAuthSignup({ email: '[email protected]' });
|
||||
expect($('.signup-form').prop('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses the login screen\'s lookup, drawing the providers before any paint', async () => {
|
||||
providers({ providers: ['google'], signupOidcOnly: false });
|
||||
void UIAuthLogin({ reload_on_success: false });
|
||||
await vi.waitFor(() => expect(screen().find('.oidc-google-btn')).toHaveLength(1));
|
||||
screen().find('.auth-to-signup').trigger('click');
|
||||
await settlePromises();
|
||||
expect(screen().attr('data-screen')).toBe('signup');
|
||||
expect(screen().find('.oidc-google-btn')).toHaveLength(1);
|
||||
expect(screen().find('.signup-with-email-wrapper').prop('hidden')).toBe(false);
|
||||
expect(fetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signup form', () => {
|
||||
it.each([
|
||||
[{}, 'username_required', 'username'],
|
||||
|
||||
@@ -387,9 +387,12 @@ export const joinAuthFlow = () => {
|
||||
*
|
||||
* @param {AuthFlow} flow
|
||||
* @param {string} title Plain text.
|
||||
* @param {{ openerNotice?: boolean }} [opts] `openerNotice: false` leaves out
|
||||
* the line explaining that the site uses Puter, for screens shown to someone
|
||||
* who already has an account here.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const authHeadHtml = (flow, title) => {
|
||||
export const authHeadHtml = (flow, title, { openerNotice = true } = {}) => {
|
||||
const opener = window.embedded_in_popup && isAttestedOrigin(window.openerOrigin) ? window.openerOrigin : '';
|
||||
let h = '<div class="auth-head">';
|
||||
h += authLogoHeader({
|
||||
@@ -399,7 +402,7 @@ export const authHeadHtml = (flow, title) => {
|
||||
openerFallbackSrc: window.icons['website.svg'],
|
||||
});
|
||||
h += `<h1 class="auth-title">${html_encode(title)}</h1>`;
|
||||
if ( opener ) {
|
||||
if ( opener && openerNotice ) {
|
||||
h += `<p class="auth-opener-notice">${i18n('popup_opener_uses_puter', [new URL(opener).hostname])}</p>`;
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
@@ -250,6 +250,16 @@ describe('authHeadHtml', () => {
|
||||
expect($dialog_head.find('a')).toHaveLength(0);
|
||||
expect($dialog_head.find('.auth-logo').attr('alt')).toBe('');
|
||||
});
|
||||
|
||||
it('explains the opener in a sign-in popup unless told not to', () => {
|
||||
window.embedded_in_popup = true;
|
||||
window.openerOrigin = 'https://builder.puter.com';
|
||||
const flow = openAuthFlow();
|
||||
const $notice = $(authHeadHtml(flow, 'Log in')).find('.auth-opener-notice');
|
||||
expect($notice).toHaveLength(1);
|
||||
expect($notice.text()).toContain('builder.puter.com');
|
||||
expect($(authHeadHtml(flow, 'Log in', { openerNotice: false })).find('.auth-opener-notice')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authFlowOptions', () => {
|
||||
|
||||
@@ -75,27 +75,46 @@ export const providerStartUrl = (provider, kind, referrer) => {
|
||||
return url;
|
||||
};
|
||||
|
||||
/** @type {WeakMap<import('./authFlow.js').AuthFlow, Promise<{ providers?: string[], signupOidcOnly?: boolean } | null>>} */
|
||||
const lookups = new WeakMap();
|
||||
|
||||
/**
|
||||
* Fetches the configured providers and fills `$list` with a button for each.
|
||||
* A self-hosted instance's own provider gets a generic button.
|
||||
* The configured providers, looked up once per auth flow so moving between
|
||||
* its screens draws them straight away instead of after another round trip.
|
||||
*
|
||||
* @param {import('./authFlow.js').AuthFlow} flow
|
||||
*/
|
||||
const lookUpProviders = (flow) => {
|
||||
let lookup = lookups.get(flow);
|
||||
if ( ! lookup ) {
|
||||
lookup = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
return res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
lookups.set(flow, lookup);
|
||||
}
|
||||
return lookup;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills `$list` with a button for each configured provider. A self-hosted
|
||||
* instance's own provider gets a generic button.
|
||||
*
|
||||
* @param {jQuery} $list Where the buttons go.
|
||||
* @param {object} opts
|
||||
* @param {'login'|'signup'} opts.kind
|
||||
* @param {() => string | undefined} opts.referrer Read when a button is pressed.
|
||||
* @param {import('./authFlow.js').AuthFlow} opts.flow The flow showing `$list`.
|
||||
* @returns {Promise<{ providers: string[], signupOidcOnly: boolean } | null>}
|
||||
* Null when none are offered here or the lookup failed.
|
||||
*/
|
||||
export async function renderProviderButtons ($list, { kind, referrer }) {
|
||||
export async function renderProviderButtons ($list, { kind, referrer, flow }) {
|
||||
if ( ! mayOfferProviders() ) return null;
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
|
||||
if ( ! res.ok ) return null;
|
||||
data = await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const data = await lookUpProviders(flow);
|
||||
if ( ! Array.isArray(data?.providers) || ! data.providers.length ) return null;
|
||||
|
||||
const ordered = [
|
||||
|
||||
@@ -24,10 +24,10 @@ import UIContextMenu from '../UIContextMenu.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
|
||||
import UIAuthLogin from '../Auth/UIAuthLogin.js';
|
||||
import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
import apply_item_added_to_containers from '../../helpers/applyItemAddedToContainers.js';
|
||||
import { clear_shared_param } from '../../helpers/parseSharedPath.js';
|
||||
import UIDashboardNotifications from './UIDashboardNotifications.js';
|
||||
import UIDashboardContactDialog from './UIDashboardContactDialog.js';
|
||||
import {
|
||||
answerNotificationReceipts,
|
||||
notificationSocketAcks,
|
||||
@@ -900,16 +900,8 @@ async function UIDashboard (options) {
|
||||
// Contact Us
|
||||
{
|
||||
html: i18n('contact_us'),
|
||||
onClick: async function () {
|
||||
UIWindowFeedback({
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
parent_center: true,
|
||||
stay_on_top: true,
|
||||
has_head: false,
|
||||
},
|
||||
});
|
||||
onClick: function () {
|
||||
UIDashboardContactDialog({ $container: $el_window, returnFocusTo: $btn.get(0) });
|
||||
},
|
||||
},
|
||||
'-',
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The dashboard's Contact Us form: a dashboard form dialog with screenshots
|
||||
// and screen recordings attached by picking, dropping or pasting them. The
|
||||
// desktop keeps UIWindowFeedback.
|
||||
|
||||
import {
|
||||
ATTACHMENT_ACCEPT_ATTRIBUTE,
|
||||
MAX_ATTACHMENTS,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
MAX_TOTAL_ATTACHMENT_BYTES,
|
||||
checkAttachment,
|
||||
} from '../../helpers/contact_attachments.js';
|
||||
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
import { ICONS, SVG } from './UIDashboardDialog.js';
|
||||
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
// Keep in sync with SystemController on the backend.
|
||||
const MESSAGE_MAX_LENGTH = 100_000;
|
||||
|
||||
// Numbers interpolated into each error message, taken from the enforced limits.
|
||||
const ERROR_VALUES = {
|
||||
contact_us_attachment_too_many: [MAX_ATTACHMENTS],
|
||||
contact_us_attachment_too_large: [MAX_ATTACHMENT_BYTES / MB],
|
||||
contact_us_attachment_total_too_large: [MAX_TOTAL_ATTACHMENT_BYTES / MB],
|
||||
};
|
||||
|
||||
const CONTACT_ICONS = {
|
||||
attach: SVG('<path d="M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/>'),
|
||||
video: SVG('<rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 10l6-3.5v11L16 14"/>'),
|
||||
};
|
||||
|
||||
const t = (key, values = []) => i18n(key, values, false);
|
||||
|
||||
/**
|
||||
* Whether a drag carries files, rather than text or a link from the page.
|
||||
*
|
||||
* @param {DragEvent} e
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const dragHasFiles = (e) => Array.from(e.originalEvent?.dataTransfer?.types ?? []).includes('Files');
|
||||
|
||||
/**
|
||||
* @param {File} file
|
||||
* @returns {string}
|
||||
*/
|
||||
const attachmentHtml = (file) => {
|
||||
const name = html_encode(file.name);
|
||||
// The preview's URL is let go once it has drawn; the <img> keeps the pixels.
|
||||
const thumb = file.type.startsWith('image/')
|
||||
? `<img src="${html_encode(URL.createObjectURL(file))}" alt="" draggable="false">`
|
||||
: CONTACT_ICONS.video;
|
||||
const remove = html_encode(t('contact_us_attachment_remove_named', [file.name]));
|
||||
return '<li class="dashboard-contact-file">'
|
||||
+ `<span class="dashboard-contact-file-thumb" aria-hidden="true">${thumb}</span>`
|
||||
+ '<span class="dashboard-contact-file-text">'
|
||||
+ `<span class="dashboard-contact-file-name" title="${name}">${name}</span>`
|
||||
+ `<span class="dashboard-contact-file-size">${html_encode(window.byte_format(file.size))}</span>`
|
||||
+ '</span>'
|
||||
+ `<button type="button" class="dashboard-contact-file-remove" aria-label="${remove}" title="${i18n('contact_us_attachment_remove')}">${ICONS.close}</button>`
|
||||
+ '</li>';
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the Contact Us form; the message goes to support, with any
|
||||
* attachments as multipart form data. The checks here only fail fast: the
|
||||
* server enforces the limits.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {jQuery} [opts.$container] Where the overlay mounts.
|
||||
* @param {HTMLElement} [opts.returnFocusTo] Where focus goes on close.
|
||||
* @returns {import('./UIDashboardFormDialog.js').FormDialog}
|
||||
*/
|
||||
export default function UIDashboardContactDialog ({ $container, returnFocusTo } = {}) {
|
||||
/** @type {File[]} */
|
||||
const attachments = [];
|
||||
let sending = false;
|
||||
// Once sent, the form is gone and a drop or paste has nothing to join.
|
||||
let sent = false;
|
||||
|
||||
const dialog = UIDashboardFormDialog({
|
||||
$container,
|
||||
returnFocusTo,
|
||||
title: t('contact_us'),
|
||||
intro: `<p>${i18n('feedback_c2a')}</p>`,
|
||||
submitLabel: t('send'),
|
||||
fields: [{
|
||||
name: 'message',
|
||||
type: 'textarea',
|
||||
label: i18n('contact_us_message'),
|
||||
placeholder: i18n('contact_us_message_placeholder'),
|
||||
attrs: { rows: 6, maxlength: MESSAGE_MAX_LENGTH },
|
||||
}],
|
||||
async onSubmit () {
|
||||
const { message } = dialog.values();
|
||||
if ( ! message.trim() ) {
|
||||
dialog.setStatus(t('contact_us_message_required'), { field: 'message' });
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const body = new FormData();
|
||||
body.append('message', message);
|
||||
for ( const file of attachments ) body.append('attachments', file, file.name);
|
||||
|
||||
// No Content-Type: the browser sets it with the boundary.
|
||||
const resp = await fetch(`${window.api_origin}/contactUs`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${window.auth_token}` },
|
||||
body,
|
||||
});
|
||||
if ( ! resp.ok ) {
|
||||
dialog.setStatus(t(resp.status === 429 ? 'contact_us_rate_limited' : 'contact_us_error'));
|
||||
return;
|
||||
}
|
||||
sent = true;
|
||||
dialog.finish({ message: t('feedback_sent_confirmation') });
|
||||
} catch ( err ) {
|
||||
console.error('contact-us: submit failed', err);
|
||||
dialog.setStatus(t('contact_us_error'));
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { $dialog } = dialog;
|
||||
const $overlay = $dialog.parent();
|
||||
const hint_id = `${$dialog.attr('aria-labelledby')}-attach-hint`;
|
||||
|
||||
let h = '<div class="dashboard-contact-attachments">';
|
||||
h += `<input type="file" class="dashboard-contact-file-input" accept="${html_encode(ATTACHMENT_ACCEPT_ATTRIBUTE)}" multiple hidden tabindex="-1">`;
|
||||
h += `<button type="button" class="dashboard-contact-dropzone" aria-describedby="${hint_id}">`;
|
||||
h += `<span class="dashboard-contact-dropzone-icon" aria-hidden="true">${CONTACT_ICONS.attach}</span>`;
|
||||
h += '<span class="dashboard-contact-dropzone-text">';
|
||||
h += `<span class="dashboard-contact-dropzone-label">${i18n(isTouchPrimaryDevice() ? 'contact_us_attach' : 'contact_us_attach_or_drop')}</span>`;
|
||||
h += `<span class="dashboard-contact-dropzone-drop" aria-hidden="true">${i18n('contact_us_drop_to_attach')}</span>`;
|
||||
h += `<span class="dashboard-contact-dropzone-hint" id="${hint_id}">${i18n('contact_us_attach_hint', [MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES / MB])}</span>`;
|
||||
h += '</span>';
|
||||
h += '</button>';
|
||||
h += `<ul class="dashboard-contact-files" aria-label="${i18n('contact_us_attachments')}" hidden></ul>`;
|
||||
h += '</div>';
|
||||
$dialog.addClass('dashboard-contact-dialog')
|
||||
.find('.dashboard-form-field[data-field="message"]').after(h);
|
||||
|
||||
const $dropzone = $dialog.find('.dashboard-contact-dropzone');
|
||||
const $fileInput = $dialog.find('.dashboard-contact-file-input');
|
||||
const $list = $dialog.find('.dashboard-contact-files');
|
||||
|
||||
const syncControls = () => {
|
||||
$list.prop('hidden', attachments.length === 0);
|
||||
// Nothing left to add once the count cap is reached.
|
||||
$dropzone.prop('disabled', sending || attachments.length >= MAX_ATTACHMENTS);
|
||||
$list.find('.dashboard-contact-file-remove').prop('disabled', sending);
|
||||
};
|
||||
|
||||
function setSending (value) {
|
||||
sending = value;
|
||||
$dialog.toggleClass('dashboard-contact-sending', value);
|
||||
syncControls();
|
||||
}
|
||||
|
||||
/** @param {File[]} files */
|
||||
const addFiles = (files) => {
|
||||
if ( sending || sent || ! files.length ) return;
|
||||
let rejection = null;
|
||||
for ( const file of files ) {
|
||||
const verdict = checkAttachment(file, attachments);
|
||||
if ( ! verdict.ok ) {
|
||||
// Report the first rejection but keep the files that fit.
|
||||
rejection = rejection ?? verdict.error;
|
||||
continue;
|
||||
}
|
||||
attachments.push(file);
|
||||
const $item = $(attachmentHtml(file));
|
||||
$item.find('img').one('load error', function () {
|
||||
URL.revokeObjectURL(this.src);
|
||||
});
|
||||
$list.append($item);
|
||||
}
|
||||
dialog.setStatus(rejection ? t(rejection, ERROR_VALUES[rejection] ?? []) : '');
|
||||
syncControls();
|
||||
};
|
||||
|
||||
$dropzone.on('click', () => $fileInput.trigger('click'));
|
||||
$fileInput.on('change', function () {
|
||||
addFiles(Array.from(this.files ?? []));
|
||||
// Reset so re-picking the same file fires `change` again.
|
||||
this.value = '';
|
||||
});
|
||||
|
||||
$list.on('click', '.dashboard-contact-file-remove', function () {
|
||||
if ( sending ) return;
|
||||
const $item = $(this).closest('.dashboard-contact-file');
|
||||
const index = $list.children().index($item);
|
||||
attachments.splice(index, 1);
|
||||
// Focus stays in the list rather than falling back to the page.
|
||||
const $next = $item.next().length ? $item.next() : $item.prev();
|
||||
$item.remove();
|
||||
dialog.setStatus('');
|
||||
syncControls();
|
||||
($next.length ? $next.find('.dashboard-contact-file-remove') : $dropzone).get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
// The whole overlay takes the drop, and keeps it from the window under it.
|
||||
let drag_depth = 0;
|
||||
const endDrag = () => {
|
||||
drag_depth = 0;
|
||||
$dialog.removeClass('dashboard-contact-dragging');
|
||||
};
|
||||
$overlay.on('dragenter', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drag_depth++;
|
||||
if ( ! sending && ! sent ) $dialog.addClass('dashboard-contact-dragging');
|
||||
});
|
||||
$overlay.on('dragover', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.originalEvent.dataTransfer.dropEffect = sending || sent ? 'none' : 'copy';
|
||||
});
|
||||
$overlay.on('dragleave', (e) => {
|
||||
// Matched to the dragenters counted, whatever this one says it carries.
|
||||
if ( drag_depth === 0 ) return;
|
||||
e.stopPropagation();
|
||||
if ( --drag_depth <= 0 ) endDrag();
|
||||
});
|
||||
$overlay.on('drop', (e) => {
|
||||
if ( ! dragHasFiles(e) ) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
endDrag();
|
||||
addFiles(Array.from(e.originalEvent.dataTransfer.files ?? []));
|
||||
});
|
||||
|
||||
// A screenshot pasted into the message is attached to it.
|
||||
$dialog.on('paste', '.dashboard-form-input[name="message"]', (e) => {
|
||||
const clipboard = e.originalEvent?.clipboardData;
|
||||
const files = Array.from(clipboard?.files ?? []);
|
||||
if ( ! files.length ) return;
|
||||
// Some apps put the image's name or markup alongside it; that text still pastes.
|
||||
if ( ! clipboard.getData('text/plain') ) e.preventDefault();
|
||||
addFiles(files);
|
||||
});
|
||||
|
||||
// Enter alone is a new line in the message; with Cmd or Ctrl, it sends.
|
||||
$dialog.on('keydown', '.dashboard-form-input[name="message"]', (e) => {
|
||||
if ( e.key !== 'Enter' || ! (e.metaKey || e.ctrlKey) ) return;
|
||||
e.preventDefault();
|
||||
$dialog.trigger('submit');
|
||||
});
|
||||
|
||||
syncControls();
|
||||
return dialog;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
import { MAX_ATTACHMENTS } from '../../helpers/contact_attachments.js';
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, values = []) => [key, ...values].join(' ');
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
window.matchMedia = () => ({ matches: false });
|
||||
window.byte_format = (bytes) => `${bytes} B`;
|
||||
URL.createObjectURL = vi.fn(() => 'blob:preview');
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
|
||||
const { default: UIDashboardContactDialog } = await import('./UIDashboardContactDialog.js');
|
||||
|
||||
const settle = async () => {
|
||||
for ( let i = 0; i < 10; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
|
||||
};
|
||||
const message = () => $('.dashboard-form-input[name="message"]');
|
||||
const status = () => $('.dashboard-form-status').text();
|
||||
const fileNames = () => $('.dashboard-contact-file-name').map((_, el) => el.textContent).get();
|
||||
const png = (name = 'shot.png', size = 1024) => new File([new Uint8Array(size)], name, { type: 'image/png' });
|
||||
const mp4 = (name = 'clip.mp4') => new File([new Uint8Array(2048)], name, { type: 'video/mp4' });
|
||||
|
||||
/** Picks files through the hidden file input, as the system picker would. */
|
||||
const pick = (...files) => {
|
||||
const input = $('.dashboard-contact-file-input').get(0);
|
||||
Object.defineProperty(input, 'files', { value: files, configurable: true });
|
||||
$(input).trigger('change');
|
||||
};
|
||||
|
||||
/** A native-event stand-in for jQuery to wrap, carrying `fields`. */
|
||||
const nativeEvent = (fields) => ({ preventDefault () {}, stopPropagation () {}, ...fields });
|
||||
|
||||
/** A file drag event carrying `files`, as the browser sends it. */
|
||||
const fileDrag = (type, files = []) => $.Event(type, {
|
||||
originalEvent: nativeEvent({ dataTransfer: { types: ['Files'], files, dropEffect: 'none' } }),
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
await settle();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.fetch = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'tok-1';
|
||||
});
|
||||
|
||||
describe('Contact Us dialog', () => {
|
||||
it('opens as a dashboard form dialog with no attachments listed', () => {
|
||||
UIDashboardContactDialog();
|
||||
expect($('.dashboard-dialog-overlay .dashboard-contact-dialog').length).toBe(1);
|
||||
expect(message().length).toBe(1);
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('asks for a message before sending', async () => {
|
||||
UIDashboardContactDialog();
|
||||
message().val(' ');
|
||||
await submit();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(status()).toBe('contact_us_message_required');
|
||||
expect(message().attr('aria-invalid')).toBe('true');
|
||||
});
|
||||
|
||||
it('posts the message and attachments as multipart and confirms', async () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png(), mp4());
|
||||
message().val('It broke');
|
||||
await submit();
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toBe('https://api.puter.test/contactUs');
|
||||
expect(init.headers).toEqual({ Authorization: 'Bearer tok-1' });
|
||||
expect(init.body.get('message')).toBe('It broke');
|
||||
expect(init.body.getAll('attachments').map(f => f.name)).toEqual(['shot.png', 'clip.mp4']);
|
||||
expect($('.dashboard-form-done').text()).toContain('feedback_sent_confirmation');
|
||||
expect($('.dashboard-contact-attachments').length).toBe(0);
|
||||
});
|
||||
|
||||
it('says when the sender is rate limited and keeps the form', async () => {
|
||||
fetch.mockResolvedValueOnce(new Response('{}', { status: 429 }));
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(status()).toBe('contact_us_rate_limited');
|
||||
expect(message().val()).toBe('Hello');
|
||||
expect(message().prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a generic error when the request fails', async () => {
|
||||
fetch.mockRejectedValueOnce(new TypeError('offline'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(status()).toBe('contact_us_error');
|
||||
});
|
||||
|
||||
it('locks the attachments while sending', async () => {
|
||||
let respond;
|
||||
fetch.mockReturnValueOnce(new Promise(resolve => {
|
||||
respond = resolve;
|
||||
}));
|
||||
UIDashboardContactDialog();
|
||||
pick(png());
|
||||
message().val('Hello');
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
await settle();
|
||||
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
|
||||
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(true);
|
||||
pick(png('late.png'));
|
||||
expect(fileNames()).toEqual(['shot.png']);
|
||||
|
||||
respond(new Response('{}', { status: 500 }));
|
||||
await settle();
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(false);
|
||||
expect($('.dashboard-contact-file-remove').prop('disabled')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachments', () => {
|
||||
it('lists picked files with a preview for images', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png('a.png'), mp4('b.mp4'));
|
||||
expect(fileNames()).toEqual(['a.png', 'b.mp4']);
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(false);
|
||||
expect($('.dashboard-contact-file').eq(0).find('img').attr('src')).toBe('blob:preview');
|
||||
expect($('.dashboard-contact-file').eq(1).find('img').length).toBe(0);
|
||||
expect($('.dashboard-contact-file-remove').eq(0).attr('aria-label')).toBe('contact_us_attachment_remove_named a.png');
|
||||
});
|
||||
|
||||
it('keeps the files that fit and reports the first rejection', () => {
|
||||
UIDashboardContactDialog();
|
||||
const text = new File(['x'], 'notes.txt', { type: 'text/plain' });
|
||||
pick(png('a.png'), text, png('b.png'));
|
||||
expect(fileNames()).toEqual(['a.png', 'b.png']);
|
||||
expect(status()).toBe('contact_us_attachment_unsupported');
|
||||
});
|
||||
|
||||
it('stops offering to attach once the count cap is reached', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(...Array.from({ length: MAX_ATTACHMENTS }, (_, i) => png(`${i}.png`)));
|
||||
expect($('.dashboard-contact-dropzone').prop('disabled')).toBe(true);
|
||||
pick(png('extra.png'));
|
||||
expect(fileNames()).toHaveLength(MAX_ATTACHMENTS);
|
||||
expect(status()).toBe(`contact_us_attachment_too_many ${MAX_ATTACHMENTS}`);
|
||||
});
|
||||
|
||||
it('removes the file whose button is pressed and moves focus to the next one', async () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png('a.png'), png('b.png'), png('c.png'));
|
||||
$('.dashboard-contact-file-remove').eq(1).trigger('click');
|
||||
expect(fileNames()).toEqual(['a.png', 'c.png']);
|
||||
expect(document.activeElement).toBe($('.dashboard-contact-file-remove').get(1));
|
||||
|
||||
message().val('Hello');
|
||||
await submit();
|
||||
expect(fetch.mock.calls[0][1].body.getAll('attachments').map(f => f.name)).toEqual(['a.png', 'c.png']);
|
||||
});
|
||||
|
||||
it('returns focus to the attach button when the last file is removed', () => {
|
||||
UIDashboardContactDialog();
|
||||
pick(png());
|
||||
$('.dashboard-contact-file-remove').trigger('click');
|
||||
expect($('.dashboard-contact-files').prop('hidden')).toBe(true);
|
||||
expect(document.activeElement).toBe($('.dashboard-contact-dropzone').get(0));
|
||||
});
|
||||
|
||||
it('takes files dropped anywhere on the overlay, without letting the drop through', () => {
|
||||
const onWindowDrop = vi.fn();
|
||||
const $win = $('<div class="window"></div>').appendTo('body').on('drop', onWindowDrop);
|
||||
UIDashboardContactDialog({ $container: $win });
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
|
||||
$overlay.trigger(fileDrag('dragenter'));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
|
||||
const drop = fileDrag('drop', [png('dropped.png')]);
|
||||
$overlay.trigger(drop);
|
||||
|
||||
expect(drop.isDefaultPrevented()).toBe(true);
|
||||
expect(onWindowDrop).not.toHaveBeenCalled();
|
||||
expect(fileNames()).toEqual(['dropped.png']);
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the drop highlight when the drag leaves', () => {
|
||||
UIDashboardContactDialog();
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
$overlay.trigger(fileDrag('dragenter'));
|
||||
$overlay.find('.dashboard-contact-dropzone').trigger(fileDrag('dragenter'));
|
||||
$overlay.find('.dashboard-contact-dropzone').trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(true);
|
||||
$overlay.trigger($.Event('dragleave', { originalEvent: nativeEvent({}) }));
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores drags that carry no files', () => {
|
||||
UIDashboardContactDialog();
|
||||
const drag = $.Event('dragenter', { originalEvent: nativeEvent({ dataTransfer: { types: ['text/plain'] } }) });
|
||||
$('.dashboard-dialog-overlay').trigger(drag);
|
||||
expect(drag.isDefaultPrevented()).toBe(false);
|
||||
expect($('.dashboard-contact-dialog').hasClass('dashboard-contact-dragging')).toBe(false);
|
||||
});
|
||||
|
||||
it('attaches an image pasted into the message', () => {
|
||||
UIDashboardContactDialog();
|
||||
const paste = $.Event('paste', {
|
||||
originalEvent: nativeEvent({ clipboardData: { files: [png('pasted.png')], getData: () => '' } }),
|
||||
});
|
||||
message().trigger(paste);
|
||||
expect(fileNames()).toEqual(['pasted.png']);
|
||||
expect(paste.isDefaultPrevented()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard', () => {
|
||||
it('sends on Cmd/Ctrl+Enter but not on Enter alone', async () => {
|
||||
UIDashboardContactDialog();
|
||||
message().val('Hello');
|
||||
message().trigger($.Event('keydown', { key: 'Enter' }));
|
||||
await settle();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
|
||||
message().trigger($.Event('keydown', { key: 'Enter', ctrlKey: true }));
|
||||
await settle();
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -17,13 +17,14 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The Files tab's publish flows, a folder as a website and a .js file as a
|
||||
// worker: dashboard form dialogs rather than desktop windows.
|
||||
// The Files tab's publish flows, a folder as a website (optionally on a
|
||||
// custom domain) and a .js file as a worker: dashboard form dialogs rather
|
||||
// than desktop windows.
|
||||
|
||||
import ensure_can_publish from '../../helpers/ensureCanPublish.js';
|
||||
import forget_parent_listing from '../../helpers/forgetParentListing.js';
|
||||
import { SVG } from './UIDashboardDialog.js';
|
||||
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
|
||||
import { openLinkHtml, publishDomainFields, setupPublishDomain } from './publishDomain.js';
|
||||
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
|
||||
|
||||
/**
|
||||
@@ -36,8 +37,6 @@ import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from '.
|
||||
|
||||
const t = (key) => i18n(key, [], false);
|
||||
|
||||
const OPEN_ICON = SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>');
|
||||
|
||||
/** Rows showing `path` or anything under it, on the dashboard or the desktop. */
|
||||
const each_item_at = (path, fn) => $('.item').each(function () {
|
||||
const item_path = this.dataset.path ?? '';
|
||||
@@ -54,6 +53,8 @@ const KINDS = {
|
||||
nameErrors: new Set(['conflict', 'subdomain_reserved']),
|
||||
suffix: () => `.${window.hosting_domain}`,
|
||||
requiresVerification: () => !! window.require_email_verification_to_publish_website,
|
||||
extraFields: publishDomainFields,
|
||||
setup: setupPublishDomain,
|
||||
async publish (name, path) {
|
||||
const site = await puter.hosting.create(name, path);
|
||||
// The server's address carries the deployment's real scheme (a
|
||||
@@ -136,6 +137,11 @@ const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
|
||||
}];
|
||||
fields.push(...(kind.extraFields?.() ?? []));
|
||||
|
||||
/** Set once the site is live; a retry for a step after it skips publishing. */
|
||||
let published = null;
|
||||
/** @type {import('./publishDomain.js').PublishDomain | undefined} */
|
||||
let extra;
|
||||
|
||||
const dialog = UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t(kind.title),
|
||||
@@ -144,30 +150,52 @@ const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
|
||||
fields,
|
||||
async onSubmit () {
|
||||
const values = dialog.values();
|
||||
const subdomain = finalizePublishName(values.name, kind.rule);
|
||||
dialog.field('name').val(subdomain);
|
||||
if ( ! subdomain ) {
|
||||
dialog.setStatus(t('publish_name_required'), { field: 'name' });
|
||||
return;
|
||||
const hostname = extra?.prepare(!! published) ?? null;
|
||||
if ( hostname === false ) return;
|
||||
if ( ! published ) {
|
||||
const subdomain = finalizePublishName(values.name, kind.rule);
|
||||
dialog.field('name').val(subdomain);
|
||||
if ( ! subdomain ) {
|
||||
dialog.setStatus(t('publish_name_required'), { field: 'name' });
|
||||
return;
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = await kind.publish(subdomain, path, values);
|
||||
} catch ( err ) {
|
||||
const code = err?.error?.code ?? err?.code;
|
||||
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
|
||||
return;
|
||||
}
|
||||
kind.markPublished(path, url);
|
||||
published = { name: subdomain, url };
|
||||
// The address is taken now; what's left to retry is the domain.
|
||||
dialog.field('name').prop('readOnly', true).attr('aria-readonly', 'true');
|
||||
update_submit_label();
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = await kind.publish(subdomain, path, values);
|
||||
} catch ( err ) {
|
||||
const code = err?.error?.code ?? err?.code;
|
||||
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
|
||||
return;
|
||||
}
|
||||
kind.markPublished(path, url);
|
||||
const open_link = `<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
|
||||
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${OPEN_ICON}</a>`;
|
||||
const domain = hostname ? await extra.attach(hostname, published) : null;
|
||||
if ( hostname && ! domain ) return;
|
||||
dialog.finish({
|
||||
message: t(kind.published),
|
||||
html: copyRowHtml({ value: url, label: t('publish_address'), compact: true, after: open_link }),
|
||||
html: copyRowHtml({ value: published.url, label: t('publish_address'), compact: true, after: openLinkHtml(published.url) })
|
||||
+ (domain ? extra.doneHtml(domain) : ''),
|
||||
// The DNS records are shown only here.
|
||||
keepOnBackdrop: !! domain,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Before the site is live the button publishes; after, it adds the domain.
|
||||
const update_submit_label = () => {
|
||||
let key = 'publish';
|
||||
if ( published ) {
|
||||
if ( extra?.claiming() ) key = 'custom_domain_verify_and_add';
|
||||
else key = extra?.addable() ? 'custom_domain_add' : 'done';
|
||||
}
|
||||
dialog.$dialog.find('.dashboard-form-submit-label').text(t(key));
|
||||
};
|
||||
extra = kind.setup?.(dialog, { onChange: update_submit_label });
|
||||
|
||||
// Shape the name as it's typed, keeping the caret where it was.
|
||||
const el_name = dialog.field('name').get(0);
|
||||
if ( document.activeElement === el_name ) el_name.select();
|
||||
|
||||
@@ -35,13 +35,59 @@ const { openPublishWebsiteDialog, openPublishWorkerDialog } = await import('./UI
|
||||
const { default: ensure_can_publish } = await import('../../helpers/ensureCanPublish.js');
|
||||
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const flush = async () => {
|
||||
for ( let i = 0; i < 5; i++ ) await settle();
|
||||
};
|
||||
const nameField = () => $('.dashboard-form-input[name="name"]');
|
||||
const status = () => $('.dashboard-form-status').text();
|
||||
|
||||
const submitWith = async (name) => {
|
||||
if ( name !== undefined ) nameField().val(name);
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
for ( let i = 0; i < 5; i++ ) await settle();
|
||||
await flush();
|
||||
};
|
||||
|
||||
const json = (code, data) => new Response(JSON.stringify(data), {
|
||||
status: code,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Stands in for the `/custom-domains` API. The list call answers with
|
||||
* `list`; anything else with its entry in `routes` (keyed `METHOD /path`),
|
||||
* or a 404.
|
||||
*/
|
||||
const serveDomains = ({ list = { items: [], canAdd: true, pricing: { amount: 0.5, currency: 'USD' } }, routes = {} } = {}) => {
|
||||
globalThis.fetch = vi.fn(async (url, opts = {}) => {
|
||||
const key = `${opts.method ?? 'GET'} ${String(url).split('/custom-domains')[1]}`;
|
||||
if ( key === 'GET ' ) return json(200, typeof list === 'function' ? list() : list);
|
||||
return routes[key]?.(opts.body ? JSON.parse(opts.body) : undefined) ?? json(404, {});
|
||||
});
|
||||
};
|
||||
const requests = (method) => fetch.mock.calls
|
||||
.filter(([, opts = {}]) => (opts.method ?? 'GET') === method)
|
||||
.map(([url, opts]) => ({ path: String(url).split('/custom-domains')[1], body: opts.body ? JSON.parse(opts.body) : undefined }));
|
||||
|
||||
const pendingDomain = (overrides = {}) => ({
|
||||
uuid: 'd1',
|
||||
hostname: 'www.example.com',
|
||||
site: 'my-site',
|
||||
status: 'pending',
|
||||
isApex: false,
|
||||
records: [{ type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing', required: true }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const domainSwitch = () => $('.dashboard-form-input[name="useDomain"]');
|
||||
const domainField = () => $('.dashboard-form-input[name="domain"]');
|
||||
const shown = (name) => $(`.dashboard-form-field[data-field="${name}"]`).prop('hidden') === false;
|
||||
const submitLabel = () => $('.dashboard-form-submit-label').text();
|
||||
const turnOnDomain = () => domainSwitch().prop('checked', true).trigger('change');
|
||||
|
||||
/** Opens the website dialog and waits for the custom domain list. */
|
||||
const openWebsite = async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await flush();
|
||||
};
|
||||
|
||||
const addRow = (path) => {
|
||||
@@ -59,6 +105,10 @@ beforeEach(() => {
|
||||
window.generate_identifier = () => 'clever-idea-42';
|
||||
window.update_sites_cache = vi.fn();
|
||||
window.require_email_verification_to_publish_website = false;
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
delete window.UIUpgradeAccount;
|
||||
// A Puter without custom domains, unless a test serves them.
|
||||
globalThis.fetch = vi.fn(async () => json(404, {}));
|
||||
globalThis.puter = {
|
||||
fsCacheKey: (kind, path) => `${kind}:${path}`,
|
||||
_cache: { del: vi.fn() },
|
||||
@@ -126,7 +176,7 @@ describe('publish website', () => {
|
||||
expect(nameField().val()).toBe('cafe');
|
||||
});
|
||||
|
||||
it('asks for an address when nothing usable was typed', async () => {
|
||||
it('asks for an address when nothing usable was typed', async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await submitWith('---');
|
||||
expect(status()).toBe('publish_name_required');
|
||||
@@ -164,6 +214,358 @@ describe('publish website', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish website on a custom domain', () => {
|
||||
it('leaves the option out where the server has no custom domains', async () => {
|
||||
await openWebsite();
|
||||
expect(shown('useDomain')).toBe(false);
|
||||
expect(shown('domain')).toBe(false);
|
||||
await submitWith('my-site');
|
||||
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
|
||||
expect(requests('POST')).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks for the domain once the switch is on, with what it costs', async () => {
|
||||
serveDomains();
|
||||
await openWebsite();
|
||||
expect(shown('useDomain')).toBe(true);
|
||||
expect(shown('domain')).toBe(false);
|
||||
|
||||
turnOnDomain();
|
||||
|
||||
expect(shown('domain')).toBe(true);
|
||||
expect(document.activeElement).toBe(domainField().get(0));
|
||||
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text())
|
||||
.toBe('publish_custom_domain_dns_hint custom_domain_price_note');
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes the site, adds the domain, and shows the records to set', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(201, pendingDomain()) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('https://WWW.Example.com/about');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
|
||||
expect(requests('POST')).toEqual([{ path: '', body: { site: 'my-site', hostname: 'www.example.com' } }]);
|
||||
expect($('.dashboard-form-done p').text()).toBe('website_published');
|
||||
expect($('.dashboard-copy-value').first().val()).toBe('https://my-site.puter.site');
|
||||
expect($('.dashboard-publish-domain-host').text()).toBe('www.example.com');
|
||||
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('pending');
|
||||
expect($('.dashboard-dns-record-type').text()).toBe('CNAME');
|
||||
expect($('.dashboard-dns-record .dashboard-copy-value').map((_, el) => el.value).get())
|
||||
.toEqual(['www', 'my-site.puter.site']);
|
||||
});
|
||||
|
||||
it('keeps the records up through a stray click outside the card', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(201, pendingDomain()) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
const $backdrop = $('.dashboard-dialog-overlay');
|
||||
$backdrop.trigger('mousedown').trigger('click');
|
||||
await flush();
|
||||
expect($backdrop.hasClass('dashboard-dialog-show')).toBe(true);
|
||||
expect($('.dashboard-dns-record')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('checks the domain again and shows its address once connected', async () => {
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain()),
|
||||
'GET /d1': () => json(200, pendingDomain({ status: 'active', records: [] })),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
$('.dashboard-publish-domain-check').trigger('click');
|
||||
await flush();
|
||||
|
||||
expect(requests('GET').map((r) => r.path)).toContain('/d1');
|
||||
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('active');
|
||||
expect($('.dashboard-publish-domain .dashboard-copy-value').val()).toBe('https://www.example.com');
|
||||
expect($('.dashboard-publish-domain-status').text()).toBe('custom_domain_success_note');
|
||||
expect($('.dashboard-publish-domain-check')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('announces a check in the status region already on screen', async () => {
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain()),
|
||||
'GET /d1': () => json(200, pendingDomain()),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
const el_status = $('.dashboard-publish-domain-status').get(0);
|
||||
|
||||
$('.dashboard-publish-domain-check').trigger('click');
|
||||
await flush();
|
||||
|
||||
expect($('.dashboard-publish-domain-status').get(0)).toBe(el_status);
|
||||
expect(el_status.textContent).toBe('custom_domain_status_pending');
|
||||
// The redrawn records are not read out again.
|
||||
expect($('.dashboard-dns-record').closest('[aria-live]').attr('aria-live')).toBe('off');
|
||||
});
|
||||
|
||||
it('runs one domain action at a time on the done screen', async () => {
|
||||
let answer_check;
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain({ hostname: 'example.com', isApex: true })),
|
||||
'GET /d1': () => new Promise((resolve) => {
|
||||
answer_check = () => resolve(json(200, pendingDomain({ hostname: 'example.com', isApex: true })));
|
||||
}),
|
||||
'POST /d1/use-www': () => json(200, pendingDomain({ redirectFrom: 'example.com' })),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
$('.dashboard-publish-domain-check').trigger('click');
|
||||
await flush();
|
||||
expect($('.dashboard-publish-domain-www').prop('disabled')).toBe(true);
|
||||
$('.dashboard-publish-domain-www').trigger('click');
|
||||
await flush();
|
||||
expect(requests('POST').map((r) => r.path)).not.toContain('/d1/use-www');
|
||||
|
||||
answer_check();
|
||||
await flush();
|
||||
expect($('.dashboard-publish-domain-www').prop('disabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('stops before publishing when the domain is not one', async () => {
|
||||
serveDomains();
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('localhost');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(status()).toBe('custom_domain_error_invalid');
|
||||
expect(domainField().attr('aria-invalid')).toBe('true');
|
||||
expect(puter.hosting.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the live site and retries only the domain when adding it fails', async () => {
|
||||
const add = vi.fn()
|
||||
.mockReturnValueOnce(json(409, { code: 'custom_domain_taken', message: 'That hostname is already in use' }))
|
||||
.mockReturnValueOnce(json(201, pendingDomain({ hostname: 'www.example.org' })));
|
||||
serveDomains({ routes: { 'POST ': add } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(status()).toBe('publish_domain_failed\ncustom_domain_error_taken');
|
||||
expect(domainField().attr('aria-invalid')).toBe('true');
|
||||
expect(nameField().prop('readOnly')).toBe(true);
|
||||
expect(submitLabel()).toBe('custom_domain_add');
|
||||
// Published now, so the hint no longer speaks of "after publishing".
|
||||
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text()).toBe('custom_domain_price_note');
|
||||
|
||||
domainField().val('www.example.org');
|
||||
await submitWith();
|
||||
|
||||
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
|
||||
expect(add).toHaveBeenLastCalledWith({ site: 'my-site', hostname: 'www.example.org' });
|
||||
expect($('.dashboard-publish-domain-host').text()).toBe('www.example.org');
|
||||
});
|
||||
|
||||
it('finishes with just the site when the domain is turned off after a failure', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(409, { code: 'custom_domain_taken' }) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
domainSwitch().prop('checked', false).trigger('change');
|
||||
expect(submitLabel()).toBe('done');
|
||||
await submitWith();
|
||||
|
||||
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
|
||||
expect($('.dashboard-form-done p').text()).toBe('website_published');
|
||||
expect($('.dashboard-publish-domain')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('moves a domain already on the account instead of adding it again', async () => {
|
||||
serveDomains({
|
||||
list: { items: [pendingDomain({ site: 'old-site', status: 'active' })], canAdd: true },
|
||||
routes: { 'PATCH /d1': () => json(200, pendingDomain({ status: 'active' })) },
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
expect($(`#${domainField().attr('list')} option`).map((_, el) => el.value).get()).toEqual(['www.example.com']);
|
||||
domainField().val('www.example.com').trigger('input');
|
||||
expect($('.dashboard-form-field[data-field="domain"] .dashboard-form-hint').text()).toBe('publish_custom_domain_moves');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(requests('POST')).toEqual([]);
|
||||
expect(requests('PATCH')).toEqual([{ path: '/d1', body: { site: 'my-site' } }]);
|
||||
expect($('.dashboard-publish-domain-badge').attr('data-status')).toBe('active');
|
||||
});
|
||||
|
||||
it('says the www hostname serves the site and the root domain forwards to it', async () => {
|
||||
serveDomains({
|
||||
routes: {
|
||||
'POST ': () => json(201, pendingDomain({
|
||||
redirectFrom: 'example.com',
|
||||
records: [
|
||||
{ type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing', required: true },
|
||||
{ type: 'A', name: '@', value: '192.0.2.1', purpose: 'redirect', required: true },
|
||||
],
|
||||
})),
|
||||
},
|
||||
});
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
const i18n_stub = globalThis.i18n;
|
||||
globalThis.i18n = (key, replacements) => (Array.isArray(replacements) && replacements.length ? `${key}:${replacements.join('|')}` : key);
|
||||
try {
|
||||
await submitWith('my-site');
|
||||
} finally {
|
||||
globalThis.i18n = i18n_stub;
|
||||
}
|
||||
|
||||
expect($('.dashboard-publish-domain .dashboard-form-hint').last().text())
|
||||
.toBe('custom_domain_redirect_note:www.example.com|example.com');
|
||||
});
|
||||
|
||||
it('shows the ownership record for a domain held by another account', async () => {
|
||||
const add = vi.fn(() => json(409, {
|
||||
code: 'custom_domain_claim_required',
|
||||
verification: { name: '_puter.www.example.com', value: 'token-1' },
|
||||
}));
|
||||
serveDomains({ routes: { 'POST ': add } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
|
||||
await submitWith('my-site');
|
||||
|
||||
expect($('.dashboard-publish-claim').prop('hidden')).toBe(false);
|
||||
expect($('.dashboard-publish-claim .dashboard-dns-record-type').text()).toBe('TXT');
|
||||
expect($('.dashboard-publish-claim .dashboard-copy-value').map((_, el) => el.value).get())
|
||||
.toEqual(['_puter.www.example.com', 'token-1']);
|
||||
expect(submitLabel()).toBe('custom_domain_verify_and_add');
|
||||
expect(status()).toBe('publish_site_live');
|
||||
|
||||
await submitWith();
|
||||
expect(status()).toBe('publish_domain_failed\ncustom_domain_claim_not_found');
|
||||
|
||||
// A claim is for one hostname; editing it starts over.
|
||||
domainField().val('www.example.net').trigger('input');
|
||||
expect($('.dashboard-publish-claim').prop('hidden')).toBe(true);
|
||||
expect(submitLabel()).toBe('custom_domain_add');
|
||||
|
||||
// Back to the claimed hostname, its record and the verify step return together.
|
||||
domainField().val('www.example.com').trigger('input');
|
||||
expect($('.dashboard-publish-claim').prop('hidden')).toBe(false);
|
||||
expect(submitLabel()).toBe('custom_domain_verify_and_add');
|
||||
});
|
||||
|
||||
describe('on a plan without custom domains', () => {
|
||||
beforeEach(() => {
|
||||
window.UIUpgradeAccount = vi.fn(function () {
|
||||
this.open_as_window = vi.fn();
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the upgrade in place of the field, leaving focus on the switch', async () => {
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
await openWebsite();
|
||||
domainSwitch().get(0).focus();
|
||||
turnOnDomain();
|
||||
|
||||
expect(shown('domain')).toBe(false);
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(false);
|
||||
expect(document.activeElement).toBe(domainSwitch().get(0));
|
||||
$('.dashboard-publish-upgrade').trigger('click');
|
||||
expect(window.UIUpgradeAccount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not publish while the switch asks for a domain it cannot add', async () => {
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
await submitWith('my-site');
|
||||
expect(status()).toBe('publish_custom_domain_needs_plan');
|
||||
expect(puter.hosting.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unlocks the field once the upgrade goes through', async () => {
|
||||
let can_add = false;
|
||||
serveDomains({ list: () => ({ items: [], canAdd: can_add }) });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
$('.dashboard-publish-upgrade').trigger('click');
|
||||
|
||||
can_add = true;
|
||||
window.dispatchEvent(new CustomEvent('puter:subscription:changed'));
|
||||
await flush();
|
||||
|
||||
expect(shown('domain')).toBe(true);
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(true);
|
||||
expect(document.activeElement).toBe(domainField().get(0));
|
||||
});
|
||||
|
||||
it('only listens for a plan change once it has offered the upgrade', async () => {
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
const listen = vi.spyOn(window, 'addEventListener');
|
||||
try {
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
expect(listen.mock.calls.map(([type]) => type)).not.toContain('puter:subscription:changed');
|
||||
$('.dashboard-publish-upgrade').trigger('click');
|
||||
expect(listen.mock.calls.map(([type]) => type)).toContain('puter:subscription:changed');
|
||||
} finally {
|
||||
listen.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('explains the plan without a button where there is no upgrade flow', async () => {
|
||||
delete window.UIUpgradeAccount;
|
||||
serveDomains({ list: { items: [], canAdd: false } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
expect($('.dashboard-publish-upgrade')).toHaveLength(0);
|
||||
await submitWith('my-site');
|
||||
expect(status()).toBe('custom_domain_error_plan');
|
||||
});
|
||||
|
||||
it('finishes with the live site when adding the domain turns out to need a plan', async () => {
|
||||
serveDomains({ routes: { 'POST ': () => json(402, { code: 'subscription_required' }) } });
|
||||
await openWebsite();
|
||||
turnOnDomain();
|
||||
domainField().val('www.example.com');
|
||||
await submitWith('my-site');
|
||||
|
||||
expect(status()).toBe('publish_domain_failed\ncustom_domain_error_plan');
|
||||
expect($('.dashboard-publish-upsell').prop('hidden')).toBe(false);
|
||||
expect(submitLabel()).toBe('done');
|
||||
|
||||
await submitWith();
|
||||
expect(puter.hosting.create).toHaveBeenCalledTimes(1);
|
||||
expect(requests('POST')).toHaveLength(1);
|
||||
expect($('.dashboard-form-done p').text()).toBe('website_published');
|
||||
expect($('.dashboard-publish-domain')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish worker', () => {
|
||||
it('always needs a verified account', async () => {
|
||||
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The custom domain part of the Publish Website dialog: a switch that asks
|
||||
// for a domain, an upgrade prompt where the plan doesn't include one, and,
|
||||
// once the site is live, the domain's DNS records with a status check.
|
||||
|
||||
import { isPlausibleHostname, normalizeHostnameInput } from '../../helpers/customDomainHostname.js';
|
||||
import {
|
||||
CUSTOM_DOMAIN_HOSTNAME_ERRORS,
|
||||
customDomainErrorText,
|
||||
customDomainsRequest,
|
||||
customDomainStatus,
|
||||
formatCustomDomainPrice,
|
||||
mergeCustomDomainRefresh,
|
||||
} from '../../helpers/customDomains.js';
|
||||
import { has_upgrade_flow, open_upgrade_flow } from '../../helpers/planGate.js';
|
||||
import { SVG } from './UIDashboardDialog.js';
|
||||
import { copyRowHtml } from './UIDashboardFormDialog.js';
|
||||
|
||||
/** @typedef {import('../../helpers/customDomains.js').CustomDomain} CustomDomain */
|
||||
/** @typedef {import('../../helpers/customDomains.js').CustomDomainRecord} CustomDomainRecord */
|
||||
/** @typedef {import('./UIDashboardFormDialog.js').FormDialog} FormDialog */
|
||||
/** @typedef {import('./UIDashboardFormDialog.js').FormDialogField} FormDialogField */
|
||||
|
||||
const t = (key, args = []) => i18n(key, args, false);
|
||||
|
||||
const ICONS = {
|
||||
open: SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>'),
|
||||
globe: SVG('<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18z"/>'),
|
||||
sparkle: SVG('<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z"/>'),
|
||||
};
|
||||
|
||||
const BADGE_KEYS = {
|
||||
active: 'custom_domain_badge_active',
|
||||
pending: 'custom_domain_badge_pending',
|
||||
failed: 'custom_domain_badge_failed',
|
||||
};
|
||||
|
||||
/**
|
||||
* An icon link opening `url` in a new tab, beside a compact copy row.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export const openLinkHtml = (url) =>
|
||||
`<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
|
||||
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${ICONS.open}</a>`;
|
||||
|
||||
/**
|
||||
* DNS records as cards: the type, then the name and value, each with a Copy
|
||||
* button the form dialog wires.
|
||||
*
|
||||
* @param {CustomDomainRecord[]} records
|
||||
* @param {{ isApex?: boolean }} [domain] A root domain's routing record can
|
||||
* be any of the record types that alias a root.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const dnsRecordsHtml = (records, { isApex = false } = {}) => records.map((record) => {
|
||||
const type = isApex && record.purpose === 'routing' ? t('custom_domain_record_type_apex') : record.type;
|
||||
const field = (labelKey, value) => '<div class="dashboard-dns-record-field">'
|
||||
+ `<span class="dashboard-dns-record-label">${i18n(labelKey)}</span>`
|
||||
+ copyRowHtml({ value, label: `${type} ${t(labelKey)}`, compact: true })
|
||||
+ '</div>';
|
||||
return '<div class="dashboard-dns-record">'
|
||||
+ '<div class="dashboard-dns-record-head">'
|
||||
+ `<span class="dashboard-dns-record-type">${html_encode(type)}</span>`
|
||||
+ (record.required === false ? `<span class="dashboard-dns-record-optional">${i18n('custom_domain_optional')}</span>` : '')
|
||||
+ '</div>'
|
||||
+ field('name', record.name)
|
||||
+ field('custom_domain_record_value', record.value)
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
/**
|
||||
* The done screen's account of a domain: its status, and until it's
|
||||
* connected, the records still to set.
|
||||
*
|
||||
* @param {CustomDomain} domain
|
||||
* @returns {string}
|
||||
*/
|
||||
export const domainSummaryHtml = (domain) => {
|
||||
const status = customDomainStatus(domain);
|
||||
let h = '<div class="dashboard-publish-domain-head">'
|
||||
+ `<span class="dashboard-publish-domain-icon">${ICONS.globe}</span>`
|
||||
+ `<span class="dashboard-publish-domain-host">${html_encode(domain.hostname)}</span>`
|
||||
+ `<span class="dashboard-publish-domain-badge" data-status="${status}">${i18n(BADGE_KEYS[status])}</span>`
|
||||
+ '</div>';
|
||||
if ( status === 'active' ) {
|
||||
const url = `https://${domain.hostname}`;
|
||||
h += copyRowHtml({ value: url, label: domain.hostname, compact: true, after: openLinkHtml(url) });
|
||||
} else {
|
||||
if ( status === 'failed' && domain.statusDetail ) {
|
||||
h += `<p class="dashboard-publish-domain-detail">${html_encode(domain.statusDetail)}</p>`;
|
||||
}
|
||||
h += `<p class="dashboard-form-hint">${i18n('custom_domain_records_c2a')}</p>`;
|
||||
h += `<div class="dashboard-dns-records">${dnsRecordsHtml(domain.records ?? [], domain)}</div>`;
|
||||
if ( domain.isApex ) {
|
||||
h += `<p class="dashboard-form-hint">${i18n('custom_domain_apex_hint')}</p>`;
|
||||
h += `<button type="button" class="dashboard-publish-link-btn dashboard-publish-domain-www">${i18n('custom_domain_no_root_alias')}</button>`;
|
||||
}
|
||||
}
|
||||
if ( domain.redirectFrom ) {
|
||||
h += `<p class="dashboard-form-hint">${i18n('custom_domain_redirect_note', [domain.hostname, domain.redirectFrom])}</p>`;
|
||||
}
|
||||
if ( status !== 'active' ) {
|
||||
h += '<div class="dashboard-publish-domain-actions">'
|
||||
+ '<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-publish-domain-check">'
|
||||
+ '<span class="dashboard-form-spinner" aria-hidden="true"></span>'
|
||||
+ `<span>${i18n('custom_domain_check_status')}</span>`
|
||||
+ '</button>'
|
||||
+ '</div>';
|
||||
}
|
||||
return h;
|
||||
};
|
||||
|
||||
/**
|
||||
* The switch and the domain field. Both start hidden: the switch shows once
|
||||
* the server answers that it has custom domains.
|
||||
*
|
||||
* @returns {FormDialogField[]}
|
||||
*/
|
||||
export const publishDomainFields = () => [
|
||||
{
|
||||
name: 'useDomain',
|
||||
type: 'switch',
|
||||
label: i18n('publish_custom_domain'),
|
||||
hint: i18n('publish_custom_domain_hint'),
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: 'domain',
|
||||
label: i18n('custom_domain_hostname_label'),
|
||||
placeholder: 'www.example.com',
|
||||
hint: i18n('publish_custom_domain_dns_hint'),
|
||||
hidden: true,
|
||||
attrs: {
|
||||
inputmode: 'url',
|
||||
enterkeyhint: 'go',
|
||||
autocomplete: 'off',
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
spellcheck: 'false',
|
||||
'data-gramm_editor': 'false',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @typedef {object} PublishDomain
|
||||
* @property {() => boolean} addable Whether the switch is on and the plan
|
||||
* lets a domain be added.
|
||||
* @property {(live: boolean) => string | null | false} prepare Before each
|
||||
* submit: null when no domain is to be added (including when the site is
|
||||
* `live` and the plan can't add one), false when the form can't go ahead
|
||||
* (the dialog says why), else the hostname to add.
|
||||
* @property {(hostname: string, site: { name: string, url: string }) => Promise<CustomDomain | null>} attach
|
||||
* Adds the hostname to the live site, or moves it there when it's already
|
||||
* on the account. Null when it didn't; the dialog says why.
|
||||
* @property {(domain: CustomDomain) => string} doneHtml The done screen's domain part.
|
||||
* @property {() => boolean} claiming Whether an ownership record for the
|
||||
* typed hostname is on screen, waiting to be verified.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wires the custom domain fields of a publish form dialog.
|
||||
*
|
||||
* @param {FormDialog} dialog
|
||||
* @param {object} opts
|
||||
* @param {() => void} opts.onChange Runs when what the submit button does may
|
||||
* have changed: the switch, the plan, or a claim to verify.
|
||||
* @returns {PublishDomain}
|
||||
*/
|
||||
export const setupPublishDomain = (dialog, { onChange }) => {
|
||||
const { $dialog } = dialog;
|
||||
const $switch = dialog.field('useDomain');
|
||||
const $input = dialog.field('domain');
|
||||
const $field = $input.closest('.dashboard-form-field');
|
||||
const $hint = $field.find('.dashboard-form-hint');
|
||||
const id = $input.attr('id');
|
||||
|
||||
const upgradable = has_upgrade_flow();
|
||||
$field.after(
|
||||
`<div class="dashboard-publish-upsell" hidden>`
|
||||
+ `<span class="dashboard-publish-upsell-icon">${ICONS.sparkle}</span>`
|
||||
+ '<div class="dashboard-publish-upsell-text">'
|
||||
+ `<p class="dashboard-publish-upsell-title">${i18n('publish_custom_domain_upsell_title')}</p>`
|
||||
+ (upgradable ? `<p class="dashboard-form-hint">${i18n('publish_custom_domain_upsell_text')}</p>` : '')
|
||||
+ '</div>'
|
||||
+ (upgradable ? `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-publish-upgrade">${i18n('billing.upgrade')}</button>` : '')
|
||||
+ '</div>'
|
||||
+ '<div class="dashboard-publish-claim" hidden>'
|
||||
+ `<p class="dashboard-publish-claim-text">${i18n('custom_domain_claim_c2a')}</p>`
|
||||
+ '<div class="dashboard-dns-records"></div>'
|
||||
+ '</div>'
|
||||
+ `<datalist id="${id}-owned"></datalist>`,
|
||||
);
|
||||
const $upsell = $dialog.find('.dashboard-publish-upsell');
|
||||
const $claim = $dialog.find('.dashboard-publish-claim');
|
||||
const $owned = $dialog.find(`#${id}-owned`);
|
||||
$input.attr('list', `${id}-owned`);
|
||||
|
||||
let available = false;
|
||||
let can_add = true;
|
||||
let price = null;
|
||||
/** @type {CustomDomain[]} */
|
||||
let owned = [];
|
||||
/** The hostname whose ownership record is on screen. */
|
||||
let claim_for = null;
|
||||
/** @type {CustomDomain | null} */
|
||||
let shown = null;
|
||||
/** Whether the site is published, so the domain is all that's left. */
|
||||
let live = false;
|
||||
|
||||
const typed = () => normalizeHostnameInput($input.val());
|
||||
const owned_named = (hostname) => owned.find((domain) => domain.hostname === hostname);
|
||||
const wanted = () => available && $switch.prop('checked');
|
||||
const addable = () => wanted() && can_add;
|
||||
|
||||
const update_hint = () => {
|
||||
const existing = owned_named(typed());
|
||||
$hint.text(existing
|
||||
? t('publish_custom_domain_moves', [`${existing.site}.${window.hosting_domain}`])
|
||||
: [! live && t('publish_custom_domain_dns_hint'), price && t('custom_domain_price_note', [price])].filter(Boolean).join(' '));
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const on = wanted();
|
||||
dialog.showField('domain', on && can_add);
|
||||
$upsell.prop('hidden', ! on || can_add);
|
||||
$claim.prop('hidden', ! on || ! can_add || claim_for !== typed());
|
||||
update_hint();
|
||||
onChange();
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
let res;
|
||||
try {
|
||||
res = await customDomainsRequest('GET');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// No custom domain API on this Puter, or none for this account:
|
||||
// the option stays out of the way.
|
||||
if ( ! res.ok || ! Array.isArray(res.data?.items) ) return;
|
||||
owned = res.data.items;
|
||||
can_add = res.data.canAdd !== false;
|
||||
price = formatCustomDomainPrice(res.data.pricing);
|
||||
$owned.html(owned.map((domain) => `<option value="${html_encode(domain.hostname)}"></option>`).join(''));
|
||||
if ( ! available ) {
|
||||
available = true;
|
||||
dialog.showField('useDomain', true);
|
||||
}
|
||||
sync();
|
||||
};
|
||||
load();
|
||||
|
||||
// The field takes focus as the switch reveals it. The upgrade prompt
|
||||
// doesn't: a second Space meant for the switch would open the upgrade.
|
||||
$switch.on('change', () => {
|
||||
sync();
|
||||
if ( addable() ) $input.get(0)?.focus({ preventScroll: true });
|
||||
});
|
||||
// A claim's record shows only while its hostname is the one typed.
|
||||
$input.on('input', sync);
|
||||
|
||||
// Once an upgrade lands, the field unlocks where the prompt was. The
|
||||
// dialog is modal, so only its own prompt can change the plan meanwhile.
|
||||
const on_plan_change = async () => {
|
||||
if ( ! document.contains($dialog.get(0)) ) {
|
||||
window.removeEventListener('puter:subscription:changed', on_plan_change);
|
||||
return;
|
||||
}
|
||||
const had = can_add;
|
||||
await load();
|
||||
if ( ! had && addable() ) $input.get(0)?.focus({ preventScroll: true });
|
||||
};
|
||||
$dialog.on('click', '.dashboard-publish-upgrade', () => {
|
||||
if ( ! open_upgrade_flow() ) return;
|
||||
window.addEventListener('puter:subscription:changed', on_plan_change);
|
||||
});
|
||||
|
||||
// -- Done screen --
|
||||
|
||||
/** Encoded HTML into the done screen's status line; empty clears it. */
|
||||
const set_done_status = (html, error = false) => {
|
||||
$dialog.find('.dashboard-publish-domain-status').html(html)
|
||||
.toggleClass('dashboard-publish-domain-status-error', error);
|
||||
};
|
||||
const render_done = (focus_selector) => {
|
||||
const $section = $dialog.find('.dashboard-publish-domain-summary');
|
||||
$section.html(domainSummaryHtml(shown));
|
||||
const el = $section.find(focus_selector).get(0)
|
||||
?? $section.find('.dashboard-copy-value').get(0)
|
||||
?? $dialog.find('.dashboard-form-done-btn').get(0);
|
||||
el?.focus({ preventScroll: true });
|
||||
};
|
||||
const DONE_ACTIONS = '.dashboard-publish-domain-check, .dashboard-publish-domain-www';
|
||||
let done_busy = false;
|
||||
/**
|
||||
* Runs a done-screen request with `$btn` busy and the other actions held,
|
||||
* since each answer redraws the domain; resolves with the response, or null.
|
||||
*/
|
||||
const done_request = async ($btn, method, path) => {
|
||||
done_busy = true;
|
||||
$dialog.find(DONE_ACTIONS).prop('disabled', true);
|
||||
$btn.addClass('dashboard-form-busy').attr('aria-busy', 'true');
|
||||
set_done_status('');
|
||||
try {
|
||||
const res = await customDomainsRequest(method, path);
|
||||
if ( res.ok ) return res;
|
||||
set_done_status(html_encode(customDomainErrorText(res)), true);
|
||||
} catch {
|
||||
set_done_status(i18n('custom_domain_error_generic'), true);
|
||||
} finally {
|
||||
done_busy = false;
|
||||
$dialog.find(DONE_ACTIONS).prop('disabled', false);
|
||||
$btn.removeClass('dashboard-form-busy').removeAttr('aria-busy');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
$dialog.on('click', '.dashboard-publish-domain-check', async function () {
|
||||
if ( ! shown || done_busy ) return;
|
||||
const res = await done_request($(this), 'GET', `/${encodeURIComponent(shown.uuid)}`);
|
||||
if ( ! res ) return;
|
||||
shown = mergeCustomDomainRefresh(shown, res.data);
|
||||
render_done('.dashboard-publish-domain-check');
|
||||
const status = customDomainStatus(shown);
|
||||
set_done_status(status === 'active'
|
||||
? i18n('custom_domain_success_note', [shown.hostname, `${shown.site}.${window.hosting_domain}`])
|
||||
: i18n(status === 'failed' ? 'custom_domain_status_failed' : 'custom_domain_status_pending', [shown.hostname]));
|
||||
});
|
||||
// Same domain, so the month already paid covers the www hostname.
|
||||
$dialog.on('click', '.dashboard-publish-domain-www', async function () {
|
||||
if ( ! shown || done_busy ) return;
|
||||
const res = await done_request($(this), 'POST', `/${encodeURIComponent(shown.uuid)}/use-www`);
|
||||
if ( ! res ) return;
|
||||
shown = res.data;
|
||||
render_done('.dashboard-publish-domain-check');
|
||||
});
|
||||
|
||||
return {
|
||||
addable,
|
||||
prepare (live) {
|
||||
if ( ! wanted() || (live && ! can_add) ) return null;
|
||||
if ( ! can_add ) {
|
||||
dialog.setStatus(t(upgradable ? 'publish_custom_domain_needs_plan' : 'custom_domain_error_plan'));
|
||||
return false;
|
||||
}
|
||||
const hostname = typed();
|
||||
$input.val(hostname);
|
||||
if ( ! isPlausibleHostname(hostname) ) {
|
||||
dialog.setStatus(t('custom_domain_error_invalid'), { field: 'domain' });
|
||||
return false;
|
||||
}
|
||||
return hostname;
|
||||
},
|
||||
async attach (hostname, site) {
|
||||
if ( ! live ) {
|
||||
live = true;
|
||||
update_hint();
|
||||
}
|
||||
const existing = owned_named(hostname);
|
||||
const failed = (reason, field) => {
|
||||
dialog.setStatus(`${t('publish_domain_failed', [site.url])}\n${reason}`, field ? { field } : undefined);
|
||||
return null;
|
||||
};
|
||||
let res;
|
||||
try {
|
||||
res = existing
|
||||
? await customDomainsRequest('PATCH', `/${encodeURIComponent(existing.uuid)}`, { site: site.name })
|
||||
: await customDomainsRequest('POST', '', { site: site.name, hostname });
|
||||
} catch {
|
||||
return failed(t('custom_domain_error_generic'));
|
||||
}
|
||||
if ( res.ok ) {
|
||||
claim_for = null;
|
||||
return res.data;
|
||||
}
|
||||
// Held by another account: shown its ownership record the first
|
||||
// time; a retry that still can't see the record says so.
|
||||
const verification = res.data?.verification;
|
||||
if ( res.data?.code === 'custom_domain_claim_required' && verification ) {
|
||||
const retry = claim_for === hostname;
|
||||
claim_for = hostname;
|
||||
$claim.find('.dashboard-dns-records').html(dnsRecordsHtml([{ type: 'TXT', name: verification.name, value: verification.value }]));
|
||||
sync();
|
||||
if ( retry ) return failed(t('custom_domain_claim_not_found'));
|
||||
dialog.setStatus(t('publish_site_live', [site.url]), { kind: 'info' });
|
||||
return null;
|
||||
}
|
||||
if ( res.status === 402 && res.data?.code === 'subscription_required' ) {
|
||||
can_add = false;
|
||||
sync();
|
||||
}
|
||||
return failed(customDomainErrorText(res), CUSTOM_DOMAIN_HOSTNAME_ERRORS.has(res.data?.code) ? 'domain' : undefined);
|
||||
},
|
||||
doneHtml (domain) {
|
||||
shown = domain;
|
||||
// The summary is redrawn after each check; the status line stays,
|
||||
// so screen readers announce what changed and not the records again.
|
||||
return '<section class="dashboard-publish-domain">'
|
||||
+ `<div class="dashboard-publish-domain-summary" aria-live="off">${domainSummaryHtml(domain)}</div>`
|
||||
+ '<p class="dashboard-publish-domain-status" role="status" aria-live="polite"></p>'
|
||||
+ '</section>';
|
||||
},
|
||||
claiming: () => addable() && claim_for !== null && claim_for === typed(),
|
||||
};
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -18,31 +18,25 @@
|
||||
*/
|
||||
|
||||
import { isPlausibleHostname, normalizeHostnameInput } from '../helpers/customDomainHostname.js';
|
||||
import {
|
||||
customDomainErrorText,
|
||||
customDomainsRequest,
|
||||
customDomainStatus as statusOf,
|
||||
formatCustomDomainPrice,
|
||||
mergeCustomDomainRefresh,
|
||||
} from '../helpers/customDomains.js';
|
||||
|
||||
let attach_modal_seq = 0;
|
||||
|
||||
const SUCCESS_AUTOCLOSE_MS = 1800;
|
||||
const COPIED_RESET_MS = 1400;
|
||||
|
||||
/** API error code → message key; anything unlisted gets the generic error. */
|
||||
const ERROR_KEYS = {
|
||||
invalid_hostname: 'custom_domain_error_invalid',
|
||||
custom_domain_taken: 'custom_domain_error_taken',
|
||||
custom_domain_limit_reached: 'custom_domain_error_limit',
|
||||
custom_domain_rejected: 'custom_domain_error_rejected',
|
||||
site_not_found: 'custom_domain_error_site',
|
||||
custom_domain_not_found: 'custom_domain_error_not_found',
|
||||
custom_domains_not_enabled: 'custom_domain_not_enabled',
|
||||
custom_domains_permission_required: 'custom_domain_error_generic',
|
||||
};
|
||||
|
||||
/** Domain status → the badge shown for it; anything else reads as pending. */
|
||||
/** Domain status → the badge shown for it. */
|
||||
const STATUS_BADGE_KEYS = {
|
||||
active: 'custom_domain_badge_active',
|
||||
pending: 'custom_domain_badge_pending',
|
||||
failed: 'custom_domain_badge_failed',
|
||||
};
|
||||
const statusOf = (domain) => (domain?.status in STATUS_BADGE_KEYS ? domain.status : 'pending');
|
||||
|
||||
const svg = (body, strokeWidth = 2) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
|
||||
@@ -55,19 +49,6 @@ const ICON_ALERT = svg('<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5"/><pat
|
||||
const ICON_PLUS = svg('<path d="M12 5v14M5 12h14"/>', 2.2);
|
||||
const ICON_X = svg('<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>', 2.2);
|
||||
|
||||
const format_price = (pricing) => {
|
||||
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: pricing.currency || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(pricing.amount);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* "Connect a custom domain to this site" modal, behind
|
||||
* `puter.hosting.attach()`. The user picks one of their domains (moving it
|
||||
@@ -254,35 +235,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
focusedBeforeBusy = null;
|
||||
};
|
||||
|
||||
const api = async (method, path = '', body) => {
|
||||
const res = await fetch(`${window.api_origin}/custom-domains${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Non-JSON body; callers go by the status.
|
||||
}
|
||||
return { status: res.status, ok: res.ok, data };
|
||||
};
|
||||
// A known code gets our wording; anything else shows what the server
|
||||
// said, which beats a generic message when something is misconfigured.
|
||||
const errorFor = (res) => {
|
||||
if ( res.status === 402 ) return text('custom_domain_error_plan');
|
||||
if ( res.status === 429 ) return text('custom_domain_error_rate_limited');
|
||||
const key = ERROR_KEYS[res.data?.code];
|
||||
if ( key ) return text(key);
|
||||
if ( typeof res.data?.message === 'string' && res.data.message ) {
|
||||
return res.data.message;
|
||||
}
|
||||
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
|
||||
};
|
||||
const api = (method, path = '', body) => customDomainsRequest(method, path, body, authToken);
|
||||
|
||||
// Escape backs out of a pending confirmation first. Escape, the
|
||||
// backdrop and the close buttons all dismiss, except while a request
|
||||
@@ -503,7 +456,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('PATCH', `/${encodeURIComponent(item.uuid)}`, { site: subdomain });
|
||||
if ( ! res.ok ) {
|
||||
showError('list', errorFor(res));
|
||||
showError('list', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
settleOn(res.data);
|
||||
@@ -524,7 +477,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('DELETE', `/${encodeURIComponent(item.uuid)}`);
|
||||
if ( ! res.ok && res.status !== 404 ) {
|
||||
showError(pane, errorFor(res));
|
||||
showError(pane, customDomainErrorText(res));
|
||||
return false;
|
||||
}
|
||||
// A removed domain is no answer to hand the caller.
|
||||
@@ -611,7 +564,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
return;
|
||||
}
|
||||
if ( ! res.ok ) {
|
||||
showError('add', errorFor(res));
|
||||
showError('add', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
hideClaim();
|
||||
@@ -650,7 +603,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
.toggle(!!domain.statusDetail && status !== 'active');
|
||||
const $redirect = $overlay.find('.custom-domain-redirect').toggle(!!domain.redirectFrom);
|
||||
if ( domain.redirectFrom ) {
|
||||
$redirect.html(i18n('custom_domain_redirect_note', [domain.redirectFrom, domain.hostname]));
|
||||
$redirect.html(i18n('custom_domain_redirect_note', [domain.hostname, domain.redirectFrom]));
|
||||
}
|
||||
$overlay.find('.custom-domain-apex').toggle(domain.isApex === true);
|
||||
$overlay.find('.custom-domain-use-www').toggle(domain.isApex === true && status !== 'active');
|
||||
@@ -670,22 +623,10 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('GET', `/${encodeURIComponent(chosen.uuid)}`);
|
||||
if ( ! res.ok ) {
|
||||
showError('records', errorFor(res));
|
||||
showError('records', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
// A domain that is no longer pending comes back with only its
|
||||
// routing record, and a GET never repeats the provider or the
|
||||
// redirect; keep what was shown when it was added.
|
||||
const known = chosen.records ?? [];
|
||||
const redirect = known.filter((r) => r.purpose === 'redirect');
|
||||
const fresh = (res.data.records?.length > 1 ? res.data.records : known)
|
||||
.filter((r) => r.purpose !== 'redirect');
|
||||
const next = {
|
||||
...res.data,
|
||||
dnsProvider: chosen.dnsProvider,
|
||||
redirectFrom: chosen.redirectFrom,
|
||||
records: [...fresh.slice(0, 1), ...redirect, ...fresh.slice(1)],
|
||||
};
|
||||
const next = mergeCustomDomainRefresh(chosen, res.data);
|
||||
if ( statusOf(next) === 'active' ) {
|
||||
showSuccess(next);
|
||||
return;
|
||||
@@ -706,7 +647,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
try {
|
||||
const res = await api('POST', `/${encodeURIComponent(chosen.uuid)}/use-www`);
|
||||
if ( ! res.ok ) {
|
||||
showError('records', errorFor(res));
|
||||
showError('records', customDomainErrorText(res));
|
||||
return;
|
||||
}
|
||||
chosen = res.data;
|
||||
@@ -758,7 +699,7 @@ async function UIWindowAttachCustomDomain (options) {
|
||||
return;
|
||||
}
|
||||
renderList(res.data?.items ?? []);
|
||||
const price = format_price(res.data?.pricing);
|
||||
const price = formatCustomDomainPrice(res.data?.pricing);
|
||||
if ( price ) {
|
||||
$overlay.find('.custom-domain-price').text(text('custom_domain_price_note', [price])).show();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -10590,6 +10590,253 @@ body.dashboard-mode .notifications-close-all {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Contact Us: a message with screenshots or recordings attached */
|
||||
.dashboard-dialog.dashboard-contact-dialog {
|
||||
width: min(500px, 100%);
|
||||
}
|
||||
|
||||
.dashboard-contact-dialog .dashboard-form-input[name='message'] {
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.dashboard-contact-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-primary);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-icon svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-label,
|
||||
.dashboard-contact-dropzone-drop {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-drop {
|
||||
display: none;
|
||||
color: var(--select-text);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone-hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-contact-dropzone:hover:not(:disabled) {
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-tint-weak);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--select-color);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-dropzone:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Files dragged anywhere over the dialog land here. */
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone {
|
||||
border-style: solid;
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-tint);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-dragging .dashboard-contact-dropzone-drop {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-contact-files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-files[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-contact-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 6px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-input-background);
|
||||
animation: dashboard-contact-file-in 160ms ease;
|
||||
}
|
||||
|
||||
@keyframes dashboard-contact-file-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
background: var(--dashboard-sidebar-background);
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-thumb svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-text {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-name {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--dashboard-text-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-size {
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-contact-file-remove:hover:not(:disabled) {
|
||||
background: var(--dashboard-hover);
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Sending: the files are on their way and no longer up for change. */
|
||||
.dashboard-contact-sending .dashboard-contact-file {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-contact-dropzone {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.dashboard-contact-file-remove {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dashboard-contact-file {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* API token, shown once */
|
||||
.dashboard-token-warning {
|
||||
padding: 10px 12px;
|
||||
@@ -10681,6 +10928,287 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================
|
||||
Publish dialog: custom domain
|
||||
====================================== */
|
||||
|
||||
/* The address, once the site is live under it, reads as settled. */
|
||||
.dashboard-form-affix:has([readonly]) {
|
||||
background: var(--dashboard-sidebar-background);
|
||||
}
|
||||
|
||||
.dashboard-form-affix:has([readonly]):focus-within {
|
||||
border-color: var(--dashboard-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 12px;
|
||||
background: var(--select-tint-weak);
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell[hidden],
|
||||
.dashboard-publish-claim[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-text {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell-title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--dashboard-text-primary);
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell .dashboard-dialog-btn {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dashboard-publish-upsell {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* The text keeps to the icon's row; only the button takes its own. */
|
||||
.dashboard-publish-upsell-text {
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.dashboard-publish-upsell .dashboard-dialog-btn {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-publish-claim {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-publish-claim-text {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--dashboard-warning-border);
|
||||
border-radius: 8px;
|
||||
background: var(--dashboard-warning-background);
|
||||
color: var(--dashboard-warning-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* DNS records: the type, then a copyable name and value. */
|
||||
.dashboard-dns-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One grid per card, so the label column fits the longest label and the
|
||||
name and value line up. */
|
||||
.dashboard-dns-record {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(44px, max-content) minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-sidebar-background);
|
||||
}
|
||||
|
||||
.dashboard-dns-record-head {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-dns-record-type {
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
background: var(--select-tint);
|
||||
color: var(--select-color);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-dns-record-optional {
|
||||
font-size: 11px;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-dns-record-field {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.dashboard-dns-record-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
/* The done screen's domain, under the live address. */
|
||||
.dashboard-publish-domain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--dashboard-border);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-host {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--dashboard-text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-left: auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--dashboard-warning-background);
|
||||
color: var(--dashboard-warning-text);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge[data-status='active'] {
|
||||
background: var(--dashboard-success-background);
|
||||
color: var(--dashboard-success-text);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-badge[data-status='failed'] {
|
||||
background: var(--dashboard-danger-background);
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-detail {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-danger-text);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-status {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--dashboard-text-secondary);
|
||||
}
|
||||
|
||||
/* Empty, it stays in the a11y tree and gives back the gap it would claim. */
|
||||
.dashboard-publish-domain-status:empty {
|
||||
margin-top: -10px;
|
||||
}
|
||||
|
||||
.dashboard-publish-domain-status-error {
|
||||
color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-publish-link-btn {
|
||||
align-self: flex-start;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--dashboard-link);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-publish-link-btn:hover:not(:disabled) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-publish-link-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dashboard-publish-link-btn:focus-visible {
|
||||
outline: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
/* ======================================
|
||||
Auth dialog (login, signup, account picker, password recovery, 2FA)
|
||||
====================================== */
|
||||
@@ -11090,7 +11618,7 @@ body.dashboard-mode .notifications-close-all {
|
||||
|
||||
.auth-dialog .signup-terms {
|
||||
margin: 18px 0 0;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--dashboard-text-muted);
|
||||
text-align: center;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -70,7 +70,7 @@ if ( window.logged_in_users.length === 0 && window.user !== null ) {
|
||||
let tuser = window.user;
|
||||
tuser.auth_token = window.auth_token;
|
||||
window.logged_in_users.push(tuser);
|
||||
localStorage.setItem('logged_in_users', window.logged_in_users);
|
||||
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users));
|
||||
}
|
||||
|
||||
window.last_window_zindex = 1;
|
||||
|
||||
+33
-23
@@ -663,33 +663,20 @@ window.update_auth_data = async (auth_token, user) => {
|
||||
$('.user-email').html(html_encode(user.email));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// load the user's profile
|
||||
// ----------------------------------------------------
|
||||
user.profile = {};
|
||||
puter.auth.getProfile().then((profile) => {
|
||||
window.user.profile.displayName = profile?.displayName ?? null;
|
||||
window.user.profile.bio = profile?.bio ?? null;
|
||||
if ( profile?.displayName ) {
|
||||
$('.dashboard-profile-display-name').text(profile.displayName);
|
||||
}
|
||||
if ( profile?.picture ) {
|
||||
window.user.profile.picture = html_encode(profile.picture);
|
||||
$('.profile-pic').css('background-image', `url(${window.user.profile.picture})`);
|
||||
// the account tab may have rendered before the profile arrived
|
||||
$('.dashboard-profile-remove-picture').prop('hidden', false);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error loading profile:', error);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// Keep this account's last known profile until the fresh one loads below;
|
||||
// the account picker reads pictures from the stored copy.
|
||||
const known_profile = window.logged_in_users.find(known => known.uuid === user.uuid)?.profile;
|
||||
user.profile = { ...(known_profile ?? user.profile ?? {}) };
|
||||
|
||||
const to_storable_user = user => {
|
||||
const storable_user = { ...user };
|
||||
delete storable_user.taskbar_items;
|
||||
return storable_user;
|
||||
};
|
||||
const store_users = () => {
|
||||
localStorage.setItem('user', JSON.stringify(to_storable_user(window.user)));
|
||||
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users.map(to_storable_user)));
|
||||
};
|
||||
|
||||
// update this session's user data
|
||||
window.user = user;
|
||||
@@ -715,10 +702,33 @@ window.update_auth_data = async (auth_token, user) => {
|
||||
userobj.auth_token = window.auth_token;
|
||||
window.logged_in_users.push(userobj);
|
||||
}
|
||||
// update local storage
|
||||
localStorage.setItem('logged_in_users', JSON.stringify(window.logged_in_users.map(to_storable_user)));
|
||||
store_users();
|
||||
}
|
||||
|
||||
// Loaded only after puter.js holds this account's token, so it can't
|
||||
// return the previous account's profile.
|
||||
puter.auth.getProfile().then((profile) => {
|
||||
// Another account may have signed in while this was loading.
|
||||
if ( !profile || window.user !== user ) return;
|
||||
const shown_picture = user.profile.picture ?? null;
|
||||
user.profile = {
|
||||
displayName: profile.displayName ?? null,
|
||||
bio: profile.bio ?? null,
|
||||
picture: profile.picture ? html_encode(profile.picture) : null,
|
||||
};
|
||||
store_users();
|
||||
if ( profile.displayName ) {
|
||||
$('.dashboard-profile-display-name').text(profile.displayName);
|
||||
}
|
||||
// the account tab may have rendered before the profile arrived
|
||||
if ( user.profile.picture !== shown_picture ) {
|
||||
$('.profile-pic').css('background-image', `url(${user.profile.picture || window.icons['profile.svg']})`);
|
||||
$('.dashboard-profile-remove-picture').prop('hidden', !user.profile.picture);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Error loading profile:', error);
|
||||
});
|
||||
|
||||
window.desktop_path = `/${ window.user.username }/Desktop`;
|
||||
window.trash_path = `/${ window.user.username }/Trash`;
|
||||
window.appdata_path = `/${ window.user.username }/AppData`;
|
||||
|
||||
@@ -51,8 +51,16 @@ describe('update_auth_data', () => {
|
||||
window.logged_in_users = [];
|
||||
window.gui_origin = 'https://puter.test';
|
||||
window.available_templates = vi.fn();
|
||||
window.icons = { 'profile.svg': '/profile.svg' };
|
||||
globalThis.fetch = vi.fn(async () => ({ ok: true }));
|
||||
puter.setAuthToken.mockClear();
|
||||
puter.auth.getProfile.mockReset();
|
||||
puter.auth.getProfile.mockResolvedValue({});
|
||||
});
|
||||
|
||||
const storedUsers = () => JSON.parse(localStorage.getItem('logged_in_users'));
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve));
|
||||
|
||||
// Sign-in awaits this while its card is still up, over every ordinary window.
|
||||
it('raises a failed session-cookie sync above the sign-in card', async () => {
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
@@ -62,4 +70,49 @@ describe('update_auth_data', () => {
|
||||
expect(UIAlert).toHaveBeenCalledOnce();
|
||||
expect(UIAlert.mock.calls[0][0]).toMatchObject({ stay_on_top: true });
|
||||
});
|
||||
|
||||
// The account picker shows pictures from the stored list, not from memory.
|
||||
it('stores the loaded profile picture with the signed-in account', async () => {
|
||||
puter.auth.getProfile.mockResolvedValue({ picture: 'data:image/png;base64,AAAA' });
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
await settle();
|
||||
expect(storedUsers()).toEqual([
|
||||
expect.objectContaining({ uuid: 'a', profile: expect.objectContaining({ picture: 'data:image/png;base64,AAAA' }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the stored picture while a refreshed profile is loading', async () => {
|
||||
window.logged_in_users = [{ uuid: 'a', username: 'a', profile: { picture: 'data:image/png;base64,AAAA' } }];
|
||||
puter.auth.getProfile.mockReturnValue(new Promise(() => {}));
|
||||
// a whoami refresh carries no profile
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
expect(storedUsers()[0].profile.picture).toBe('data:image/png;base64,AAAA');
|
||||
});
|
||||
|
||||
it('drops a stored picture the account no longer has', async () => {
|
||||
window.logged_in_users = [{ uuid: 'a', username: 'a', profile: { picture: 'data:image/png;base64,AAAA' } }];
|
||||
puter.auth.getProfile.mockResolvedValue({ picture: null });
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
await settle();
|
||||
expect(storedUsers()[0].profile.picture).toBeNull();
|
||||
});
|
||||
|
||||
it('loads the profile with the new account\'s token', async () => {
|
||||
await window.update_auth_data('token-b', { uuid: 'b', username: 'b' });
|
||||
expect(puter.setAuthToken.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(puter.auth.getProfile.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('ignores a profile that arrives after another account signed in', async () => {
|
||||
let resolveA;
|
||||
puter.auth.getProfile.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveA = resolve;
|
||||
}));
|
||||
await window.update_auth_data('token-a', { uuid: 'a', username: 'a' });
|
||||
await window.update_auth_data('token-b', { uuid: 'b', username: 'b' });
|
||||
resolveA({ picture: 'data:image/png;base64,AAAA' });
|
||||
await settle();
|
||||
expect(storedUsers().find(stored => stored.uuid === 'b').profile.picture).toBeNull();
|
||||
expect(window.user.profile.picture).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// The `/custom-domains` API as the GUI talks to it, with its own session:
|
||||
// the desktop's attach dialog and the dashboard's publish dialog share it.
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomDomainRecord
|
||||
* @property {string} type
|
||||
* @property {string} name
|
||||
* @property {string} value
|
||||
* @property {'routing'|'redirect'|string} [purpose]
|
||||
* @property {boolean} [required]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomDomain
|
||||
* @property {string} uuid
|
||||
* @property {string} hostname
|
||||
* @property {string} site Subdomain of the website it serves.
|
||||
* @property {'active'|'pending'|'failed'|string} status
|
||||
* @property {string} [statusDetail]
|
||||
* @property {boolean} [isApex]
|
||||
* @property {string} [redirectFrom] The root domain forwarding to `hostname`.
|
||||
* @property {string} [dnsProvider]
|
||||
* @property {CustomDomainRecord[]} [records]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomDomainResponse
|
||||
* @property {number} status
|
||||
* @property {boolean} ok
|
||||
* @property {any} data The parsed JSON body, or null.
|
||||
*/
|
||||
|
||||
/** API error code → message key; anything unlisted gets the server's message. */
|
||||
const ERROR_KEYS = {
|
||||
invalid_hostname: 'custom_domain_error_invalid',
|
||||
custom_domain_taken: 'custom_domain_error_taken',
|
||||
custom_domain_limit_reached: 'custom_domain_error_limit',
|
||||
custom_domain_rejected: 'custom_domain_error_rejected',
|
||||
site_not_found: 'custom_domain_error_site',
|
||||
custom_domain_not_found: 'custom_domain_error_not_found',
|
||||
custom_domains_not_enabled: 'custom_domain_not_enabled',
|
||||
custom_domains_permission_required: 'custom_domain_error_generic',
|
||||
};
|
||||
|
||||
/** Error codes that mean "try another hostname". */
|
||||
export const CUSTOM_DOMAIN_HOSTNAME_ERRORS = new Set([
|
||||
'invalid_hostname',
|
||||
'custom_domain_taken',
|
||||
'custom_domain_rejected',
|
||||
]);
|
||||
|
||||
const STATUSES = new Set(['active', 'pending', 'failed']);
|
||||
|
||||
/**
|
||||
* A domain's status as the GUI draws it; anything unknown reads as pending.
|
||||
*
|
||||
* @param {Partial<CustomDomain> | null | undefined} domain
|
||||
* @returns {'active'|'pending'|'failed'}
|
||||
*/
|
||||
export const customDomainStatus = (domain) => (STATUSES.has(domain?.status) ? domain.status : 'pending');
|
||||
|
||||
/**
|
||||
* The monthly price the list call reports, in the user's locale.
|
||||
*
|
||||
* @param {{ amount?: number, currency?: string } | null | undefined} pricing
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export const formatCustomDomainPrice = (pricing) => {
|
||||
if ( ! pricing || typeof pricing.amount !== 'number' ) return null;
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: pricing.currency || 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
}).format(pricing.amount);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls `/custom-domains{path}`. Resolves with the status and parsed body,
|
||||
* even for an error status; rejects only when the request itself fails.
|
||||
*
|
||||
* @param {'GET'|'POST'|'PATCH'|'DELETE'} method
|
||||
* @param {string} [path] Starting with `/`, after `/custom-domains`.
|
||||
* @param {object} [body]
|
||||
* @param {string} [authToken] Defaults to the GUI's session.
|
||||
* @returns {Promise<CustomDomainResponse>}
|
||||
*/
|
||||
export const customDomainsRequest = async (method, path = '', body, authToken = puter.authToken ?? window.auth_token) => {
|
||||
const res = await fetch(`${window.api_origin}/custom-domains${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
// Non-JSON body; callers go by the status.
|
||||
}
|
||||
return { status: res.status, ok: res.ok, data };
|
||||
};
|
||||
|
||||
/**
|
||||
* Plain text for a failed response. A known code gets our wording; anything
|
||||
* else shows what the server said, which beats a generic message when
|
||||
* something is misconfigured.
|
||||
*
|
||||
* @param {CustomDomainResponse} res
|
||||
* @returns {string}
|
||||
*/
|
||||
export const customDomainErrorText = (res) => {
|
||||
const text = (key) => i18n(key, [], false);
|
||||
if ( res.status === 402 ) return text('custom_domain_error_plan');
|
||||
if ( res.status === 429 ) return text('custom_domain_error_rate_limited');
|
||||
const key = ERROR_KEYS[res.data?.code];
|
||||
if ( key ) return text(key);
|
||||
if ( typeof res.data?.message === 'string' && res.data.message ) {
|
||||
return res.data.message;
|
||||
}
|
||||
return `${text('custom_domain_error_generic')} (HTTP ${res.status})`;
|
||||
};
|
||||
|
||||
/**
|
||||
* What to show after a status check. A domain that is no longer pending
|
||||
* comes back with only its routing record, and a GET never repeats the DNS
|
||||
* provider or the redirect, so those are kept from what was shown before.
|
||||
*
|
||||
* @param {CustomDomain} known What the add (or the last check) returned.
|
||||
* @param {CustomDomain} fresh What the GET returned.
|
||||
* @returns {CustomDomain}
|
||||
*/
|
||||
export const mergeCustomDomainRefresh = (known, fresh) => {
|
||||
const knownRecords = known.records ?? [];
|
||||
const redirect = knownRecords.filter((r) => r.purpose === 'redirect');
|
||||
const records = (fresh.records?.length > 1 ? fresh.records : knownRecords)
|
||||
.filter((r) => r.purpose !== 'redirect');
|
||||
return {
|
||||
...fresh,
|
||||
dnsProvider: known.dnsProvider,
|
||||
redirectFrom: known.redirectFrom,
|
||||
records: [...records.slice(0, 1), ...redirect, ...records.slice(1)],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
customDomainErrorText,
|
||||
customDomainsRequest,
|
||||
customDomainStatus,
|
||||
formatCustomDomainPrice,
|
||||
mergeCustomDomainRefresh,
|
||||
} from './customDomains.js';
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.puter = { authToken: 'session-token' };
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
});
|
||||
|
||||
describe('customDomainStatus', () => {
|
||||
it('keeps a known status and reads anything else as pending', () => {
|
||||
expect(customDomainStatus({ status: 'active' })).toBe('active');
|
||||
expect(customDomainStatus({ status: 'failed' })).toBe('failed');
|
||||
expect(customDomainStatus({ status: 'verifying' })).toBe('pending');
|
||||
expect(customDomainStatus(null)).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCustomDomainPrice', () => {
|
||||
it('formats the amount in its currency', () => {
|
||||
expect(formatCustomDomainPrice({ amount: 0.5, currency: 'USD' })).toMatch(/0[.,]50/);
|
||||
});
|
||||
|
||||
it('gives null without an amount or for a currency it cannot format', () => {
|
||||
expect(formatCustomDomainPrice(null)).toBeNull();
|
||||
expect(formatCustomDomainPrice({ currency: 'USD' })).toBeNull();
|
||||
expect(formatCustomDomainPrice({ amount: 1, currency: 'not-a-currency' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('customDomainsRequest', () => {
|
||||
it('sends the session token and a JSON body, and parses the reply', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({ uuid: 'd1' }), { status: 201 }));
|
||||
const res = await customDomainsRequest('POST', '', { site: 'my-site', hostname: 'www.example.com' });
|
||||
expect(fetch).toHaveBeenCalledWith('https://api.puter.test/custom-domains', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer session-token', 'Content-Type': 'application/json' },
|
||||
body: '{"site":"my-site","hostname":"www.example.com"}',
|
||||
});
|
||||
expect(res).toEqual({ status: 201, ok: true, data: { uuid: 'd1' } });
|
||||
});
|
||||
|
||||
it('resolves an error status with a null body when it is not JSON', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response('Not Found', { status: 404 }));
|
||||
expect(await customDomainsRequest('GET', '/d1')).toEqual({ status: 404, ok: false, data: null });
|
||||
expect(fetch.mock.calls[0][0]).toBe('https://api.puter.test/custom-domains/d1');
|
||||
expect(fetch.mock.calls[0][1].headers).toEqual({ 'Authorization': 'Bearer session-token' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('customDomainErrorText', () => {
|
||||
it('words plan, rate limit and known codes itself', () => {
|
||||
expect(customDomainErrorText({ status: 402, data: { code: 'subscription_required' } })).toBe('custom_domain_error_plan');
|
||||
expect(customDomainErrorText({ status: 429, data: null })).toBe('custom_domain_error_rate_limited');
|
||||
expect(customDomainErrorText({ status: 409, data: { code: 'custom_domain_taken', message: 'x' } })).toBe('custom_domain_error_taken');
|
||||
});
|
||||
|
||||
it("falls back to the server's message, then to the status", () => {
|
||||
expect(customDomainErrorText({ status: 502, data: { message: 'Provider down' } })).toBe('Provider down');
|
||||
expect(customDomainErrorText({ status: 500, data: null })).toBe('custom_domain_error_generic (HTTP 500)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeCustomDomainRefresh', () => {
|
||||
const routing = { type: 'CNAME', name: 'www', value: 'my-site.puter.site', purpose: 'routing' };
|
||||
const redirect = { type: 'A', name: '@', value: '192.0.2.1', purpose: 'redirect' };
|
||||
const ownership = { type: 'TXT', name: '_cf', value: 'abc', purpose: 'ownership' };
|
||||
const known = {
|
||||
uuid: 'd1',
|
||||
status: 'pending',
|
||||
dnsProvider: 'example-dns',
|
||||
redirectFrom: 'example.com',
|
||||
records: [routing, redirect, ownership],
|
||||
};
|
||||
|
||||
it('keeps the records shown before when the check returns only routing', () => {
|
||||
const merged = mergeCustomDomainRefresh(known, { uuid: 'd1', status: 'active', records: [routing] });
|
||||
expect(merged.status).toBe('active');
|
||||
expect(merged.records).toEqual([routing, redirect, ownership]);
|
||||
expect(merged.dnsProvider).toBe('example-dns');
|
||||
expect(merged.redirectFrom).toBe('example.com');
|
||||
});
|
||||
|
||||
it('takes fresh records, with the redirect after the routing one', () => {
|
||||
const fresh_ownership = { ...ownership, value: 'def' };
|
||||
const merged = mergeCustomDomainRefresh(known, { uuid: 'd1', status: 'pending', records: [routing, fresh_ownership] });
|
||||
expect(merged.records).toEqual([routing, redirect, fresh_ownership]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,14 +28,21 @@ export const is_plan_gate_error = (error) =>
|
||||
Boolean(error && typeof error === 'object' && error.code === 'subscription_required');
|
||||
|
||||
/**
|
||||
* Open the upgrade flow, where the deployment has one. Hosted puter.com
|
||||
* attaches `UIUpgradeAccount`; a self-hosted install has no plan to offer,
|
||||
* and the caller explains the refusal in its own words instead.
|
||||
* Whether the deployment has an upgrade flow. Hosted puter.com attaches
|
||||
* `UIUpgradeAccount`; a self-hosted install has no plan to offer.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const has_upgrade_flow = () => typeof window.UIUpgradeAccount === 'function';
|
||||
|
||||
/**
|
||||
* Open the upgrade flow, where the deployment has one. Without one, the
|
||||
* caller explains the refusal in its own words instead.
|
||||
*
|
||||
* @returns {boolean} Whether the upgrade flow was opened.
|
||||
*/
|
||||
export const open_upgrade_flow = () => {
|
||||
if ( typeof window.UIUpgradeAccount !== 'function' ) return false;
|
||||
if ( ! has_upgrade_flow() ) return false;
|
||||
(new window.UIUpgradeAccount()).open_as_window();
|
||||
return true;
|
||||
};
|
||||
@@ -129,13 +129,19 @@ const en = {
|
||||
choose_publishing_option: 'Choose how you want to publish your website:',
|
||||
contact_us: 'Contact Us',
|
||||
contact_us_attach: 'Attach Files',
|
||||
contact_us_attach_or_drop: 'Attach files or drop them here',
|
||||
contact_us_attach_hint: 'Images and videos — up to %% files, %% MB each.',
|
||||
contact_us_attachment_remove: 'Remove attachment',
|
||||
contact_us_attachment_remove_named: 'Remove %%',
|
||||
contact_us_attachment_too_large: 'That file is too large. Each attachment can be up to %% MB.',
|
||||
contact_us_attachment_too_many: 'You can attach up to %% files.',
|
||||
contact_us_attachment_total_too_large: 'Those files add up to more than %% MB. Try attaching fewer of them, or smaller ones.',
|
||||
contact_us_attachments: 'Attachments',
|
||||
contact_us_attachment_unsupported: 'That file type is not supported. Attach an image (PNG, JPEG, GIF, WebP) or a video (MP4, MOV, WebM).',
|
||||
contact_us_drop_to_attach: 'Drop to attach',
|
||||
contact_us_error: 'Something went wrong. Please try again.',
|
||||
contact_us_message: 'Message',
|
||||
contact_us_message_placeholder: 'How can we help? For a bug, the steps that lead to it help us most.',
|
||||
contact_us_message_required: 'Please write a message before sending.',
|
||||
contact_us_rate_limited: 'You have sent a lot of messages recently. Please try again later.',
|
||||
contact_us_verification_required: 'You must have a verified email address to use this.',
|
||||
@@ -472,7 +478,16 @@ const en = {
|
||||
publish_as_website: 'Publish as website',
|
||||
publish_as_serverless_worker: 'Publish as Worker',
|
||||
publish_address: 'Address',
|
||||
publish_custom_domain: 'Use a custom domain',
|
||||
publish_custom_domain_dns_hint: 'After publishing, you\'ll get the DNS records to add at your domain provider.',
|
||||
publish_custom_domain_hint: 'Serve this website from a domain you own.',
|
||||
publish_custom_domain_moves: 'This domain is on your account. Publishing moves it here from %%, at no extra cost.',
|
||||
publish_custom_domain_needs_plan: 'Upgrade to use a custom domain, or turn it off to publish without one.',
|
||||
publish_custom_domain_upsell_text: 'Upgrade to serve this website from a domain you own.',
|
||||
publish_custom_domain_upsell_title: 'Custom domains come with a paid plan',
|
||||
publish_domain_failed: 'Your website is live at %%, but the domain wasn\'t added.',
|
||||
publish_name_required: 'Enter an address.',
|
||||
publish_site_live: 'Your website is live at %%.',
|
||||
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
|
||||
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
|
||||
push_app_turn_off_failed: "Couldn't turn that off. Please try again.",
|
||||
@@ -996,7 +1011,7 @@ const en = {
|
||||
sign_in_with_provider: 'Sign in with %%',
|
||||
sign_up_with_provider: 'Sign up with %%',
|
||||
sign_up_with_email: 'Sign up using email',
|
||||
popup_opener_uses_puter: '%strong% is powered by Puter for its AI and cloud features.\nUse your Puter account to continue.',
|
||||
popup_opener_uses_puter: '%strong% is powered by Puter',
|
||||
popup_opener_origin_unsupported: 'Puter cannot sign you in to this page because the browser gives it no identifiable address. Pages opened straight from a file (file://) and iframes sandboxed without allow-same-origin are not supported — serve the page over http://localhost or a real domain and try again.',
|
||||
oidc_switched_to_login_message: 'You have been logged in to an existing account.',
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user